blob: 957ab85da834ff43e60e2993e614303ba784eca2 [file] [log] [blame]
Adam Israeldcdf82b2017-08-15 15:26:43 -04001import asyncio
2import logging
3
Adam Israelc3e6c2e2018-03-01 09:31:50 -05004from . import errors, tag, utils
5from .client import client, connector
Adam Israel1a15d1c2017-10-23 12:00:49 -04006from .user import User
Adam Israeldcdf82b2017-08-15 15:26:43 -04007
8log = logging.getLogger(__name__)
9
10
Adam Israelc3e6c2e2018-03-01 09:31:50 -050011class Controller:
12 def __init__(
13 self,
14 loop=None,
15 max_frame_size=None,
16 bakery_client=None,
17 jujudata=None,
18 ):
Adam Israeldcdf82b2017-08-15 15:26:43 -040019 """Instantiate a new Controller.
20
21 One of the connect_* methods will need to be called before this
22 object can be used for anything interesting.
23
Adam Israelc3e6c2e2018-03-01 09:31:50 -050024 If jujudata is None, jujudata.FileJujuData will be used.
25
Adam Israeldcdf82b2017-08-15 15:26:43 -040026 :param loop: an asyncio event loop
Adam Israelc3e6c2e2018-03-01 09:31:50 -050027 :param max_frame_size: See
28 `juju.client.connection.Connection.MAX_FRAME_SIZE`
29 :param bakery_client httpbakery.Client: The bakery client to use
30 for macaroon authorization.
31 :param jujudata JujuData: The source for current controller information.
Adam Israeldcdf82b2017-08-15 15:26:43 -040032 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -050033 self._connector = connector.Connector(
34 loop=loop,
35 max_frame_size=max_frame_size,
36 bakery_client=bakery_client,
37 jujudata=jujudata,
38 )
Adam Israeldcdf82b2017-08-15 15:26:43 -040039
Adam Israelc3e6c2e2018-03-01 09:31:50 -050040 async def __aenter__(self):
41 await self.connect()
42 return self
Adam Israeldcdf82b2017-08-15 15:26:43 -040043
Adam Israelc3e6c2e2018-03-01 09:31:50 -050044 async def __aexit__(self, exc_type, exc, tb):
45 await self.disconnect()
46
47 @property
48 def loop(self):
49 return self._connector.loop
50
51 async def connect(self, controller_name=None, **kwargs):
52 """Connect to a Juju controller.
53
54 If any arguments are specified other than controller_name,
55 then controller_name must be None and an explicit
56 connection will be made using Connection.connect
57 using those parameters (the 'uuid' parameter must
58 be absent or None).
59
60 Otherwise, if controller_name is None, connect to the
61 current controller.
62
63 Otherwise, controller_name must specify the name
64 of a known controller.
Adam Israeldcdf82b2017-08-15 15:26:43 -040065 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -050066 await self.disconnect()
67 if not kwargs:
68 await self._connector.connect_controller(controller_name)
69 else:
70 if controller_name is not None:
71 raise ValueError('controller name may not be specified with other connect parameters')
72 if kwargs.get('uuid') is not None:
73 # A UUID implies a model connection, not a controller connection.
74 raise ValueError('model UUID specified when connecting to controller')
75 await self._connector.connect(**kwargs)
Adam Israeldcdf82b2017-08-15 15:26:43 -040076
Adam Israelc3e6c2e2018-03-01 09:31:50 -050077 async def _connect_direct(self, **kwargs):
78 await self.disconnect()
79 await self._connector.connect(**kwargs)
Adam Israeldcdf82b2017-08-15 15:26:43 -040080
Adam Israelc3e6c2e2018-03-01 09:31:50 -050081 def is_connected(self):
82 """Reports whether the Controller is currently connected."""
83 return self._connector.is_connected()
Adam Israeldcdf82b2017-08-15 15:26:43 -040084
Adam Israelc3e6c2e2018-03-01 09:31:50 -050085 def connection(self):
86 """Return the current Connection object. It raises an exception
87 if the Controller is disconnected"""
88 return self._connector.connection()
Adam Israeldcdf82b2017-08-15 15:26:43 -040089
Adam Israelc3e6c2e2018-03-01 09:31:50 -050090 @property
91 def controller_name(self):
92 return self._connector.controller_name
Adam Israeldcdf82b2017-08-15 15:26:43 -040093
94 async def disconnect(self):
95 """Shut down the watcher task and close websockets.
96
97 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -050098 await self._connector.disconnect()
Adam Israeldcdf82b2017-08-15 15:26:43 -040099
Adam Israel1a15d1c2017-10-23 12:00:49 -0400100 async def add_credential(self, name=None, credential=None, cloud=None,
101 owner=None):
102 """Add or update a credential to the controller.
103
104 :param str name: Name of new credential. If None, the default
105 local credential is used. Name must be provided if a credential
106 is given.
107 :param CloudCredential credential: Credential to add. If not given,
108 it will attempt to read from local data, if available.
109 :param str cloud: Name of cloud to associate the credential with.
110 Defaults to the same cloud as the controller.
111 :param str owner: Username that will own the credential. Defaults to
112 the current user.
113 :returns: Name of credential that was uploaded.
114 """
115 if not cloud:
116 cloud = await self.get_cloud()
117
118 if not owner:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500119 owner = self.connection().info['user-info']['identity']
Adam Israel1a15d1c2017-10-23 12:00:49 -0400120
121 if credential and not name:
122 raise errors.JujuError('Name must be provided for credential')
123
124 if not credential:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500125 name, credential = self._connector.jujudata.load_credential(cloud, name)
Adam Israel1a15d1c2017-10-23 12:00:49 -0400126 if credential is None:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500127 raise errors.JujuError(
128 'Unable to find credential: {}'.format(name))
Adam Israel1a15d1c2017-10-23 12:00:49 -0400129
130 log.debug('Uploading credential %s', name)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500131 cloud_facade = client.CloudFacade.from_connection(self.connection())
Adam Israel1a15d1c2017-10-23 12:00:49 -0400132 await cloud_facade.UpdateCredentials([
133 client.UpdateCloudCredential(
134 tag=tag.credential(cloud, tag.untag('user-', owner), name),
135 credential=credential,
136 )])
137
138 return name
139
Adam Israeldcdf82b2017-08-15 15:26:43 -0400140 async def add_model(
141 self, model_name, cloud_name=None, credential_name=None,
142 owner=None, config=None, region=None):
143 """Add a model to this controller.
144
145 :param str model_name: Name to give the new model.
146 :param str cloud_name: Name of the cloud in which to create the
147 model, e.g. 'aws'. Defaults to same cloud as controller.
148 :param str credential_name: Name of the credential to use when
Adam Israel1a15d1c2017-10-23 12:00:49 -0400149 creating the model. If not given, it will attempt to find a
150 default credential.
Adam Israeldcdf82b2017-08-15 15:26:43 -0400151 :param str owner: Username that will own the model. Defaults to
152 the current user.
153 :param dict config: Model configuration.
154 :param str region: Region in which to create the model.
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500155 :return Model: A connection to the newly created model.
Adam Israeldcdf82b2017-08-15 15:26:43 -0400156 """
157 model_facade = client.ModelManagerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500158 self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400159
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500160 owner = owner or self.connection().info['user-info']['identity']
Adam Israeldcdf82b2017-08-15 15:26:43 -0400161 cloud_name = cloud_name or await self.get_cloud()
162
Adam Israel1a15d1c2017-10-23 12:00:49 -0400163 try:
164 # attempt to add/update the credential from local data if available
165 credential_name = await self.add_credential(
166 name=credential_name,
167 cloud=cloud_name,
168 owner=owner)
169 except errors.JujuError:
170 # if it's not available locally, assume it's on the controller
171 pass
172
Adam Israeldcdf82b2017-08-15 15:26:43 -0400173 if credential_name:
174 credential = tag.credential(
175 cloud_name,
176 tag.untag('user-', owner),
177 credential_name
178 )
179 else:
180 credential = None
181
182 log.debug('Creating model %s', model_name)
183
Adam Israel1a15d1c2017-10-23 12:00:49 -0400184 if not config or 'authorized-keys' not in config:
185 config = config or {}
186 config['authorized-keys'] = await utils.read_ssh_key(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500187 loop=self._connector.loop)
Adam Israel1a15d1c2017-10-23 12:00:49 -0400188
Adam Israeldcdf82b2017-08-15 15:26:43 -0400189 model_info = await model_facade.CreateModel(
190 tag.cloud(cloud_name),
191 config,
192 credential,
193 model_name,
194 owner,
195 region
196 )
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500197 from juju.model import Model
198 model = Model(jujudata=self._connector.jujudata)
199 kwargs = self.connection().connect_params()
200 kwargs['uuid'] = model_info.uuid
201 await model._connect_direct(**kwargs)
Adam Israeldcdf82b2017-08-15 15:26:43 -0400202
203 return model
204
Adam Israel1a15d1c2017-10-23 12:00:49 -0400205 async def destroy_models(self, *models):
Adam Israeldcdf82b2017-08-15 15:26:43 -0400206 """Destroy one or more models.
207
Adam Israel1a15d1c2017-10-23 12:00:49 -0400208 :param str \*models: Names or UUIDs of models to destroy
Adam Israeldcdf82b2017-08-15 15:26:43 -0400209
210 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500211 uuids = await self.model_uuids()
Adam Israel1a15d1c2017-10-23 12:00:49 -0400212 models = [uuids[model] if model in uuids else model
213 for model in models]
214
Adam Israeldcdf82b2017-08-15 15:26:43 -0400215 model_facade = client.ModelManagerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500216 self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400217
218 log.debug(
219 'Destroying model%s %s',
Adam Israel1a15d1c2017-10-23 12:00:49 -0400220 '' if len(models) == 1 else 's',
221 ', '.join(models)
Adam Israeldcdf82b2017-08-15 15:26:43 -0400222 )
223
224 await model_facade.DestroyModels([
Adam Israel1a15d1c2017-10-23 12:00:49 -0400225 client.Entity(tag.model(model))
226 for model in models
Adam Israeldcdf82b2017-08-15 15:26:43 -0400227 ])
228 destroy_model = destroy_models
229
230 async def add_user(self, username, password=None, display_name=None):
231 """Add a user to this controller.
232
233 :param str username: Username
Adam Israel1a15d1c2017-10-23 12:00:49 -0400234 :param str password: Password
Adam Israeldcdf82b2017-08-15 15:26:43 -0400235 :param str display_name: Display name
Adam Israel1a15d1c2017-10-23 12:00:49 -0400236 :returns: A :class:`~juju.user.User` instance
Adam Israeldcdf82b2017-08-15 15:26:43 -0400237 """
238 if not display_name:
239 display_name = username
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500240 user_facade = client.UserManagerFacade.from_connection(self.connection())
Adam Israel1a15d1c2017-10-23 12:00:49 -0400241 users = [client.AddUser(display_name=display_name,
242 username=username,
243 password=password)]
244 await user_facade.AddUser(users)
245 return await self.get_user(username)
246
247 async def remove_user(self, username):
248 """Remove a user from this controller.
249 """
250 client_facade = client.UserManagerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500251 self.connection())
Adam Israel1a15d1c2017-10-23 12:00:49 -0400252 user = tag.user(username)
253 await client_facade.RemoveUser([client.Entity(user)])
Adam Israeldcdf82b2017-08-15 15:26:43 -0400254
255 async def change_user_password(self, username, password):
256 """Change the password for a user in this controller.
257
258 :param str username: Username
259 :param str password: New password
260
261 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500262 user_facade = client.UserManagerFacade.from_connection(self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400263 entity = client.EntityPassword(password, tag.user(username))
264 return await user_facade.SetPassword([entity])
265
266 async def destroy(self, destroy_all_models=False):
267 """Destroy this controller.
268
269 :param bool destroy_all_models: Destroy all hosted models in the
270 controller.
271
272 """
273 controller_facade = client.ControllerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500274 self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400275 return await controller_facade.DestroyController(destroy_all_models)
276
277 async def disable_user(self, username):
278 """Disable a user.
279
280 :param str username: Username
281
282 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500283 user_facade = client.UserManagerFacade.from_connection(self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400284 entity = client.Entity(tag.user(username))
285 return await user_facade.DisableUser([entity])
286
287 async def enable_user(self, username):
288 """Re-enable a previously disabled user.
289
290 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500291 user_facade = client.UserManagerFacade.from_connection(self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400292 entity = client.Entity(tag.user(username))
293 return await user_facade.EnableUser([entity])
294
295 def kill(self):
296 """Forcibly terminate all machines and other associated resources for
297 this controller.
298
299 """
300 raise NotImplementedError()
301
302 async def get_cloud(self):
303 """
304 Get the name of the cloud that this controller lives on.
305 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500306 cloud_facade = client.CloudFacade.from_connection(self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400307
308 result = await cloud_facade.Clouds()
309 cloud = list(result.clouds.keys())[0] # only lives on one cloud
310 return tag.untag('cloud-', cloud)
311
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500312 async def get_models(self, all_=False, username=None):
313 """
314 .. deprecated:: 0.7.0
315 Use :meth:`.list_models` instead.
316 """
Adam Israel1a15d1c2017-10-23 12:00:49 -0400317 controller_facade = client.ControllerFacade.from_connection(
318 self.connection)
319 for attempt in (1, 2, 3):
320 try:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500321 return await controller_facade.AllModels()
322 except errors.JujuAPIError as e:
323 # retry concurrency error until resolved in Juju
324 # see: https://bugs.launchpad.net/juju/+bug/1721786
325 if 'has been removed' not in e.message or attempt == 3:
326 raise
327
328 async def model_uuids(self):
329 """Return a mapping of model names to UUIDs.
330 """
331 controller_facade = client.ControllerFacade.from_connection(
332 self.connection())
333 for attempt in (1, 2, 3):
334 try:
Adam Israel1a15d1c2017-10-23 12:00:49 -0400335 response = await controller_facade.AllModels()
336 return {um.model.name: um.model.uuid
337 for um in response.user_models}
338 except errors.JujuAPIError as e:
339 # retry concurrency error until resolved in Juju
340 # see: https://bugs.launchpad.net/juju/+bug/1721786
341 if 'has been removed' not in e.message or attempt == 3:
342 raise
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500343 await asyncio.sleep(attempt, loop=self._connector.loop)
Adam Israel1a15d1c2017-10-23 12:00:49 -0400344
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500345 async def list_models(self):
Adam Israel1a15d1c2017-10-23 12:00:49 -0400346 """Return list of names of the available models on this controller.
Adam Israeldcdf82b2017-08-15 15:26:43 -0400347
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500348 Equivalent to ``sorted((await self.model_uuids()).keys())``
Adam Israeldcdf82b2017-08-15 15:26:43 -0400349 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500350 uuids = await self.model_uuids()
Adam Israel1a15d1c2017-10-23 12:00:49 -0400351 return sorted(uuids.keys())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400352
353 def get_payloads(self, *patterns):
354 """Return list of known payloads.
355
356 :param str \*patterns: Patterns to match against
357
358 Each pattern will be checked against the following info in Juju::
359
360 - unit name
361 - machine id
362 - payload type
363 - payload class
364 - payload id
365 - payload tag
366 - payload status
367
368 """
369 raise NotImplementedError()
370
Adam Israeldcdf82b2017-08-15 15:26:43 -0400371 def login(self):
372 """Log in to this controller.
373
374 """
375 raise NotImplementedError()
376
377 def logout(self, force=False):
378 """Log out of this controller.
379
380 :param bool force: Don't fail even if user not previously logged in
381 with a password
382
383 """
384 raise NotImplementedError()
385
Adam Israel1a15d1c2017-10-23 12:00:49 -0400386 async def get_model(self, model):
387 """Get a model by name or UUID.
Adam Israeldcdf82b2017-08-15 15:26:43 -0400388
Adam Israel1a15d1c2017-10-23 12:00:49 -0400389 :param str model: Model name or UUID
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500390 :returns Model: Connected Model instance.
Adam Israeldcdf82b2017-08-15 15:26:43 -0400391 """
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500392 uuids = await self.model_uuids()
Adam Israel1a15d1c2017-10-23 12:00:49 -0400393 if model in uuids:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500394 uuid = uuids[model]
Adam Israel1a15d1c2017-10-23 12:00:49 -0400395 else:
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500396 uuid = model
Adam Israeldcdf82b2017-08-15 15:26:43 -0400397
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500398 from juju.model import Model
Adam Israel1a15d1c2017-10-23 12:00:49 -0400399 model = Model()
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500400 kwargs = self.connection().connect_params()
401 kwargs['uuid'] = uuid
402 await model._connect_direct(**kwargs)
Adam Israel1a15d1c2017-10-23 12:00:49 -0400403 return model
404
405 async def get_user(self, username):
Adam Israeldcdf82b2017-08-15 15:26:43 -0400406 """Get a user by name.
407
408 :param str username: Username
Adam Israel1a15d1c2017-10-23 12:00:49 -0400409 :returns: A :class:`~juju.user.User` instance
Adam Israeldcdf82b2017-08-15 15:26:43 -0400410 """
411 client_facade = client.UserManagerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500412 self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400413 user = tag.user(username)
Adam Israel1a15d1c2017-10-23 12:00:49 -0400414 args = [client.Entity(user)]
415 try:
416 response = await client_facade.UserInfo(args, True)
417 except errors.JujuError as e:
418 if 'permission denied' in e.errors:
419 # apparently, trying to get info for a nonexistent user returns
420 # a "permission denied" error rather than an empty result set
421 return None
422 raise
423 if response.results and response.results[0].result:
424 return User(self, response.results[0].result)
425 return None
426
427 async def get_users(self, include_disabled=False):
428 """Return list of users that can connect to this controller.
429
430 :param bool include_disabled: Include disabled users
431 :returns: A list of :class:`~juju.user.User` instances
432 """
433 client_facade = client.UserManagerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500434 self.connection())
Adam Israel1a15d1c2017-10-23 12:00:49 -0400435 response = await client_facade.UserInfo(None, include_disabled)
436 return [User(self, r.result) for r in response.results]
Adam Israeldcdf82b2017-08-15 15:26:43 -0400437
438 async def grant(self, username, acl='login'):
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500439 """Grant access level of the given user on the controller.
440 Note that if the user already has higher permissions than the
441 provided ACL, this will do nothing (see revoke for a way to
442 remove permissions).
Adam Israeldcdf82b2017-08-15 15:26:43 -0400443 :param str username: Username
444 :param str acl: Access control ('login', 'add-model' or 'superuser')
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500445 :returns: True if new access was granted, False if user already had
446 requested access or greater. Raises JujuError if failed.
Adam Israeldcdf82b2017-08-15 15:26:43 -0400447 """
448 controller_facade = client.ControllerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500449 self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400450 user = tag.user(username)
Adam Israeldcdf82b2017-08-15 15:26:43 -0400451 changes = client.ModifyControllerAccess(acl, 'grant', user)
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500452 try:
453 await controller_facade.ModifyControllerAccess([changes])
454 return True
455 except errors.JujuError as e:
456 if 'user already has' in str(e):
457 return False
458 else:
459 raise
Adam Israeldcdf82b2017-08-15 15:26:43 -0400460
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500461 async def revoke(self, username, acl='login'):
462 """Removes some or all access of a user to from a controller
463 If 'login' access is revoked, the user will no longer have any
464 permissions on the controller. Revoking a higher privilege from
465 a user without that privilege will have no effect.
Adam Israeldcdf82b2017-08-15 15:26:43 -0400466
467 :param str username: username
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500468 :param str acl: Access to remove ('login', 'add-model' or 'superuser')
Adam Israeldcdf82b2017-08-15 15:26:43 -0400469 """
470 controller_facade = client.ControllerFacade.from_connection(
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500471 self.connection())
Adam Israeldcdf82b2017-08-15 15:26:43 -0400472 user = tag.user(username)
473 changes = client.ModifyControllerAccess('login', 'revoke', user)
474 return await controller_facade.ModifyControllerAccess([changes])
Adam Israelc3e6c2e2018-03-01 09:31:50 -0500475
476 async def grant_model(self, username, model_uuid, acl='read'):
477 """Grant a user access to a model. Note that if the user
478 already has higher permissions than the provided ACL,
479 this will do nothing (see revoke_model for a way to remove permissions).
480
481 :param str username: Username
482 :param str model_uuid: The UUID of the model to change.
483 :param str acl: Access control ('read, 'write' or 'admin')
484 """
485 model_facade = client.ModelManagerFacade.from_connection(
486 self.connection())
487 user = tag.user(username)
488 model = tag.model(model_uuid)
489 changes = client.ModifyModelAccess(acl, 'grant', model, user)
490 return await model_facade.ModifyModelAccess([changes])
491
492 async def revoke_model(self, username, model_uuid, acl='read'):
493 """Revoke some or all of a user's access to a model.
494 If 'read' access is revoked, the user will no longer have any
495 permissions on the model. Revoking a higher privilege from
496 a user without that privilege will have no effect.
497
498 :param str username: Username to revoke
499 :param str model_uuid: The UUID of the model to change.
500 :param str acl: Access control ('read, 'write' or 'admin')
501 """
502 model_facade = client.ModelManagerFacade.from_connection(
503 self.connection())
504 user = tag.user(username)
505 model = tag.model(self.info.uuid)
506 changes = client.ModifyModelAccess(acl, 'revoke', model, user)
507 return await model_facade.ModifyModelAccess([changes])