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

SwissDataScienceCenter / renku-data-services / 10490359484

21 Aug 2024 12:56PM UTC coverage: 90.485% (+0.05%) from 90.438%
10490359484

Pull #346

github

web-flow
Merge 5abb7ddc0 into a79eda32f
Pull Request #346: refactor: add custom ULID type for sqlalchemy

158 of 159 new or added lines in 34 files covered. (99.37%)

8 existing lines in 2 files now uncovered.

9263 of 10237 relevant lines covered (90.49%)

1.6 hits per line

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

90.89
/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 UserWithNamespace, UserWithNamespaceUpdate
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 | UserWithNamespaceUpdate | list[UserWithNamespace] | 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_project(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_group(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, UserWithNamespaceUpdate):
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, UserWithNamespace):
×
502
                            raise errors.ProgrammingError(
×
503
                                message="Expected list of UserWithNamespace 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 _:
×
NEW
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
    def _add_project(self, project: Project) -> _AuthzChange:
2✔
573
        """Create the new project and associated resources and relations in the DB."""
574
        creator = SubjectReference(object=_AuthzConverter.user(project.created_by))
1✔
575
        project_res = _AuthzConverter.project(project.id)
1✔
576
        creator_is_owner = Relationship(resource=project_res, relation=_Relation.owner.value, subject=creator)
1✔
577
        all_users = SubjectReference(object=_AuthzConverter.all_users())
1✔
578
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
579
        project_namespace = SubjectReference(
1✔
580
            object=(
581
                _AuthzConverter.user_namespace(project.namespace.id)
582
                if project.namespace.kind == NamespaceKind.user
583
                else _AuthzConverter.group(cast(ULID, project.namespace.underlying_resource_id))
584
            )
585
        )
586
        project_in_platform = Relationship(
1✔
587
            resource=project_res,
588
            relation=_Relation.project_platform.value,
589
            subject=SubjectReference(object=self._platform),
590
        )
591
        project_in_namespace = Relationship(
1✔
592
            resource=project_res,
593
            relation=_Relation.project_namespace,
594
            subject=project_namespace,
595
        )
596
        relationships = [creator_is_owner, project_in_platform, project_in_namespace]
1✔
597
        if project.visibility == Visibility.PUBLIC:
1✔
598
            all_users_are_viewers = Relationship(
1✔
599
                resource=project_res,
600
                relation=_Relation.viewer.value,
601
                subject=all_users,
602
            )
603
            all_anon_users_are_viewers = Relationship(
1✔
604
                resource=project_res,
605
                relation=_Relation.viewer.value,
606
                subject=all_anon_users,
607
            )
608
            relationships.extend([all_users_are_viewers, all_anon_users_are_viewers])
1✔
609
        apply = WriteRelationshipsRequest(
1✔
610
            updates=[
611
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
612
            ]
613
        )
614
        undo = WriteRelationshipsRequest(
1✔
615
            updates=[
616
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
617
            ]
618
        )
619
        return _AuthzChange(apply=apply, undo=undo)
1✔
620

621
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
622
    async def _remove_project(
2✔
623
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
624
    ) -> _AuthzChange:
625
        """Remove the relationships associated with the project."""
626
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
627
        rel_filter = RelationshipFilter(resource_type=ResourceType.project.value, optional_resource_id=str(project.id))
1✔
628
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
629
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
630
        )
631
        rels: list[Relationship] = []
1✔
632
        async for response in responses:
1✔
633
            rels.append(response.relationship)
1✔
634
        apply = WriteRelationshipsRequest(
1✔
635
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
636
        )
637
        undo = WriteRelationshipsRequest(
1✔
638
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
639
        )
640
        return _AuthzChange(apply=apply, undo=undo)
1✔
641

642
    # NOTE changing visibility is the same access level as removal
643
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
644
    async def _update_project_visibility(
2✔
645
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
646
    ) -> _AuthzChange:
647
        """Update the visibility of the project in the authorization database."""
648
        project_id_str = str(project.id)
1✔
649
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
650
        project_res = _AuthzConverter.project(project.id)
1✔
651
        all_users_sub = SubjectReference(object=_AuthzConverter.all_users())
1✔
652
        anon_users_sub = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
653
        all_users_are_viewers = Relationship(
1✔
654
            resource=project_res,
655
            relation=_Relation.viewer.value,
656
            subject=all_users_sub,
657
        )
