• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SwissDataScienceCenter / renku-data-services / 11034384359

25 Sep 2024 01:46PM UTC coverage: 90.429% (+0.03%) from 90.403%
11034384359

Pull #425

github

web-flow
Merge 871b24a54 into 013683161
Pull Request #425: refactor: define a single method to remove entities from authz

28 of 33 new or added lines in 1 file covered. (84.85%)

2 existing lines in 2 files now uncovered.

9335 of 10323 relevant lines covered (90.43%)

1.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.33
/components/renku_data_services/authz/authz.py
1
"""Projects authorization adapter."""
2✔
2

3
import asyncio
2✔
4
from collections.abc import AsyncIterable, Awaitable, Callable
2✔
5
from dataclasses import dataclass, field
2✔
6
from enum import StrEnum
2✔
7
from functools import wraps
2✔
8
from typing import ClassVar, Concatenate, ParamSpec, Protocol, TypeVar, cast
2✔
9

10
from authzed.api.v1 import AsyncClient
2✔
11
from authzed.api.v1.core_pb2 import ObjectReference, Relationship, RelationshipUpdate, SubjectReference, ZedToken
2✔
12
from authzed.api.v1.permission_service_pb2 import (
2✔
13
    LOOKUP_PERMISSIONSHIP_HAS_PERMISSION,
14
    CheckPermissionRequest,
15
    CheckPermissionResponse,
16
    Consistency,
17
    LookupResourcesRequest,
18
    LookupResourcesResponse,
19
    LookupSubjectsRequest,
20
    LookupSubjectsResponse,
21
    ReadRelationshipsRequest,
22
    ReadRelationshipsResponse,
23
    RelationshipFilter,
24
    SubjectFilter,
25
    WriteRelationshipsRequest,
26
)
27
from sanic.log import logger
2✔
28
from sqlalchemy.ext.asyncio import AsyncSession
2✔
29
from ulid import ULID
2✔
30

31
from renku_data_services import base_models
2✔
32
from renku_data_services.authz.config import AuthzConfig
2✔
33
from renku_data_services.authz.models import Change, Member, MembershipChange, Role, Scope, Visibility
2✔
34
from renku_data_services.base_models.core import InternalServiceAdmin
2✔
35
from renku_data_services.errors import errors
2✔
36
from renku_data_services.namespace.models import Group, GroupUpdate, Namespace, NamespaceKind, NamespaceUpdate
2✔
37
from renku_data_services.project.models import Project, ProjectUpdate
2✔
38
from renku_data_services.users.models import UserInfo, UserInfoUpdate
2✔
39

40
_P = ParamSpec("_P")
2✔
41

42

43
class WithAuthz(Protocol):
2✔
44
    """Protocol for a class that has a authorization database client as property."""
2✔
45

46
    @property
2✔
47
    def authz(self) -> "Authz":
2✔
48
        """Returns the authorization database client."""
49
        ...
×
50

51

52
_AuthzChangeFuncResult = TypeVar(
2✔
53
    "_AuthzChangeFuncResult",
54
    bound=Project | ProjectUpdate | Group | UserInfoUpdate | list[UserInfo] | None,
55
)
56
_T = TypeVar("_T")
2✔
57
_WithAuthz = TypeVar("_WithAuthz", bound=WithAuthz)
2✔
58

59

60
@dataclass
2✔
61
class _AuthzChange:
2✔
62
    """Used to designate relationships to be created/updated/deleted and how those can be undone.
2✔
63

64
    Sending the apply and undo relationships to the database must always be the equivalent of
65
    a no-op.
66
    """
67

68
    apply: WriteRelationshipsRequest = field(default_factory=WriteRelationshipsRequest)
2✔
69
    undo: WriteRelationshipsRequest = field(default_factory=WriteRelationshipsRequest)
2✔
70

71
    def extend(self, other: "_AuthzChange") -> None:
2✔
72
        self.apply.updates.extend(other.apply.updates)
1✔
73
        self.apply.optional_preconditions.extend(other.apply.optional_preconditions)
1✔
74
        self.undo.updates.extend(other.undo.updates)
1✔
75
        self.undo.optional_preconditions.extend(other.undo.optional_preconditions)
1✔
76

77

78
class _Relation(StrEnum):
2✔
79
    """Relations for Authzed."""
2✔
80

81
    owner: str = "owner"
2✔
82
    editor: str = "editor"
2✔
83
    viewer: str = "viewer"
2✔
84
    public_viewer: str = "public_viewer"
2✔
85
    admin: str = "admin"
2✔
86
    project_platform: str = "project_platform"
2✔
87
    group_platform: str = "group_platform"
2✔
88
    user_namespace_platform: str = "user_namespace_platform"
2✔
89
    project_namespace: str = "project_namespace"
2✔
90

91
    @classmethod
2✔
92
    def from_role(cls, role: Role) -> "_Relation":
2✔
93
        match role:
1✔
94
            case Role.OWNER:
1✔
95
                return cls.owner
1✔
96
            case Role.EDITOR:
1✔
97
                return cls.editor
1✔
98
            case Role.VIEWER:
1✔
99
                return cls.viewer
1✔
100
        raise errors.ProgrammingError(message=f"Cannot map role {role} to any authorization database relation")
×
101

102
    def to_role(self) -> Role:
2✔
103
        match self:
1✔
104
            case _Relation.owner:
1✔
105
                return Role.OWNER
1✔
106
            case _Relation.editor:
1✔
107
                return Role.EDITOR
1✔
108
            case _Relation.viewer:
1✔
109
                return Role.VIEWER
1✔
110
        raise errors.ProgrammingError(message=f"Cannot map relation {self} to any role")
×
111

112

113
class ResourceType(StrEnum):
2✔
114
    """All possible resources stored in Authzed."""
2✔
115

116
    project: str = "project"
2✔
117
    user: str = "user"
2✔
118
    anonymous_user: str = "anonymous_user"
2✔
119
    platform: str = "platform"
2✔
120
    group: str = "group"
2✔
121
    user_namespace: str = "user_namespace"
2✔
122

123

124
class AuthzOperation(StrEnum):
2✔
125
    """The type of change that requires authorization database update."""
2✔
126

127
    create: str = "create"
2✔
128
    delete: str = "delete"
2✔
129
    update: str = "update"
2✔
130
    update_or_insert: str = "update_or_insert"
2✔
131
    insert_many: str = "insert_many"
2✔
132

133

134
class _AuthzConverter:
2✔
135
    @staticmethod
2✔
136
    def project(id: ULID) -> ObjectReference:
2✔
137
        return ObjectReference(object_type=ResourceType.project.value, object_id=str(id))
2✔
138

139
    @staticmethod
2✔
140
    def user(id: str | None) -> ObjectReference:
2✔
141
        if not id:
2✔
142
            return _AuthzConverter.all_users()
×
143
        return ObjectReference(object_type=ResourceType.user.value, object_id=id)
2✔
144

145
    @staticmethod
2✔
146
    def user_subject(id: str | None) -> SubjectReference:
2✔
147
        return SubjectReference(object=_AuthzConverter.user(id))
2✔
148

149
    @staticmethod
2✔
150
    def platform() -> ObjectReference:
2✔
151
        return ObjectReference(object_type=ResourceType.platform.value, object_id="renku")
2✔
152

153
    @staticmethod
2✔
154
    def anonymous_users() -> ObjectReference:
2✔
155
        return ObjectReference(object_type=ResourceType.anonymous_user, object_id="*")
2✔
156

157
    @staticmethod
2✔
158
    def anonymous_user() -> ObjectReference:
2✔
159
        return ObjectReference(object_type=ResourceType.anonymous_user, object_id="anonymous")
1✔
160

161
    @staticmethod
2✔
162
    def all_users() -> ObjectReference:
