Merge ~blake-rouse/maas:rbac-thread-local into maas:master
- Git
- lp:~blake-rouse/maas
- rbac-thread-local
- Merge into master
Status: | Merged |
---|---|
Approved by: | Blake Rouse |
Approved revision: | 9d695f5b6d2af876b16f456934ea02bb16682805 |
Merge reported by: | MAAS Lander |
Merged at revision: | not available |
Proposed branch: | ~blake-rouse/maas:rbac-thread-local |
Merge into: | maas:master |
Prerequisite: | ~blake-rouse/maas:fix-buildout-versions |
Diff against target: |
446 lines (+206/-24) 9 files modified
src/maasserver/djangosettings/settings.py (+3/-0) src/maasserver/middleware.py (+21/-0) src/maasserver/models/node.py (+1/-2) src/maasserver/models/tests/test_node.py (+7/-4) src/maasserver/rbac.py (+53/-9) src/maasserver/testing/fixtures.py (+8/-0) src/maasserver/testing/testcase.py (+4/-0) src/maasserver/tests/test_middleware.py (+20/-0) src/maasserver/tests/test_rbac.py (+89/-9) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
MAAS Lander | Approve | ||
Newell Jensen (community) | Approve | ||
Review via email: mp+357783@code.launchpad.net |
Commit message
Use a thread-local to keep an RBACClient per request being handler per thread.
This simplifies the API for using RBAC in MAAS as well as its simple as asking rbac.is_enabled() and that will handle all the thread-local transparently.
Description of the change
Blake Rouse (blake-rouse) wrote : | # |
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b rbac-thread-local lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: SUCCESS
COMMIT: aff3337df3e14f1
MAAS Lander (maas-lander) wrote : | # |
LANDING
-b rbac-thread-local lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: FAILED MERGE
LOG: http://
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b rbac-thread-local lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: SUCCESS
COMMIT: 9d695f5b6d2af87
MAAS Lander (maas-lander) wrote : | # |
LANDING
-b rbac-thread-local lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: FAILED MERGE
LOG: http://
MAAS Lander (maas-lander) wrote : | # |
LANDING
-b rbac-thread-local lp:~blake-rouse/maas/+git/maas into -b master lp:~maas-committers/maas
STATUS: FAILED MERGE
LOG: http://
Preview Diff
1 | diff --git a/src/maasserver/djangosettings/settings.py b/src/maasserver/djangosettings/settings.py |
2 | index d49fe96..1587e74 100644 |
3 | --- a/src/maasserver/djangosettings/settings.py |
4 | +++ b/src/maasserver/djangosettings/settings.py |
5 | @@ -237,6 +237,9 @@ MIDDLEWARE = ( |
6 | # Used for rendering and logging exceptions. |
7 | 'maasserver.middleware.ExceptionMiddleware', |
8 | |
9 | + # Used to clear the RBAC thread-local cache. |
10 | + 'maasserver.middleware.RBACMiddleware', |
11 | + |
12 | # Handle errors that should really be handled in application code: |
13 | # NoConnectionsAvailable, PowerActionAlreadyInProgress, TimeoutError. |
14 | # FIXME. |
15 | diff --git a/src/maasserver/middleware.py b/src/maasserver/middleware.py |
16 | index 1de6072..22a6012 100644 |
17 | --- a/src/maasserver/middleware.py |
18 | +++ b/src/maasserver/middleware.py |
19 | @@ -48,6 +48,7 @@ from maasserver.enum import COMPONENT |
20 | from maasserver.exceptions import MAASAPIException |
21 | from maasserver.models.config import Config |
22 | from maasserver.models.node import RackController |
23 | +from maasserver.rbac import rbac |
24 | from maasserver.rpc import getAllClients |
25 | from maasserver.utils.django_urls import reverse |
26 | from maasserver.utils.orm import is_retryable_failure |
27 | @@ -491,3 +492,23 @@ class ExternalAuthInfoMiddleware: |
28 | domain=auth_domain, admin_group=auth_admin_group) |
29 | request.external_auth_info = auth_info |
30 | return self.get_response(request) |
31 | + |
32 | + |
33 | +class RBACMiddleware: |
34 | + """Middleware that cleans the RBAC thread-local cache. |
35 | + |
36 | + |
37 | + At the end of each request the RBAC client that is held in the thread-local |
38 | + needs to be cleaned up. That way the next request on the same thread will |
39 | + use a new RBAC client. |
40 | + """ |
41 | + |
42 | + def __init__(self, get_response): |
43 | + self.get_response = get_response |
44 | + |
45 | + def __call__(self, request): |
46 | + result = self.get_response(request) |
47 | + # Now that the response has been handled, clear the thread-local |
48 | + # state of the RBAC connection. |
49 | + rbac.clear() |
50 | + return result |
51 | diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py |
52 | index 05da4cf..8c57169 100644 |
53 | --- a/src/maasserver/models/node.py |
54 | +++ b/src/maasserver/models/node.py |
55 | @@ -456,7 +456,7 @@ class BaseNodeManager(Manager, NodeQueriesMixin): |
56 | nodes that `user` is allowed to access. |
57 | """ |
58 | # Local import to avoid circular imports. |
59 | - from maasserver.rbac import RBAC |
60 | + from maasserver.rbac import rbac |
61 | # If the data is corrupt, this can get called with None for |
62 | # user where a Node should have an owner but doesn't. |
63 | # Nonetheless, the code should not crash with corrupt data. |
64 | @@ -484,7 +484,6 @@ class BaseNodeManager(Manager, NodeQueriesMixin): |
65 | raise NotImplementedError( |
66 | "Invalid permission check (invalid permission name: %s)." % |
67 | perm) |
68 | - rbac = RBAC() |
69 | if rbac.is_enabled(): |
70 | visible_pools = rbac.get_resource_pools( |
71 | user.username, NODE_PERMISSION.VIEW) |
72 | diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py |
73 | index b41cd70..e7b418c 100644 |
74 | --- a/src/maasserver/models/tests/test_node.py |
75 | +++ b/src/maasserver/models/tests/test_node.py |
76 | @@ -39,7 +39,6 @@ from fixtures import LoggerFixture |
77 | from maasserver import ( |
78 | bootresources, |
79 | preseed as preseed_module, |
80 | - rbac, |
81 | server_address, |
82 | ) |
83 | from maasserver.clusterrpc import boot_images |
84 | @@ -125,6 +124,10 @@ from maasserver.node_status import ( |
85 | NODE_TRANSITIONS, |
86 | ) |
87 | from maasserver.preseed import CURTIN_INSTALL_LOG |
88 | +from maasserver.rbac import ( |
89 | + FakeRBACClient, |
90 | + rbac, |
91 | +) |
92 | from maasserver.rpc.testing.fixtures import MockLiveRegionToClusterRPCFixture |
93 | import maasserver.server_address as server_address_module |
94 | from maasserver.storage_layouts import ( |
95 | @@ -5304,10 +5307,10 @@ class NodeManagerGetNodesRBACTest(MAASServerTestCase): |
96 | def setUp(self): |
97 | super().setUp() |
98 | Config.objects.set_config('rbac_url', 'http://rbac.example.com') |
99 | - self.client = rbac.FakeRBACClient() |
100 | + self.client = FakeRBACClient() |
101 | + rbac._store.client = self.client |
102 | + rbac._store.cleared = False # Prevent re-creation of the client. |
103 | self.store = self.client.store |
104 | - self.mocked_rbacclient = self.patch(rbac, 'RBACClient') |
105 | - self.mocked_rbacclient.return_value = self.client |
106 | |
107 | def make_ResourcePool(self, *args, **kwargs): |
108 | """Create a resource pool and register it with RBAC.""" |
109 | diff --git a/src/maasserver/rbac.py b/src/maasserver/rbac.py |
110 | index 8274f81..21c54bf 100644 |
111 | --- a/src/maasserver/rbac.py |
112 | +++ b/src/maasserver/rbac.py |
113 | @@ -1,5 +1,6 @@ |
114 | from collections import defaultdict |
115 | from functools import partial |
116 | +import threading |
117 | from typing import ( |
118 | Sequence, |
119 | Union, |
120 | @@ -71,7 +72,7 @@ class RBACClient(MacaroonClient): |
121 | self, resource_type: str, |
122 | updates: Sequence[Resource]=None, |
123 | removals: Sequence[int]=None, |
124 | - last_sync_id: int=None): |
125 | + last_sync_id: str=None): |
126 | """Put all the resources for `resource_type`. |
127 | |
128 | This replaces all the resources for `resource_type`. |
129 | @@ -157,8 +158,11 @@ class FakeRBACClient(RBACClient): |
130 | server. |
131 | """ |
132 | |
133 | - def __init__(self): |
134 | - self._url = Config.objects.get_config('rbac_url') |
135 | + def __init__(self, url: str=None, auth_info: AuthInfo=None): |
136 | + if url is None: |
137 | + url = Config.objects.get_config('rbac_url') |
138 | + self._url = url |
139 | + self._auth_info = auth_info |
140 | self.store = FakeResourceStore() |
141 | |
142 | def _request(self, method, url): |
143 | @@ -182,14 +186,51 @@ NODE_PERMISSION_TO_RBAC = { |
144 | } |
145 | |
146 | |
147 | -class RBAC: |
148 | +class RBACWrapper: |
149 | """Object for querying RBAC information.""" |
150 | |
151 | - def __init__(self, client=None): |
152 | - url = Config.objects.get_config('rbac_url') |
153 | - if client is None and url: |
154 | - client = RBACClient(url) |
155 | - self.client = client |
156 | + def __init__(self, client_class=None): |
157 | + # A client is created per thread. |
158 | + self._store = threading.local() |
159 | + self._client_class = client_class |
160 | + if self._client_class is None: |
161 | + self._client_class = RBACClient |
162 | + |
163 | + @property |
164 | + def client(self): |
165 | + """Get thread-local client.""" |
166 | + # Get the current cleared status and reset it to False for the |
167 | + # next request on this thread. |
168 | + cleared = getattr(self._store, 'cleared', False) |
169 | + self._store.cleared = False |
170 | + |
171 | + client = getattr(self._store, 'client', None) |
172 | + if client is None: |
173 | + url = Config.objects.get_config('rbac_url') |
174 | + if url: |
175 | + client = self._client_class(url) |
176 | + self._store.client = client |
177 | + elif cleared: |
178 | + # Check that the `rbac_url` and the credentials match. |
179 | + url = Config.objects.get_config('rbac_url') |
180 | + if not url: |
181 | + # RBAC is now disabled. |
182 | + delattr(self._store, 'client') |
183 | + return None |
184 | + auth_info = get_auth_info() |
185 | + if (client._url != url or client._auth_info != auth_info): |
186 | + # URL or creds differ, re-create the client. |
187 | + client = self._client_class(url, auth_info) |
188 | + self._store.client = client |
189 | + return client |
190 | + |
191 | + def clear(self): |
192 | + """Clear the current client. |
193 | + |
194 | + This marks a client as cleared that way only a new client is created |
195 | + if the `rbac_url` is changed. |
196 | + """ |
197 | + self._store.cleared = True |
198 | |
199 | def is_enabled(self): |
200 | """Return whether MAAS has been configured to use RBAC.""" |
201 | @@ -209,3 +250,6 @@ class RBAC: |
202 | pool_ids = [int(identifier) for identifier in pool_identifiers] |
203 | pools = pools.filter(id__in=pool_ids) |
204 | return pools |
205 | + |
206 | + |
207 | +rbac = RBACWrapper() |
208 | diff --git a/src/maasserver/testing/fixtures.py b/src/maasserver/testing/fixtures.py |
209 | index 307b6d7..89cbcc1 100644 |
210 | --- a/src/maasserver/testing/fixtures.py |
211 | +++ b/src/maasserver/testing/fixtures.py |
212 | @@ -13,6 +13,7 @@ import logging |
213 | |
214 | import fixtures |
215 | from maasserver.models.config import Config |
216 | +from maasserver.rbac import rbac |
217 | from maasserver.testing.factory import factory |
218 | |
219 | |
220 | @@ -77,3 +78,10 @@ class LogSQL(fixtures.Fixture): |
221 | if self.include_stacktrace: |
222 | log.removeFilter(self._addedFilter) |
223 | self.removeHandler(self._setHandler) |
224 | + |
225 | + |
226 | +class RBACClearFixture(fixtures.Fixture): |
227 | + """Fixture that clears the RBAC thread-local cache between tests.""" |
228 | + |
229 | + def _setUp(self): |
230 | + self.addCleanup(rbac.clear) |
231 | diff --git a/src/maasserver/testing/testcase.py b/src/maasserver/testing/testcase.py |
232 | index 931d04e..bb6c802 100644 |
233 | --- a/src/maasserver/testing/testcase.py |
234 | +++ b/src/maasserver/testing/testcase.py |
235 | @@ -30,6 +30,7 @@ from maasserver.fields import register_mac_type |
236 | from maasserver.testing.fixtures import ( |
237 | IntroCompletedFixture, |
238 | PackageRepositoryFixture, |
239 | + RBACClearFixture, |
240 | ) |
241 | from maasserver.testing.orm import PostCommitHooksTestMixin |
242 | from maasserver.testing.resources import DjangoDatabasesManager |
243 | @@ -86,6 +87,9 @@ class MAASRegionTestCaseBase(PostCommitHooksTestMixin): |
244 | # Avoid circular imports. |
245 | from maasserver.models import signals |
246 | |
247 | + # Always clear the RBAC thread-local between tests. |
248 | + self.useFixture(RBACClearFixture()) |
249 | + |
250 | # XXX: allenap bug=1427628 2015-03-03: This should not be here. |
251 | self.useFixture(IntroCompletedFixture()) |
252 | |
253 | diff --git a/src/maasserver/tests/test_middleware.py b/src/maasserver/tests/test_middleware.py |
254 | index b01914a..04bd540 100644 |
255 | --- a/src/maasserver/tests/test_middleware.py |
256 | +++ b/src/maasserver/tests/test_middleware.py |
257 | @@ -41,9 +41,11 @@ from maasserver.middleware import ( |
258 | ExternalAuthInfoMiddleware, |
259 | ExternalComponentsMiddleware, |
260 | is_public_path, |
261 | + RBACMiddleware, |
262 | RPCErrorsMiddleware, |
263 | ) |
264 | from maasserver.models.config import Config |
265 | +from maasserver.rbac import rbac |
266 | from maasserver.testing import extract_redirect |
267 | from maasserver.testing.factory import factory |
268 | from maasserver.testing.testcase import MAASServerTestCase |
269 | @@ -688,3 +690,21 @@ class TestExternalAuthInfoMiddleware(MAASServerTestCase): |
270 | self.assertEqual(request.external_auth_info.type, 'candid') |
271 | self.assertEqual( |
272 | request.external_auth_info.url, 'https://example.com') |
273 | + |
274 | + |
275 | +class RBACMiddlewareTest(MAASServerTestCase): |
276 | + """Tests for the RBACMiddleware.""" |
277 | + |
278 | + def process_request(self, request): |
279 | + |
280 | + def get_response(request): |
281 | + return None |
282 | + |
283 | + middleware = RBACMiddleware(get_response) |
284 | + return middleware(request) |
285 | + |
286 | + def test_calls_rbac_clear(self): |
287 | + mock_clear = self.patch(rbac, 'clear') |
288 | + request = factory.make_fake_request(factory.make_string(), 'GET') |
289 | + self.process_request(request) |
290 | + self.assertThat(mock_clear, MockCalledOnceWith()) |
291 | diff --git a/src/maasserver/tests/test_rbac.py b/src/maasserver/tests/test_rbac.py |
292 | index 9867580..cd5ebf1 100644 |
293 | --- a/src/maasserver/tests/test_rbac.py |
294 | +++ b/src/maasserver/tests/test_rbac.py |
295 | @@ -1,5 +1,8 @@ |
296 | +from queue import Queue |
297 | +from threading import Thread |
298 | from unittest import mock |
299 | |
300 | +from django.db import transaction |
301 | from maasserver.enum import NODE_PERMISSION |
302 | from maasserver.models import ( |
303 | Config, |
304 | @@ -8,12 +11,16 @@ from maasserver.models import ( |
305 | from maasserver.rbac import ( |
306 | ALL_RESOURCES, |
307 | FakeRBACClient, |
308 | - RBAC, |
309 | + rbac, |
310 | RBACClient, |
311 | + RBACWrapper, |
312 | Resource, |
313 | ) |
314 | from maasserver.testing.factory import factory |
315 | -from maasserver.testing.testcase import MAASServerTestCase |
316 | +from maasserver.testing.testcase import ( |
317 | + MAASServerTestCase, |
318 | + MAASTransactionServerTestCase, |
319 | +) |
320 | from maastesting.matchers import MockCalledOnceWith |
321 | from macaroonbakery.bakery import PrivateKey |
322 | from macaroonbakery.httpbakery.agent import ( |
323 | @@ -186,7 +193,7 @@ class TestRBACClient(MAASServerTestCase): |
324 | auth=mock.ANY, cookies=mock.ANY, json=None)) |
325 | |
326 | |
327 | -class TestRBACIsEnabled(MAASServerTestCase): |
328 | +class TestRBACWrapperIsEnabled(MAASServerTestCase): |
329 | |
330 | def setUp(self): |
331 | super().setUp() |
332 | @@ -198,31 +205,31 @@ class TestRBACIsEnabled(MAASServerTestCase): |
333 | def test_local_disabled(self): |
334 | Config.objects.set_config('external_auth_url', '') |
335 | Config.objects.set_config('rbac_url', '') |
336 | - rbac = RBAC() |
337 | + rbac = RBACWrapper() |
338 | self.assertFalse(rbac.is_enabled()) |
339 | |
340 | def test_candid_disabled(self): |
341 | Config.objects.set_config( |
342 | 'external_auth_url', 'http://candid.example.com') |
343 | Config.objects.set_config('rbac_url', '') |
344 | - rbac = RBAC() |
345 | + rbac = RBACWrapper() |
346 | self.assertFalse(rbac.is_enabled()) |
347 | |
348 | def test_rbac_enabled(self): |
349 | Config.objects.set_config('external_auth_url', '') |
350 | Config.objects.set_config('rbac_url', 'http://rbac.example.com') |
351 | - rbac = RBAC() |
352 | + rbac = RBACWrapper() |
353 | self.assertTrue(rbac.is_enabled()) |
354 | |
355 | |
356 | -class TestRBACGetResourcePools(MAASServerTestCase): |
357 | +class TestRBACWrapperGetResourcePools(MAASServerTestCase): |
358 | |
359 | def setUp(self): |
360 | super().setUp() |
361 | Config.objects.set_config('rbac_url', 'http://rbac.example.com') |
362 | - self.client = FakeRBACClient() |
363 | + self.rbac = RBACWrapper(client_class=FakeRBACClient) |
364 | + self.client = self.rbac.client |
365 | self.store = self.client.store |
366 | - self.rbac = RBAC(client=self.client) |
367 | self.default_pool = ( |
368 | ResourcePool.objects.get_default_resource_pool()) |
369 | self.store.add_pool(self.default_pool) |
370 | @@ -267,3 +274,76 @@ class TestRBACGetResourcePools(MAASServerTestCase): |
371 | self.assertEqual( |
372 | sorted([pool1]), |
373 | sorted(self.rbac.get_resource_pools('user', NODE_PERMISSION.VIEW))) |
374 | + |
375 | + |
376 | +class TestRBACWrapperClient(MAASServerTestCase): |
377 | + |
378 | + def setUp(self): |
379 | + super().setUp() |
380 | + Config.objects.set_config('rbac_url', 'http://rbac.example.com') |
381 | + Config.objects.set_config( |
382 | + 'external_auth_url', 'http://candid.example.com') |
383 | + Config.objects.set_config('external_auth_user', 'user@candid') |
384 | + Config.objects.set_config( |
385 | + 'external_auth_key', |
386 | + 'x0NeASLPFhOFfq3Q9M0joMveI4HjGwEuJ9dtX/HTSRY=') |
387 | + |
388 | + def test_same_client(self): |
389 | + self.assertIs(rbac.client, rbac.client) |
390 | + |
391 | + def test_clear_same_url_same_client(self): |
392 | + rbac1 = rbac.client |
393 | + rbac.clear() |
394 | + self.assertIs(rbac1, rbac.client) |
395 | + |
396 | + def test_clear_new_url_creates_new_client(self): |
397 | + rbac1 = rbac.client |
398 | + rbac.clear() |
399 | + Config.objects.set_config('rbac_url', 'http://rbac-other.example.com') |
400 | + self.assertIsNot(rbac1, rbac.client) |
401 | + |
402 | + def test_clear_new_auth_url_creates_new_client(self): |
403 | + rbac1 = rbac.client |
404 | + rbac.clear() |
405 | + Config.objects.set_config( |
406 | + 'external_auth_url', 'http://candid-other.example.com') |
407 | + self.assertIsNot(rbac1, rbac.client) |
408 | + |
409 | + |
410 | +class TestRBACWrapperClientThreads(MAASTransactionServerTestCase): |
411 | + |
412 | + def test_different_clients_per_threads(self): |
413 | + |
414 | + # Commit the settings to the database so the created threads have |
415 | + # access to the same data. Each thread will start its own transaction |
416 | + # so the settings must be committed. |
417 | + # |
418 | + # Since actually data is committed into the database the |
419 | + # `MAASTransactionServerTestCase` is used to reset the database to |
420 | + # a clean state after this test. |
421 | + with transaction.atomic(): |
422 | + Config.objects.set_config('rbac_url', 'http://rbac.example.com') |
423 | + Config.objects.set_config( |
424 | + 'external_auth_url', 'http://candid.example.com') |
425 | + Config.objects.set_config('external_auth_user', 'user@candid') |
426 | + Config.objects.set_config( |
427 | + 'external_auth_key', |
428 | + 'x0NeASLPFhOFfq3Q9M0joMveI4HjGwEuJ9dtX/HTSRY=') |
429 | + |
430 | + queue = Queue() |
431 | + |
432 | + def target(): |
433 | + queue.put(rbac.client) |
434 | + |
435 | + thread1 = Thread(target=target) |
436 | + thread1.start() |
437 | + thread2 = Thread(target=target) |
438 | + thread2.start() |
439 | + |
440 | + rbac1 = queue.get() |
441 | + queue.task_done() |
442 | + rbac2 = queue.get() |
443 | + queue.task_done() |
444 | + thread1.join() |
445 | + thread2.join() |
446 | + self.assertIsNot(rbac1, rbac2) |
This builds off of - https:/ /code.launchpad .net/~ack/ maas/+git/ maas/+merge/ 357754