658
        anon_users_are_viewers = Relationship(
1✔
659
            resource=project_res,
660
            relation=_Relation.viewer.value,
661
            subject=anon_users_sub,
662
        )
663
        make_public = WriteRelationshipsRequest(
1✔
664
            updates=[
665
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=all_users_are_viewers),
666
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=anon_users_are_viewers),
667
            ]
668
        )
669
        make_private = WriteRelationshipsRequest(
1✔
670
            updates=[
671
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=all_users_are_viewers),
672
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=anon_users_are_viewers),
673
            ]
674
        )
675
        rel_filter = RelationshipFilter(
1✔
676
            resource_type=ResourceType.project.value,
677
            optional_resource_id=project_id_str,
678
            optional_subject_filter=SubjectFilter(
679
                subject_type=ResourceType.user.value, optional_subject_id=all_users_sub.object.object_id
680
            ),
681
        )
682
        current_relation_users: ReadRelationshipsResponse | None = await anext(
1✔
683
            aiter(self.client.ReadRelationships(ReadRelationshipsRequest(relationship_filter=rel_filter))), None
684
        )
685
        rel_filter = RelationshipFilter(
1✔
686
            resource_type=ResourceType.project.value,
687
            optional_resource_id=project_id_str,
688
            optional_subject_filter=SubjectFilter(
689
                subject_type=ResourceType.anonymous_user.value,
690
                optional_subject_id=anon_users_sub.object.object_id,
691
            ),
692
        )
693
        current_relation_anon_users: ReadRelationshipsResponse | None = await anext(
1✔
694
            aiter(
695
                self.client.ReadRelationships(
696
                    ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
697
                )
698
            ),
699
            None,
700
        )
701
        project_is_public_for_users = (
1✔
702
            current_relation_users is not None
703
            and current_relation_users.relationship.subject.object.object_type == ResourceType.user.value
704
            and current_relation_users.relationship.subject.object.object_id == all_users_sub.object.object_id
705
        )
706
        project_is_public_for_anon_users = (
1✔
707
            current_relation_anon_users is not None
708
            and current_relation_anon_users.relationship.subject.object.object_type == ResourceType.anonymous_user.value
709
            and current_relation_anon_users.relationship.subject.object.object_id == anon_users_sub.object.object_id,
710
        )
711
        project_already_public = project_is_public_for_users and project_is_public_for_anon_users
1✔
712
        project_already_private = not project_already_public
1✔
713
        match project.visibility:
1✔
714
            case Visibility.PUBLIC:
1✔
715
                if project_already_public:
1✔
716
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
717
                return _AuthzChange(apply=make_public, undo=make_private)
1✔
718
            case Visibility.PRIVATE:
1✔
719
                if project_already_private:
1✔
720
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
721
                return _AuthzChange(apply=make_private, undo=make_public)
1✔
722
        raise errors.ProgrammingError(
×
723
            message=f"Encountered unknown project visibility {project.visibility} when trying to "
724
            f"make a visibility change for project with ID {project.id}",
725
        )
726

727
    # NOTE changing namespace is the same access level as removal
728
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
729
    async def _update_project_namespace(
2✔
730
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
731
    ) -> _AuthzChange:
732
        """Update the namespace/group of the project in the authorization database."""
733
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
734
        project_res = _AuthzConverter.project(project.id)
1✔
735
        project_namespace_filter = RelationshipFilter(
1✔
736
            resource_type=ResourceType.project.value,
737
            optional_resource_id=str(project.id),
738
            optional_relation=_Relation.project_namespace.value,
739
        )
740
        current_namespace: ReadRelationshipsResponse | None = await anext(
1✔
741
            aiter(
742
                self.client.ReadRelationships(
743
                    ReadRelationshipsRequest(relationship_filter=project_namespace_filter, consistency=consistency)
744
                )
745
            ),
746
            None,
747
        )
748
        if not current_namespace:
1✔
749
            raise errors.ProgrammingError(
×
750
                message=f"The project with ID {project.id} whose namespace is being updated "
751
                "does not currently have a namespace"
752
            )
753
        if current_namespace.relationship.subject.object.object_id == project.namespace.id:
1✔
754
            return _AuthzChange()