2✔
163
        return ObjectReference(object_type=ResourceType.user, object_id="*")
2✔
164

165
    @staticmethod
2✔
166
    def group(id: ULID) -> ObjectReference:
2✔
167
        return ObjectReference(object_type=ResourceType.group, object_id=str(id))
2✔
168

169
    @staticmethod
2✔
170
    def user_namespace(id: ULID) -> ObjectReference:
2✔
171
        return ObjectReference(object_type=ResourceType.user_namespace, object_id=str(id))
2✔
172

173
    @staticmethod
2✔
174
    def to_object(resource_type: ResourceType, resource_id: str | ULID | int) -> ObjectReference:
2✔
175
        match (resource_type, resource_id):
2✔
176
            case (ResourceType.project, sid) if isinstance(sid, ULID):
2✔
177
                return _AuthzConverter.project(sid)
2✔
178
            case (ResourceType.user, sid) if isinstance(sid, str) or sid is None:
2✔
179
                return _AuthzConverter.user(sid)
2✔
180
            case (ResourceType.anonymous_user, _):
2✔
181
                return _AuthzConverter.anonymous_users()
×
182
            case (ResourceType.user_namespace, rid) if isinstance(rid, ULID):
2✔
183
                return _AuthzConverter.user_namespace(rid)
1✔
184
            case (ResourceType.group, rid) if isinstance(rid, ULID):
2✔
185
                return _AuthzConverter.group(rid)
2✔
186
        raise errors.ProgrammingError(
×
187
            message=f"Unexpected or unknown resource type when checking permissions {resource_type}"
188
        )
189

190

191
def _is_allowed_on_resource(
2✔
192
    operation: Scope, resource_type: ResourceType
193
) -> Callable[
194
    [Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]]],
195
    Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]],
196
]:
197
    """A decorator that checks if the operation on a specific resource type is allowed or not."""
198

199
    def decorator(
2✔
200
        f: Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]],
201
    ) -> Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]]:
202
        @wraps(f)
2✔
203
        async def decorated_function(
2✔
204
            self: "Authz", user: base_models.APIUser, *args: _P.args, **kwargs: _P.kwargs
205
        ) -> _T:
206
            if isinstance(user, base_models.InternalServiceAdmin):
2✔
207
                return await f(self, user, *args, **kwargs)
×
208
            if not isinstance(user, base_models.APIUser):
2✔
209
                raise errors.ProgrammingError(
×
210
                    message="The decorator for checking permissions for authorization database operations "
211
                    "needs to access the user in the decorated function keyword arguments but it did not find it"
212
                )
213
            if len(args) == 0:
2✔
214
                raise errors.ProgrammingError(
×
215
                    message="The authorization decorator needs to have at least one positional argument after 'user'"
216
                )
217
            potential_resource = args[0]
2✔
218
            resource: Project | Group | Namespace | None = None
2✔
219
            match resource_type:
2✔
220
                case ResourceType.project if isinstance(potential_resource, Project):
2✔
221
                    resource = potential_resource
1✔
222
                case ResourceType.group if isinstance(potential_resource, Group):
2✔
223
                    resource = potential_resource
2✔
224
                case ResourceType.user_namespace if isinstance(potential_resource, Namespace):
×
225
                    resource = potential_resource
×
226
                case _:
×
227
                    raise errors.ProgrammingError(
×
228
                        message="The decorator for checking permissions for authorization database operations "
229
                        "failed to find the expected positional argument in the decorated function "
230
                        f"for the {resource_type} resource, it found {type(resource)}"
231
                    )
232
            allowed, zed_token = await self._has_permission(user, resource_type, resource.id, operation)
2✔
233
            if not allowed:
2✔
234
                raise errors.MissingResourceError(
×
235
                    message=f"The user with ID {user.id} cannot perform operation {operation} "
236
                    f"on resource {resource_type} with ID {resource.id} or the resource does not exist."
237
                )
238
            kwargs["zed_token"] = zed_token
2✔
239
            return await f(self, user, *args, **kwargs)
2✔
240

241
        return decorated_function
2✔
242

243
    return decorator
2✔
244

245

246
_ID = TypeVar("_ID", str, ULID)
2✔
247

248

249
def _is_allowed(
2✔
250
    operation: Scope,
251
) -> Callable[
252
    [Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]]],
253
    Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]],
254
]:
255
    """A decorator that checks if the operation on a resource is allowed or not."""
256

257
    def decorator(
2✔
258
        f: Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]],
259
    ) -> Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]]:
260
        @wraps(f)
2✔
261
        async def decorated_function(
2✔
262
            self: "Authz",
263
            user: base_models.APIUser,
264
            resource_type: ResourceType,
265
            resource_id: _ID,
266
            *args: _P.args,
267
            **kwargs: _P.kwargs,
268
        ) -> _T:
269
            if isinstance(user, base_models.InternalServiceAdmin):
1✔
270
                return await f(self, user, resource_type, resource_id, *args, **kwargs)
×
271
            allowed, zed_token = await self._has_permission(user, resource_type, resource_id, operation)
1✔
272
            if not allowed:
1✔
273
                raise errors.MissingResourceError(
1✔
274
                    message=f"The user with ID {user.id} cannot perform operation {operation} on {resource_type.value} "
275
                    f"with ID {resource_id} or the resource does not exist."
276
                )
277
            kwargs["zed_token"] = zed_token
1✔
278
            return await f(self, user, resource_type, resource_id, *args, **kwargs)
1✔
279

280
        return decorated_function
2✔
281

282
    return decorator
2✔
283

284

285
@dataclass
2✔
286
class Authz:
2✔
287
    """Authorization decisions and updates."""
2✔
288

289
    authz_config: AuthzConfig
2✔
290
    _platform: ClassVar[ObjectReference] = field(default=_AuthzConverter.platform())
2✔
291
    _client: AsyncClient | None = field(default=None, init=False)
2✔
292

293
    @property
2✔
294
    def client(self) -> AsyncClient:
2✔
295
        """The authzed DB asynchronous client."""
296
        if not self._client:
2✔
297
            self._client = self.authz_config.authz_async_client()
2✔
298
        return self._client
2✔
299

300
    async def _has_permission(
2✔
301
        self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str | ULID | None, scope: Scope
302
    ) -> tuple[bool, ZedToken | None]:
303
        """Checks whether the provided user has a specific permission on the specific resource."""
304
        if not resource_id:
2✔
305
            raise errors.ProgrammingError(
×
306
                message=f"Cannot check permissions on a resource of type {resource_type} with missing resource ID."
307
            )
308
        if isinstance(user, InternalServiceAdmin):
2✔
309
            return True, None
1✔
310
        res = _AuthzConverter.to_object(resource_type, resource_id)
2✔
311
        sub = SubjectReference(
2✔
312
            object=(
313
                _AuthzConverter.to_object(ResourceType.user, user.id) if user.id else _AuthzConverter.anonymous_user()
314
            )
315
        )
316
        response: CheckPermissionResponse = await self.client.CheckPermission(
2✔
317
            CheckPermissionRequest(
318
                consistency=Consistency(fully_consistent=True), resource=res, subject=sub, permission=scope.value
319
            )
320
        )
321
        return response.permissionship == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION, response.checked_at
2✔
322

323
    async def has_permission(
2✔
324
        self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str | ULID, scope: Scope
325
    ) -> bool:
326
        """Checks whether the provided user has a specific permission on the specific resource."""
327
        res, _ = await self._has_permission(user, resource_type, resource_id, scope)
2✔
328
        return res
2✔
329

330
    async def resources_with_permission(
2✔
331
        self, requested_by: base_models.APIUser, user_id: str | None, resource_type: ResourceType, scope: Scope
332
    ) -> list[str]:
333
        """Get all the resource IDs (for a specific resource kind) that a specific user has access to.
334

335
        The person requesting the information can be the user or someone else. I.e. the admin can request
336
        what are the resources that a user has access to.
337
        """
338
        if not requested_by.is_admin and requested_by.id != user_id:
2✔
339
            raise errors.ForbiddenError(
1✔
340
                message=f"User with ID {requested_by.id} cannot check the permissions of another user with ID {user_id}"
341
            )
342
        sub = SubjectReference(
2✔
343
            object=(
344
                _AuthzConverter.to_object(ResourceType.user, user_id) if user_id else _AuthzConverter.anonymous_user()
345
            )
346
        )
347
        ids: list[str] = []
2✔
348
        responses: AsyncIterable[LookupResourcesResponse] = self.client.LookupResources(
2✔
349
            LookupResourcesRequest(
350
                consistency=Consistency(fully_consistent=True),
351
                resource_object_type=resource_type.value,
352
                permission=scope.value,
353
                subject=sub,
354
            )
355
        )
356
        async for response in responses:
2✔
357
            if response.permissionship == LOOKUP_PERMISSIONSHIP_HAS_PERMISSION:
1✔
358
                ids.append(response.resource_object_id)
1✔
359
        return ids
2✔
360

361
    @_is_allowed(Scope.READ)  # The scope on the resource that allows the user to perform this check in the first place
2✔
362
    async def users_with_permission(
2✔
363
        self,
364
        user: base_models.APIUser,
365
        resource_type: ResourceType,
366
        resource_id: str,
367
        scope: Scope,  # The scope that the users should be allowed to exercise on the resource
368
        *,
369
        zed_token: ZedToken | None = None,
370
    ) -> list[str]:
371
        """Get all user IDs that have a specific permission on a specific resource."""
372
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
373
        res = _AuthzConverter.to_object(resource_type, resource_id)
1✔
374
        ids: list[str] = []
1✔
375
        responses: AsyncIterable[LookupSubjectsResponse] = self.client.LookupSubjects(
1✔
376
            LookupSubjectsRequest(
377
                consistency=consistency,
378
                resource=res,
379
                permission=scope.value,
380
                subject_object_type=ResourceType.user.value,
381
            )
382
        )
383
        async for response in responses:
1✔
384
            if response.permissionship == LOOKUP_PERMISSIONSHIP_HAS_PERMISSION:
1✔
385
                ids.append(response.subject.subject_object_id)
1✔
386
        return ids
1✔
387

388
    @_is_allowed(Scope.READ)
2✔
389
    async def members(
2✔
390
        self,
391
        user: base_models.APIUser,
392
        resource_type: ResourceType,
393
        resource_id: ULID,
394
        role: Role | None = None,
395
        *,
396
        zed_token: ZedToken | None = None,
397
    ) -> list[Member]:
398
        """Get all users that are members of a resource, if role is None then all roles are retrieved."""
399
        resource_id_str = str(resource_id)
1✔
400
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
401
        sub_filter = SubjectFilter(subject_type=ResourceType.user.value)
1✔
402
        rel_filter = RelationshipFilter(
1✔
403
            resource_type=resource_type,
404
            optional_resource_id=resource_id_str,
405
            optional_subject_filter=sub_filter,
406
        )
407
        if role:
1✔
408
            relation = _Relation.from_role(role)
1✔
409
            rel_filter = RelationshipFilter(
1✔
410
                resource_type=resource_type,
411
                optional_resource_id=resource_id_str,
412
                optional_relation=relation,
413
                optional_subject_filter=sub_filter,
414
            )
415
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
416
            ReadRelationshipsRequest(
417
                consistency=consistency,
418
                relationship_filter=rel_filter,
419
            )
420
        )
421
        members: list[Member] = []
1✔
422
        async for response in responses:
1✔
423
            # Skip "public_viewer" relationships
424
            if response.relationship.relation == _Relation.public_viewer.value:
1✔
425
                continue
1✔
426
            member_role = _Relation(response.relationship.relation).to_role()
1✔
427
            members.append(
1✔
428
                Member(
429
                    user_id=response.relationship.subject.object.object_id, role=member_role, resource_id=resource_id
430
                )
431
            )
432
        return members
1✔
433

434
    @staticmethod
2✔
435
    def authz_change(
2✔
436
        op: AuthzOperation, resource: ResourceType
437
    ) -> Callable[
438
        [Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]]],
439
        Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]],
440
    ]:
441
        """A decorator that updates the authorization database for different types of operations."""
442

443
        def _extract_user_from_args(*args: _P.args, **kwargs: _P.kwargs) -> base_models.APIUser:
2✔
444
            if len(args) == 0:
2✔
445
                user_kwarg = kwargs.get("user")
2✔
446
                requested_by_kwarg = kwargs.get("requested_by")
2✔
447
                if isinstance(user_kwarg, base_models.APIUser) and isinstance(requested_by_kwarg, base_models.APIUser):
2✔
448
                    raise errors.ProgrammingError(
×
449
                        message="The decorator for authorization database changes found two APIUser parameters in the "
450
                        "'user' and 'requested_by' keyword arguments but expected only one of them to be present."
451
                    )
452
                potential_user = user_kwarg if isinstance(user_kwarg, base_models.APIUser) else requested_by_kwarg
2✔
453
            else:
454
                potential_user = args[0]
×
455
            if not isinstance(potential_user, base_models.APIUser):
2✔
456
                raise errors.ProgrammingError(
×
457
                    message="The decorator for authorization database changes could not find APIUser in the function "
458
                    f"arguments, the type of the argument that was found is {type(potential_user)}."
459
                )
460
            return potential_user
2✔
461

462
        async def _get_authz_change(
2✔
463
            db_repo: _WithAuthz,
464
            operation: AuthzOperation,
465
            resource: ResourceType,
466
            result: _AuthzChangeFuncResult,
467
            *func_args: _P.args,
468
            **func_kwargs: _P.kwargs,
469
        ) -> _AuthzChange:
470
            authz_change = _AuthzChange()
2✔
471
            match operation, resource:
2✔
472
                case AuthzOperation.create, ResourceType.project if isinstance(result, Project):
2✔
473
                    authz_change = db_repo.authz._add_project(result)
1✔
474
                case AuthzOperation.delete, ResourceType.project if isinstance(result, Project):
2✔
475
                    user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
476
                    authz_change = await db_repo.authz._remove_entity(user, result)
1✔
477
                case AuthzOperation.delete, ResourceType.project if result is None:
2✔
478
                    # NOTE: This means that the project does not exist in the first place so nothing was deleted
479
                    pass
×
480
                case AuthzOperation.update, ResourceType.project if isinstance(result, ProjectUpdate):
2✔
481
                    authz_change = _AuthzChange()
1✔
482
                    if result.old.visibility != result.new.visibility:
1✔
483
                        user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
484
                        authz_change.extend(await db_repo.authz._update_project_visibility(user, result.new))
1✔
485
                    if result.old.namespace.id != result.new.namespace.id:
1✔
486
                        user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
487
                        authz_change.extend(await db_repo.authz._update_project_namespace(user, result.new))
1✔
488
                case AuthzOperation.create, ResourceType.group if isinstance(result, Group):
2✔
489
                    authz_change = db_repo.authz._add_group(result)
2✔
490
                case AuthzOperation.delete, ResourceType.group if isinstance(result, Group):
2✔
491
                    user = _extract_user_from_args(*func_args, **func_kwargs)
2✔
492
                    authz_change = await db_repo.authz._remove_entity(user, result)
2✔
493
                case AuthzOperation.delete, ResourceType.group if result is None:
2✔
494
                    # NOTE: This means that the group does not exist in the first place so nothing was deleted
495
                    pass
