Skip to content

Commit ad62156

Browse files
authored
Merge pull request #882 from cderici/revisit-access-control-levels
#882 #### Description This is something that we [stumbled upon](#874 (comment)) in one of the integration tests, which initially led me to believe there might be a regression in `ModelManager` facade. However, I realized that the libjuju only deals with the controller level access and doesn't know anything about model or offer access levels. There are two main contributions here: 1) Changes the way the controller object gets the `model_uuid`s (i.e. `cmd list-models` on juju). The old way was to call the `ControllerFacade.AllModels()` for admin users and `ModelManager.ListModels()` for anyone else. This changes it by calling `ModelManager.ListModelSummaries` for everyone just like [how `cmd` does it](https://github.com/juju/juju/blob/576c487266443c1b3d788bd1c85c4a45d0f91db0/cmd/juju/controller/listmodels.go#L126). 2) Adds new module `access.py` that has all the access levels for models, controllers and offers that can be used with `user.grant()` and `user.revoke()`. 3) Improves the existing `user.grant()` and `user.revoke()` which together with (2) should give a more fine grained control over permissions for pylibjuju users to test their stuff in setups involving multiple models, relations and users. #### QA Steps This revisits the existing integration tests and adds a new one too, so there are two main ways to QA this, manual, and automatic. The automatic way is to run the integration tests: ``` tox -e integration -- tests/integration/test_controller.py::test_grant_revoke_controller_access ``` ``` tox -e integration -- tests/integration/test_controller.py::test_grant_revoke_model_access ``` All CI tests need to pass. The manual way is to fire up a pylibjuju console and : ```python $ python -m asyncio asyncio REPL 3.10.6 (main, May 29 2023, 11:10:38) [GCC 11.3.0] on linux Use "await" directly instead of "asyncio.run()". Type "help", "copyright", "credits" or "license" for more information. >>> import asyncio >>> from juju import controller;c = controller.Controller(); await c.connect() >>> user = await c.add_user('foouser') >>> await c.list_models(username=user.username) [] >>> await user.grant('read', model_name='controller') True >>> await c.list_models(username=user.username) ['controller'] >>> await user.revoke('read', model_name='controller') True >>> await c.list_models(username=user.username) [] >>> ``` It is advisable to QA also the offer stuff as well.
2 parents 9d6dea6 + c73b62d commit ad62156

5 files changed

Lines changed: 237 additions & 75 deletions

File tree

juju/access.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
2+
from .errors import JujuNotValid
3+
4+
# No permissions at all
5+
NO_ACCESS = ""
6+
7+
# Model permissions
8+
9+
READ_ACCESS = "read"
10+
WRITE_ACCESS = "write"
11+
CONSUME_ACCESS = "consume"
12+
ADMIN_ACCESS = "admin"
13+
MODEL_ACCESS_LEVELS = {READ_ACCESS, WRITE_ACCESS, CONSUME_ACCESS, ADMIN_ACCESS}
14+
15+
# Controller permissions
16+
17+
LOGIN_ACCESS = "login"
18+
ADD_MODEL_ACCESS = "add-model"
19+
SUPERUSER_ACCESS = "superuser"
20+
CONTROLLER_ACCESS_LEVELS = {LOGIN_ACCESS, ADD_MODEL_ACCESS, SUPERUSER_ACCESS}
21+
22+
OFFER_ACCESS_LEVELS = {READ_ACCESS, CONSUME_ACCESS, ADMIN_ACCESS}
23+
24+
ALL_ACCESS_LEVELS = MODEL_ACCESS_LEVELS.union(CONTROLLER_ACCESS_LEVELS)
25+
26+
27+
def validate_access_level(access):
28+
if access not in ALL_ACCESS_LEVELS:
29+
raise JujuNotValid("access level", access)
30+
31+
32+
def validate_offer_access(access):
33+
validate_access_level()
34+
if access not in OFFER_ACCESS_LEVELS:
35+
raise JujuNotValid("offer access level", access)
36+
37+
38+
def validate_model_access(access):
39+
validate_access_level(access)
40+
if access not in MODEL_ACCESS_LEVELS:
41+
raise JujuNotValid("model access level", access)
42+
43+
44+
def validate_controller_access(access):
45+
validate_access_level(access)
46+
if access not in CONTROLLER_ACCESS_LEVELS:
47+
raise JujuNotValid("controller access level", access)