×
755
        new_namespace_sub = (
1✔
756
            SubjectReference(object=_AuthzConverter.group(project.namespace.id))
757
            if project.namespace.kind == NamespaceKind.group
758
            else SubjectReference(object=_AuthzConverter.user_namespace(project.namespace.id))
759
        )
760
        old_namespace_sub = (
1✔
761
            SubjectReference(
762
                object=_AuthzConverter.group(ULID.from_str(current_namespace.relationship.subject.object.object_id))
763
            )
764
            if current_namespace.relationship.subject.object.object_type == ResourceType.group.value
765
            else SubjectReference(
766
                object=_AuthzConverter.user_namespace(
767
                    ULID.from_str(current_namespace.relationship.subject.object.object_id)
768
                )
769
            )
770
        )
771
        new_namespace = Relationship(
1✔
772
            resource=project_res,
773
            relation=_Relation.project_namespace.value,
774
            subject=new_namespace_sub,
775
        )
776
        old_namespace = Relationship(
1✔
777
            resource=project_res,
778
            relation=_Relation.project_namespace.value,
779
            subject=old_namespace_sub,
780
        )
781
        apply_change = WriteRelationshipsRequest(
1✔
782
            updates=[
783
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=new_namespace),
784
            ]
785
        )
786
        undo_change = WriteRelationshipsRequest(
1✔
787
            updates=[
788
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=old_namespace),
789
            ]
790
        )
791
        return _AuthzChange(apply=apply_change, undo=undo_change)
1✔
792

793
    async def _get_resource_owners(
2✔
794
        self, resource_type: ResourceType, resource_id: str, consistency: Consistency
795
    ) -> list[ReadRelationshipsResponse]:
796
        existing_owners_filter = RelationshipFilter(
1✔
797
            resource_type=resource_type.value,
798
            optional_resource_id=resource_id,
799
            optional_subject_filter=SubjectFilter(subject_type=ResourceType.user),
800
            optional_relation=_Relation.owner.value,
801
        )
802
        return [
1✔
803
            i
804
            async for i in self.client.ReadRelationships(
805
                ReadRelationshipsRequest(
806
                    consistency=consistency,
807
                    relationship_filter=existing_owners_filter,
808
                )
809
            )
810
        ]
811

812
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
813
    async def upsert_project_members(
2✔
814
        self,
815
        user: base_models.APIUser,
816
        resource_type: ResourceType,
817
        resource_id: ULID,
818
        members: list[Member],
819
        *,
820
        zed_token: ZedToken | None = None,
821
    ) -> list[MembershipChange]:
822
        """Updates the project members or inserts them if they do not exist.
823

824
        Returns the list that was updated/inserted.
825
        """
826
        resource_id_str = str(resource_id)
1✔
827
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
828
        project_res = _AuthzConverter.project(resource_id)
1✔
829
        add_members: list[RelationshipUpdate] = []
1✔
830
        undo: list[RelationshipUpdate] = []
1✔
831
        output: list[MembershipChange] = []
1✔
832
        expected_user_roles = {_Relation.viewer.value, _Relation.owner.value, _Relation.editor.value}
1✔
833
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
834
        n_existing_owners = len(existing_owners_rels)
1✔
835
        for member in members:
1✔
836
            rel = Relationship(
1✔
837
                resource=project_res,
838
                relation=_Relation.from_role(member.role).value,
839
                subject=SubjectReference(object=_AuthzConverter.user(member.user_id)),
840
            )
841
            existing_rel_filter = RelationshipFilter(
1✔
842
                resource_type=resource_type.value,
843
                optional_resource_id=resource_id_str,
844
                optional_subject_filter=SubjectFilter(
845
                    subject_type=ResourceType.user, optional_subject_id=member.user_id
846
                ),
847
            )
848
            existing_rels_iter: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
849
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
850
            )
851
            existing_rels = [i async for i in existing_rels_iter if i.relationship.relation in expected_user_roles]
1✔
852
            if len(existing_rels) > 0:
1✔
853
                # The existing relationships should be deleted if all goes well and added back in if we have to undo
854
                existing_rel = existing_rels[0]
1✔
855
                if existing_rel.relationship != rel:
1✔
856
                    if existing_rel.relationship.relation == _Relation.owner.value:
1✔
857
                        n_existing_owners -= 1
1✔
858
                    elif rel.relation == _Relation.owner.value:
1✔
859
                        n_existing_owners += 1
1✔
860