1✔
496
                case AuthzOperation.update_or_insert, ResourceType.user if isinstance(result, UserInfoUpdate):
2✔
497
                    if result.old is None:
2✔
498
                        authz_change = db_repo.authz._add_user_namespace(result.new.namespace)
2✔
499
                case AuthzOperation.insert_many, ResourceType.user_namespace if isinstance(result, list):
2✔
500
                    for res in result:
2✔
501
                        if not isinstance(res, UserInfo):
×
502
                            raise errors.ProgrammingError(
×
503
                                message="Expected list of UserInfo when generating authorization "
504
                                f"database updates for inserting namespaces but found {type(res)}"
505
                            )
506
                        authz_change.extend(db_repo.authz._add_user_namespace(res.namespace))
×
507
                case _:
×
508
                    resource_id: str | ULID | None = "unknown"
×
509
                    if isinstance(result, (Project, Namespace, Group)):
×
510
                        resource_id = result.id
×
511
                    elif isinstance(result, (ProjectUpdate, NamespaceUpdate, GroupUpdate)):
×
512
                        resource_id = result.new.id
×
513
                    raise errors.ProgrammingError(
×
514
                        message=f"Encountered an unknown authorization operation {op} on resource {resource} "
515
                        f"with ID {resource_id} when updating the authorization database",
516
                    )
517
            return authz_change
2✔
518

519
        def decorator(
2✔
520
            f: Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]],
521
        ) -> Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]]:
522
            @wraps(f)
2✔
523
            async def decorated_function(
2✔
524
                db_repo: _WithAuthz, *args: _P.args, **kwargs: _P.kwargs
525
            ) -> _AuthzChangeFuncResult:
526
                # NOTE: db_repo is the "self" of the project postgres DB repository method that this function decorates.
527
                # I did not call it "self" here to avoid confusion with the self of the Authz class,
528
                # even though this is a static method.
529
                session = kwargs.get("session")
2✔
530
                if not isinstance(session, AsyncSession):
2✔
531
                    raise errors.ProgrammingError(
×
532
                        message="The authorization change decorator requires a DB session in the function "
533
                        "keyword arguments"
534
                    )
535
                if not session.in_transaction():
2✔
536
                    raise errors.ProgrammingError(
×
537
                        message="The authorization database decorator needs a session with an open transaction."
538
                    )
539

540
                authz_change = _AuthzChange()
2✔
541
                try:
2✔
542
                    # NOTE: Here we have to maintain the following order of operations:
543
                    # 1. Run decorated function
544
                    # 2. Write resources to the Authzed DB
545
                    # 3. Commit the open transaction
546
                    # 4. If something goes wrong abort the transaction and remove things from Authzed DB
547
                    # See https://authzed.com/docs/spicedb/concepts/relationships#writing-relationships
548
                    # If this order of operations is changed you can get a case where for a short period of time
549
                    # resources exists in the postgres DB without any authorization information in the Authzed DB.
550
                    result = await f(db_repo, *args, **kwargs)
2✔
551
                    authz_change = await _get_authz_change(db_repo, op, resource, result, *args, **kwargs)
2✔
552
                    await db_repo.authz.client.WriteRelationships(authz_change.apply)
2✔
553
                    await session.commit()
2✔
554
                    return result
2✔
555
                except Exception as err:
2✔
556
                    db_rollback_err = None
2✔
557
                    try:
2✔
558
                        # NOTE: If the rollback fails do not stop just continue to make sure the resource
559
                        # from the Authzed DB is also removed
560
                        await asyncio.shield(session.rollback())
2✔
561
                    except Exception as _db_rollback_err:
×
562
                        db_rollback_err = _db_rollback_err
×
563
                    await asyncio.shield(db_repo.authz.client.WriteRelationships(authz_change.undo))
2✔
564
                    if db_rollback_err:
2✔
565
                        raise db_rollback_err from err
×
566
                    raise err
2✔
567

568
            return decorated_function
2✔
569

570
        return decorator
2✔
571

572
    async def _remove_entity(
2✔
573
        self, user: base_models.APIUser, resource: UserInfo | Group | Namespace | Project
574
    ) -> _AuthzChange:
575
        resource_type: ResourceType
576
        match resource:
2✔
577
            case _ if isinstance(resource, UserInfo):
2✔
NEW
578
                resource_type = ResourceType.user
×
579
            case _ if isinstance(resource, Group):
2✔
580
                resource_type = ResourceType.group
2✔
581
            case _ if isinstance(resource, Namespace) and resource.kind == NamespaceKind.user:
1✔
NEW
582
                resource_type = ResourceType.user_namespace
×
583
            case _ if isinstance(resource, Namespace):
1✔
NEW
584
                raise errors.ProgrammingError(
×
585
                    message=f"Cannot handle deletetion of namespace {resource.slug} of kind {resource.kind.value}."
586
                )
587
            case _ if isinstance(resource, Project):
1✔
588
                resource_type = ResourceType.project
1✔
NEW
589
            case _:
×
NEW
590
                raise errors.ProgrammingError(message="Cannot handle deletion of unknown resource.")
×
591
        resource_id = str(resource.id)
2✔
592

593
        @_is_allowed_on_resource(Scope.DELETE, resource_type)
2✔
594
        async def _remove_entity_wrapped(
2✔
595
            authz: Authz,
596
            user: base_models.APIUser,
597
            resource: UserInfo | Group | Namespace | Project,
598
            *,
599
            zed_token: ZedToken | None = None,
600
        ) -> _AuthzChange:
601
            consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
2✔
602
            rels: list[Relationship] = []
2✔
603
            # Get relations where the entity is the resource
604
            rel_filter = RelationshipFilter(resource_type=resource_type.value, optional_resource_id=resource_id)
2✔
605
            responses: AsyncIterable[ReadRelationshipsResponse] = authz.client.ReadRelationships(
2✔
606
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
607
            )
608
            async for response in responses:
2✔
609
                rels.append(response.relationship)
2✔
610
            # Get relations where the entity is the subject
611
            rel_filter = RelationshipFilter(
2✔
612
                optional_subject_filter=SubjectFilter(subject_type=resource_type, optional_subject_id=resource_id)
613
            )
614
            responses: AsyncIterable[ReadRelationshipsResponse] = authz.client.ReadRelationships(
2✔
615
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
616
            )
617
            async for response in responses:
2✔
618
                rels.append(response.relationship)
1✔
619
            apply = WriteRelationshipsRequest(
2✔
620
                updates=[
621
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels
622
                ]
623
            )
624
            undo = WriteRelationshipsRequest(
2✔
625
                updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
626
            )
627
            return _AuthzChange(apply=apply, undo=undo)
2✔
628

629
        return await _remove_entity_wrapped(self, user, resource)
2✔
630

631
    def _add_project(self, project: Project) -> _AuthzChange:
2✔
632
        """Create the new project and associated resources and relations in the DB."""
633
        creator = SubjectReference(object=_AuthzConverter.user(project.created_by))
1✔
634
        project_res = _AuthzConverter.project(project.id)
1✔
635
        creator_is_owner = Relationship(resource=project_res, relation=_Relation.owner.value, subject=creator)
1✔
636
        all_users = SubjectReference(object=_AuthzConverter.all_users())
1✔
637
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
638
        project_namespace = SubjectReference(
1✔
639
            object=(
640
                _AuthzConverter.user_namespace(project.namespace.id)
641
                if project.namespace.kind == NamespaceKind.user
642
                else _AuthzConverter.group(cast(ULID, project.namespace.underlying_resource_id))
643
            )
644
        )
645
        project_in_platform = Relationship(
1✔
646
            resource=project_res,
647
            relation=_Relation.project_platform.value,
648
            subject=SubjectReference(object=self._platform),
649
        )
