Merge lp:~joetalbott/uci-engine/user_auth into lp:uci-engine
- user_auth
- Merge into trunk
Status: | Needs review |
---|---|
Proposed branch: | lp:~joetalbott/uci-engine/user_auth |
Merge into: | lp:uci-engine |
Diff against target: |
573 lines (+336/-17) 11 files modified
charms/precise/wsgi-app/config.yaml (+5/-0) charms/precise/wsgi-app/hooks/hooks.py (+35/-0) charms/precise/wsgi-app/unit_tests/test_hooks.py (+10/-0) ci-utils/ci_utils/tastypie/test.py (+29/-7) ticket_system/ticket/api.py (+10/-8) ticket_system/ticket/authorization.py (+97/-0) ticket_system/ticket/models.py (+3/-0) ticket_system/ticket/tests/__init__.py (+1/-0) ticket_system/ticket/tests/test_authorization.py (+138/-0) ticket_system/ticket/tests/test_write_api.py (+1/-2) ticket_system/ticket_system/settings.py (+7/-0) |
To merge this branch: | bzr merge lp:~joetalbott/uci-engine/user_auth |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
PS Jenkins bot (community) | continuous-integration | Approve | |
Celso Providelo (community) | Needs Fixing | ||
Paul Larson | Approve | ||
Review via email: mp+239067@code.launchpad.net |
Commit message
ticket-system - Add first pass custom authorization class for APIs.
Description of the change
ticket-system - Add first pass custom authorization class for APIs.
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:833
http://
Executed test runs:
Click here to trigger a rebuild:
http://
Celso Providelo (cprov) wrote : | # |
I have some questions inline that needs clarification, I am probably misunderstanding things.
- 834. By Joe Talbott
-
ticket-system - Add authentication tests
PS Jenkins bot (ps-jenkins) wrote : | # |
PASSED: Continuous integration, rev:834
http://
Executed test runs:
Click here to trigger a rebuild:
http://
- 835. By Joe Talbott
-
ticket-system - Switch away from global permissions.
- 836. By Joe Talbott
-
ticket-system - Turn off debugging.
- 837. By Joe Talbott
-
ticket-system - Fix typo that snuck into previous commit.
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:837
http://
Executed test runs:
Click here to trigger a rebuild:
http://
- 838. By Joe Talbott
-
ticket-system - remove obsolete global permission code.
- 839. By Joe Talbott
-
ticket-system - Add docstring explaining custom authorization class.
- 840. By Joe Talbott
-
ticket-system - Add config option for internal hosts.
* adds tests
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:840
http://
Executed test runs:
Click here to trigger a rebuild:
http://
- 841. By Joe Talbott
-
ci-utils - Pep8 fixes for tastypie tests
PS Jenkins bot (ps-jenkins) wrote : | # |
PASSED: Continuous integration, rev:841
http://
Executed test runs:
Click here to trigger a rebuild:
http://
Celso Providelo (cprov) wrote : | # |
Joe,
It looks pretty good, I just have few inline comments to be addresses/discussed about testing infrastructure.
- 842. By Joe Talbott
-
ticket-system - Clean up tests and remove oauth bits.
- 843. By Joe Talbott
-
merge with trunk
* resolve conflicts
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:843
http://
Executed test runs:
Click here to trigger a rebuild:
http://
- 844. By Joe Talbott
-
Remove tests for unneeded functionality
We won't be bulk adding or updating reviews via the /review/ end-point so drop
the tests. This was a side-effect of finding out that we need to allow PUT for
these tests to pass but allowing PUT makes POST'ing a list not add entries if a
matching one already exists, thus causing another more important test to fail. - 845. By Joe Talbott
-
Remove no longer needed self.review_
permission - 846. By Joe Talbott
-
Re-add overzealously removed import.
- 847. By Joe Talbott
-
Use .count() on querysets not len()
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:847
http://
Executed test runs:
Click here to trigger a rebuild:
http://
Celso Providelo (cprov) wrote : | # |
Thanks for the changes, Joe.
We are very close! Please address my inline comments and the test failure.
PS Jenkins bot (ps-jenkins) wrote : | # |
PASSED: Continuous integration, rev:847
http://
Executed test runs:
Click here to trigger a rebuild:
http://
- 848. By Joe Talbott
-
Use TicketTastypieT
estCase and ticket.uuid rather than .pk.
PS Jenkins bot (ps-jenkins) wrote : | # |
FAILED: Continuous integration, rev:848
http://
Executed test runs:
Click here to trigger a rebuild:
http://
- 849. By Joe Talbott
-
ticket-system - flake8 cleanup.
PS Jenkins bot (ps-jenkins) wrote : | # |
PASSED: Continuous integration, rev:849
http://
Executed test runs:
Click here to trigger a rebuild:
http://
Unmerged revisions
- 849. By Joe Talbott
-
ticket-system - flake8 cleanup.
- 848. By Joe Talbott
-
Use TicketTastypieT
estCase and ticket.uuid rather than .pk. - 847. By Joe Talbott
-
Use .count() on querysets not len()
- 846. By Joe Talbott
-
Re-add overzealously removed import.
- 845. By Joe Talbott
-
Remove no longer needed self.review_
permission - 844. By Joe Talbott
-
Remove tests for unneeded functionality
We won't be bulk adding or updating reviews via the /review/ end-point so drop
the tests. This was a side-effect of finding out that we need to allow PUT for
these tests to pass but allowing PUT makes POST'ing a list not add entries if a
matching one already exists, thus causing another more important test to fail. - 843. By Joe Talbott
-
merge with trunk
* resolve conflicts
- 842. By Joe Talbott
-
ticket-system - Clean up tests and remove oauth bits.
- 841. By Joe Talbott
-
ci-utils - Pep8 fixes for tastypie tests
- 840. By Joe Talbott
-
ticket-system - Add config option for internal hosts.
* adds tests
Preview Diff
1 | === modified file 'charms/precise/wsgi-app/config.yaml' | |||
2 | --- charms/precise/wsgi-app/config.yaml 2014-10-15 10:51:10 +0000 | |||
3 | +++ charms/precise/wsgi-app/config.yaml 2014-11-06 19:15:32 +0000 | |||
4 | @@ -63,6 +63,11 @@ | |||
5 | 63 | default: "INFO" | 63 | default: "INFO" |
6 | 64 | description: The logging level. | 64 | description: The logging level. |
7 | 65 | 65 | ||
8 | 66 | internal_hosts: | ||
9 | 67 | type: string | ||
10 | 68 | default: '' | ||
11 | 69 | description: A comma separated list of internal hosts that don't need permissions checked. Ticket-system only. | ||
12 | 70 | |||
13 | 66 | # Apt info used by charmhelpers | 71 | # Apt info used by charmhelpers |
14 | 67 | install_sources: | 72 | install_sources: |
15 | 68 | type: string | 73 | type: string |
16 | 69 | 74 | ||
17 | === modified file 'charms/precise/wsgi-app/hooks/hooks.py' | |||
18 | --- charms/precise/wsgi-app/hooks/hooks.py 2014-10-16 13:24:26 +0000 | |||
19 | +++ charms/precise/wsgi-app/hooks/hooks.py 2014-11-06 19:15:32 +0000 | |||
20 | @@ -244,6 +244,30 @@ | |||
21 | 244 | return reverseproxies | 244 | return reverseproxies |
22 | 245 | 245 | ||
23 | 246 | 246 | ||
24 | 247 | def _get_internal_hosts(): | ||
25 | 248 | filepath = os.path.join(_service_dir(), 'internal-hosts.json') | ||
26 | 249 | internal_hosts = [] | ||
27 | 250 | if os.path.exists(filepath): | ||
28 | 251 | with open(filepath, 'r') as f: | ||
29 | 252 | internal_hosts = json.load(f) | ||
30 | 253 | |||
31 | 254 | return internal_hosts | ||
32 | 255 | |||
33 | 256 | def _set_internal_hosts(): | ||
34 | 257 | config = charmhelpers.core.hookenv.config() | ||
35 | 258 | |||
36 | 259 | internal_hosts = _get_internal_hosts() | ||
37 | 260 | internal_hosts += [x.strip() for x in | ||
38 | 261 | config.get('internal_hosts').split(',') if x] | ||
39 | 262 | internal_hosts = list(set(internal_hosts)) | ||
40 | 263 | if internal_hosts: | ||
41 | 264 | juju_log("Adding internal whitelisted hosts: {}".format( | ||
42 | 265 | internal_hosts)) | ||
43 | 266 | with open(os.path.join(_service_dir(), | ||
44 | 267 | 'internal-hosts.json'), 'w') as f: | ||
45 | 268 | json.dump(internal_hosts, f) | ||
46 | 269 | |||
47 | 270 | |||
48 | 247 | def _set_allowed_hosts(): | 271 | def _set_allowed_hosts(): |
49 | 248 | config = charmhelpers.core.hookenv.config() | 272 | config = charmhelpers.core.hookenv.config() |
50 | 249 | ips = [ | 273 | ips = [ |
51 | @@ -306,6 +330,7 @@ | |||
52 | 306 | _create_client(relation['url']) | 330 | _create_client(relation['url']) |
53 | 307 | 331 | ||
54 | 308 | _set_allowed_hosts() | 332 | _set_allowed_hosts() |
55 | 333 | _set_internal_hosts() | ||
56 | 309 | update_nrpe_config() | 334 | update_nrpe_config() |
57 | 310 | _set_logging() | 335 | _set_logging() |
58 | 311 | _wsgi_reload() | 336 | _wsgi_reload() |
59 | @@ -322,6 +347,7 @@ | |||
60 | 322 | charmhelpers.core.hookenv.relation_set( | 347 | charmhelpers.core.hookenv.relation_set( |
61 | 323 | relation_id, {'port': config["port"], 'hostname': host}) | 348 | relation_id, {'port': config["port"], 'hostname': host}) |
62 | 324 | _set_allowed_hosts() | 349 | _set_allowed_hosts() |
63 | 350 | _set_internal_hosts() | ||
64 | 325 | 351 | ||
65 | 326 | 352 | ||
66 | 327 | @hooks.hook('json_status-relation-joined') | 353 | @hooks.hook('json_status-relation-joined') |
67 | @@ -523,7 +549,16 @@ | |||
68 | 523 | @hooks.hook('oauth-server-relation-joined') | 549 | @hooks.hook('oauth-server-relation-joined') |
69 | 524 | @hooks.hook('oauth-server-relation-changed') | 550 | @hooks.hook('oauth-server-relation-changed') |
70 | 525 | def oauth_server_relation_joined_changed(): | 551 | def oauth_server_relation_joined_changed(): |
71 | 552 | config = charmhelpers.core.hookenv.config() | ||
72 | 553 | |||
73 | 526 | relation = charmhelpers.core.hookenv.relation_get() | 554 | relation = charmhelpers.core.hookenv.relation_get() |
74 | 555 | internal_hosts = map( | ||
75 | 556 | unicode.strip, config.get('internal_hosts', '').split(',')) | ||
76 | 557 | internal_hosts.append(relation.get('private-address')) | ||
77 | 558 | |||
78 | 559 | # use set() to make a unique list | ||
79 | 560 | config['internal_hosts'] = ','.join(set(internal_hosts)) | ||
80 | 561 | config.save() | ||
81 | 527 | 562 | ||
82 | 528 | client_redirect_uri = relation.get('url') | 563 | client_redirect_uri = relation.get('url') |
83 | 529 | 564 | ||
84 | 530 | 565 | ||
85 | === modified file 'charms/precise/wsgi-app/unit_tests/test_hooks.py' | |||
86 | --- charms/precise/wsgi-app/unit_tests/test_hooks.py 2014-10-16 13:24:26 +0000 | |||
87 | +++ charms/precise/wsgi-app/unit_tests/test_hooks.py 2014-11-06 19:15:32 +0000 | |||
88 | @@ -254,6 +254,16 @@ | |||
89 | 254 | self.assertTrue( | 254 | self.assertTrue( |
90 | 255 | os.path.exists(os.path.join(self.tmpdir, 'allowed_hosts.json'))) | 255 | os.path.exists(os.path.join(self.tmpdir, 'allowed_hosts.json'))) |
91 | 256 | 256 | ||
92 | 257 | def test__get_internal_hosts(self): | ||
93 | 258 | self.assertEqual(0, len(hooks._get_internal_hosts())) | ||
94 | 259 | |||
95 | 260 | @mock.patch('hooks._get_internal_hosts') | ||
96 | 261 | def test__set_internal_hosts(self, _get_internal_hosts): | ||
97 | 262 | _get_internal_hosts.return_value = ['127.0.0.1'] | ||
98 | 263 | hooks._set_internal_hosts() | ||
99 | 264 | self.assertTrue( | ||
100 | 265 | os.path.exists(os.path.join(self.tmpdir, 'internal-hosts.json'))) | ||
101 | 266 | |||
102 | 257 | 267 | ||
103 | 258 | class TestWebsiteHooks(RestishTestCase): | 268 | class TestWebsiteHooks(RestishTestCase): |
104 | 259 | 269 | ||
105 | 260 | 270 | ||
106 | === modified file 'ci-utils/ci_utils/tastypie/test.py' | |||
107 | --- ci-utils/ci_utils/tastypie/test.py 2014-05-27 11:41:04 +0000 | |||
108 | +++ ci-utils/ci_utils/tastypie/test.py 2014-11-06 19:15:32 +0000 | |||
109 | @@ -16,22 +16,40 @@ | |||
110 | 16 | import json | 16 | import json |
111 | 17 | import mock | 17 | import mock |
112 | 18 | 18 | ||
113 | 19 | from django.contrib.contenttypes.models import ContentType | ||
114 | 20 | from django.contrib.auth.models import User, Permission | ||
115 | 19 | from tastypie.test import ResourceTestCase, TestApiClient | 21 | from tastypie.test import ResourceTestCase, TestApiClient |
116 | 20 | 22 | ||
117 | 21 | 23 | ||
118 | 22 | class TastypieTestCase(ResourceTestCase): | 24 | class TastypieTestCase(ResourceTestCase): |
119 | 23 | '''A base class for RESTFul services.''' | 25 | '''A base class for RESTFul services.''' |
120 | 24 | 26 | ||
122 | 25 | def setUp(self, resource_base): | 27 | @mock.patch('ticket.models.Ticket.create_container') |
123 | 28 | def setUp(self, resource_base, create_container): | ||
124 | 26 | '''Set up an admin user that can make POST/PATCH calls.''' | 29 | '''Set up an admin user that can make POST/PATCH calls.''' |
125 | 30 | super(TastypieTestCase, self).setUp() | ||
126 | 27 | self.resource_base = resource_base | 31 | self.resource_base = resource_base |
127 | 28 | if resource_base[-1] == '/': | 32 | if resource_base[-1] == '/': |
128 | 29 | self.resource_base = resource_base[:-1] | 33 | self.resource_base = resource_base[:-1] |
129 | 30 | # create an api key for "post" operations | 34 | # create an api key for "post" operations |
130 | 31 | # we currently don't use authentication|authorization, so its nothing | 35 | # we currently don't use authentication|authorization, so its nothing |
131 | 32 | self.auth = None | ||
132 | 33 | self.client = TestApiClient() | 36 | self.client = TestApiClient() |
133 | 34 | 37 | ||
134 | 38 | self.username = "testuser" | ||
135 | 39 | self.password = "testpass" | ||
136 | 40 | create_container.return_value = None | ||
137 | 41 | self.user = User.objects.create_user(username=self.username, | ||
138 | 42 | password=self.password) | ||
139 | 43 | |||
140 | 44 | content_type = ContentType.objects.get( | ||
141 | 45 | app_label='ticket', model='ticket', | ||
142 | 46 | ) | ||
143 | 47 | |||
144 | 48 | for codename in ['review_ticket', 'change_ticket', 'add_ticket']: | ||
145 | 49 | permission = Permission.objects.get( | ||
146 | 50 | codename=codename, content_type=content_type) | ||
147 | 51 | self.user.user_permissions.add(permission) | ||
148 | 52 | |||
149 | 35 | def _resource(self, resource): | 53 | def _resource(self, resource): |
150 | 36 | if resource[0] == '/' or resource.startswith('http://'): | 54 | if resource[0] == '/' or resource.startswith('http://'): |
151 | 37 | # assume the caller is passing the full path | 55 | # assume the caller is passing the full path |
152 | @@ -46,16 +64,18 @@ | |||
153 | 46 | def post(self, resource, params): | 64 | def post(self, resource, params): |
154 | 47 | '''Create the resource and return location of the new object.''' | 65 | '''Create the resource and return location of the new object.''' |
155 | 48 | resource = self._resource(resource) | 66 | resource = self._resource(resource) |
158 | 49 | resp = self.client.post( | 67 | self.client.client.login(username=self.username, |
159 | 50 | resource, data=params, authentication=self.auth) | 68 | password=self.password) |
160 | 69 | resp = self.client.post(resource, data=params) | ||
161 | 51 | self.assertHttpCreated(resp) | 70 | self.assertHttpCreated(resp) |
162 | 52 | return resp['location'] | 71 | return resp['location'] |
163 | 53 | 72 | ||
164 | 54 | def patch(self, resource, params): | 73 | def patch(self, resource, params): |
165 | 55 | '''Update an existing resource.''' | 74 | '''Update an existing resource.''' |
166 | 56 | resource = self._resource(resource) | 75 | resource = self._resource(resource) |
169 | 57 | resp = self.client.patch( | 76 | self.client.client.login(username=self.username, |
170 | 58 | resource, data=params, authentication=self.auth) | 77 | password=self.password) |
171 | 78 | resp = self.client.patch(resource, data=params) | ||
172 | 59 | self.assertHttpAccepted(resp) | 79 | self.assertHttpAccepted(resp) |
173 | 60 | 80 | ||
174 | 61 | def getResource(self, resource, params={}): | 81 | def getResource(self, resource, params={}): |
175 | @@ -74,7 +94,9 @@ | |||
176 | 74 | def delete(self, resource): | 94 | def delete(self, resource): |
177 | 75 | '''Delete an existing resource.''' | 95 | '''Delete an existing resource.''' |
178 | 76 | resource = self._resource(resource) | 96 | resource = self._resource(resource) |
180 | 77 | resp = self.client.delete(resource, authentication=self.auth) | 97 | self.client.client.login(username=self.username, |
181 | 98 | password=self.password) | ||
182 | 99 | resp = self.client.delete(resource) | ||
183 | 78 | return resp | 100 | return resp |
184 | 79 | 101 | ||
185 | 80 | 102 | ||
186 | 81 | 103 | ||
187 | === modified file 'ticket_system/ticket/api.py' | |||
188 | --- ticket_system/ticket/api.py 2014-11-06 05:57:57 +0000 | |||
189 | +++ ticket_system/ticket/api.py 2014-11-06 19:15:32 +0000 | |||
190 | @@ -60,6 +60,8 @@ | |||
191 | 60 | ) | 60 | ) |
192 | 61 | from project.api import SourcePackageResource | 61 | from project.api import SourcePackageResource |
193 | 62 | 62 | ||
194 | 63 | from authorization import UserOrInternalAuthorization | ||
195 | 64 | |||
196 | 63 | 65 | ||
197 | 64 | class PageNumberPaginator(Paginator): | 66 | class PageNumberPaginator(Paginator): |
198 | 65 | """Copes with YUI 'page'-number based pagination.""" | 67 | """Copes with YUI 'page'-number based pagination.""" |
199 | @@ -223,7 +225,7 @@ | |||
200 | 223 | class Meta: | 225 | class Meta: |
201 | 224 | queryset = SourcePackageUpload.objects.all() | 226 | queryset = SourcePackageUpload.objects.all() |
202 | 225 | allowed_methods = ['get', 'post'] | 227 | allowed_methods = ['get', 'post'] |
204 | 226 | authorization = Authorization() | 228 | authorization = UserOrInternalAuthorization() |
205 | 227 | resource_name = 'spu' | 229 | resource_name = 'spu' |
206 | 228 | 230 | ||
207 | 229 | 231 | ||
208 | @@ -270,7 +272,7 @@ | |||
209 | 270 | # authorization mechanisms, not on the local resource queryset. | 272 | # authorization mechanisms, not on the local resource queryset. |
210 | 271 | queryset = Ticket.objects.filter(private=False) | 273 | queryset = Ticket.objects.filter(private=False) |
211 | 272 | allowed_methods = ['get', 'post', 'patch'] | 274 | allowed_methods = ['get', 'post', 'patch'] |
213 | 273 | authorization = Authorization() | 275 | authorization = UserOrInternalAuthorization() |
214 | 274 | filtering = { | 276 | filtering = { |
215 | 275 | "status": ALL, | 277 | "status": ALL, |
216 | 276 | } | 278 | } |
217 | @@ -286,7 +288,7 @@ | |||
218 | 286 | """ | 288 | """ |
219 | 287 | 289 | ||
220 | 288 | class Meta: | 290 | class Meta: |
222 | 289 | authorization = Authorization() | 291 | authorization = UserOrInternalAuthorization() |
223 | 290 | list_allowed_methods = [] | 292 | list_allowed_methods = [] |
224 | 291 | detail_allowed_methods = [] | 293 | detail_allowed_methods = [] |
225 | 292 | excludes = ['id'] | 294 | excludes = ['id'] |
226 | @@ -328,7 +330,7 @@ | |||
227 | 328 | class Meta: | 330 | class Meta: |
228 | 329 | queryset = SubTicket.objects.all() | 331 | queryset = SubTicket.objects.all() |
229 | 330 | allowed_methods = ['get', 'post'] | 332 | allowed_methods = ['get', 'post'] |
231 | 331 | authorization = Authorization() | 333 | authorization = UserOrInternalAuthorization() |
232 | 332 | 334 | ||
233 | 333 | def hydrate_sourcepackage(self, bundle): | 335 | def hydrate_sourcepackage(self, bundle): |
234 | 334 | """Re-use existing `SourcePackage` when creating new SubTickets.""" | 336 | """Re-use existing `SourcePackage` when creating new SubTickets.""" |
235 | @@ -360,7 +362,7 @@ | |||
236 | 360 | class Meta: | 362 | class Meta: |
237 | 361 | queryset = TicketArtifact.objects.all() | 363 | queryset = TicketArtifact.objects.all() |
238 | 362 | allowed_methods = ['get', 'post'] | 364 | allowed_methods = ['get', 'post'] |
240 | 363 | authorization = Authorization() | 365 | authorization = UserOrInternalAuthorization() |
241 | 364 | filtering = { | 366 | filtering = { |
242 | 365 | 'type': ['exact'], | 367 | 'type': ['exact'], |
243 | 366 | 'ticket': ['exact'], | 368 | 'ticket': ['exact'], |
244 | @@ -375,7 +377,7 @@ | |||
245 | 375 | class Meta: | 377 | class Meta: |
246 | 376 | queryset = SubTicketArtifact.objects.all() | 378 | queryset = SubTicketArtifact.objects.all() |
247 | 377 | allowed_methods = ['get', 'post'] | 379 | allowed_methods = ['get', 'post'] |
249 | 378 | authorization = Authorization() | 380 | authorization = UserOrInternalAuthorization() |
250 | 379 | 381 | ||
251 | 380 | 382 | ||
252 | 381 | class FullTicketArtifactResource(ModelResource): | 383 | class FullTicketArtifactResource(ModelResource): |
253 | @@ -447,7 +449,7 @@ | |||
254 | 447 | queryset = SubTicket.objects.all() | 449 | queryset = SubTicket.objects.all() |
255 | 448 | fields = ['id', 'current_workflow_step', 'status'] | 450 | fields = ['id', 'current_workflow_step', 'status'] |
256 | 449 | allowed_methods = ['get', 'patch'] | 451 | allowed_methods = ['get', 'patch'] |
258 | 450 | authorization = Authorization() | 452 | authorization = UserOrInternalAuthorization() |
259 | 451 | resource_name = 'updatesubticketstatus' | 453 | resource_name = 'updatesubticketstatus' |
260 | 452 | filtering = { | 454 | filtering = { |
261 | 453 | "id": ('exact'), | 455 | "id": ('exact'), |
262 | @@ -620,7 +622,7 @@ | |||
263 | 620 | 622 | ||
264 | 621 | class Meta: | 623 | class Meta: |
265 | 622 | queryset = Review.objects.all() | 624 | queryset = Review.objects.all() |
267 | 623 | authorization = Authorization() | 625 | authorization = UserOrInternalAuthorization() |
268 | 624 | allowed_methods = ['get', 'post', 'patch', 'delete'] | 626 | allowed_methods = ['get', 'post', 'patch', 'delete'] |
269 | 625 | filtering = { | 627 | filtering = { |
270 | 626 | 'ticket': ALL_WITH_RELATIONS, | 628 | 'ticket': ALL_WITH_RELATIONS, |
271 | 627 | 629 | ||
272 | === added file 'ticket_system/ticket/authorization.py' | |||
273 | --- ticket_system/ticket/authorization.py 1970-01-01 00:00:00 +0000 | |||
274 | +++ ticket_system/ticket/authorization.py 2014-11-06 19:15:32 +0000 | |||
275 | @@ -0,0 +1,97 @@ | |||
276 | 1 | # Ubuntu Continuous Integration Engine | ||
277 | 2 | # Copyright 2014 Canonical Ltd. | ||
278 | 3 | |||
279 | 4 | # This program is free software: you can redistribute it and/or modify it | ||
280 | 5 | # under the terms of the GNU Affero General Public License version 3, as | ||
281 | 6 | # published by the Free Software Foundation. | ||
282 | 7 | |||
283 | 8 | # This program is distributed in the hope that it will be useful, but | ||
284 | 9 | # WITHOUT ANY WARRANTY; without even the implied warranties of | ||
285 | 10 | # MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR | ||
286 | 11 | # PURPOSE. See the GNU Affero General Public License for more details. | ||
287 | 12 | |||
288 | 13 | # You should have received a copy of the GNU Affero General Public License | ||
289 | 14 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
290 | 15 | |||
291 | 16 | from tastypie.authorization import Authorization | ||
292 | 17 | from tastypie.exceptions import Unauthorized | ||
293 | 18 | |||
294 | 19 | from ticket_system import settings | ||
295 | 20 | from ticket.models import Review | ||
296 | 21 | |||
297 | 22 | |||
298 | 23 | class UserOrInternalAuthorization(Authorization): | ||
299 | 24 | ''' Custom authorization class to control permissions for ticket | ||
300 | 25 | creation, editing, and reviewing. Also allows a list of internal | ||
301 | 26 | IPs. | ||
302 | 27 | |||
303 | 28 | Expected permissions: | ||
304 | 29 | - ticket.add_ticket | ||
305 | 30 | - ticket.change_ticket | ||
306 | 31 | - ticket.review_ticket | ||
307 | 32 | ''' | ||
308 | 33 | |||
309 | 34 | def _all_access(self, request, perm): | ||
310 | 35 | user = request.user | ||
311 | 36 | return user.has_perm(perm) or self._internal_address(request) | ||
312 | 37 | |||
313 | 38 | def _internal_address(self, request): | ||
314 | 39 | reload(settings) | ||
315 | 40 | return request.META.get('REMOTE_ADDR') in settings.internal_hosts | ||
316 | 41 | |||
317 | 42 | def read_list(self, object_list, bundle): | ||
318 | 43 | return object_list | ||
319 | 44 | |||
320 | 45 | def read_detail(self, object_list, bundle): | ||
321 | 46 | return True | ||
322 | 47 | |||
323 | 48 | def create_list(self, object_list, bundle): | ||
324 | 49 | if self._all_access(bundle.request, 'ticket.add_ticket'): | ||
325 | 50 | return object_list | ||
326 | 51 | else: | ||
327 | 52 | raise Unauthorized("You don't have write access") | ||
328 | 53 | |||
329 | 54 | def create_detail(self, object_list, bundle): | ||
330 | 55 | return self._all_access(bundle.request, 'ticket.add_ticket') | ||
331 | 56 | |||
332 | 57 | def update_list(self, object_list, bundle): | ||
333 | 58 | # TODO: for now we inspect the object type and enforce review | ||
334 | 59 | # permissions once we switch to the all-in-one ticket api we'll | ||
335 | 60 | # need to rework this logic. <joe.talbott@canonical.com> | ||
336 | 61 | if (object_list and object_list.count() > 0 and | ||
337 | 62 | isinstance(object_list[0], Review)): | ||
338 | 63 | if self._all_access(bundle.request, 'ticket.review_ticket'): | ||
339 | 64 | return object_list | ||
340 | 65 | else: | ||
341 | 66 | raise Unauthorized("You don't have write access") | ||
342 | 67 | else: | ||
343 | 68 | if self._all_access(bundle.request, 'ticket.change_ticket'): | ||
344 | 69 | return object_list | ||
345 | 70 | else: | ||
346 | 71 | raise Unauthorized("You don't have write access") | ||
347 | 72 | |||
348 | 73 | def update_detail(self, object_list, bundle): | ||
349 | 74 | # TODO: for now we inspect the object type and enforce review | ||
350 | 75 | # permissions once we switch to the all-in-one ticket api we'll | ||
351 | 76 | # need to rework this logic. <joe.talbott@canonical.com> | ||
352 | 77 | if (object_list and object_list.count() > 0 and | ||
353 | 78 | isinstance(object_list[0], Review)): | ||
354 | 79 | return self._all_access(bundle.request, 'ticket.review_ticket') | ||
355 | 80 | |||
356 | 81 | return self._all_access(bundle.request, 'ticket.change_ticket') | ||
357 | 82 | |||
358 | 83 | def delete_list(self, object_list, bundle): | ||
359 | 84 | if (object_list and object_list.count() > 0 and | ||
360 | 85 | isinstance(object_list[0], Review)): | ||
361 | 86 | if self._all_access(bundle.request, 'ticket.review_ticket'): | ||
362 | 87 | return object_list | ||
363 | 88 | |||
364 | 89 | raise Unauthorized("Sorry, no deletes.") | ||
365 | 90 | |||
366 | 91 | def delete_detail(self, object_list, bundle): | ||
367 | 92 | if (object_list and object_list.count() > 0 and | ||
368 | 93 | isinstance(object_list[0], Review)): | ||
369 | 94 | if self._all_access(bundle.request, 'ticket.review_ticket'): | ||
370 | 95 | return True | ||
371 | 96 | |||
372 | 97 | raise Unauthorized("Sorry, no deletes.") | ||
373 | 0 | 98 | ||
374 | === modified file 'ticket_system/ticket/models.py' | |||
375 | --- ticket_system/ticket/models.py 2014-11-06 04:56:47 +0000 | |||
376 | +++ ticket_system/ticket/models.py 2014-11-06 19:15:32 +0000 | |||
377 | @@ -114,6 +114,9 @@ | |||
378 | 114 | class Meta: | 114 | class Meta: |
379 | 115 | db_table = 'ticket' | 115 | db_table = 'ticket' |
380 | 116 | ordering = ['id'] | 116 | ordering = ['id'] |
381 | 117 | permissions = ( | ||
382 | 118 | ('review_ticket', 'Can review tickets'), | ||
383 | 119 | ) | ||
384 | 117 | 120 | ||
385 | 118 | SERIES_CHOICES = tuple( | 121 | SERIES_CHOICES = tuple( |
386 | 119 | (series, series.upper()) for series in SUPPORTED_SERIES) | 122 | (series, series.upper()) for series in SUPPORTED_SERIES) |
387 | 120 | 123 | ||
388 | === modified file 'ticket_system/ticket/tests/__init__.py' | |||
389 | --- ticket_system/ticket/tests/__init__.py 2014-10-14 15:10:23 +0000 | |||
390 | +++ ticket_system/ticket/tests/__init__.py 2014-11-06 19:15:32 +0000 | |||
391 | @@ -13,6 +13,7 @@ | |||
392 | 13 | # You should have received a copy of the GNU Affero General Public License | 13 | # You should have received a copy of the GNU Affero General Public License |
393 | 14 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | 14 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
394 | 15 | 15 | ||
395 | 16 | from test_authorization import * | ||
396 | 16 | from test_commands import * | 17 | from test_commands import * |
397 | 17 | from test_full_read_api import * | 18 | from test_full_read_api import * |
398 | 18 | from test_models import * | 19 | from test_models import * |
399 | 19 | 20 | ||
400 | === added file 'ticket_system/ticket/tests/test_authorization.py' | |||
401 | --- ticket_system/ticket/tests/test_authorization.py 1970-01-01 00:00:00 +0000 | |||
402 | +++ ticket_system/ticket/tests/test_authorization.py 2014-11-06 19:15:32 +0000 | |||
403 | @@ -0,0 +1,138 @@ | |||
404 | 1 | # Ubuntu Continuous Integration Engine | ||
405 | 2 | # Copyright 2014 Canonical Ltd. | ||
406 | 3 | |||
407 | 4 | # This program is free software: you can redistribute it and/or modify it | ||
408 | 5 | # under the terms of the GNU Affero General Public License version 3, as | ||
409 | 6 | # published by the Free Software Foundation. | ||
410 | 7 | |||
411 | 8 | # This program is distributed in the hope that it will be useful, but | ||
412 | 9 | # WITHOUT ANY WARRANTY; without even the implied warranties of | ||
413 | 10 | # MERCHANTABILITY, SATISFACTORY QUALITY, or FITNESS FOR A PARTICULAR | ||
414 | 11 | # PURPOSE. See the GNU Affero General Public License for more details. | ||
415 | 12 | |||
416 | 13 | # You should have received a copy of the GNU Affero General Public License | ||
417 | 14 | # along with this program. If not, see <http://www.gnu.org/licenses/>. | ||
418 | 15 | |||
419 | 16 | import json | ||
420 | 17 | import os | ||
421 | 18 | import tempfile | ||
422 | 19 | |||
423 | 20 | from django.contrib.contenttypes.models import ContentType | ||
424 | 21 | from django.contrib.auth.models import Permission | ||
425 | 22 | from ci_utils.tastypie.test import TicketTastypieTestCase | ||
426 | 23 | |||
427 | 24 | from ticket.models import Ticket | ||
428 | 25 | from ticket_system import settings | ||
429 | 26 | |||
430 | 27 | |||
431 | 28 | class TestUserOrInternalAuthorization(TicketTastypieTestCase): | ||
432 | 29 | |||
433 | 30 | def _setup_internal_ips(self): | ||
434 | 31 | self.tmpfile = tempfile.NamedTemporaryFile() | ||
435 | 32 | json.dump(['127.0.0.1'], self.tmpfile) | ||
436 | 33 | self.tmpfile.flush() | ||
437 | 34 | |||
438 | 35 | def get_credentials(self): | ||
439 | 36 | return "" | ||
440 | 37 | |||
441 | 38 | def _get_content_type(self): | ||
442 | 39 | return ContentType.objects.get( | ||
443 | 40 | app_label='ticket', model='ticket', | ||
444 | 41 | ) | ||
445 | 42 | |||
446 | 43 | def _get_review_permission(self): | ||
447 | 44 | return Permission.objects.get(codename='review_ticket', | ||
448 | 45 | content_type=self._get_content_type()) | ||
449 | 46 | |||
450 | 47 | def setUp(self): | ||
451 | 48 | super(TestUserOrInternalAuthorization, self).setUp('/api/v1/') | ||
452 | 49 | |||
453 | 50 | self.ticket = Ticket.objects.create(title="blah", description="blah") | ||
454 | 51 | self.assert_(self.ticket) | ||
455 | 52 | |||
456 | 53 | self.detail_url = "/api/v1/ticket/{0}/".format(self.ticket.uuid) | ||
457 | 54 | self.post_data = { | ||
458 | 55 | 'id': '{}'.format(self.ticket.pk), | ||
459 | 56 | } | ||
460 | 57 | |||
461 | 58 | def test_ticket_detail_read_authenticated(self): | ||
462 | 59 | self.api_client.client.login(username=self.username, | ||
463 | 60 | password=self.password) | ||
464 | 61 | response = self.client.get(self.detail_url, format='json', | ||
465 | 62 | authentication=self.get_credentials()) | ||
466 | 63 | self.assertEqual(200, response.status_code) | ||
467 | 64 | |||
468 | 65 | def test_ticket_detail_read_internal_ip(self): | ||
469 | 66 | self._setup_internal_ips() | ||
470 | 67 | os.environ['TS_INTERNAL_IPS_PATH'] = self.tmpfile.name | ||
471 | 68 | reload(settings) | ||
472 | 69 | response = self.client.get(self.detail_url, format='json') | ||
473 | 70 | if self.tmpfile: | ||
474 | 71 | self.tmpfile.close() | ||
475 | 72 | self.assertEqual(200, response.status_code) | ||
476 | 73 | |||
477 | 74 | def test_ticket_detail_read_unauthenticated(self): | ||
478 | 75 | """ Test that unauthenticated read requests are allowed. """ | ||
479 | 76 | response = self.client.get(self.detail_url, format='json') | ||
480 | 77 | self.assertEqual(200, response.status_code) | ||
481 | 78 | |||
482 | 79 | def test_ticket_read_authenticated(self): | ||
483 | 80 | self.api_client.client.login(username=self.username, | ||
484 | 81 | password=self.password) | ||
485 | 82 | response = self.client.get("/api/v1/ticket/", | ||
486 | 83 | authentication=self.get_credentials()) | ||
487 | 84 | self.assertEqual(200, response.status_code) | ||
488 | 85 | |||
489 | 86 | def test_ticket_read_unauthenticated(self): | ||
490 | 87 | response = self.client.get("/api/v1/ticket/") | ||
491 | 88 | self.assertEqual(200, response.status_code) | ||
492 | 89 | |||
493 | 90 | def skipped_test_ticket_write_unauthenticated(self): | ||
494 | 91 | self.assertHttpUnauthorized(self.api_client.post('/api/v1/ticket/', | ||
495 | 92 | format='json', | ||
496 | 93 | data=self.post_data)) | ||
497 | 94 | |||
498 | 95 | def test_ticket_write_authenticated(self): | ||
499 | 96 | data = {} | ||
500 | 97 | |||
501 | 98 | self.api_client.client.login( | ||
502 | 99 | username=self.username, password=self.password) | ||
503 | 100 | response = self.api_client.post('/api/v1/ticket/', format='json', | ||
504 | 101 | data=data, | ||
505 | 102 | authentication=self.get_credentials()) | ||
506 | 103 | self.assertEqual(201, response.status_code) | ||
507 | 104 | |||
508 | 105 | # Make sure a ticket was added | ||
509 | 106 | response = self.api_client.get("/api/v1/ticket/", | ||
510 | 107 | authentication=self.get_credentials()) | ||
511 | 108 | self.assertEqual(200, response.status_code) | ||
512 | 109 | |||
513 | 110 | self.assertEqual(2, len(json.loads(response.content)['objects'])) | ||
514 | 111 | |||
515 | 112 | def test_ticket_review_authenticated(self): | ||
516 | 113 | data = {'review_type': 'publishing', 'status': 'Doing stuff'} | ||
517 | 114 | |||
518 | 115 | self.ticket.review_set.create(workflow_step=0) | ||
519 | 116 | |||
520 | 117 | self.api_client.client.login( | ||
521 | 118 | username=self.username, password=self.password) | ||
522 | 119 | url = "/api/v1/review/{}/".format(self.ticket.review_set.all()[0].id) | ||
523 | 120 | response = self.api_client.patch(url, format='json', | ||
524 | 121 | data=data, | ||
525 | 122 | authentication=self.get_credentials()) | ||
526 | 123 | self.assertEqual(202, response.status_code) | ||
527 | 124 | |||
528 | 125 | def test_ticket_review_no_perm_authenticated(self): | ||
529 | 126 | data = {'review_type': 'publishing', 'status': 'Doing stuff'} | ||
530 | 127 | |||
531 | 128 | self.user.user_permissions.remove(self._get_review_permission()) | ||
532 | 129 | |||
533 | 130 | self.ticket.review_set.create(workflow_step=0) | ||
534 | 131 | |||
535 | 132 | self.api_client.client.login( | ||
536 | 133 | username=self.username, password=self.password) | ||
537 | 134 | url = "/api/v1/review/{}/".format(self.ticket.review_set.all()[0].id) | ||
538 | 135 | response = self.api_client.patch(url, format='json', | ||
539 | 136 | data=data, | ||
540 | 137 | authentication=self.get_credentials()) | ||
541 | 138 | self.assertEqual(401, response.status_code) | ||
542 | 0 | 139 | ||
543 | === modified file 'ticket_system/ticket/tests/test_write_api.py' | |||
544 | --- ticket_system/ticket/tests/test_write_api.py 2014-11-06 00:05:55 +0000 | |||
545 | +++ ticket_system/ticket/tests/test_write_api.py 2014-11-06 19:15:32 +0000 | |||
546 | @@ -329,8 +329,7 @@ | |||
547 | 329 | def test_patch_detail(self): | 329 | def test_patch_detail(self): |
548 | 330 | # `Ticket` object can be patched via the API. | 330 | # `Ticket` object can be patched via the API. |
549 | 331 | new_data = {'owner': 'ci@example.com'} | 331 | new_data = {'owner': 'ci@example.com'} |
552 | 332 | resp = self.client.patch('/api/v1/' + self.detail_url, data=new_data) | 332 | self.patch('/api/v1/' + self.detail_url, new_data) |
551 | 333 | self.assertHttpAccepted(resp) | ||
553 | 334 | self.assertEqual( | 333 | self.assertEqual( |
554 | 335 | new_data['owner'], Ticket.objects.get(pk=self.ticket.pk).owner) | 334 | new_data['owner'], Ticket.objects.get(pk=self.ticket.pk).owner) |
555 | 336 | self.assertEqual(Ticket.objects.count(), 1) | 335 | self.assertEqual(Ticket.objects.count(), 1) |
556 | 337 | 336 | ||
557 | === modified file 'ticket_system/ticket_system/settings.py' | |||
558 | --- ticket_system/ticket_system/settings.py 2014-10-07 10:04:01 +0000 | |||
559 | +++ ticket_system/ticket_system/settings.py 2014-11-06 19:15:32 +0000 | |||
560 | @@ -249,6 +249,13 @@ | |||
561 | 249 | with open(allowed) as f: | 249 | with open(allowed) as f: |
562 | 250 | ALLOWED_HOSTS = json.load(f) | 250 | ALLOWED_HOSTS = json.load(f) |
563 | 251 | 251 | ||
564 | 252 | internal_hosts = [] | ||
565 | 253 | internal = os.environ.get('TS_INTERNAL_IPS_PATH') | ||
566 | 254 | if internal and os.path.exists(internal): | ||
567 | 255 | with open(internal) as f: | ||
568 | 256 | json_str = f.read() | ||
569 | 257 | internal_hosts = json.loads(json_str) | ||
570 | 258 | |||
571 | 252 | logging = os.path.join(BASEDIR, '../../../logging.json') | 259 | logging = os.path.join(BASEDIR, '../../../logging.json') |
572 | 253 | if os.path.exists(logging): | 260 | if os.path.exists(logging): |
573 | 254 | data = {} | 261 | data = {} |
minor comment, otherwise looks like a good start to me