861
                    add_members.extend(
1✔
862
                        [
863
                            RelationshipUpdate(
864
                                operation=RelationshipUpdate.OPERATION_TOUCH,
865
                                relationship=rel,
866
                            ),
867
                            # NOTE: The old role for the user still exists and we have to remove it
868
                            # if not both the old and new role for the same user will be present in the database
869
                            RelationshipUpdate(
870
                                operation=RelationshipUpdate.OPERATION_DELETE,
871
                                relationship=existing_rel.relationship,
872
                            ),
873
                        ]
874
                    )
875
                    undo.extend(
1✔
876
                        [
877
                            RelationshipUpdate(
878
                                operation=RelationshipUpdate.OPERATION_DELETE,
879
                                relationship=rel,
880
                            ),
881
                            RelationshipUpdate(
882
                                operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
883
                            ),
884
                        ]
885
                    )
886
                    output.append(MembershipChange(member, Change.UPDATE))
1✔
887
                for rel_to_remove in existing_rels[1:]:
1✔
888
                    # NOTE: This means that the user has more than 1 role on the project - which should not happen
889
                    # But if this does occur then we simply delete the extra roles of the user here.
890
                    logger.warning(
×
891
                        f"Removing additional unexpected role {rel_to_remove.relationship.relation} "
892
                        f"of user {member.user_id} on project {resource_id}, "
893
                        f"kept role {existing_rel.relationship.relation} which will be updated to {rel.relation}."
894
                    )
895
                    add_members.append(
×
896
                        RelationshipUpdate(
897
                            operation=RelationshipUpdate.OPERATION_DELETE,
898
                            relationship=rel_to_remove.relationship,
899
                        ),
900
                    )
901
                    undo.append(
×
902
                        RelationshipUpdate(
903
                            operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel_to_remove.relationship
904
                        ),
905
                    )
906
                    output.append(MembershipChange(member, Change.REMOVE))
×
907
            else:
908
                if rel.relation == _Relation.owner.value:
1✔
909
                    n_existing_owners += 1
1✔
910
                # The new relationship is added if all goes well and deleted if we have to undo
911
                add_members.append(
1✔
912
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel),
913
                )
914
                undo.append(
1✔
915
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel),
916
                )
917
                output.append(MembershipChange(member, Change.ADD))
1✔
918

919
        if n_existing_owners == 0:
1✔
920
            raise errors.ValidationError(
1✔
921
                message="You are trying to change the role of all the owners of the project, which is not allowed. "
922
                "Assign at least one user as owner and then retry."
923
            )
924

925
        change = _AuthzChange(
1✔
926
            apply=WriteRelationshipsRequest(updates=add_members), undo=WriteRelationshipsRequest(updates=undo)
927
        )
928
        await self.client.WriteRelationships(change.apply)
1✔
929
        return output
1✔
930

931
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
932
    async def remove_project_members(
2✔
933
        self,
934
        user: base_models.APIUser,
935
        resource_type: ResourceType,
936
        resource_id: ULID,
937
        user_ids: list[str],
938
        *,
939
        zed_token: ZedToken | None = None,
940
    ) -> list[MembershipChange]:
941
        """Remove the specific members from the project, then return the list of members that were removed."""
942
        resource_id_str = str(resource_id)
1✔
943
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
944
        add_members: list[RelationshipUpdate] = []
1✔
945
        remove_members: list[RelationshipUpdate] = []
1✔
946
        output: list[MembershipChange] = []
1✔
947
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
948
        existing_owners: set[str] = {rel.relationship.subject.object.object_id for rel in existing_owners_rels}
1✔
949
        for user_id in user_ids:
1✔
950
            if user_id == "*":
1✔
951
                raise errors.ValidationError(message="Cannot remove a project member with ID '*'")
×
952
            existing_rel_filter = RelationshipFilter(
1✔
953
                resource_type=resource_type.value,
954
                optional_resource_id=resource_id_str,
955
                optional_subject_filter=SubjectFilter(subject_type=ResourceType.user, optional_subject_id=user_id),
956
            )
957
            existing_rels: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
958
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
959
            )
960
            # NOTE: We have to make sure that when we undo we only put back relationships that existed already.
961
            # Blindly undoing everything that was passed in may result in adding things that weren't there before.
962
            async for existing_rel in existing_rels:
1✔
963
                if existing_rel.relationship.relation == _Relation.owner.value and user_id in existing_owners:
1✔
964
                    if len(existing_owners) == 1:
1✔
965
                        raise errors.ValidationError(
1✔
966
                            message="You are trying to remove the single last owner of the project, "
967
                            "which is not allowed. Assign another user as owner and then retry."
968
                        )
969
                    existing_owners.remove(user_id)
1✔
970
                add_members.append(
1✔
971
                    RelationshipUpdate(
972
                        operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
973
                    )
974
                )
975
                remove_members.append(
1✔
976
                    RelationshipUpdate(
977
                        operation=RelationshipUpdate.OPERATION_DELETE, relationship=existing_rel.relationship
978
                    )
979
                )
980
                output.append(
1✔
981
                    MembershipChange(
982
                        Member(
983
                            Role(existing_rel.relationship.relation),
984
                            existing_rel.relationship.subject.object.object_id,
985
                            resource_id,
986
                        ),
987
                        Change.REMOVE,
988
                    ),
989
                )
990
        change = _AuthzChange(
1✔
991
            apply=WriteRelationshipsRequest(updates=remove_members), undo=WriteRelationshipsRequest(updates=add_members)
992
        )
993
        await self.client.WriteRelationships(change.apply)
1✔
994
        return output
1✔
995

996
    async def _get_admin_user_ids(self) -> list[str]:
2✔
997
        platform = _AuthzConverter.platform()
2✔
998
        sub_filter = SubjectFilter(subject_type=ResourceType.user.value)
2✔
999
        rel_filter = RelationshipFilter(
2✔
1000
            resource_type=platform.object_type,
1001
            optional_resource_id=platform.object_id,
1002
            optional_subject_filter=sub_filter,
1003
        )
1004
        existing_admins: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
2✔
1005
            ReadRelationshipsRequest(
1006
                consistency=Consistency(fully_consistent=True),
1007
                relationship_filter=rel_filter,
1008
            )
1009
        )
1010
        return [admin.relationship.subject.object.object_id async for admin in existing_admins]
2✔
1011

1012
    def _add_admin(self, user_id: str) -> _AuthzChange:
2✔
1013
        """Add a deployment-wide administrator in the authorization database."""
1014
        rel = Relationship(
2✔
1015
            resource=_AuthzConverter.platform(),
1016
            relation=_Relation.admin.value,
1017
            subject=_AuthzConverter.user_subject(user_id),
1018
        )
1019
        apply = WriteRelationshipsRequest(
2✔
1020
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel)]
1021
        )
1022
        undo = WriteRelationshipsRequest(
2✔
1023
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel)]
1024
        )
1025
        return _AuthzChange(apply=apply, undo=undo)
2✔
1026

1027
    async def _remove_admin(self, user_id: str) -> _AuthzChange:
2✔
1028
        """Add a deployment-wide administrator in the authorization database."""
1029
        existing_admin_ids = await self._get_admin_user_ids()
1✔
1030
        rel = Relationship(
1✔
1031
            resource=_AuthzConverter.platform(),
1032
            relation=_Relation.admin.value,
1033
            subject=_AuthzConverter.user_subject(user_id),
1034
        )
1035
        apply = WriteRelationshipsRequest(
1✔
1036
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel)]
1037
        )
1038
        undo = WriteRelationshipsRequest()
1✔
1039
        if user_id in existing_admin_ids:
1✔
1040
            undo = WriteRelationshipsRequest(
1✔
1041
                updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel)]
1042
            )
1043
        return _AuthzChange(apply=apply, undo=undo)
1✔
1044

1045
    def _add_group(self, group: Group) -> _AuthzChange:
2✔
1046
        """Add a group to the authorization database."""
1047
        if not group.id:
2✔
1048
            raise errors.ProgrammingError(
×
1049
                message="Cannot create a group in the authorization database if its ID is missing."
1050
            )
1051
        creator = SubjectReference(object=_AuthzConverter.user(group.created_by))
2✔
1052
        group_res = _AuthzConverter.group(group.id)
2✔
1053
        creator_is_owner = Relationship(resource=group_res, relation=_Relation.owner.value, subject=creator)
2✔
1054
        all_users = SubjectReference(object=_AuthzConverter.all_users())
2✔
1055
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
2✔
1056
        group_in_platform = Relationship(
2✔
1057
            resource=group_res,
1058
            relation=_Relation.group_platform.value,
1059
            subject=SubjectReference(object=self._platform),
1060
        )