650
        project_in_namespace = Relationship(
1✔
651
            resource=project_res,
652
            relation=_Relation.project_namespace,
653
            subject=project_namespace,
654
        )
655
        relationships = [creator_is_owner, project_in_platform, project_in_namespace]
1✔
656
        if project.visibility == Visibility.PUBLIC:
1✔
657
            all_users_are_viewers = Relationship(
1✔
658
                resource=project_res,
659
                relation=_Relation.viewer.value,
660
                subject=all_users,
661
            )
662
            all_anon_users_are_viewers = Relationship(
1✔
663
                resource=project_res,
664
                relation=_Relation.viewer.value,
665
                subject=all_anon_users,
666
            )
667
            relationships.extend([all_users_are_viewers, all_anon_users_are_viewers])
1✔
668
        apply = WriteRelationshipsRequest(
1✔
669
            updates=[
670
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
671
            ]
672
        )
673
        undo = WriteRelationshipsRequest(
1✔
674
            updates=[
675
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
676
            ]
677
        )
678
        return _AuthzChange(apply=apply, undo=undo)
1✔
679

680
    # NOTE changing visibility is the same access level as removal
681
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
682
    async def _update_project_visibility(
2✔
683
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
684
    ) -> _AuthzChange:
685
        """Update the visibility of the project in the authorization database."""
686
        project_id_str = str(project.id)
1✔
687
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
688
        project_res = _AuthzConverter.project(project.id)
1✔
689
        all_users_sub = SubjectReference(object=_AuthzConverter.all_users())
1✔
690
        anon_users_sub = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
691
        all_users_are_viewers = Relationship(
1✔
692
            resource=project_res,
693
            relation=_Relation.viewer.value,
694
            subject=all_users_sub,
695
        )
696
        anon_users_are_viewers = Relationship(
1✔
697
            resource=project_res,
698
            relation=_Relation.viewer.value,
699
            subject=anon_users_sub,
700
        )
701
        make_public = WriteRelationshipsRequest(
1✔
702
            updates=[
703
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=all_users_are_viewers),
704
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=anon_users_are_viewers),
705
            ]
706
        )
707
        make_private = WriteRelationshipsRequest(
1✔
708
            updates=[
709
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=all_users_are_viewers),
710
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=anon_users_are_viewers),
711
            ]
712
        )
713
        rel_filter = RelationshipFilter(
1✔
714
            resource_type=ResourceType.project.value,
715
            optional_resource_id=project_id_str,
716
            optional_subject_filter=SubjectFilter(
717
                subject_type=ResourceType.user.value, optional_subject_id=all_users_sub.object.object_id
718
            ),
719
        )
720
        current_relation_users: ReadRelationshipsResponse | None = await anext(
1✔
721
            aiter(self.client.ReadRelationships(ReadRelationshipsRequest(relationship_filter=rel_filter))), None
722
        )
723
        rel_filter = RelationshipFilter(
1✔
724
            resource_type=ResourceType.project.value,
725
            optional_resource_id=project_id_str,
726
            optional_subject_filter=SubjectFilter(
727
                subject_type=ResourceType.anonymous_user.value,
728
                optional_subject_id=anon_users_sub.object.object_id,
729
            ),
730
        )
731
        current_relation_anon_users: ReadRelationshipsResponse | None = await anext(
1✔
732
            aiter(
733
                self.client.ReadRelationships(
734
                    ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
735
                )
736
            ),
737
            None,
738
        )
739
        project_is_public_for_users = (
1✔
740
            current_relation_users is not None
741
            and current_relation_users.relationship.subject.object.object_type == ResourceType.user.value
742
            and current_relation_users.relationship.subject.object.object_id == all_users_sub.object.object_id
743
        )
744
        project_is_public_for_anon_users = (
1✔
745
            current_relation_anon_users is not None
746
            and current_relation_anon_users.relationship.subject.object.object_type == ResourceType.anonymous_user.value
747
            and current_relation_anon_users.relationship.subject.object.object_id == anon_users_sub.object.object_id,
748
        )
749
        project_already_public = project_is_public_for_users and project_is_public_for_anon_users
1✔
750
        project_already_private = not project_already_public
1✔
751
        match project.visibility:
1✔
752
            case Visibility.PUBLIC:
1✔
753
                if project_already_public:
1✔
754
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
755
                return _AuthzChange(apply=make_public, undo=make_private)
1✔
756
            case Visibility.PRIVATE:
1✔
757
                if project_already_private:
1✔
758
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
759
                return _AuthzChange(apply=make_private, undo=make_public)
1✔
760
        raise errors.ProgrammingError(
×
761
            message=f"Encountered unknown project visibility {project.visibility} when trying to "
762
            f"make a visibility change for project with ID {project.id}",
763
        )
764

765
    # NOTE changing namespace is the same access level as removal
766
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
767
    async def _update_project_namespace(
2✔
768
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
769
    ) -> _AuthzChange:
770
        """Update the namespace/group of the project in the authorization database."""
771
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
772
        project_res = _AuthzConverter.project(project.id)
1✔
773
        project_namespace_filter = RelationshipFilter(
1✔
774
            resource_type=ResourceType.project.value,
775
            optional_resource_id=str(project.id),
776
            optional_relation=_Relation.project_namespace.value,
777
        )
778
        current_namespace: ReadRelationshipsResponse | None = await anext(
1✔
779
            aiter(
780
                self.client.ReadRelationships(
781
                    ReadRelationshipsRequest(relationship_filter=project_namespace_filter, consistency=consistency)
782
                )
783
            ),
784
            None,
785
        )
786
        if not current_namespace:
1✔
787
            raise errors.ProgrammingError(
×
788
                message=f"The project with ID {project.id} whose namespace is being updated "
789
                "does not currently have a namespace"
790
            )
791
        if current_namespace.relationship.subject.object.object_id == project.namespace.id:
1✔
792
            return _AuthzChange()
×
793
        new_namespace_sub = (
1✔
794
            SubjectReference(object=_AuthzConverter.group(project.namespace.id))
795
            if project.namespace.kind == NamespaceKind.group
796
            else SubjectReference(object=_AuthzConverter.user_namespace(project.namespace.id))
797
        )
798
        old_namespace_sub = (
1✔
799
            SubjectReference(
800
                object=_AuthzConverter.group(ULID.from_str(current_namespace.relationship.subject.object.object_id))
801
            )
802
            if current_namespace.relationship.subject.object.object_type == ResourceType.group.value
803
            else SubjectReference(
804
                object=_AuthzConverter.user_namespace(
805
                    ULID.from_str(current_namespace.relationship.subject.object.object_id)
806
                )
807
            )
808
        )
809
        new_namespace = Relationship(
1✔
810
            resource=project_res,
811
            relation=_Relation.project_namespace.value,
812
            subject=new_namespace_sub,
813
        )
814
        old_namespace = Relationship(
1✔
815
            resource=project_res,
816
            relation=_Relation.project_namespace.value,
817
            subject=old_namespace_sub,
818
        )
819
        apply_change = WriteRelationshipsRequest(
1✔
820
            updates=[
821
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=new_namespace),
822
            ]
823
        )
824
        undo_change = WriteRelationshipsRequest(
1✔
825
            updates=[
826
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=old_namespace),
827
            ]
828
        )
829
        return _AuthzChange(apply=apply_change, undo=undo_change)
1✔
830

831
    async def _get_resource_owners(
2✔
832
        self, resource_type: ResourceType, resource_id: str, consistency: Consistency
833
    ) -> list[ReadRelationshipsResponse]:
834
        existing_owners_filter = RelationshipFilter(
1✔
835
            resource_type=resource_type.value,
836
            optional_resource_id=resource_id,
837
            optional_subject_filter=SubjectFilter(subject_type=ResourceType.user),
838
            optional_relation=_Relation.owner.value,
839
        )
