Merge ~blake-rouse/maas:rbac-thread-local into maas:master

Proposed by Blake Rouse
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)
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.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :
Revision history for this message
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: aff3337df3e14f1b482a0147284516958189f06f

review: Approve
Revision history for this message
Newell Jensen (newell-jensen) wrote :

LGTM.

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Revision history for this message
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: 9d695f5b6d2af876b16f456934ea02bb16682805

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Revision history for this message
MAAS Lander (maas-lander) wrote :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/src/maasserver/djangosettings/settings.py b/src/maasserver/djangosettings/settings.py
2index 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.
15diff --git a/src/maasserver/middleware.py b/src/maasserver/middleware.py
16index 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
51diff --git a/src/maasserver/models/node.py b/src/maasserver/models/node.py
52index 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)
72diff --git a/src/maasserver/models/tests/test_node.py b/src/maasserver/models/tests/test_node.py
73index 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."""
109diff --git a/src/maasserver/rbac.py b/src/maasserver/rbac.py
110index 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()
208diff --git a/src/maasserver/testing/fixtures.py b/src/maasserver/testing/fixtures.py
209index 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)
231diff --git a/src/maasserver/testing/testcase.py b/src/maasserver/testing/testcase.py
232index 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
253diff --git a/src/maasserver/tests/test_middleware.py b/src/maasserver/tests/test_middleware.py
254index 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())
291diff --git a/src/maasserver/tests/test_rbac.py b/src/maasserver/tests/test_rbac.py
292index 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)

Subscribers

People subscribed via source and target branches