juju/controller.py

Lines changed: 8 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -563,31 +563,14 @@ async def model_uuids(self, username=None, all=False):
563563
564564
:returns: {str name : str UUID}
565565
"""
566-
567-
if all:
568-
facade = client.ControllerFacade.from_connection(
569-
self.connection())
570-
else:
571-
facade = client.ModelManagerFacade.from_connection(
572-
self.connection())
573-
u_name = username if username else self.get_current_username()
574-
user = tag.user(u_name)
575-
576-
for attempt in (1, 2, 3):
577-
try:
578-
if all:
579-
userModelList = await facade.AllModels()
580-
else:
581-
userModelList = await facade.ListModels(tag=user)
582-
583-
return {um.model.name: um.model.uuid
584-
for um in userModelList.user_models}
585-
except errors.JujuAPIError as e:
586-
# retry concurrency error until resolved in Juju
587-
# see: https://bugs.launchpad.net/juju/+bug/1721786
588-
if 'has been removed' not in e.message or attempt == 3:
589-
raise
590-
await jasyncio.sleep(attempt)
566+
model_manager_facade = client.ModelManagerFacade.from_connection(self.connection())
567+
u_name = username if username else self.get_current_username()
568+
user = tag.user(u_name)
569+
570+
user_model_list = await model_manager_facade.ListModelSummaries(user_tag=user, all_=all)
571+
model_summaries = [msr.result for msr in user_model_list.results]
572+
return {model_summary.name: model_summary.uuid
573+
for model_summary in model_summaries}
591574

592575
async def list_models(self, username=None, all=False):
593576
"""Return list of names of the available models on this controller.

juju/errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ class JujuBackupError(JujuError):
8787
pass
8888

8989