840
        return [
1✔
841
            i
842
            async for i in self.client.ReadRelationships(
843
                ReadRelationshipsRequest(
844
                    consistency=consistency,
845
                    relationship_filter=existing_owners_filter,
846
                )
847
            )
848
        ]
849

850
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
851
    async def upsert_project_members(
2✔
852
        self,
853
        user: base_models.APIUser,
854
        resource_type: ResourceType,
855
        resource_id: ULID,
856
        members: list[Member],
857
        *,
858
        zed_token: ZedToken | None = None,
859
    ) -> list[MembershipChange]:
860
        """Updates the project members or inserts them if they do not exist.
861

862
        Returns the list that was updated/inserted.
863
        """
864
        resource_id_str = str(resource_id)
1✔
865
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
866
        project_res = _AuthzConverter.project(resource_id)
1✔
867
        add_members: list[RelationshipUpdate] = []
1✔
868
        undo: list[RelationshipUpdate] = []
1✔
869
        output: list[MembershipChange] = []
1✔
870
        expected_user_roles = {_Relation.viewer.value, _Relation.owner.value, _Relation.editor.value}
1✔
871
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
872
        n_existing_owners = len(existing_owners_rels)
1✔
873
        for member in members:
1✔
874
            rel = Relationship(
1✔
875
                resource=project_res,
876
                relation=_Relation.from_role(member.role).value,
877
                subject=SubjectReference(object=_AuthzConverter.user(member.user_id)),
878
            )
879
            existing_rel_filter = RelationshipFilter(
1✔
880
                resource_type=resource_type.value,
881
                optional_resource_id=resource_id_str,
882
                optional_subject_filter=SubjectFilter(
883
                    subject_type=ResourceType.user, optional_subject_id=member.user_id
884
                ),
885
            )
886
            existing_rels_iter: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
887
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
888
            )
889
            existing_rels = [i async for i in existing_rels_iter if i.relationship.relation in expected_user_roles]
1✔
890
            if len(existing_rels) > 0:
1✔
891
                # The existing relationships should be deleted if all goes well and added back in if we have to undo
892
                existing_rel = existing_rels[0]
1✔
893
                if existing_rel.relationship != rel:
1✔
894
                    if existing_rel.relationship.relation == _Relation.owner.value:
1✔
895
                        n_existing_owners -= 1
1✔
896
                    elif rel.relation == _Relation.owner.value:
1✔
897
                        n_existing_owners += 1
1✔
898

899
                    add_members.extend(
1✔
900
                        [
901
                            RelationshipUpdate(
902
                                operation=RelationshipUpdate.OPERATION_TOUCH,
903
                                relationship=rel,
904
                            ),
905
                            # NOTE: The old role for the user still exists and we have to remove it
906
                            # if not both the old and new role for the same user will be present in the database
907
                            RelationshipUpdate(
908
                                operation=RelationshipUpdate.OPERATION_DELETE,
909
                                relationship=existing_rel.relationship,
910
                            ),
911
                        ]
912
                    )
913
                    undo.extend(
1✔
914
                        [
915
                            RelationshipUpdate(
916
                                operation=RelationshipUpdate.OPERATION_DELETE,
917
                                relationship=rel,
918
                            ),
919
                            RelationshipUpdate(
920
                                operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
921
                            ),
922
                        ]
923
                    )
924
                    output.append(MembershipChange(member, Change.UPDATE))
1✔
925
                for rel_to_remove in existing_rels[1:]:
1✔
926
                    # NOTE: This means that the user has more than 1 role on the project - which should not happen
927
                    # But if this does occur then we simply delete the extra roles of the user here.
928
                    logger.warning(
×
929
                        f"Removing additional unexpected role {rel_to_remove.relationship.relation} "
930
                        f"of user {member.user_id} on project {resource_id}, "
931
                        f"kept role {existing_rel.relationship.relation} which will be updated to {rel.relation}."
932
                    )
933
                    add_members.append(
×
934
                        RelationshipUpdate(
935
                            operation=RelationshipUpdate.OPERATION_DELETE,
936
                            relationship=rel_to_remove.relationship,
937
                        ),
938
                    )
939
                    undo.append(
×
940
                        RelationshipUpdate(
941
                            operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel_to_remove.relationship
942
                        ),
943
                    )
944
                    output.append(MembershipChange(member, Change.REMOVE))
×
945
            else:
946
                if rel.relation == _Relation.owner.value:
1✔
947
                    n_existing_owners += 1
1✔
948
                # The new relationship is added if all goes well and deleted if we have to undo
949
                add_members.append(
1✔
950
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel),
951
                )
952
                undo.append(
1✔
953
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel),
954
                )
955
                output.append(MembershipChange(member, Change.ADD))
1✔
956

957
        if n_existing_owners == 0:
1✔
958
            raise errors.ValidationError(
1✔
959
                message="You are trying to change the role of all the owners of the project, which is not allowed. "
960
                "Assign at least one user as owner and then retry."
961
            )
962

963
        change = _AuthzChange(
1✔
964
            apply=WriteRelationshipsRequest(updates=add_members), undo=WriteRelationshipsRequest(updates=undo)
965
        )
966
        await self.client.WriteRelationships(change.apply)
1✔
967
        return output
1✔
968

969
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
970
    async def remove_project_members(
2✔
971
        self,
972
        user: base_models.APIUser,
973
        resource_type: ResourceType,
974
        resource_id: ULID,
975
        user_ids: list[str],
976
        *,
977
        zed_token: ZedToken | None = None,
978
    ) -> list[MembershipChange]:
979
        """Remove the specific members from the project, then return the list of members that were removed."""
980
        resource_id_str = str(resource_id)
1✔
981
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
982
        add_members: list[RelationshipUpdate] = []
1✔
983
        remove_members: list[RelationshipUpdate] = []
1✔
984
        output: list[MembershipChange] = []
1✔
985
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
986
        existing_owners: set[str] = {rel.relationship.subject.object.object_id for rel in existing_owners_rels}
1✔
987
        for user_id in user_ids:
1✔
988
            if user_id == "*":
1✔
989
                raise errors.ValidationError(message="Cannot remove a project member with ID '*'")
×
990
            existing_rel_filter = RelationshipFilter(
1✔
991
                resource_type=resource_type.value,
992
                optional_resource_id=resource_id_str,
993
                optional_subject_filter=SubjectFilter(subject_type=ResourceType.user, optional_subject_id=user_id),
994
            )
995
            existing_rels: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
996
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
997
            )
998
            # NOTE: We have to make sure that when we undo we only put back relationships that existed already.
999
            # Blindly undoing everything that was passed in may result in adding things that weren't there before.
1000
            async for existing_rel in existing_rels:
1✔
1001
                if existing_rel.relationship.relation == _Relation.owner.value and user_id in existing_owners:
1✔
1002
                    if len(existing_owners) == 1:
1✔
1003
                        raise errors.ValidationError(
1✔
1004
                            message="You are trying to remove the single last owner of the project, "
1005
                            "which is not allowed. Assign another user as owner and then retry."
1006
                        )
1007
                    existing_owners.remove(user_id)
1✔
1008
                add_members.append(
1✔
1009
                    RelationshipUpdate(
1010
                        operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
1011
                    )
1012
                )
1013
                remove_members.append(
1✔
1014
                    RelationshipUpdate(
1015
                        operation=RelationshipUpdate.OPERATION_DELETE, relationship=existing_rel.relationship
1016
                    )
1017
                )
1018
                output.append(
1✔
1019
                    MembershipChange(
1020
                        Member(
1021
                            Role(existing_rel.relationship.relation),
1022
                            existing_rel.relationship.subject.object.object_id,
1023
                            resource_id,
1024
                        ),
1025
                        Change.REMOVE,
1026
                    ),
1027
                )