1061
        all_users_are_public_viewers = Relationship(
2✔
1062
            resource=group_res,
1063
            relation=_Relation.public_viewer.value,
1064
            subject=all_users,
1065
        )
1066
        all_anon_users_are_public_viewers = Relationship(
2✔
1067
            resource=group_res,
1068
            relation=_Relation.public_viewer.value,
1069
            subject=all_anon_users,
1070
        )
1071
        relationships = [
2✔
1072
            creator_is_owner,
1073
            group_in_platform,
1074
            all_users_are_public_viewers,
1075
            all_anon_users_are_public_viewers,
1076
        ]
1077
        apply = WriteRelationshipsRequest(
2✔
1078
            updates=[
1079
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
1080
            ]
1081
        )
1082
        undo = WriteRelationshipsRequest(
2✔
1083
            updates=[
1084
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
1085
            ]
1086
        )
1087
        return _AuthzChange(apply=apply, undo=undo)
2✔
1088

1089
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.group)
2✔
1090
    async def _remove_group(
2✔
1091
        self, user: base_models.APIUser, group: Group, *, zed_token: ZedToken | None = None
1092
    ) -> _AuthzChange:
1093
        """Remove the group from the authorization database."""
1094
        if not group.id:
2✔
1095
            raise errors.ProgrammingError(
×
1096
                message="Cannot remove a group in the authorization database if the group has no ID"
1097
            )
1098
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
2✔
1099
        rel_filter = RelationshipFilter(resource_type=ResourceType.group.value, optional_resource_id=str(group.id))
2✔
1100
        responses = self.client.ReadRelationships(
2✔
1101
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1102
        )
1103
        rels: list[Relationship] = []
2✔
1104
        async for response in responses:
2✔
1105
            rels.append(response.relationship)
2✔
1106
        apply = WriteRelationshipsRequest(
2✔
1107
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1108
        )
1109
        undo = WriteRelationshipsRequest(
2✔
1110
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1111
        )
1112
        return _AuthzChange(apply=apply, undo=undo)
2✔
1113

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

1151
            existing_rels = [i async for i in existing_rels_result if i.relationship.relation in expected_user_roles]
1✔
1152
            if len(existing_rels) > 0:
1✔
1153
                # The existing relationships should be deleted if all goes well and added back in if we have to undo
1154
                existing_rel = existing_rels[0]
1✔
1155
                if existing_rel.relationship != rel:
1✔
1156
                    if existing_rel.relationship.relation == _Relation.owner.value:
1✔
1157
                        n_existing_owners -= 1
1✔
1158
                    elif rel.relation == _Relation.owner.value:
1✔
1159
                        n_existing_owners += 1
1✔
1160

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

1212
                # The new relationship is added if all goes well and deleted if we have to undo
1213
                add_members.append(
1✔
1214
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel),
1215
                )
1216
                undo.append(
1✔
1217
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel),
1218
                )
1219
                output.append(MembershipChange(member, Change.ADD))
1✔
1220

1221
        if n_existing_owners == 0:
1✔
1222
            raise errors.ValidationError(
1✔
1223
                message="You are trying to change the role of all the owners of the group, which is not allowed. "
1224
                "Assign at least one user as owner and then retry."
1225
            )
1226

1227
        change = _AuthzChange(
1✔
1228
            apply=WriteRelationshipsRequest(updates=add_members), undo=WriteRelationshipsRequest(updates=undo)
1229
        )
1230
        await self.client.WriteRelationships(change.apply)
1✔
1231
        return output
1✔
1232

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

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

1344
    async def _remove_user_namespace(self, user_id: str, zed_token: ZedToken | None = None) -> _AuthzChange:
2✔
1345
        """Remove the user namespace from the authorization database."""
1346
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1347
        rel_filter = RelationshipFilter(resource_type=ResourceType.user_namespace.value, optional_resource_id=user_id)
1✔
1348
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1349
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1350
        )
1351
        rels: list[Relationship] = []
1✔
1352
        async for response in responses:
1✔
1353
            rels.append(response.relationship)
×
1354
        apply = WriteRelationshipsRequest(
1✔
1355
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1356
        )
1357
        undo = WriteRelationshipsRequest(
1✔
1358
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1359
        )
1360
        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