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

SwissDataScienceCenter / renku-data-services / 11288123511

11 Oct 2024 07:23AM UTC coverage: 90.667% (+0.2%) from 90.477%
11288123511

Pull #407

github

web-flow
Merge 20c6c8af6 into 5b095d795
Pull Request #407: feat!: add data connectors

1226 of 1325 new or added lines in 28 files covered. (92.53%)

3 existing lines in 3 files now uncovered.

10589 of 11679 relevant lines covered (90.67%)

1.6 hits per line

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

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

3
import asyncio
2✔
4
from collections.abc import AsyncGenerator, 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.data_connectors.models import DataConnector, DataConnectorToProjectLink, DataConnectorUpdate
2✔
36
from renku_data_services.errors import errors
2✔
37
from renku_data_services.namespace.models import Group, GroupUpdate, Namespace, NamespaceKind, NamespaceUpdate
2✔
38
from renku_data_services.project.models import Project, ProjectUpdate
2✔
39
from renku_data_services.users.models import UserInfo, UserInfoUpdate
2✔
40

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

43

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

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

52

53
_AuthzChangeFuncResult = TypeVar(
2✔
54
    "_AuthzChangeFuncResult",
55
    bound=Project
56
    | ProjectUpdate
57
    | Group
58
    | UserInfoUpdate
59
    | list[UserInfo]
60
    | UserInfo
61
    | DataConnector
62
    | DataConnectorUpdate
63
    | DataConnectorToProjectLink
64
    | None,
65
)
66
_T = TypeVar("_T")
2✔
67
_WithAuthz = TypeVar("_WithAuthz", bound=WithAuthz)
2✔
68

69

70
@dataclass
2✔
71
class _AuthzChange:
2✔
72
    """Used to designate relationships to be created/updated/deleted and how those can be undone.
2✔
73

74
    Sending the apply and undo relationships to the database must always be the equivalent of
75
    a no-op.
76
    """
77

78
    apply: WriteRelationshipsRequest = field(default_factory=WriteRelationshipsRequest)
2✔
79
    undo: WriteRelationshipsRequest = field(default_factory=WriteRelationshipsRequest)
2✔
80

81
    def extend(self, other: "_AuthzChange") -> None:
2✔
82
        self.apply.updates.extend(other.apply.updates)
1✔
83
        self.apply.optional_preconditions.extend(other.apply.optional_preconditions)
1✔
84
        self.undo.updates.extend(other.undo.updates)
1✔
85
        self.undo.optional_preconditions.extend(other.undo.optional_preconditions)
1✔
86

87

88
class _Relation(StrEnum):
2✔
89
    """Relations for Authzed."""
2✔
90

91
    owner: str = "owner"
2✔
92
    editor: str = "editor"
2✔
93
    viewer: str = "viewer"
2✔
94
    public_viewer: str = "public_viewer"
2✔
95
    admin: str = "admin"
2✔
96
    project_platform: str = "project_platform"
2✔
97
    group_platform: str = "group_platform"
2✔
98
    user_namespace_platform: str = "user_namespace_platform"
2✔
99
    project_namespace: str = "project_namespace"
2✔
100
    data_connector_platform: str = "data_connector_platform"
2✔
101
    data_connector_namespace: str = "data_connector_namespace"
2✔
102
    linked_to: str = "linked_to"
2✔
103

104
    @classmethod
2✔
105
    def from_role(cls, role: Role) -> "_Relation":
2✔
106
        match role:
1✔
107
            case Role.OWNER:
1✔
108
                return cls.owner
1✔
109
            case Role.EDITOR:
1✔
110
                return cls.editor
1✔
111
            case Role.VIEWER:
1✔
112
                return cls.viewer
1✔
113
        raise errors.ProgrammingError(message=f"Cannot map role {role} to any authorization database relation")
×
114

115
    def to_role(self) -> Role:
2✔
116
        match self:
2✔
117
            case _Relation.owner:
2✔
118
                return Role.OWNER
2✔
119
            case _Relation.editor:
1✔
120
                return Role.EDITOR
1✔
121
            case _Relation.viewer:
1✔
122
                return Role.VIEWER
1✔
123
        raise errors.ProgrammingError(message=f"Cannot map relation {self} to any role")
×
124

125

126
class ResourceType(StrEnum):
2✔
127
    """All possible resources stored in Authzed."""
2✔
128

129
    project: str = "project"
2✔
130
    user: str = "user"
2✔
131
    anonymous_user: str = "anonymous_user"
2✔
132
    platform: str = "platform"
2✔
133
    group: str = "group"
2✔
134
    user_namespace: str = "user_namespace"
2✔
135
    data_connector: str = "data_connector"
2✔
136

137

138
class AuthzOperation(StrEnum):
2✔
139
    """The type of change that requires authorization database update."""
2✔
140

141
    create: str = "create"
2✔
142
    delete: str = "delete"
2✔
143
    update: str = "update"
2✔
144
    update_or_insert: str = "update_or_insert"
2✔
145
    insert_many: str = "insert_many"
2✔
146
    create_link: str = "create_link"
2✔
147
    delete_link: str = "delete_link"
2✔
148

149

150
class _AuthzConverter:
2✔
151
    @staticmethod
2✔
152
    def project(id: ULID) -> ObjectReference:
2✔
153
        return ObjectReference(object_type=ResourceType.project.value, object_id=str(id))
2✔
154

155
    @staticmethod
2✔
156
    def user(id: str | None) -> ObjectReference:
2✔
157
        if not id:
2✔
158
            return _AuthzConverter.all_users()
×
159
        return ObjectReference(object_type=ResourceType.user.value, object_id=id)
2✔
160

161
    @staticmethod
2✔
162
    def user_subject(id: str | None) -> SubjectReference:
2✔
163
        return SubjectReference(object=_AuthzConverter.user(id))
2✔
164

165
    @staticmethod
2✔
166
    def platform() -> ObjectReference:
2✔
167
        return ObjectReference(object_type=ResourceType.platform.value, object_id="renku")
2✔
168

169
    @staticmethod
2✔
170
    def anonymous_users() -> ObjectReference:
2✔
171
        return ObjectReference(object_type=ResourceType.anonymous_user, object_id="*")
2✔
172

173
    @staticmethod
2✔
174
    def anonymous_user() -> ObjectReference:
2✔
175
        return ObjectReference(object_type=ResourceType.anonymous_user, object_id="anonymous")
1✔
176

177
    @staticmethod
2✔
178
    def all_users() -> ObjectReference:
2✔
179
        return ObjectReference(object_type=ResourceType.user, object_id="*")
2✔
180

181
    @staticmethod
2✔
182
    def group(id: ULID) -> ObjectReference:
2✔
183
        return ObjectReference(object_type=ResourceType.group, object_id=str(id))
2✔
184

185
    @staticmethod
2✔
186
    def user_namespace(id: ULID) -> ObjectReference:
2✔
187
        return ObjectReference(object_type=ResourceType.user_namespace, object_id=str(id))
2✔
188

189
    @staticmethod
2✔
190
    def data_connector(id: ULID) -> ObjectReference:
2✔
191
        return ObjectReference(object_type=ResourceType.data_connector.value, object_id=str(id))
2✔
192

193
    @staticmethod
2✔
194
    def to_object(resource_type: ResourceType, resource_id: str | ULID | int) -> ObjectReference:
2✔
195
        match (resource_type, resource_id):
2✔
196
            case (ResourceType.project, sid) if isinstance(sid, ULID):
2✔
197
                return _AuthzConverter.project(sid)
2✔
198
            case (ResourceType.user, sid) if isinstance(sid, str) or sid is None:
2✔
199
                return _AuthzConverter.user(sid)
2✔
200
            case (ResourceType.anonymous_user, _):
2✔
201
                return _AuthzConverter.anonymous_users()
×
202
            case (ResourceType.user_namespace, rid) if isinstance(rid, ULID):
2✔
203
                return _AuthzConverter.user_namespace(rid)
1✔
204
            case (ResourceType.group, rid) if isinstance(rid, ULID):
2✔
205
                return _AuthzConverter.group(rid)
2✔
206
            case (ResourceType.data_connector, dcid) if isinstance(dcid, ULID):
2✔
207
                return _AuthzConverter.data_connector(dcid)
2✔
208
            case (ResourceType.platform, _):
1✔
209
                return _AuthzConverter.platform()
1✔
210
        raise errors.ProgrammingError(
×
211
            message=f"Unexpected or unknown resource type when checking permissions {resource_type}"
212
        )
213

214

215
def _is_allowed_on_resource(
2✔
216
    operation: Scope, resource_type: ResourceType
217
) -> Callable[
218
    [Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]]],
219
    Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]],
220
]:
221
    """A decorator that checks if the operation on a specific resource type is allowed or not."""
222

223
    def decorator(
2✔
224
        f: Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]],
225
    ) -> Callable[Concatenate["Authz", base_models.APIUser, _P], Awaitable[_T]]:
226
        @wraps(f)
2✔
227
        async def decorated_function(
2✔
228
            self: "Authz", user: base_models.APIUser, *args: _P.args, **kwargs: _P.kwargs
229
        ) -> _T:
230
            if isinstance(user, base_models.InternalServiceAdmin):
2✔
231
                return await f(self, user, *args, **kwargs)
×
232
            if not isinstance(user, base_models.APIUser):
2✔
233
                raise errors.ProgrammingError(
×
234
                    message="The decorator for checking permissions for authorization database operations "
235
                    "needs to access the user in the decorated function keyword arguments but it did not find it"
236
                )
237
            if len(args) == 0:
2✔
238
                raise errors.ProgrammingError(
×
239
                    message="The authorization decorator needs to have at least one positional argument after 'user'"
240
                )
241
            potential_resource = args[0]
2✔
242
            resource: Project | Group | Namespace | DataConnector | None = None
2✔
243
            match resource_type:
2✔
244
                case ResourceType.project if isinstance(potential_resource, Project):
2✔
245
                    resource = potential_resource
1✔
246
                case ResourceType.group if isinstance(potential_resource, Group):
2✔
247
                    resource = potential_resource
2✔
248
                case ResourceType.user_namespace if isinstance(potential_resource, Namespace):
1✔
249
                    resource = potential_resource
×
250
                case ResourceType.data_connector if isinstance(potential_resource, DataConnector):
1✔
251
                    resource = potential_resource
1✔
252
                case _:
×
253
                    raise errors.ProgrammingError(
×
254
                        message="The decorator for checking permissions for authorization database operations "
255
                        "failed to find the expected positional argument in the decorated function "
256
                        f"for the {resource_type} resource, it found {type(resource)}"
257
                    )
258
            allowed, zed_token = await self._has_permission(user, resource_type, resource.id, operation)
2✔
259
            if not allowed:
2✔
260
                raise errors.MissingResourceError(
×
261
                    message=f"The user with ID {user.id} cannot perform operation {operation} "
262
                    f"on resource {resource_type} with ID {resource.id} or the resource does not exist."
263
                )
264
            kwargs["zed_token"] = zed_token
2✔
265
            return await f(self, user, *args, **kwargs)
2✔
266

267
        return decorated_function
2✔
268

269
    return decorator
2✔
270

271

272
_ID = TypeVar("_ID", str, ULID)
2✔
273

274

275
def _is_allowed(
2✔
276
    operation: Scope,
277
) -> Callable[
278
    [Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]]],
279
    Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]],
280
]:
281
    """A decorator that checks if the operation on a resource is allowed or not."""
282

283
    def decorator(
2✔
284
        f: Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]],
285
    ) -> Callable[Concatenate["Authz", base_models.APIUser, ResourceType, _ID, _P], Awaitable[_T]]:
286
        @wraps(f)
2✔
287
        async def decorated_function(
2✔
288
            self: "Authz",
289
            user: base_models.APIUser,
290
            resource_type: ResourceType,
291
            resource_id: _ID,
292
            *args: _P.args,
293
            **kwargs: _P.kwargs,
294
        ) -> _T:
295
            if isinstance(user, base_models.InternalServiceAdmin):
1✔
296
                return await f(self, user, resource_type, resource_id, *args, **kwargs)
×
297
            allowed, zed_token = await self._has_permission(user, resource_type, resource_id, operation)
1✔
298
            if not allowed:
1✔
299
                raise errors.MissingResourceError(
1✔
300
                    message=f"The user with ID {user.id} cannot perform operation {operation} on {resource_type.value} "
301
                    f"with ID {resource_id} or the resource does not exist."
302
                )
303
            kwargs["zed_token"] = zed_token
1✔
304
            return await f(self, user, resource_type, resource_id, *args, **kwargs)
1✔
305

306
        return decorated_function
2✔
307

308
    return decorator
2✔
309

310

311
@dataclass
2✔
312
class Authz:
2✔
313
    """Authorization decisions and updates."""
2✔
314

315
    authz_config: AuthzConfig
2✔
316
    _platform: ClassVar[ObjectReference] = field(default=_AuthzConverter.platform())
2✔
317
    _client: AsyncClient | None = field(default=None, init=False)
2✔
318

319
    @property
2✔
320
    def client(self) -> AsyncClient:
2✔
321
        """The authzed DB asynchronous client."""
322
        if not self._client:
2✔
323
            self._client = self.authz_config.authz_async_client()
2✔
324
        return self._client
2✔
325

326
    async def _has_permission(
2✔
327
        self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str | ULID | None, scope: Scope
328
    ) -> tuple[bool, ZedToken | None]:
329
        """Checks whether the provided user has a specific permission on the specific resource."""
330
        if not resource_id:
2✔
331
            raise errors.ProgrammingError(
×
332
                message=f"Cannot check permissions on a resource of type {resource_type} with missing resource ID."
333
            )
334
        if isinstance(user, InternalServiceAdmin):
2✔
335
            return True, None
1✔
336
        res = _AuthzConverter.to_object(resource_type, resource_id)
2✔
337
        sub = SubjectReference(
2✔
338
            object=(
339
                _AuthzConverter.to_object(ResourceType.user, user.id) if user.id else _AuthzConverter.anonymous_user()
340
            )
341
        )
342
        response: CheckPermissionResponse = await self.client.CheckPermission(
2✔
343
            CheckPermissionRequest(
344
                consistency=Consistency(fully_consistent=True), resource=res, subject=sub, permission=scope.value
345
            )
346
        )
347
        return response.permissionship == CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION, response.checked_at
2✔
348

349
    async def has_permission(
2✔
350
        self, user: base_models.APIUser, resource_type: ResourceType, resource_id: str | ULID, scope: Scope
351
    ) -> bool:
352
        """Checks whether the provided user has a specific permission on the specific resource."""
353
        res, _ = await self._has_permission(user, resource_type, resource_id, scope)
2✔
354
        return res
2✔
355

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

361
        The person requesting the information can be the user or someone else. I.e. the admin can request
362
        what are the resources that a user has access to.
363
        """
364
        if not requested_by.is_admin and requested_by.id != user_id:
2✔
365
            raise errors.ForbiddenError(
1✔
366
                message=f"User with ID {requested_by.id} cannot check the permissions of another user with ID {user_id}"
367
            )
368
        sub = SubjectReference(
2✔
369
            object=(
370
                _AuthzConverter.to_object(ResourceType.user, user_id) if user_id else _AuthzConverter.anonymous_user()
371
            )
372
        )
373
        ids: list[str] = []
2✔
374
        responses: AsyncIterable[LookupResourcesResponse] = self.client.LookupResources(
2✔
375
            LookupResourcesRequest(
376
                consistency=Consistency(fully_consistent=True),
377
                resource_object_type=resource_type.value,
378
                permission=scope.value,
379
                subject=sub,
380
            )
381
        )
382
        async for response in responses:
2✔
383
            if response.permissionship == LOOKUP_PERMISSIONSHIP_HAS_PERMISSION:
2✔
384
                ids.append(response.resource_object_id)
2✔
385
        return ids
2✔
386

387
    async def resources_with_direct_membership(
2✔
388
        self, user: base_models.APIUser, resource_type: ResourceType
389
    ) -> list[str]:
390
        """Get all the resource IDs (for a specific resource kind) that a specific user is a direct member of."""
391
        resource_ids: list[str] = []
1✔
392
        if user.id is None:
1✔
393
            return resource_ids
×
394

395
        rel_filter = RelationshipFilter(
1✔
396
            resource_type=resource_type.value,
397
            optional_subject_filter=SubjectFilter(subject_type=ResourceType.user.value, optional_subject_id=user.id),
398
        )
399

400
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
401
            ReadRelationshipsRequest(
402
                consistency=Consistency(fully_consistent=True),
403
                relationship_filter=rel_filter,
404
            )
405
        )
406

407
        async for response in responses:
1✔
408
            resource_ids.append(response.relationship.resource.object_id)
1✔
409

410
        return resource_ids
1✔
411

412
    @_is_allowed(Scope.READ)  # The scope on the resource that allows the user to perform this check in the first place
2✔
413
    async def users_with_permission(
2✔
414
        self,
415
        user: base_models.APIUser,
416
        resource_type: ResourceType,
417
        resource_id: str,
418
        scope: Scope,  # The scope that the users should be allowed to exercise on the resource
419
        *,
420
        zed_token: ZedToken | None = None,
421
    ) -> list[str]:
422
        """Get all user IDs that have a specific permission on a specific resource."""
423
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
424
        res = _AuthzConverter.to_object(resource_type, resource_id)
1✔
425
        ids: list[str] = []
1✔
426
        responses: AsyncIterable[LookupSubjectsResponse] = self.client.LookupSubjects(
1✔
427
            LookupSubjectsRequest(
428
                consistency=consistency,
429
                resource=res,
430
                permission=scope.value,
431
                subject_object_type=ResourceType.user.value,
432
            )
433
        )
434
        async for response in responses:
1✔
435
            if response.permissionship == LOOKUP_PERMISSIONSHIP_HAS_PERMISSION:
1✔
436
                ids.append(response.subject.subject_object_id)
1✔
437
        return ids
1✔
438

439
    async def get_all_members(
2✔
440
        self, resource_type: ResourceType, *, zed_token: ZedToken | None = None
441
    ) -> AsyncGenerator[Member, None]:
442
        """Get all users that are members of a specific resource."""
443
        members = self._get_members_helper(resource_type, resource_id=None, zed_token=zed_token)
2✔
444
        async for member in members:
2✔
445
            if member.user_id and member.user_id != "*":
2✔
446
                yield member
2✔
447

448
    @_is_allowed(Scope.READ)
2✔
449
    async def members(
2✔
450
        self,
451
        user: base_models.APIUser,
452
        resource_type: ResourceType,
453
        resource_id: ULID,
454
        role: Role | None = None,
455
        *,
456
        zed_token: ZedToken | None = None,
457
    ) -> list[Member]:
458
        """Get all users that are members of a specific resource type, if role is None then all roles are retrieved."""
459
        members = self._get_members_helper(resource_type, str(resource_id), role, zed_token=zed_token)
1✔
460
        return [m async for m in members]
1✔
461

462
    async def _get_members_helper(
2✔
463
        self,
464
        resource_type: ResourceType,
465
        resource_id: str | None,
466
        role: Role | None = None,
467
        *,
468
        zed_token: ZedToken | None = None,
469
    ) -> AsyncGenerator[Member, None]:
470
        """Get all users that are members of a resource, if role is None then all roles are retrieved."""
471
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
2✔
472
        sub_filter = SubjectFilter(subject_type=ResourceType.user.value)
2✔
473
        if resource_id is None:
2✔
474
            rel_filter = RelationshipFilter(resource_type=resource_type, optional_subject_filter=sub_filter)
2✔
475
        else:
476
            rel_filter = RelationshipFilter(
1✔
477
                resource_type=resource_type,
478
                optional_resource_id=resource_id,
479
                optional_subject_filter=sub_filter,
480
            )
481
        if role:
2✔
482
            relation = _Relation.from_role(role)
1✔
483
            if resource_id is None:
1✔
484
                rel_filter = RelationshipFilter(
×
485
                    resource_type=resource_type,
486
                    optional_relation=relation,
487
                    optional_subject_filter=sub_filter,
488
                )
489
            else:
490
                rel_filter = RelationshipFilter(
1✔
491
                    resource_type=resource_type,
492
                    optional_resource_id=resource_id,
493
                    optional_relation=relation,
494
                    optional_subject_filter=sub_filter,
495
                )
496
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
2✔
497
            ReadRelationshipsRequest(
498
                consistency=consistency,
499
                relationship_filter=rel_filter,
500
            )
501
        )
502

503
        async for response in responses:
2✔
504
            # Skip "public_viewer" relationships
505
            if response.relationship.relation == _Relation.public_viewer.value:
2✔
506
                continue
2✔
507
            member_role = _Relation(response.relationship.relation).to_role()
2✔
508
            member = Member(
2✔
509
                user_id=response.relationship.subject.object.object_id,
510
                role=member_role,
511
                resource_id=response.relationship.resource.object_id,
512
            )
513

514
            yield member
2✔
515

516
    @staticmethod
2✔
517
    def authz_change(
2✔
518
        op: AuthzOperation, resource: ResourceType
519
    ) -> Callable[
520
        [Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]]],
521
        Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]],
522
    ]:
523
        """A decorator that updates the authorization database for different types of operations."""
524

525
        def _extract_user_from_args(*args: _P.args, **kwargs: _P.kwargs) -> base_models.APIUser:
2✔
526
            if len(args) == 0:
2✔
527
                user_kwarg = kwargs.get("user")
2✔
528
                requested_by_kwarg = kwargs.get("requested_by")
2✔
529
                if isinstance(user_kwarg, base_models.APIUser) and isinstance(requested_by_kwarg, base_models.APIUser):
2✔
530
                    raise errors.ProgrammingError(
×
531
                        message="The decorator for authorization database changes found two APIUser parameters in the "
532
                        "'user' and 'requested_by' keyword arguments but expected only one of them to be present."
533
                    )
534
                potential_user = user_kwarg if isinstance(user_kwarg, base_models.APIUser) else requested_by_kwarg
2✔
535
            else:
536
                potential_user = args[0]
×
537
            if not isinstance(potential_user, base_models.APIUser):
2✔
538
                raise errors.ProgrammingError(
×
539
                    message="The decorator for authorization database changes could not find APIUser in the function "
540
                    f"arguments, the type of the argument that was found is {type(potential_user)}."
541
                )
542
            return potential_user
2✔
543

544
        async def _get_authz_change(
2✔
545
            db_repo: _WithAuthz,
546
            operation: AuthzOperation,
547
            resource: ResourceType,
548
            result: _AuthzChangeFuncResult,
549
            *func_args: _P.args,
550
            **func_kwargs: _P.kwargs,
551
        ) -> _AuthzChange:
552
            authz_change = _AuthzChange()
2✔
553
            match operation, resource:
2✔
554
                case AuthzOperation.create, ResourceType.project if isinstance(result, Project):
2✔
555
                    authz_change = db_repo.authz._add_project(result)
1✔
556
                case AuthzOperation.delete, ResourceType.project if isinstance(result, Project):
2✔
557
                    user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
558
                    authz_change = await db_repo.authz._remove_project(user, result)
1✔
559
                case AuthzOperation.delete, ResourceType.project if result is None:
2✔
560
                    # NOTE: This means that the project does not exist in the first place so nothing was deleted
561
                    pass
×
562
                case AuthzOperation.update, ResourceType.project if isinstance(result, ProjectUpdate):
2✔
563
                    authz_change = _AuthzChange()
1✔
564
                    if result.old.visibility != result.new.visibility:
1✔
565
                        user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
566
                        authz_change.extend(await db_repo.authz._update_project_visibility(user, result.new))
1✔
567
                    if result.old.namespace.id != result.new.namespace.id:
1✔
568
                        user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
569
                        authz_change.extend(await db_repo.authz._update_project_namespace(user, result.new))
1✔
570
                case AuthzOperation.create, ResourceType.group if isinstance(result, Group):
2✔
571
                    authz_change = db_repo.authz._add_group(result)
2✔
572
                case AuthzOperation.delete, ResourceType.group if isinstance(result, Group):
2✔
573
                    user = _extract_user_from_args(*func_args, **func_kwargs)
2✔
574
                    authz_change = await db_repo.authz._remove_group(user, result)
2✔
575
                case AuthzOperation.delete, ResourceType.group if result is None:
2✔
576
                    # NOTE: This means that the group does not exist in the first place so nothing was deleted
577
                    pass
1✔
578
                case AuthzOperation.update_or_insert, ResourceType.user if isinstance(result, UserInfoUpdate):
2✔
579
                    if result.old is None:
2✔
580
                        authz_change = db_repo.authz._add_user_namespace(result.new.namespace)
2✔
581
                case AuthzOperation.delete, ResourceType.user if isinstance(result, UserInfo):
2✔
582
                    user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
583
                    authz_change = await db_repo.authz._remove_user_namespace(result.id)
1✔
584
                    authz_change.extend(await db_repo.authz._remove_user(user, result))
1✔
585
                case AuthzOperation.delete, ResourceType.user if result is None:
2✔
586
                    # NOTE: This means that the user does not exist in the first place so nothing was deleted
587
                    pass
2✔
588
                case AuthzOperation.insert_many, ResourceType.user_namespace if isinstance(result, list):
2✔
589
                    for res in result:
2✔
590
                        if not isinstance(res, UserInfo):
×
591
                            raise errors.ProgrammingError(
×
592
                                message="Expected list of UserInfo when generating authorization "
593
                                f"database updates for inserting namespaces but found {type(res)}"
594
                            )
595
                        authz_change.extend(db_repo.authz._add_user_namespace(res.namespace))
×
596
                case AuthzOperation.create, ResourceType.data_connector if isinstance(result, DataConnector):
2✔
597
                    authz_change = db_repo.authz._add_data_connector(result)
1✔
598
                case AuthzOperation.delete, ResourceType.data_connector if result is None:
2✔
599
                    # NOTE: This means that the data connector does not exist in the first place so nothing was deleted
NEW
600
                    pass
×
601
                case AuthzOperation.delete, ResourceType.data_connector if isinstance(result, DataConnector):
2✔
602
                    user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
603
                    authz_change = await db_repo.authz._remove_data_connector(user, result)
1✔
604
                case AuthzOperation.update, ResourceType.data_connector if isinstance(result, DataConnectorUpdate):
2✔
605
                    authz_change = _AuthzChange()
1✔
606
                    if result.old.visibility != result.new.visibility:
1✔
607
                        user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
608
                        authz_change.extend(await db_repo.authz._update_data_connector_visibility(user, result.new))
1✔
609
                    if result.old.namespace.id != result.new.namespace.id:
1✔
610
                        user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
611
                        authz_change.extend(await db_repo.authz._update_data_connector_namespace(user, result.new))
1✔
612
                case AuthzOperation.create_link, ResourceType.data_connector if isinstance(
2✔
613
                    result, DataConnectorToProjectLink
614
                ):
615
                    user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
616
                    authz_change = await db_repo.authz._add_data_connector_to_project_link(user, result)
1✔
617
                case AuthzOperation.delete_link, ResourceType.data_connector if result is None:
2✔
618
                    # NOTE: This means that the link does not exist in the first place so nothing was deleted
619
                    pass
2✔
620
                case AuthzOperation.delete_link, ResourceType.data_connector if isinstance(
1✔
621
                    result, DataConnectorToProjectLink
622
                ):
623
                    user = _extract_user_from_args(*func_args, **func_kwargs)
1✔
624
                    authz_change = await db_repo.authz._remove_data_connector_to_project_link(user, result)
1✔
625
                case _:
×
626
                    resource_id: str | ULID | None = "unknown"
×
NEW
627
                    if isinstance(result, (Project, Namespace, Group, DataConnector)):
×
628
                        resource_id = result.id
×
NEW
629
                    elif isinstance(result, (ProjectUpdate, NamespaceUpdate, GroupUpdate, DataConnectorUpdate)):
×
630
                        resource_id = result.new.id
×
631
                    raise errors.ProgrammingError(
×
632
                        message=f"Encountered an unknown authorization operation {op} on resource {resource} "
633
                        f"with ID {resource_id} when updating the authorization database",
634
                    )
635
            return authz_change
2✔
636

637
        def decorator(
2✔
638
            f: Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]],
639
        ) -> Callable[Concatenate[_WithAuthz, _P], Awaitable[_AuthzChangeFuncResult]]:
640
            @wraps(f)
2✔
641
            async def decorated_function(
2✔
642
                db_repo: _WithAuthz, *args: _P.args, **kwargs: _P.kwargs
643
            ) -> _AuthzChangeFuncResult:
644
                # NOTE: db_repo is the "self" of the project postgres DB repository method that this function decorates.
645
                # I did not call it "self" here to avoid confusion with the self of the Authz class,
646
                # even though this is a static method.
647
                session = kwargs.get("session")
2✔
648
                if not isinstance(session, AsyncSession):
2✔
649
                    raise errors.ProgrammingError(
×
650
                        message="The authorization change decorator requires a DB session in the function "
651
                        "keyword arguments"
652
                    )
653
                if not session.in_transaction():
2✔
654
                    raise errors.ProgrammingError(
×
655
                        message="The authorization database decorator needs a session with an open transaction."
656
                    )
657

658
                authz_change = _AuthzChange()
2✔
659
                try:
2✔
660
                    # NOTE: Here we have to maintain the following order of operations:
661
                    # 1. Run decorated function
662
                    # 2. Write resources to the Authzed DB
663
                    # 3. Commit the open transaction
664
                    # 4. If something goes wrong abort the transaction and remove things from Authzed DB
665
                    # See https://authzed.com/docs/spicedb/concepts/relationships#writing-relationships
666
                    # If this order of operations is changed you can get a case where for a short period of time
667
                    # resources exists in the postgres DB without any authorization information in the Authzed DB.
668
                    result = await f(db_repo, *args, **kwargs)
2✔
669
                    authz_change = await _get_authz_change(db_repo, op, resource, result, *args, **kwargs)
2✔
670
                    await db_repo.authz.client.WriteRelationships(authz_change.apply)
2✔
671
                    await session.commit()
2✔
672
                    return result
2✔
673
                except Exception as err:
2✔
674
                    db_rollback_err = None
2✔
675
                    try:
2✔
676
                        # NOTE: If the rollback fails do not stop just continue to make sure the resource
677
                        # from the Authzed DB is also removed
678
                        await asyncio.shield(session.rollback())
2✔
679
                    except Exception as _db_rollback_err:
×
680
                        db_rollback_err = _db_rollback_err
×
681
                    await asyncio.shield(db_repo.authz.client.WriteRelationships(authz_change.undo))
2✔
682
                    if db_rollback_err:
2✔
683
                        raise db_rollback_err from err
×
684
                    raise err
2✔
685

686
            return decorated_function
2✔
687

688
        return decorator
2✔
689

690
    def _add_project(self, project: Project) -> _AuthzChange:
2✔
691
        """Create the new project and associated resources and relations in the DB."""
692
        creator = SubjectReference(object=_AuthzConverter.user(project.created_by))
1✔
693
        project_res = _AuthzConverter.project(project.id)
1✔
694
        creator_is_owner = Relationship(resource=project_res, relation=_Relation.owner.value, subject=creator)
1✔
695
        all_users = SubjectReference(object=_AuthzConverter.all_users())
1✔
696
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
697
        project_namespace = SubjectReference(
1✔
698
            object=(
699
                _AuthzConverter.user_namespace(project.namespace.id)
700
                if project.namespace.kind == NamespaceKind.user
701
                else _AuthzConverter.group(cast(ULID, project.namespace.underlying_resource_id))
702
            )
703
        )
704
        project_in_platform = Relationship(
1✔
705
            resource=project_res,
706
            relation=_Relation.project_platform.value,
707
            subject=SubjectReference(object=self._platform),
708
        )
709
        project_in_namespace = Relationship(
1✔
710
            resource=project_res,
711
            relation=_Relation.project_namespace,
712
            subject=project_namespace,
713
        )
714
        relationships = [creator_is_owner, project_in_platform, project_in_namespace]
1✔
715
        if project.visibility == Visibility.PUBLIC:
1✔
716
            all_users_are_viewers = Relationship(
1✔
717
                resource=project_res,
718
                relation=_Relation.public_viewer.value,
719
                subject=all_users,
720
            )
721
            all_anon_users_are_viewers = Relationship(
1✔
722
                resource=project_res,
723
                relation=_Relation.public_viewer.value,
724
                subject=all_anon_users,
725
            )
726
            relationships.extend([all_users_are_viewers, all_anon_users_are_viewers])
1✔
727
        apply = WriteRelationshipsRequest(
1✔
728
            updates=[
729
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
730
            ]
731
        )
732
        undo = WriteRelationshipsRequest(
1✔
733
            updates=[
734
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
735
            ]
736
        )
737
        return _AuthzChange(apply=apply, undo=undo)
1✔
738

739
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
740
    async def _remove_project(
2✔
741
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
742
    ) -> _AuthzChange:
743
        """Remove the relationships associated with the project."""
744
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
745
        rel_filter = RelationshipFilter(resource_type=ResourceType.project.value, optional_resource_id=str(project.id))
1✔
746
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
747
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
748
        )
749
        rels: list[Relationship] = []
1✔
750
        async for response in responses:
1✔
751
            rels.append(response.relationship)
1✔
752
        # Project is also a subject for "linked_to" relations
753
        rel_filter = RelationshipFilter(
1✔
754
            optional_subject_filter=SubjectFilter(
755
                subject_type=ResourceType.project.value, optional_subject_id=str(project.id)
756
            )
757
        )
758
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
759
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
760
        )
761
        async for response in responses:
1✔
762
            rels.append(response.relationship)
1✔
763
        apply = WriteRelationshipsRequest(
1✔
764
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
765
        )
766
        undo = WriteRelationshipsRequest(
1✔
767
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
768
        )
769
        return _AuthzChange(apply=apply, undo=undo)
1✔
770

771
    # NOTE changing visibility is the same access level as removal
772
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
773
    async def _update_project_visibility(
2✔
774
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
775
    ) -> _AuthzChange:
776
        """Update the visibility of the project in the authorization database."""
777
        project_id_str = str(project.id)
1✔
778
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
779
        project_res = _AuthzConverter.project(project.id)
1✔
780
        all_users_sub = SubjectReference(object=_AuthzConverter.all_users())
1✔
781
        anon_users_sub = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
782
        all_users_are_viewers = Relationship(
1✔
783
            resource=project_res,
784
            relation=_Relation.public_viewer.value,
785
            subject=all_users_sub,
786
        )
787
        anon_users_are_viewers = Relationship(
1✔
788
            resource=project_res,
789
            relation=_Relation.public_viewer.value,
790
            subject=anon_users_sub,
791
        )
792
        make_public = WriteRelationshipsRequest(
1✔
793
            updates=[
794
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=all_users_are_viewers),
795
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=anon_users_are_viewers),
796
            ]
797
        )
798
        make_private = WriteRelationshipsRequest(
1✔
799
            updates=[
800
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=all_users_are_viewers),
801
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=anon_users_are_viewers),
802
            ]
803
        )
804
        rel_filter = RelationshipFilter(
1✔
805
            resource_type=ResourceType.project.value,
806
            optional_resource_id=project_id_str,
807
            optional_subject_filter=SubjectFilter(
808
                subject_type=ResourceType.user.value, optional_subject_id=all_users_sub.object.object_id
809
            ),
810
        )
811
        current_relation_users: ReadRelationshipsResponse | None = await anext(
1✔
812
            aiter(self.client.ReadRelationships(ReadRelationshipsRequest(relationship_filter=rel_filter))), None
813
        )
814
        rel_filter = RelationshipFilter(
1✔
815
            resource_type=ResourceType.project.value,
816
            optional_resource_id=project_id_str,
817
            optional_subject_filter=SubjectFilter(
818
                subject_type=ResourceType.anonymous_user.value,
819
                optional_subject_id=anon_users_sub.object.object_id,
820
            ),
821
        )
822
        current_relation_anon_users: ReadRelationshipsResponse | None = await anext(
1✔
823
            aiter(
824
                self.client.ReadRelationships(
825
                    ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
826
                )
827
            ),
828
            None,
829
        )
830
        project_is_public_for_users = (
1✔
831
            current_relation_users is not None
832
            and current_relation_users.relationship.subject.object.object_type == ResourceType.user.value
833
            and current_relation_users.relationship.subject.object.object_id == all_users_sub.object.object_id
834
        )
835
        project_is_public_for_anon_users = (
1✔
836
            current_relation_anon_users is not None
837
            and current_relation_anon_users.relationship.subject.object.object_type == ResourceType.anonymous_user.value
838
            and current_relation_anon_users.relationship.subject.object.object_id == anon_users_sub.object.object_id,
839
        )
840
        project_already_public = project_is_public_for_users and project_is_public_for_anon_users
1✔
841
        project_already_private = not project_already_public
1✔
842
        match project.visibility:
1✔
843
            case Visibility.PUBLIC:
1✔
844
                if project_already_public:
1✔
845
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
846
                return _AuthzChange(apply=make_public, undo=make_private)
1✔
847
            case Visibility.PRIVATE:
1✔
848
                if project_already_private:
1✔
849
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
850
                return _AuthzChange(apply=make_private, undo=make_public)
1✔
851
        raise errors.ProgrammingError(
×
852
            message=f"Encountered unknown project visibility {project.visibility} when trying to "
853
            f"make a visibility change for project with ID {project.id}",
854
        )
855

856
    # NOTE changing namespace is the same access level as removal
857
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.project)
2✔
858
    async def _update_project_namespace(
2✔
859
        self, user: base_models.APIUser, project: Project, *, zed_token: ZedToken | None = None
860
    ) -> _AuthzChange:
861
        """Update the namespace/group of the project in the authorization database."""
862
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
863
        project_res = _AuthzConverter.project(project.id)
1✔
864
        project_namespace_filter = RelationshipFilter(
1✔
865
            resource_type=ResourceType.project.value,
866
            optional_resource_id=str(project.id),
867
            optional_relation=_Relation.project_namespace.value,
868
        )
869
        current_namespace: ReadRelationshipsResponse | None = await anext(
1✔
870
            aiter(
871
                self.client.ReadRelationships(
872
                    ReadRelationshipsRequest(relationship_filter=project_namespace_filter, consistency=consistency)
873
                )
874
            ),
875
            None,
876
        )
877
        if not current_namespace:
1✔
878
            raise errors.ProgrammingError(
×
879
                message=f"The project with ID {project.id} whose namespace is being updated "
880
                "does not currently have a namespace"
881
            )
882
        if current_namespace.relationship.subject.object.object_id == project.namespace.id:
1✔
883
            return _AuthzChange()
×
884
        new_namespace_sub = (
1✔
885
            SubjectReference(object=_AuthzConverter.group(project.namespace.id))
886
            if project.namespace.kind == NamespaceKind.group
887
            else SubjectReference(object=_AuthzConverter.user_namespace(project.namespace.id))
888
        )
889
        old_namespace_sub = (
1✔
890
            SubjectReference(
891
                object=_AuthzConverter.group(ULID.from_str(current_namespace.relationship.subject.object.object_id))
892
            )
893
            if current_namespace.relationship.subject.object.object_type == ResourceType.group.value
894
            else SubjectReference(
895
                object=_AuthzConverter.user_namespace(
896
                    ULID.from_str(current_namespace.relationship.subject.object.object_id)
897
                )
898
            )
899
        )
900
        new_namespace = Relationship(
1✔
901
            resource=project_res,
902
            relation=_Relation.project_namespace.value,
903
            subject=new_namespace_sub,
904
        )
905
        old_namespace = Relationship(
1✔
906
            resource=project_res,
907
            relation=_Relation.project_namespace.value,
908
            subject=old_namespace_sub,
909
        )
910
        apply_change = WriteRelationshipsRequest(
1✔
911
            updates=[
912
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=new_namespace),
913
            ]
914
        )
915
        undo_change = WriteRelationshipsRequest(
1✔
916
            updates=[
917
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=old_namespace),
918
            ]
919
        )
920
        return _AuthzChange(apply=apply_change, undo=undo_change)
1✔
921

922
    async def _get_resource_owners(
2✔
923
        self, resource_type: ResourceType, resource_id: str, consistency: Consistency
924
    ) -> list[ReadRelationshipsResponse]:
925
        existing_owners_filter = RelationshipFilter(
1✔
926
            resource_type=resource_type.value,
927
            optional_resource_id=resource_id,
928
            optional_subject_filter=SubjectFilter(subject_type=ResourceType.user),
929
            optional_relation=_Relation.owner.value,
930
        )
931
        return [
1✔
932
            i
933
            async for i in self.client.ReadRelationships(
934
                ReadRelationshipsRequest(
935
                    consistency=consistency,
936
                    relationship_filter=existing_owners_filter,
937
                )
938
            )
939
        ]
940

941
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
942
    async def upsert_project_members(
2✔
943
        self,
944
        user: base_models.APIUser,
945
        resource_type: ResourceType,
946
        resource_id: ULID,
947
        members: list[Member],
948
        *,
949
        zed_token: ZedToken | None = None,
950
    ) -> list[MembershipChange]:
951
        """Updates the project members or inserts them if they do not exist.
952

953
        Returns the list that was updated/inserted.
954
        """
955
        resource_id_str = str(resource_id)
1✔
956
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
957
        project_res = _AuthzConverter.project(resource_id)
1✔
958
        add_members: list[RelationshipUpdate] = []
1✔
959
        undo: list[RelationshipUpdate] = []
1✔
960
        output: list[MembershipChange] = []
1✔
961
        expected_user_roles = {_Relation.viewer.value, _Relation.owner.value, _Relation.editor.value}
1✔
962
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
963
        n_existing_owners = len(existing_owners_rels)
1✔
964
        for member in members:
1✔
965
            rel = Relationship(
1✔
966
                resource=project_res,
967
                relation=_Relation.from_role(member.role).value,
968
                subject=SubjectReference(object=_AuthzConverter.user(member.user_id)),
969
            )
970
            existing_rel_filter = RelationshipFilter(
1✔
971
                resource_type=resource_type.value,
972
                optional_resource_id=resource_id_str,
973
                optional_subject_filter=SubjectFilter(
974
                    subject_type=ResourceType.user, optional_subject_id=member.user_id
975
                ),
976
            )
977
            existing_rels_iter: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
978
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
979
            )
980
            existing_rels = [i async for i in existing_rels_iter if i.relationship.relation in expected_user_roles]
1✔
981
            if len(existing_rels) > 0:
1✔
982
                # The existing relationships should be deleted if all goes well and added back in if we have to undo
983
                existing_rel = existing_rels[0]
1✔
984
                if existing_rel.relationship != rel:
1✔
985
                    if existing_rel.relationship.relation == _Relation.owner.value:
1✔
986
                        n_existing_owners -= 1
1✔
987
                    elif rel.relation == _Relation.owner.value:
1✔
988
                        n_existing_owners += 1
1✔
989

990
                    add_members.extend(
1✔
991
                        [
992
                            RelationshipUpdate(
993
                                operation=RelationshipUpdate.OPERATION_TOUCH,
994
                                relationship=rel,
995
                            ),
996
                            # NOTE: The old role for the user still exists and we have to remove it
997
                            # if not both the old and new role for the same user will be present in the database
998
                            RelationshipUpdate(
999
                                operation=RelationshipUpdate.OPERATION_DELETE,
1000
                                relationship=existing_rel.relationship,
1001
                            ),
1002
                        ]
1003
                    )
1004
                    undo.extend(
1✔
1005
                        [
1006
                            RelationshipUpdate(
1007
                                operation=RelationshipUpdate.OPERATION_DELETE,
1008
                                relationship=rel,
1009
                            ),
1010
                            RelationshipUpdate(
1011
                                operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
1012
                            ),
1013
                        ]
1014
                    )
1015
                    output.append(MembershipChange(member, Change.UPDATE))
1✔
1016
                for rel_to_remove in existing_rels[1:]:
1✔
1017
                    # NOTE: This means that the user has more than 1 role on the project - which should not happen
1018
                    # But if this does occur then we simply delete the extra roles of the user here.
1019
                    logger.warning(
×
1020
                        f"Removing additional unexpected role {rel_to_remove.relationship.relation} "
1021
                        f"of user {member.user_id} on project {resource_id}, "
1022
                        f"kept role {existing_rel.relationship.relation} which will be updated to {rel.relation}."
1023
                    )
1024
                    add_members.append(
×
1025
                        RelationshipUpdate(
1026
                            operation=RelationshipUpdate.OPERATION_DELETE,
1027
                            relationship=rel_to_remove.relationship,
1028
                        ),
1029
                    )
1030
                    undo.append(
×
1031
                        RelationshipUpdate(
1032
                            operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel_to_remove.relationship
1033
                        ),
1034
                    )
1035
                    output.append(MembershipChange(member, Change.REMOVE))
×
1036
            else:
1037
                if rel.relation == _Relation.owner.value:
1✔
1038
                    n_existing_owners += 1
1✔
1039
                # The new relationship is added if all goes well and deleted if we have to undo
1040
                add_members.append(
1✔
1041
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel),
1042
                )
1043
                undo.append(
1✔
1044
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel),
1045
                )
1046
                output.append(MembershipChange(member, Change.ADD))
1✔
1047

1048
        if n_existing_owners == 0:
1✔
1049
            raise errors.ValidationError(
1✔
1050
                message="You are trying to change the role of all the owners of the project, which is not allowed. "
1051
                "Assign at least one user as owner and then retry."
1052
            )
1053

1054
        change = _AuthzChange(
1✔
1055
            apply=WriteRelationshipsRequest(updates=add_members), undo=WriteRelationshipsRequest(updates=undo)
1056
        )
1057
        await self.client.WriteRelationships(change.apply)
1✔
1058
        return output
1✔
1059

1060
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
1061
    async def remove_project_members(
2✔
1062
        self,
1063
        user: base_models.APIUser,
1064
        resource_type: ResourceType,
1065
        resource_id: ULID,
1066
        user_ids: list[str],
1067
        *,
1068
        zed_token: ZedToken | None = None,
1069
    ) -> list[MembershipChange]:
1070
        """Remove the specific members from the project, then return the list of members that were removed."""
1071
        resource_id_str = str(resource_id)
1✔
1072
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1073
        add_members: list[RelationshipUpdate] = []
1✔
1074
        remove_members: list[RelationshipUpdate] = []
1✔
1075
        output: list[MembershipChange] = []
1✔
1076
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
1077
        existing_owners: set[str] = {rel.relationship.subject.object.object_id for rel in existing_owners_rels}
1✔
1078
        for user_id in user_ids:
1✔
1079
            if user_id == "*":
1✔
1080
                raise errors.ValidationError(message="Cannot remove a project member with ID '*'")
×
1081
            existing_rel_filter = RelationshipFilter(
1✔
1082
                resource_type=resource_type.value,
1083
                optional_resource_id=resource_id_str,
1084
                optional_subject_filter=SubjectFilter(subject_type=ResourceType.user, optional_subject_id=user_id),
1085
            )
1086
            existing_rels: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1087
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
1088
            )
1089
            # NOTE: We have to make sure that when we undo we only put back relationships that existed already.
1090
            # Blindly undoing everything that was passed in may result in adding things that weren't there before.
1091
            async for existing_rel in existing_rels:
1✔
1092
                if existing_rel.relationship.relation == _Relation.owner.value and user_id in existing_owners:
1✔
1093
                    if len(existing_owners) == 1:
1✔
1094
                        raise errors.ValidationError(
1✔
1095
                            message="You are trying to remove the single last owner of the project, "
1096
                            "which is not allowed. Assign another user as owner and then retry."
1097
                        )
1098
                    existing_owners.remove(user_id)
1✔
1099
                add_members.append(
1✔
1100
                    RelationshipUpdate(
1101
                        operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
1102
                    )
1103
                )
1104
                remove_members.append(
1✔
1105
                    RelationshipUpdate(
1106
                        operation=RelationshipUpdate.OPERATION_DELETE, relationship=existing_rel.relationship
1107
                    )
1108
                )
1109
                output.append(
1✔
1110
                    MembershipChange(
1111
                        Member(
1112
                            Role(existing_rel.relationship.relation),
1113
                            existing_rel.relationship.subject.object.object_id,
1114
                            resource_id,
1115
                        ),
1116
                        Change.REMOVE,
1117
                    ),
1118
                )
1119
        change = _AuthzChange(
1✔
1120
            apply=WriteRelationshipsRequest(updates=remove_members), undo=WriteRelationshipsRequest(updates=add_members)
1121
        )
1122
        await self.client.WriteRelationships(change.apply)
1✔
1123
        return output
1✔
1124

1125
    async def _get_admin_user_ids(self) -> list[str]:
2✔
1126
        platform = _AuthzConverter.platform()
2✔
1127
        sub_filter = SubjectFilter(subject_type=ResourceType.user.value)
2✔
1128
        rel_filter = RelationshipFilter(
2✔
1129
            resource_type=platform.object_type,
1130
            optional_resource_id=platform.object_id,
1131
            optional_subject_filter=sub_filter,
1132
        )
1133
        existing_admins: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
2✔
1134
            ReadRelationshipsRequest(
1135
                consistency=Consistency(fully_consistent=True),
1136
                relationship_filter=rel_filter,
1137
            )
1138
        )
1139
        return [admin.relationship.subject.object.object_id async for admin in existing_admins]
2✔
1140

1141
    def _add_admin(self, user_id: str) -> _AuthzChange:
2✔
1142
        """Add a deployment-wide administrator in the authorization database."""
1143
        rel = Relationship(
2✔
1144
            resource=_AuthzConverter.platform(),
1145
            relation=_Relation.admin.value,
1146
            subject=_AuthzConverter.user_subject(user_id),
1147
        )
1148
        apply = WriteRelationshipsRequest(
2✔
1149
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel)]
1150
        )
1151
        undo = WriteRelationshipsRequest(
2✔
1152
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel)]
1153
        )
1154
        return _AuthzChange(apply=apply, undo=undo)
2✔
1155

1156
    async def _remove_admin(self, user_id: str) -> _AuthzChange:
2✔
1157
        """Add a deployment-wide administrator in the authorization database."""
1158
        existing_admin_ids = await self._get_admin_user_ids()
1✔
1159
        rel = Relationship(
1✔
1160
            resource=_AuthzConverter.platform(),
1161
            relation=_Relation.admin.value,
1162
            subject=_AuthzConverter.user_subject(user_id),
1163
        )
1164
        apply = WriteRelationshipsRequest(
1✔
1165
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel)]
1166
        )
1167
        undo = WriteRelationshipsRequest()
1✔
1168
        if user_id in existing_admin_ids:
1✔
1169
            undo = WriteRelationshipsRequest(
1✔
1170
                updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel)]
1171
            )
1172
        return _AuthzChange(apply=apply, undo=undo)
1✔
1173

1174
    def _add_group(self, group: Group) -> _AuthzChange:
2✔
1175
        """Add a group to the authorization database."""
1176
        if not group.id:
2✔
1177
            raise errors.ProgrammingError(
×
1178
                message="Cannot create a group in the authorization database if its ID is missing."
1179
            )
1180
        creator = SubjectReference(object=_AuthzConverter.user(group.created_by))
2✔
1181
        group_res = _AuthzConverter.group(group.id)
2✔
1182
        creator_is_owner = Relationship(resource=group_res, relation=_Relation.owner.value, subject=creator)
2✔
1183
        all_users = SubjectReference(object=_AuthzConverter.all_users())
2✔
1184
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
2✔
1185
        group_in_platform = Relationship(
2✔
1186
            resource=group_res,
1187
            relation=_Relation.group_platform.value,
1188
            subject=SubjectReference(object=self._platform),
1189
        )
1190
        all_users_are_public_viewers = Relationship(
2✔
1191
            resource=group_res,
1192
            relation=_Relation.public_viewer.value,
1193
            subject=all_users,
1194
        )
1195
        all_anon_users_are_public_viewers = Relationship(
2✔
1196
            resource=group_res,
1197
            relation=_Relation.public_viewer.value,
1198
            subject=all_anon_users,
1199
        )
1200
        relationships = [
2✔
1201
            creator_is_owner,
1202
            group_in_platform,
1203
            all_users_are_public_viewers,
1204
            all_anon_users_are_public_viewers,
1205
        ]
1206
        apply = WriteRelationshipsRequest(
2✔
1207
            updates=[
1208
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
1209
            ]
1210
        )
1211
        undo = WriteRelationshipsRequest(
2✔
1212
            updates=[
1213
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
1214
            ]
1215
        )
1216
        return _AuthzChange(apply=apply, undo=undo)
2✔
1217

1218
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.group)
2✔
1219
    async def _remove_group(
2✔
1220
        self, user: base_models.APIUser, group: Group, *, zed_token: ZedToken | None = None
1221
    ) -> _AuthzChange:
1222
        """Remove the group from the authorization database."""
1223
        if not group.id:
2✔
1224
            raise errors.ProgrammingError(
×
1225
                message="Cannot remove a group in the authorization database if the group has no ID"
1226
            )
1227
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
2✔
1228
        rel_filter = RelationshipFilter(resource_type=ResourceType.group.value, optional_resource_id=str(group.id))
2✔
1229
        responses = self.client.ReadRelationships(
2✔
1230
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1231
        )
1232
        rels: list[Relationship] = []
2✔
1233
        async for response in responses:
2✔
1234
            rels.append(response.relationship)
2✔
1235
        apply = WriteRelationshipsRequest(
2✔
1236
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1237
        )
1238
        undo = WriteRelationshipsRequest(
2✔
1239
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1240
        )
1241
        return _AuthzChange(apply=apply, undo=undo)
2✔
1242

1243
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
1244
    async def upsert_group_members(
2✔
1245
        self,
1246
        user: base_models.APIUser,
1247
        resource_type: ResourceType,
1248
        resource_id: ULID,
1249
        members: list[Member],
1250
        *,
1251
        zed_token: ZedToken | None = None,
1252
    ) -> list[MembershipChange]:
1253
        """Insert or update group member roles."""
1254
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1255
        group_res = _AuthzConverter.group(resource_id)
1✔
1256
        add_members: list[RelationshipUpdate] = []
1✔
1257
        undo: list[RelationshipUpdate] = []
1✔
1258
        output: list[MembershipChange] = []
1✔
1259
        resource_id_str = str(resource_id)
1✔
1260
        expected_user_roles = {_Relation.viewer.value, _Relation.owner.value, _Relation.editor.value}
1✔
1261
        existing_owners_rels = await self._get_resource_owners(resource_type, resource_id_str, consistency)
1✔
1262
        n_existing_owners = len(existing_owners_rels)
1✔
1263
        for member in members:
1✔
1264
            rel = Relationship(
1✔
1265
                resource=group_res,
1266
                relation=_Relation.from_role(member.role).value,
1267
                subject=SubjectReference(object=_AuthzConverter.user(member.user_id)),
1268
            )
1269
            existing_rel_filter = RelationshipFilter(
1✔
1270
                resource_type=resource_type.value,
1271
                optional_resource_id=resource_id_str,
1272
                optional_subject_filter=SubjectFilter(
1273
                    subject_type=ResourceType.user, optional_subject_id=member.user_id
1274
                ),
1275
            )
1276
            existing_rels_result: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1277
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
1278
            )
1279

1280
            existing_rels = [i async for i in existing_rels_result if i.relationship.relation in expected_user_roles]
1✔
1281
            if len(existing_rels) > 0:
1✔
1282
                # The existing relationships should be deleted if all goes well and added back in if we have to undo
1283
                existing_rel = existing_rels[0]
1✔
1284
                if existing_rel.relationship != rel:
1✔
1285
                    if existing_rel.relationship.relation == _Relation.owner.value:
1✔
1286
                        n_existing_owners -= 1
1✔
1287
                    elif rel.relation == _Relation.owner.value:
1✔
1288
                        n_existing_owners += 1
1✔
1289

1290
                    add_members.extend(
1✔
1291
                        [
1292
                            RelationshipUpdate(
1293
                                operation=RelationshipUpdate.OPERATION_TOUCH,
1294
                                relationship=rel,
1295
                            ),
1296
                            # NOTE: The old role for the user still exists and we have to remove it
1297
                            # if not both the old and new role for the same user will be present in the database
1298
                            RelationshipUpdate(
1299
                                operation=RelationshipUpdate.OPERATION_DELETE,
1300
                                relationship=existing_rel.relationship,
1301
                            ),
1302
                        ]
1303
                    )
1304
                    undo.extend(
1✔
1305
                        [
1306
                            RelationshipUpdate(
1307
                                operation=RelationshipUpdate.OPERATION_TOUCH,
1308
                                relationship=existing_rel.relationship,
1309
                            ),
1310
                            RelationshipUpdate(
1311
                                operation=RelationshipUpdate.OPERATION_DELETE,
1312
                                relationship=rel,
1313
                            ),
1314
                        ]
1315
                    )
1316
                    output.append(MembershipChange(member, Change.UPDATE))
1✔
1317
                for rel_to_remove in existing_rels[1:]:
1✔
1318
                    # NOTE: This means that the user has more than 1 role on the group - which should not happen
1319
                    # But if this does occur then we simply delete the extra roles of the user here.
1320
                    logger.warning(
×
1321
                        f"Removing additional unexpected role {rel_to_remove.relationship.relation} "
1322
                        f"of user {member.user_id} on group {resource_id}, "
1323
                        f"kept role {existing_rel.relationship.relation} which will be updated to {rel.relation}."
1324
                    )
1325
                    add_members.append(
×
1326
                        RelationshipUpdate(
1327
                            operation=RelationshipUpdate.OPERATION_DELETE,
1328
                            relationship=rel_to_remove.relationship,
1329
                        ),
1330
                    )
1331
                    undo.append(
×
1332
                        RelationshipUpdate(
1333
                            operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel_to_remove.relationship
1334
                        ),
1335
                    )
1336
                    output.append(MembershipChange(member, Change.REMOVE))
×
1337
            else:
1338
                if rel.relation == _Relation.owner.value:
1✔
1339
                    n_existing_owners += 1
1✔
1340

1341
                # The new relationship is added if all goes well and deleted if we have to undo
1342
                add_members.append(
1✔
1343
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=rel),
1344
                )
1345
                undo.append(
1✔
1346
                    RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=rel),
1347
                )
1348
                output.append(MembershipChange(member, Change.ADD))
1✔
1349

1350
        if n_existing_owners == 0:
1✔
1351
            raise errors.ValidationError(
1✔
1352
                message="You are trying to change the role of all the owners of the group, which is not allowed. "
1353
                "Assign at least one user as owner and then retry."
1354
            )
1355

1356
        change = _AuthzChange(
1✔
1357
            apply=WriteRelationshipsRequest(updates=add_members), undo=WriteRelationshipsRequest(updates=undo)
1358
        )
1359
        await self.client.WriteRelationships(change.apply)
1✔
1360
        return output
1✔
1361

1362
    @_is_allowed(Scope.CHANGE_MEMBERSHIP)
2✔
1363
    async def remove_group_members(
2✔
1364
        self,
1365
        user: base_models.APIUser,
1366
        resource_type: ResourceType,
1367
        resource_id: ULID,
1368
        user_ids: list[str],
1369
        *,
1370
        zed_token: ZedToken | None = None,
1371
    ) -> list[MembershipChange]:
1372
        """Remove the specific members from the group, then return the list of members that were removed."""
1373
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1374
        add_members: list[RelationshipUpdate] = []
1✔
1375
        remove_members: list[RelationshipUpdate] = []
1✔
1376
        output: list[MembershipChange] = []
1✔
1377
        existing_owners_rels: list[ReadRelationshipsResponse] | None = None
1✔
1378
        resource_id_str = str(resource_id)
1✔
1379
        for user_id in user_ids:
1✔
1380
            if user_id == "*":
1✔
1381
                raise errors.ValidationError(message="Cannot remove a group member with ID '*'")
×
1382
            existing_rel_filter = RelationshipFilter(
1✔
1383
                resource_type=resource_type.value,
1384
                optional_resource_id=resource_id_str,
1385
                optional_subject_filter=SubjectFilter(subject_type=ResourceType.user, optional_subject_id=user_id),
1386
            )
1387
            existing_rels: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1388
                ReadRelationshipsRequest(consistency=consistency, relationship_filter=existing_rel_filter)
1389
            )
1390
            # NOTE: We have to make sure that when we undo we only put back relationships that existed already.
1391
            # Blindly undoing everything that was passed in may result in adding things that weren't there before.
1392
            async for existing_rel in existing_rels:
1✔
1393
                if existing_rel.relationship.relation == _Relation.owner.value:
1✔
1394
                    if existing_owners_rels is None:
1✔
1395
                        existing_owners_rels = await self._get_resource_owners(
1✔
1396
                            resource_type, resource_id_str, consistency
1397
                        )
1398
                    if len(existing_owners_rels) == 1:
1✔
1399
                        raise errors.ValidationError(
1✔
1400
                            message="You are trying to remove the single last owner of the group, "
1401
                            "which is not allowed. Assign another user as owner and then retry."
1402
                        )
1403
                add_members.append(
1✔
1404
                    RelationshipUpdate(
1405
                        operation=RelationshipUpdate.OPERATION_TOUCH, relationship=existing_rel.relationship
1406
                    )
1407
                )
1408
                remove_members.append(
1✔
1409
                    RelationshipUpdate(
1410
                        operation=RelationshipUpdate.OPERATION_DELETE, relationship=existing_rel.relationship
1411
                    )
1412
                )
1413
                output.append(
1✔
1414
                    MembershipChange(
1415
                        Member(
1416
                            Role(existing_rel.relationship.relation),
1417
                            existing_rel.relationship.subject.object.object_id,
1418
                            resource_id,
1419
                        ),
1420
                        Change.REMOVE,
1421
                    ),
1422
                )
1423
        change = _AuthzChange(
1✔
1424
            apply=WriteRelationshipsRequest(updates=remove_members), undo=WriteRelationshipsRequest(updates=add_members)
1425
        )
1426
        await self.client.WriteRelationships(change.apply)
1✔
1427
        return output
1✔
1428

1429
    def _add_user_namespace(self, namespace: Namespace) -> _AuthzChange:
2✔
1430
        """Add a user namespace to the authorization database."""
1431
        if not namespace.id:
2✔
1432
            raise errors.ProgrammingError(
×
1433
                message="Cannot create a user namespace in the authorization database if its ID is missing."
1434
            )
1435
        creator = SubjectReference(object=_AuthzConverter.user(namespace.created_by))
2✔
1436
        namespace_res = _AuthzConverter.user_namespace(namespace.id)
2✔
1437
        creator_is_owner = Relationship(resource=namespace_res, relation=_Relation.owner.value, subject=creator)
2✔
1438
        all_users = SubjectReference(object=_AuthzConverter.all_users())
2✔
1439
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
2✔
1440
        namespace_in_platform = Relationship(
2✔
1441
            resource=namespace_res,
1442
            relation=_Relation.user_namespace_platform.value,
1443
            subject=SubjectReference(object=self._platform),
1444
        )
1445
        all_users_are_public_viewers = Relationship(
2✔
1446
            resource=namespace_res,
1447
            relation=_Relation.public_viewer.value,
1448
            subject=all_users,
1449
        )
1450
        all_anon_users_are_public_viewers = Relationship(
2✔
1451
            resource=namespace_res,
1452
            relation=_Relation.public_viewer.value,
1453
            subject=all_anon_users,
1454
        )
1455
        relationships = [
2✔
1456
            creator_is_owner,
1457
            namespace_in_platform,
1458
            all_users_are_public_viewers,
1459
            all_anon_users_are_public_viewers,
1460
        ]
1461
        apply = WriteRelationshipsRequest(
2✔
1462
            updates=[
1463
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
1464
            ]
1465
        )
1466
        undo = WriteRelationshipsRequest(
2✔
1467
            updates=[
1468
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
1469
            ]
1470
        )
1471
        return _AuthzChange(apply=apply, undo=undo)
2✔
1472

1473
    async def _remove_user_namespace(self, user_id: str, zed_token: ZedToken | None = None) -> _AuthzChange:
2✔
1474
        """Remove the user namespace from the authorization database."""
1475
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1476
        rel_filter = RelationshipFilter(resource_type=ResourceType.user_namespace.value, optional_resource_id=user_id)
1✔
1477
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1478
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1479
        )
1480
        rels: list[Relationship] = []
1✔
1481
        async for response in responses:
1✔
1482
            rels.append(response.relationship)
×
1483
        apply = WriteRelationshipsRequest(
1✔
1484
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1485
        )
1486
        undo = WriteRelationshipsRequest(
1✔
1487
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1488
        )
1489
        return _AuthzChange(apply=apply, undo=undo)
1✔
1490

1491
    def _add_data_connector(self, data_connector: DataConnector) -> _AuthzChange:
2✔
1492
        """Create the new data connector and associated resources and relations in the DB."""
1493
        creator = SubjectReference(object=_AuthzConverter.user(data_connector.created_by))
1✔
1494
        data_connector_res = _AuthzConverter.data_connector(data_connector.id)
1✔
1495
        creator_is_owner = Relationship(resource=data_connector_res, relation=_Relation.owner.value, subject=creator)
1✔
1496
        all_users = SubjectReference(object=_AuthzConverter.all_users())
1✔
1497
        all_anon_users = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
1498
        data_connector_namespace = SubjectReference(
1✔
1499
            object=_AuthzConverter.user_namespace(data_connector.namespace.id)
1500
            if data_connector.namespace.kind == NamespaceKind.user
1501
            else _AuthzConverter.group(cast(ULID, data_connector.namespace.underlying_resource_id))
1502
        )
1503
        data_connector_in_platform = Relationship(
1✔
1504
            resource=data_connector_res,
1505
            relation=_Relation.data_connector_platform,
1506
            subject=SubjectReference(object=self._platform),
1507
        )
1508
        data_connector_in_namespace = Relationship(
1✔
1509
            resource=data_connector_res, relation=_Relation.data_connector_namespace, subject=data_connector_namespace
1510
        )
1511
        relationships = [creator_is_owner, data_connector_in_platform, data_connector_in_namespace]
1✔
1512
        if data_connector.visibility == Visibility.PUBLIC:
1✔
1513
            all_users_are_viewers = Relationship(
1✔
1514
                resource=data_connector_res,
1515
                relation=_Relation.public_viewer.value,
1516
                subject=all_users,
1517
            )
1518
            all_anon_users_are_viewers = Relationship(
1✔
1519
                resource=data_connector_res,
1520
                relation=_Relation.public_viewer.value,
1521
                subject=all_anon_users,
1522
            )
1523
            relationships.extend([all_users_are_viewers, all_anon_users_are_viewers])
1✔
1524
        apply = WriteRelationshipsRequest(
1✔
1525
            updates=[
1526
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in relationships
1527
            ]
1528
        )
1529
        undo = WriteRelationshipsRequest(
1✔
1530
            updates=[
1531
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in relationships
1532
            ]
1533
        )
1534
        return _AuthzChange(apply=apply, undo=undo)
1✔
1535

1536
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.data_connector)
2✔
1537
    async def _remove_data_connector(
2✔
1538
        self, user: base_models.APIUser, data_connector: DataConnector, *, zed_token: ZedToken | None = None
1539
    ) -> _AuthzChange:
1540
        """Remove the relationships associated with the data connector."""
1541
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1542
        rel_filter = RelationshipFilter(
1✔
1543
            resource_type=ResourceType.data_connector.value, optional_resource_id=str(data_connector.id)
1544
        )
1545
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1546
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1547
        )
1548
        rels: list[Relationship] = []
1✔
1549
        async for response in responses:
1✔
1550
            rels.append(response.relationship)
1✔
1551
        apply = WriteRelationshipsRequest(
1✔
1552
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1553
        )
1554
        undo = WriteRelationshipsRequest(
1✔
1555
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1556
        )
1557
        return _AuthzChange(apply=apply, undo=undo)
1✔
1558

1559
    async def _remove_user(
2✔
1560
        self,
1561
        requested_by: base_models.APIUser,
1562
        user_to_delete: UserInfo,
1563
    ) -> _AuthzChange:
1564
        """Remove a user from the authorization database."""
1565
        # Compute permission by hand for user deletion
1566
        # NOTE that for user deletion, the permission is "is_admin" on the platform
1567
        has_permission, zed_token = await self._has_permission(requested_by, ResourceType.platform, "*", Scope.IS_ADMIN)
1✔
1568
        if not has_permission:
1✔
1569
            raise errors.MissingResourceError(
×
1570
                message=f"The user with ID {requested_by.id} cannot perform operation {Scope.DELETE} "
1571
                f"on {ResourceType.user} with ID {user_to_delete.id} or the resource does not exist."
1572
            )
1573
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1574
        rels: list[Relationship] = []
1✔
1575
        rel_filter = RelationshipFilter(
1✔
1576
            optional_subject_filter=SubjectFilter(subject_type=ResourceType.user, optional_subject_id=user_to_delete.id)
1577
        )
1578
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1579
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1580
        )
1581
        async for response in responses:
1✔
1582
            rels.append(response.relationship)
1✔
1583
        apply = WriteRelationshipsRequest(
1✔
1584
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1585
        )
1586
        undo = WriteRelationshipsRequest(
1✔
1587
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1588
        )
1589
        return _AuthzChange(apply=apply, undo=undo)
1✔
1590

1591
    # NOTE changing visibility is the same access level as removal
1592
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.data_connector)
2✔
1593
    async def _update_data_connector_visibility(
2✔
1594
        self, user: base_models.APIUser, data_connector: DataConnector, *, zed_token: ZedToken | None = None
1595
    ) -> _AuthzChange:
1596
        """Update the visibility of the data connector in the authorization database."""
1597
        data_connector_id_str = str(data_connector.id)
1✔
1598
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1599
        data_connector_res = _AuthzConverter.data_connector(data_connector.id)
1✔
1600
        all_users_sub = SubjectReference(object=_AuthzConverter.all_users())
1✔
1601
        anon_users_sub = SubjectReference(object=_AuthzConverter.anonymous_users())
1✔
1602
        all_users_are_viewers = Relationship(
1✔
1603
            resource=data_connector_res,
1604
            relation=_Relation.public_viewer.value,
1605
            subject=all_users_sub,
1606
        )
1607
        anon_users_are_viewers = Relationship(
1✔
1608
            resource=data_connector_res,
1609
            relation=_Relation.public_viewer.value,
1610
            subject=anon_users_sub,
1611
        )
1612
        make_public = WriteRelationshipsRequest(
1✔
1613
            updates=[
1614
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=all_users_are_viewers),
1615
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=anon_users_are_viewers),
1616
            ]
1617
        )
1618
        make_private = WriteRelationshipsRequest(
1✔
1619
            updates=[
1620
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=all_users_are_viewers),
1621
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=anon_users_are_viewers),
1622
            ]
1623
        )
1624
        rel_filter = RelationshipFilter(
1✔
1625
            resource_type=ResourceType.data_connector.value,
1626
            optional_resource_id=data_connector_id_str,
1627
            optional_subject_filter=SubjectFilter(
1628
                subject_type=ResourceType.user.value, optional_subject_id=all_users_sub.object.object_id
1629
            ),
1630
        )
1631
        current_relation_users: ReadRelationshipsResponse | None = await anext(
1✔
1632
            aiter(
1633
                self.client.ReadRelationships(
1634
                    ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1635
                )
1636
            ),
1637
            None,
1638
        )
1639
        rel_filter = RelationshipFilter(
1✔
1640
            resource_type=ResourceType.project.value,
1641
            optional_resource_id=data_connector_id_str,
1642
            optional_subject_filter=SubjectFilter(
1643
                subject_type=ResourceType.anonymous_user.value,
1644
                optional_subject_id=anon_users_sub.object.object_id,
1645
            ),
1646
        )
1647
        current_relation_anon_users: ReadRelationshipsResponse | None = await anext(
1✔
1648
            aiter(
1649
                self.client.ReadRelationships(
1650
                    ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1651
                )
1652
            ),
1653
            None,
1654
        )
1655
        data_connector_is_public_for_users = (
1✔
1656
            current_relation_users is not None
1657
            and current_relation_users.relationship.subject.object.object_type == ResourceType.user.value
1658
            and current_relation_users.relationship.subject.object.object_id == all_users_sub.object.object_id
1659
        )
1660
        data_connector_is_public_for_anon_users = (
1✔
1661
            current_relation_anon_users is not None
1662
            and current_relation_anon_users.relationship.subject.object.object_type == ResourceType.anonymous_user.value
1663
            and current_relation_anon_users.relationship.subject.object.object_id == anon_users_sub.object.object_id,
1664
        )
1665
        data_connector_already_public = data_connector_is_public_for_users and data_connector_is_public_for_anon_users
1✔
1666
        data_connector_already_private = not data_connector_already_public
1✔
1667
        match data_connector.visibility:
1✔
1668
            case Visibility.PUBLIC:
1✔
1669
                if data_connector_already_public:
1✔
NEW
1670
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
1671
                return _AuthzChange(apply=make_public, undo=make_private)
1✔
1672
            case Visibility.PRIVATE:
1✔
1673
                if data_connector_already_private:
1✔
NEW
1674
                    return _AuthzChange(apply=WriteRelationshipsRequest(), undo=WriteRelationshipsRequest())
×
1675
                return _AuthzChange(apply=make_private, undo=make_public)
1✔
NEW
1676
        raise errors.ProgrammingError(
×
1677
            message=f"Encountered unknown data connector visibility {data_connector.visibility} when trying to "
1678
            f"make a visibility change for data connector with ID {data_connector.id}",
1679
        )
1680

1681
    # NOTE changing namespace is the same access level as removal
1682
    @_is_allowed_on_resource(Scope.DELETE, ResourceType.data_connector)
2✔
1683
    async def _update_data_connector_namespace(
2✔
1684
        self, user: base_models.APIUser, data_connector: DataConnector, *, zed_token: ZedToken | None = None
1685
    ) -> _AuthzChange:
1686
        """Update the namespace of the data connector in the authorization database."""
1687
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1688
        data_connector_res = _AuthzConverter.data_connector(data_connector.id)
1✔
1689
        data_connector_filter = RelationshipFilter(
1✔
1690
            resource_type=ResourceType.data_connector.value,
1691
            optional_resource_id=str(data_connector.id),
1692
            optional_relation=_Relation.data_connector_namespace.value,
1693
        )
1694
        current_namespace: ReadRelationshipsResponse | None = await anext(
1✔
1695
            aiter(
1696
                self.client.ReadRelationships(
1697
                    ReadRelationshipsRequest(relationship_filter=data_connector_filter, consistency=consistency)
1698
                )
1699
            ),
1700
            None,
1701
        )
1702
        if not current_namespace:
1✔
NEW
1703
            raise errors.ProgrammingError(
×
1704
                message=f"The data connector with ID {data_connector.id} whose namespace is being updated "
1705
                "does not currently have a namespace."
1706
            )
1707
        if current_namespace.relationship.subject.object.object_id == data_connector.namespace.id:
1✔
NEW
1708
            return _AuthzChange()
×
1709
        new_namespace_sub = (
1✔
1710
            SubjectReference(object=_AuthzConverter.group(data_connector.namespace.id))
1711
            if data_connector.namespace.kind == NamespaceKind.group
1712
            else SubjectReference(object=_AuthzConverter.user_namespace(data_connector.namespace.id))
1713
        )
1714
        old_namespace_sub = (
1✔
1715
            SubjectReference(
1716
                object=_AuthzConverter.group(ULID.from_str(current_namespace.relationship.subject.object.object_id))
1717
            )
1718
            if current_namespace.relationship.subject.object.object_type == ResourceType.group.value
1719
            else SubjectReference(
1720
                object=_AuthzConverter.user_namespace(
1721
                    ULID.from_str(current_namespace.relationship.subject.object.object_id)
1722
                )
1723
            )
1724
        )
1725
        new_namespace = Relationship(
1✔
1726
            resource=data_connector_res,
1727
            relation=_Relation.data_connector_namespace.value,
1728
            subject=new_namespace_sub,
1729
        )
1730
        old_namespace = Relationship(
1✔
1731
            resource=data_connector_res,
1732
            relation=_Relation.data_connector_namespace.value,
1733
            subject=old_namespace_sub,
1734
        )
1735
        apply_change = WriteRelationshipsRequest(
1✔
1736
            updates=[
1737
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=new_namespace),
1738
            ]
1739
        )
1740
        undo_change = WriteRelationshipsRequest(
1✔
1741
            updates=[
1742
                RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=old_namespace),
1743
            ]
1744
        )
1745
        return _AuthzChange(apply=apply_change, undo=undo_change)
1✔
1746

1747
    async def _add_data_connector_to_project_link(
2✔
1748
        self, user: base_models.APIUser, link: DataConnectorToProjectLink
1749
    ) -> _AuthzChange:
1750
        """Links a data connector to a project."""
1751
        # NOTE: we manually check for permissions here since it is not trivially expressed through decorators
1752
        allowed_from = await self.has_permission(
1✔
1753
            user, ResourceType.data_connector, link.data_connector_id, Scope.ADD_LINK
1754
        )
1755
        if not allowed_from:
1✔
1756
            raise errors.MissingResourceError(
1✔
1757
                message=f"The user with ID {user.id} cannot perform operation {Scope.ADD_LINK} "
1758
                f"on {ResourceType.data_connector.value} "
1759
                f"with ID {link.data_connector_id} or the resource does not exist."
1760
            )
1761
        allowed_to = await self.has_permission(user, ResourceType.project, link.project_id, Scope.WRITE)
1✔
1762
        if not allowed_to:
1✔
1763
            raise errors.MissingResourceError(
1✔
1764
                message=f"The user with ID {user.id} cannot perform operation {Scope.WRITE} "
1765
                f"on {ResourceType.project.value} "
1766
                f"with ID {link.project_id} or the resource does not exist."
1767
            )
1768

1769
        data_connector_res = _AuthzConverter.data_connector(link.data_connector_id)
1✔
1770
        project_subject = SubjectReference(object=_AuthzConverter.project(link.project_id))
1✔
1771
        relationship = Relationship(
1✔
1772
            resource=data_connector_res,
1773
            relation=_Relation.linked_to.value,
1774
            subject=project_subject,
1775
        )
1776
        apply = WriteRelationshipsRequest(
1✔
1777
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=relationship)]
1778
        )
1779
        undo = WriteRelationshipsRequest(
1✔
1780
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=relationship)]
1781
        )
1782
        change = _AuthzChange(
1✔
1783
            apply=apply,
1784
            undo=undo,
1785
        )
1786
        return change
1✔
1787

1788
    async def _remove_data_connector_to_project_link(
2✔
1789
        self, user: base_models.APIUser, link: DataConnectorToProjectLink
1790
    ) -> _AuthzChange:
1791
        """Remove the relationships associated with the link from a data connector to a project."""
1792
        # NOTE: we manually check for permissions here since it is not trivially expressed through decorators
1793
        allowed_from = await self.has_permission(
1✔
1794
            user, ResourceType.data_connector, link.data_connector_id, Scope.DELETE
1795
        )
1796
        allowed_to, zed_token = await self._has_permission(user, ResourceType.project, link.project_id, Scope.WRITE)
1✔
1797
        allowed = allowed_from or allowed_to
1✔
1798
        if not allowed:
1✔
NEW
1799
            raise errors.MissingResourceError(
×
1800
                message=f"The user with ID {user.id} cannot perform operation {AuthzOperation.delete_link}"
1801
                f"on the data connector to project link with ID {link.id} or the resource does not exist."
1802
            )
1803
        consistency = Consistency(at_least_as_fresh=zed_token) if zed_token else Consistency(fully_consistent=True)
1✔
1804
        rel_filter = RelationshipFilter(
1✔
1805
            resource_type=ResourceType.data_connector.value,
1806
            optional_resource_id=str(link.data_connector_id),
1807
            optional_relation=_Relation.linked_to.value,
1808
            optional_subject_filter=SubjectFilter(
1809
                subject_type=ResourceType.project.value, optional_subject_id=str(link.project_id)
1810
            ),
1811
        )
1812
        responses: AsyncIterable[ReadRelationshipsResponse] = self.client.ReadRelationships(
1✔
1813
            ReadRelationshipsRequest(consistency=consistency, relationship_filter=rel_filter)
1814
        )
1815
        rels: list[Relationship] = []
1✔
1816
        async for response in responses:
1✔
1817
            rels.append(response.relationship)
1✔
1818
        apply = WriteRelationshipsRequest(
1✔
1819
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_DELETE, relationship=i) for i in rels]
1820
        )
1821
        undo = WriteRelationshipsRequest(
1✔
1822
            updates=[RelationshipUpdate(operation=RelationshipUpdate.OPERATION_TOUCH, relationship=i) for i in rels]
1823
        )
1824
        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