1028
        change = _AuthzChange(
1✔
1029
            apply=WriteRelationshipsRequest(updates=remove_members), undo=WriteRelationshipsRequest(updates=add_members)
1030
        )
1031
        await self.client.WriteRelationships(change.apply)
1✔
1032
        return output
1✔
1033

1034
    async def _get_admin_user_ids(self) -> list[str]:
2✔
1035
        platform = _AuthzConverter.platform()
2✔
1036
        sub_filter = SubjectFilter(subject_type=ResourceType.user.value)
2✔
1037
        rel_filter = RelationshipFilter(
2✔
1038
            resource_type=platform.object_type,
1039
            optional_resource_id=platform.object_id,
1040
            optional_subject_filter=sub_filter,
1041
        )
1042
        existing_admins: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
2✔
1043
            ReadRelationshipsRequest(
1044
                consistency=Consistency(fully_consistent=True),
1045
                relationship_filter=rel_filter,
1046
            )
1047
        )
1048
        return [admin.relationship.subject.object.object_id async for admin in existing_admins]
2✔
1049

1050
    def _add_admin(self, user_id: str) -> _AuthzChange:
2✔
1051
        """Add a deployment-wide administrator in the authorization database."""
1052
        rel = Relationship(
2✔
1053
            resource=_AuthzConverter.platform(),
1054
            relation=_Relation.admin.value,
1055
            subject=_AuthzConverter.user_subject(user_id),
1056
        )
1057
        apply = WriteRelationshipsRequest(
2✔
1058
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel)]
1059
        )
1060
        undo = WriteRelationshipsRequest(
2✔
1061
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel)]
1062
        )
1063
        return _AuthzChange(apply=apply, undo=undo)
2✔
1064

1065
    async def _remove_admin(self, user_id: str) -> _AuthzChange:
2✔
1066
        """Remove a deployment-wide administrator from the authorization database."""
1067
        existing_admin_ids = await self._get_admin_user_ids()
1✔
1068
        rel = Relationship(
1✔
1069
            resource=_AuthzConverter.platform(),
1070
            relation=_Relation.admin.value,
1071
            subject=_AuthzConverter.user_subject(user_id),
1072
        )
1073
        apply = WriteRelationshipsRequest(
1✔
1074
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel)]
1075
        )
1076
        undo = WriteRelationshipsRequest()
1✔
1077
        if user_id in existing_admin_ids:
1✔
1078
            undo = WriteRelationshipsRequest(
1✔
1079
                updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel)]
1080
            )
1081
        return _AuthzChange(apply=apply, undo=undo)
1✔
1082

1083
    def _add_group(self, group: Group) -> _AuthzChange:
2✔
1084
        """Add a group to the authorization database."""
1085
        if not group.id:
2✔
1086
            raise errors.ProgrammingError(
×
1087
                message="Cannot create a group in the authorization database if its ID is missing."
1088
            )
1089
        creator = SubjectReference(object=_AuthzConverter.user(group.created_by))
2✔
1090
        group_res = _AuthzConverter.group(group.id)
2✔
1091
        creator_is_owner = Relationship(resource=group_res, relation=_Relation.owner.value, subject=creator)
2✔
1092
        all_users = SubjectReference(object=_AuthzConverter.all_users())
2✔
1093
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
2✔
1094
        group_in_platform = Relationship(
2✔
1095
            resource=group_res,
1096
            relation=_Relation.group_platform.value,
1097
            subject=SubjectReference(object=self._platform),
1098
        )
1099
        all_users_are_public_viewers = Relationship(
2✔
1100
            resource=group_res,
1101
            relation=_Relation.public_viewer.value,
1102
            subject=all_users,
1103
        )
1104
        all_anon_users_are_public_viewers = Relationship(
2✔
1105
            resource=group_res,
1106
            relation=_Relation.public_viewer.value,
1107
            subject=all_anon_users,
1108
        )
1109
        relationships = [
2✔
1110
            creator_is_owner,
1111
            group_in_platform,
1112
            all_users_are_public_viewers,
1113
            all_anon_users_are_public_viewers,
1114
        ]
1115
        apply = WriteRelationshipsRequest(
2✔
1116
            updates=[
1117
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
1118
            ]
1119
        )
1120
        undo = WriteRelationshipsRequest(
2✔
1121
            updates=[
1122
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
1123
            ]
1124
        )
1125
        return _AuthzChange(apply=apply, undo=undo)
2✔
1126

1127
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
1128
    async def upsert_group_members(
2✔
1129
        self,
1130
        user: base_models.APIUser,
1131
        resource_type: ResourceType,
1132
        resource_id: ULID,
1133
        members: list[Member],
1134
        *,
1135
        zed_token: ZedToken | None = None,
1136
    ) -> list[MembershipChange]:
1137
        """Insert or update group member roles."""
1138
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1139
        group_res = _AuthzConverter.group(resource_id)
1✔
1140
        add_members: list[RelationshipUpdate] = []
1✔
1141
        undo: list[RelationshipUpdate] = []
1✔
1142
        output: list[MembershipChange] = []
1✔
1143
        resource_id_str = str(resource_id)
1✔
1144
        expected_user_roles = {_Relation.viewer.value, _Relation.owner.value, _Relation.editor.value}
1✔
1145
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
1146
        n_existing_owners = len(existing_owners_rels)
1✔
1147
        for member in members:
1✔
1148
            rel = Relationship(
1✔
1149
                resource=group_res,
1150
                relation=_Relation.from_role(member.role).value,
1151
                subject=SubjectReference(object=_AuthzConverter.user(member.user_id)),
1152
            )
1153
            existing_rel_filter = RelationshipFilter(
1✔
1154
                resource_type=resource_type.value,
1155
                optional_resource_id=resource_id_str,
1156
                optional_subject_filter=SubjectFilter(
1157
                    subject_type=ResourceType.user, optional_subject_id=member.user_id
1158
                ),
1159
            )
1160
            existing_rels_result: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1161
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
1162
            )
1163

1164
            existing_rels = [i async for i in existing_rels_result if i.relationship.relation in expected_user_roles]
1✔
1165
            if len(existing_rels) > 0:
1✔
1166
                # The existing relationships should be deleted if all goes well and added back in if we have to undo
1167
                existing_rel = existing_rels[0]
1✔
1168
                if existing_rel.relationship != rel:
1✔
1169
                    if existing_rel.relationship.relation == _Relation.owner.value:
1✔
1170
                        n_existing_owners -= 1
1✔
1171
                    elif rel.relation == _Relation.owner.value:
1✔
1172
                        n_existing_owners += 1
1✔
1173

1174
                    add_members.extend(
1✔
1175
                        [
1176
                            RelationshipUpdate(
1177
                                operation=RelationshipUpdate.OPERATION_TOUCH,
1178
                                relationship=rel,
1179
                            ),
1180
                            # NOTE: The old role for the user still exists and we have to remove it
1181
                            # if not both the old and new role for the same user will be present in the database
1182
                            RelationshipUpdate(
1183
                                operation=RelationshipUpdate.OPERATION_DELETE,
1184
                                relationship=existing_rel.relationship,
1185
                            ),
1186
                        ]
1187
                    )
1188
                    undo.extend(
1✔
1189
                        [
1190
                            RelationshipUpdate(
1191
                                operation=RelationshipUpdate.OPERATION_TOUCH,
1192
                                relationship=existing_rel.relationship,
1193
                            ),
1194
                            RelationshipUpdate(
1195
                                operation=RelationshipUpdate.OPERATION_DELETE,
1196
                                relationship=rel,
1197
                            ),
1198
                        ]
1199
                    )
1200
                    output.append(MembershipChange(member, Change.UPDATE))