90+
class JujuNotValid(JujuError):
91+
def __init__(self, entity_type, entity_name):
92+
self.entity_type = entity_type
93+
self.entity_name = entity_name
94+
super().__init__(f'Invalid {entity_type} : {entity_name}')
95+
96+
9097
class JujuConfigError(JujuError):
9198
"""Exception raised during processing a configuration key-value pair
9299
in a config set for an application.

juju/user.py

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import pyrfc3339
44

5-
from . import tag
5+
from . import tag, errors
6+
from .client import client
67

78
log = logging.getLogger(__name__)
89

@@ -31,6 +32,7 @@ def last_connection(self):
3132

3233
@property
3334
def access(self):
35+
"""Identifies the controller access levels of this user"""
3436
return self._user_info.access
3537

3638
@property
@@ -59,19 +61,108 @@ async def set_password(self, password):
5961
await self.controller.change_user_password(self.username, password)
6062
self._user_info.password = password
6163

62-
async def grant(self, acl='login'):
63-
"""Set access level of this user on the controller.
64+
async def modify_model_access(self, acl, action, model_name):
65+
"""Grants or revokes the given access level for this user for a given model
6466
65-
:param str acl: Access control ('login', 'add-model', or 'superuser')
67+
:param str acl: Model access levels (see access module)
68+
:param str action: grant/revoke
69+
:param str model_name: Name of the model
70+
71+
:return bool: True if access changed, Error if user already has it
72+
"""
73+
modelmanager_facade = client.ModelManagerFacade.from_connection(
74+
self.controller.connection())
75+
models = await self.controller.model_uuids()
76+
if model_name not in models:
77+
raise errors.JujuError(f'Unable to find model : {model_name}')
78+
changes = client.ModifyModelAccess(acl, action, tag.model(models[model_name]), self.tag)
79+
await modelmanager_facade.ModifyModelAccess(changes=[changes])
80+
return True
81+
82+
async def modify_controller_access(self, acl, action):
83+
"""Grants or revokes the given access level for this user on the current controller
84+
85+
:param str acl: Controller access levels (see access module)
86+
:param str action: grant/revoke
87+
88+
:return bool: True if access changed, Error if user already has it
89+
"""
90+
controller_facade = client.ControllerFacade.from_connection(self.controller.connection())
91+
changes = client.ModifyControllerAccess(acl, action, self.tag)
92+
await controller_facade.ModifyControllerAccess(changes=[changes])
93+
94+
new_access = acl
95+
if action == 'revoke':
96+
new_access = ''
97+
self._user_info.access = new_access
98+
return True
99+
100+
async def modify_offer_access(self, acl, action, offer_url):
101+
"""Grants or revokes the given access level for this user on a given offer
102+
103+
:param str acl: Controller access levels (see access module)
104+
:param str action: grant/revoke
105+
:param str offer_url: url for the offer
106+
107+
:return bool: True if access changed, Error if user already has it
66108
"""
67-
if await self.controller.grant(self.username, acl):
68-
self._user_info.access = acl
109+
application_offers_facade = client.ApplicationOffersFacade.from_connection(
110+
self.controller.connection())
111+
changes = client.ModifyOfferAccess(acl, action, offer_url, self.tag)
112+
await application_offers_facade.ModifyOfferAccess(changes=[changes])
113+
return True
114+
115+
async def grant_or_revoke(self, acl, action, **kwargs):
116+
"""Grants or revokes the given access level of this user on model, offer or controller,
117+
depending on the access level (see the access module)
118+
119+
:param str acl: Access control level
120+
:param str action: 'grant' or 'revoke'
121+
122+
Depending on the access level, the available keyword parameters are:
123+
:param str model_name: name of the model if acl is one of model access levels
124+
:param str offer_url: url for the offer if acl is one of offer access levels
125+
126+
:return: True if access changed, False if user already has it
127+
"""
128+
try:
129+
if 'model_name' in kwargs:
130+
return await self.modify_model_access(acl, action, kwargs['model_name'])
131+
elif 'offer_url' in kwargs:
132+
return await self.modify_offer_access(acl, action, kwargs['offer_url'])
133+
else:
134+
return await self.modify_controller_access(acl, action)
135+
except errors.JujuError as e:
136+
if 'user already has' in str(e):
137+
return False
138+
else:
139+
raise
140+
141+
async def grant(self, acl, **kwargs):
142+
"""Grant the given access level of this user on model, offer or controller, depending on
143+
the access level (see the access module)
144+
145+
:param str acl: Access control level
146+
147+
Depending on the access level, the available keyword parameters are:
148+
:param str model_name: name of the model if acl is one of model access levels
149+
:param str offer_url: url for the offer if acl is one of offer access levels
150+
151+
:return: None or Error
152+
"""
153+
return await self.grant_or_revoke(acl, 'grant', **kwargs)
154+
155+
async def revoke(self, acl='login', **kwargs):
156+
"""The opposite of user.grant(). Revokes the given access level of this user on model,
157+
offer or controller, depending on the given access level.
158+
159+
:param str acl: Access control level (see access module)
69160
70-
async def revoke(self, acl='login'):
71-
"""Removes all access rights for this user from the controller.
161+
Available keyword parameters are:
162+
:param str model_name: name of the model if acl is one of model access levels
163+
:param str offer_url: url for the offer if acl is one of offer access levels
72164
"""
73-
await self.controller.revoke(self.username, acl)
74-
self._user_info.access = ''
165+
return await self.grant_or_revoke(acl, 'revoke', **kwargs)
75166

76167
async def disable(self):
77168
"""Disable this user.

tests/integration/test_controller.py

Lines changed: 74 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import uuid
33
import hvac
44

5+
from juju import access
56
from juju.client.connection import Connection
67
from juju.client import client
7-
from juju.errors import JujuAPIError
8+
from juju.errors import JujuAPIError, JujuError
89

910
import pytest
1011

@@ -105,26 +106,6 @@ async def test_reset_user_password(event_loop):
105106
raise AssertionError()
106107

107108

108-
@base.bootstrapped
109-
@pytest.mark.asyncio
110-
async def test_grant_revoke(event_loop):
111-
async with base.CleanController() as controller:
112-
username = 'test-grant{}'.format(uuid.uuid4())
113-
user = await controller.add_user(username)
114-
await user.grant('superuser')
115-
assert user.access == 'superuser'
116-
fresh = await controller.get_user(username) # fetch fresh copy
117-
assert fresh.access == 'superuser'
118-
await user.grant('login') # already has 'superuser', so no-op
119-
assert user.access == 'superuser'
120-
fresh = await controller.get_user(username) # fetch fresh copy
121-
assert fresh.access == 'superuser'
122-
await user.revoke()
123-
assert user.access == ''
124-
fresh = await controller.get_user(username) # fetch fresh copy
125-
assert fresh.access == ''
126-
127-
128109
@base.bootstrapped
129110
@pytest.mark.asyncio
130111
async def test_list_models(event_loop):
@@ -134,25 +115,6 @@ async def test_list_models(event_loop):
134115
assert model.name in result
135116

136117

137-
@base.bootstrapped
138-
@pytest.mark.asyncio
139-
async def test_list_models_user_access(event_loop):
140-
async with base.CleanController() as controller:
141-
username = 'test-grant{}'.format(uuid.uuid4())
142-
user = await controller.add_user(username)
143-
await user.grant(acl='superuser')
144-
assert user.access == 'superuser'
145-
models1 = await controller.list_models(username)
146-
await user.revoke(acl='superuser')
147-
models2 = await controller.list_models(username)
148-
assert len(models1) > len(models2)
149-
150-
# testing all flag
151-
await user.grant(acl='superuser')
152-
models_all = await controller.list_models(username, all=True)
153-
assert len(models_all) > len(models2)
154-
155-
156118
@base.bootstrapped
157119
@pytest.mark.asyncio
158120
async def test_get_model(event_loop):
@@ -314,3 +276,75 @@ async def test_secrets_backend_lifecycle(event_loop):
314276
list_after = await controller.list_secret_backends()
315277
assert len(list_after["results"]) == 1
316278
assert list_after["results"][0]["result"].name == "internal"
279+
280+
281+
@base.bootstrapped
282+
@pytest.mark.asyncio
283+
async def test_grant_revoke_controller_access(event_loop):
284+
async with base.CleanController() as controller:
285+
username = 'test-grant{}'.format(uuid.uuid4())
286+
user = await controller.add_user(username)
287+
await user.grant('superuser')
288+
assert user.access == 'superuser'
289+
fresh = await controller.get_user(username) # fetch fresh copy
290+
assert fresh.access == 'superuser'
291+
await user.grant('login') # already has 'superuser', so no-op
292+
assert user.access == 'superuser'
293+
fresh = await controller.get_user(username) # fetch fresh copy
294+
assert fresh.access == 'superuser'
295+
await user.revoke()
296+
assert user.access == ''
297+
fresh = await controller.get_user(username) # fetch fresh copy
298+
assert fresh.access == ''
299+
try:
300+
# try removing the created user
301+
await controller.remove_user(username)
302+
except JujuError as e:
303+
if 'state changing too quickly' in str(e):
304+
pass
305+
else:
306+
raise
307+
308+
309+
@base.bootstrapped
310+
@pytest.mark.asyncio
311+
async def test_grant_revoke_model_access(event_loop):
312+
async with base.CleanController() as controller:
313+
username = 'test-grant{}'.format(uuid.uuid4())
314+
user = await controller.add_user(username)
315+
316+
model_name = 'test-{}'.format(uuid.uuid4())
317+
model = await controller.add_model(model_name)
318+
319+
with pytest.raises(JujuError):
320+
# superuser is a controller access level, i.e. not a valid model acl
321+
await user.grant('superuser', model_name=model_name)
322+
323+
models1 = await controller.list_models(username)
324+
assert models1 == []
325+
326+
# grant user the access to see the model
327+
await user.grant(access.READ_ACCESS, model_name=model_name)
328+
models2 = await controller.list_models(username)
329+
330+
# assert that the user sees the model
331+
assert model_name in models2
332+
333+
# now let's revoke the read access
334+
await user.revoke(access.READ_ACCESS, model_name=model_name)
335+
models3 = await controller.list_models(username)
336+
337+
# user shouldn't be able to see the model
338+
assert models3 == []
339+
340+
# cleanup
341+
await model.disconnect()
342+
await controller.destroy_model(model_name)
343+
try:
344+
# try removing the created user
345+
await controller.remove_user(username)
346+
except JujuError as e:
347+
if 'state changing too quickly' in str(e):
348+
pass
349+
else:
350+
raise

0 commit comments

Comments
 (0)