1✔
1201
                for rel_to_remove in existing_rels[1:]:
1✔
1202
                    # NOTE: This means that the user has more than 1 role on the group - which should not happen
1203
                    # But if this does occur then we simply delete the extra roles of the user here.
1204
                    logger.warning(
×
1205
                        f"Removing additional unexpected role {rel_to_remove.relationship.relation} "
1206
                        f"of user {member.user_id} on group {resource_id}, "
1207
                        f"kept role {existing_rel.relationship.relation} which will be updated to {rel.relation}."
1208
                    )
1209
                    add_members.append(
×
1210
                        RelationshipUpdate(
1211
                            operation=RelationshipUpdate.OPERATION_DELETE,
1212
                            relationship=rel_to_remove.relationship,
1213
                        ),
1214
                    )
1215
                    undo.append(
×
1216
                        RelationshipUpdate(
1217
                            operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel_to_remove.relationship
1218
                        ),
1219
                    )
1220
                    output.append(MembershipChange(member, Change.REMOVE))
×
1221
            else:
1222
                if rel.relation == _Relation.owner.value:
1✔
1223
                    n_existing_owners += 1
1✔
1224

1225
                # The new relationship is added if all goes well and deleted if we have to undo
1226
                add_members.append(
1✔
1227
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel),
1228
                )
1229
                undo.append(
1✔
1230
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel),
1231
                )
1232
                output.append(MembershipChange(member, Change.ADD))
1✔
1233

1234
        if n_existing_owners == 0:
1✔
1235
            raise errors.ValidationError(
1✔
1236
                message="You are trying to change the role of all the owners of the group, which is not allowed. "
1237
                "Assign at least one user as owner and then retry."
1238
            )
1239

1240
        change = _AuthzChange(
1✔
1241
            apply=WriteRelationshipsRequest(updates=add_members), undo=WriteRelationshipsRequest(updates=undo)
1242
        )
1243
        await self.client.WriteRelationships(change.apply)
1✔
1244
        return output
1✔
1245

1246
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
1247
    async def remove_group_members(
2✔
1248
        self,
1249
        user: base_models.APIUser,
1250
        resource_type: ResourceType,
1251
        resource_id: ULID,
1252
        user_ids: list[str],
1253
        *,
1254
        zed_token: ZedToken | None = None,
1255
    ) -> list[MembershipChange]:
1256
        """Remove the specific members from the group, then return the list of members that were removed."""
1257
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1258
        add_members: list[RelationshipUpdate] = []
1✔
1259
        remove_members: list[RelationshipUpdate] = []
1✔
1260
        output: list[MembershipChange] = []
1✔
1261
        existing_owners_rels: list[ReadRelationshipsResponse] | None = None
1✔
1262
        resource_id_str = str(resource_id)
1✔
1263
        for user_id in user_ids:
1✔
1264
            if user_id == "*":
1✔
1265
                raise errors.ValidationError(message="Cannot remove a group member with ID '*'")
×
1266
            existing_rel_filter = RelationshipFilter(
1✔
1267
                resource_type=resource_type.value,
1268
                optional_resource_id=resource_id_str,
1269
                optional_subject_filter=SubjectFilter(subject_type=ResourceType.user, optional_subject_id=user_id),
1270
            )
1271
            existing_rels: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1272
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
1273
            )
1274
            # NOTE: We have to make sure that when we undo we only put back relationships that existed already.
1275
            # Blindly undoing everything that was passed in may result in adding things that weren't there before.
1276
            async for existing_rel in existing_rels:
1✔
1277
                if existing_rel.relationship.relation == _Relation.owner.value:
1✔
1278
                    if existing_owners_rels is None:
1✔
1279
                        existing_owners_rels = await self._get_resource_owners(
1✔
1280
                            resource_type, resource_id_str, consistency
1281
                        )
1282
                    if len(existing_owners_rels) == 1:
1✔
1283
                        raise errors.ValidationError(
1✔
1284
                            message="You are trying to remove the single last owner of the group, "
1285
                            "which is not allowed. Assign another user as owner and then retry."
1286
                        )
1287
                add_members.append(
1✔
1288
                    RelationshipUpdate(
1289
                        operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
1290
                    )
1291
                )
1292
                remove_members.append(
1✔
1293
                    RelationshipUpdate(
1294
                        operation=RelationshipUpdate.OPERATION_DELETE, relationship=existing_rel.relationship
1295
                    )
1296
                )
1297
                output.append(
1✔
1298
                    MembershipChange(
1299
                        Member(
1300
                            Role(existing_rel.relationship.relation),
1301
                            existing_rel.relationship.subject.object.object_id,
1302
                            resource_id,
1303
                        ),
1304
                        Change.REMOVE,
1305
                    ),
1306
                )
1307
        change = _AuthzChange(
1✔
1308
            apply=WriteRelationshipsRequest(updates=remove_members), undo=WriteRelationshipsRequest(updates=add_members)
1309
        )
1310
        await self.client.WriteRelationships(change.apply)
1✔
1311
        return output
1✔
1312

1313
    def _add_user_namespace(self, namespace: Namespace) -> _AuthzChange:
2✔
1314
        """Add a user namespace to the authorization database."""
1315
        if not namespace.id:
2✔
1316
            raise errors.ProgrammingError(
×
1317
                message="Cannot create a user namespace in the authorization database if its ID is missing."
1318
            )
1319
        creator = SubjectReference(object=_AuthzConverter.user(namespace.created_by))
2✔
1320
        namespace_res = _AuthzConverter.user_namespace(namespace.id)
2✔
1321
        creator_is_owner = Relationship(resource=namespace_res, relation=_Relation.owner.value, subject=creator)
2✔
1322
        all_users = SubjectReference(object=_AuthzConverter.all_users())
2✔
1323
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
2✔
1324
        namespace_in_platform = Relationship(
2✔
1325
            resource=namespace_res,
1326
            relation=_Relation.user_namespace_platform.value,
1327
            subject=SubjectReference(object=self._platform),
1328
        )
1329
        all_users_are_public_viewers = Relationship(
2✔
1330
            resource=namespace_res,
1331
            relation=_Relation.public_viewer.value,
1332
            subject=all_users,
1333
        )
1334
        all_anon_users_are_public_viewers = Relationship(
2✔
1335
            resource=namespace_res,
1336
            relation=_Relation.public_viewer.value,
1337
            subject=all_anon_users,
1338
        )
1339
        relationships = [
2✔
1340
            creator_is_owner,
1341
            namespace_in_platform,
1342
            all_users_are_public_viewers,
1343
            all_anon_users_are_public_viewers,
1344
        ]
1345
        apply = WriteRelationshipsRequest(
2✔
1346
            updates=[
1347
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
1348
            ]
1349
        )
1350
        undo = WriteRelationshipsRequest(
2✔
1351
            updates=[
1352
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
1353
            ]
1354
        )
1355
        return _AuthzChange(apply=apply, undo=undo)
2✔
1356

1357
    # TODO: remove this method and replace it with _remove_entity()
1358
    async def _remove_user_namespace(self, user_id: str, zed_token: ZedToken | None = None) -> _AuthzChange:
2✔
1359
        """Remove the user namespace from the authorization database."""
1360
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1361
        rel_filter = RelationshipFilter(resource_type=ResourceType.user_namespace.value, optional_resource_id=user_id)
1✔
1362
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1363
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1364
        )
1365
        rels: list[Relationship] = []
1✔
1366
        async for response in responses:
1✔
1367
            rels.append(response.relationship)
×
1368
        apply = WriteRelationshipsRequest(
1✔
1369
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1370
        )
1371
        undo = WriteRelationshipsRequest(
1✔
1372
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1373
        )
1374
        return _AuthzChange(apply=apply, undo=undo)
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc