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

SwissDataScienceCenter / renku-data-services / 12047437567

27 Nov 2024 09:31AM UTC coverage: 86.006% (+0.1%) from 85.882%
12047437567

Pull #545

github

web-flow
Merge c434d43ab into 7ae3af62e
Pull Request #545: feat!: add support for session secrets

216 of 222 new or added lines in 10 files covered. (97.3%)

5 existing lines in 3 files now uncovered.

14682 of 17071 relevant lines covered (86.01%)

1.52 hits per line

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

94.12
/components/renku_data_services/project/db.py
1
"""Adapters for project database classes."""
2

3
from __future__ import annotations
2✔
4

5
import functools
2✔
6
from collections.abc import AsyncGenerator, Awaitable, Callable
2✔
7
from datetime import UTC, datetime
2✔
8
from typing import Concatenate, ParamSpec, TypeVar
2✔
9

10
from sqlalchemy import Select, delete, func, select, update
2✔
11
from sqlalchemy.ext.asyncio import AsyncSession
2✔
12
from sqlalchemy.orm import undefer
2✔
13
from sqlalchemy.sql.functions import coalesce
2✔
14
from ulid import ULID
2✔
15

16
import renku_data_services.base_models as base_models
2✔
17
from renku_data_services import errors
2✔
18
from renku_data_services.authz.authz import Authz, AuthzOperation, ResourceType
2✔
19
from renku_data_services.authz.models import CheckPermissionItem, Member, MembershipChange, Scope
2✔
20
from renku_data_services.base_api.pagination import PaginationRequest
2✔
21
from renku_data_services.message_queue import events
2✔
22
from renku_data_services.message_queue.avro_models.io.renku.events import v2 as avro_schema_v2
2✔
23
from renku_data_services.message_queue.db import EventRepository
2✔
24
from renku_data_services.message_queue.interface import IMessageQueue
2✔
25
from renku_data_services.message_queue.redis_queue import dispatch_message
2✔
26
from renku_data_services.namespace import orm as ns_schemas
2✔
27
from renku_data_services.namespace.db import GroupRepository
2✔
28
from renku_data_services.project import apispec as project_apispec
2✔
29
from renku_data_services.project import models
2✔
30
from renku_data_services.project import orm as schemas
2✔
31
from renku_data_services.storage import orm as storage_schemas
2✔
32
from renku_data_services.users.orm import UserORM
2✔
33
from renku_data_services.utils.core import with_db_transaction
2✔
34

35

36
class ProjectRepository:
2✔
37
    """Repository for projects."""
38

39
    def __init__(
2✔
40
        self,
41
        session_maker: Callable[..., AsyncSession],
42
        message_queue: IMessageQueue,
43
        event_repo: EventRepository,
44
        group_repo: GroupRepository,
45
        authz: Authz,
46
    ) -> None:
47
        self.session_maker = session_maker
2✔
48
        self.message_queue: IMessageQueue = message_queue
2✔
49
        self.event_repo: EventRepository = event_repo
2✔
50
        self.group_repo: GroupRepository = group_repo
2✔
51
        self.authz = authz
2✔
52

53
    async def get_projects(
2✔
54
        self,
55
        user: base_models.APIUser,
56
        pagination: PaginationRequest,
57
        namespace: str | None = None,
58
        direct_member: bool = False,
59
    ) -> tuple[list[models.Project], int]:
60
        """Get all projects from the database."""
61
        if direct_member:
2✔
62
            project_ids = await self.authz.resources_with_direct_membership(user, ResourceType.project)
1✔
63
        else:
64
            project_ids = await self.authz.resources_with_permission(user, user.id, ResourceType.project, Scope.READ)
2✔
65

66
        async with self.session_maker() as session:
2✔
67
            stmt = select(schemas.ProjectORM)
2✔
68
            stmt = stmt.where(schemas.ProjectORM.id.in_(project_ids))
2✔
69
            if namespace:
2✔
70
                stmt = _filter_by_namespace_slug(stmt, namespace)
1✔
71

72
            stmt = stmt.order_by(coalesce(schemas.ProjectORM.updated_at, schemas.ProjectORM.creation_date).desc())
2✔
73

74
            stmt = stmt.limit(pagination.per_page).offset(pagination.offset)
2✔
75

76
            stmt_count = (
2✔
77
                select(func.count()).select_from(schemas.ProjectORM).where(schemas.ProjectORM.id.in_(project_ids))
78
            )
79
            if namespace:
2✔
80
                stmt_count = _filter_by_namespace_slug(stmt_count, namespace)
1✔
81
            results = await session.scalars(stmt), await session.scalar(stmt_count)
2✔
82
            projects_orm = results[0].all()
2✔
83
            total_elements = results[1] or 0
2✔
84
            return [p.dump() for p in projects_orm], total_elements
2✔
85

86
    async def get_all_projects(self, requested_by: base_models.APIUser) -> AsyncGenerator[models.Project, None]:
2✔
87
        """Get all projects from the database when reprovisioning."""
88
        if not requested_by.is_admin:
2✔
89
            raise errors.ForbiddenError(message="You do not have the required permissions for this operation.")
×
90

91
        async with self.session_maker() as session:
2✔
92
            projects = await session.stream_scalars(select(schemas.ProjectORM))
2✔
93
            async for project in projects:
2✔
94
                yield project.dump()
1✔
95

96
    async def get_project(
2✔
97
        self, user: base_models.APIUser, project_id: ULID, with_documentation: bool = False
98
    ) -> models.Project:
99
        """Get one project from the database."""
100
        authorized = await self.authz.has_permission(user, ResourceType.project, project_id, Scope.READ)
2✔
101
        if not authorized:
2✔
102
            raise errors.MissingResourceError(
2✔
103
                message=f"Project with id '{project_id}' does not exist or you do not have access to it."
104
            )
105

106
        async with self.session_maker() as session:
1✔
107
            stmt = select(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id)
1✔
108
            if with_documentation:
1✔
109
                stmt = stmt.options(undefer(schemas.ProjectORM.documentation))
1✔
110
            result = await session.execute(stmt)
1✔
111
            project_orm = result.scalars().first()
1✔
112

113
            if project_orm is None:
1✔
114
                raise errors.MissingResourceError(message=f"Project with id '{project_id}' does not exist.")
1✔
115

116
            return project_orm.dump(with_documentation=with_documentation)
1✔
117

118
    async def get_all_copied_projects(
2✔
119
        self, user: base_models.APIUser, project_id: ULID, only_writable: bool
120
    ) -> list[models.Project]:
121
        """Get all projects that are copied from the specified project."""
122
        authorized = await self.authz.has_permission(user, ResourceType.project, project_id, Scope.READ)
2✔
123
        if not authorized:
2✔
124
            raise errors.MissingResourceError(
1✔
125
                message=f"Project with id '{project_id}' does not exist or you do not have access to it."
126
            )
127

128
        async with self.session_maker() as session:
1✔
129
            stmt = select(schemas.ProjectORM).where(schemas.ProjectORM.template_id == project_id)
1✔
130
            result = await session.execute(stmt)
1✔
131
            project_orms = result.scalars().all()
1✔
132

133
            # NOTE: Show only those projects that user has access to
134
            scope = Scope.WRITE if only_writable else Scope.READ
1✔
135
            project_ids = await self.authz.resources_with_permission(user, user.id, ResourceType.project, scope=scope)
1✔
136
            project_orms = [p for p in project_orms if p.id in project_ids]
1✔
137

138
            return [p.dump() for p in project_orms]
1✔
139

140
    async def get_project_by_namespace_slug(
2✔
141
        self, user: base_models.APIUser, namespace: str, slug: str, with_documentation: bool = False
142
    ) -> models.Project:
143
        """Get one project from the database."""
144
        async with self.session_maker() as session:
2✔
145
            stmt = select(schemas.ProjectORM)
2✔
146
            stmt = _filter_by_namespace_slug(stmt, namespace)
2✔
147
            stmt = stmt.where(schemas.ProjectORM.slug.has(ns_schemas.EntitySlugORM.slug == slug))
2✔
148
            if with_documentation:
2✔
149
                stmt = stmt.options(undefer(schemas.ProjectORM.documentation))
2✔
150
            result = await session.execute(stmt)
2✔
151
            project_orm = result.scalars().first()
2✔
152

153
            not_found_msg = (
2✔
154
                f"Project with identifier '{namespace}/{slug}' does not exist or you do not have access to it."
155
            )
156

157
            if project_orm is None:
2✔
158
                raise errors.MissingResourceError(message=not_found_msg)
1✔
159

160
            authorized = await self.authz.has_permission(
1✔
161
                user=user,
162
                resource_type=ResourceType.project,
163
                resource_id=project_orm.id,
164
                scope=Scope.READ,
165
            )
166
            if not authorized:
1✔
167
                raise errors.MissingResourceError(message=not_found_msg)
×
168

169
            return project_orm.dump(with_documentation=with_documentation)
1✔
170

171
    @with_db_transaction
2✔
172
    @Authz.authz_change(AuthzOperation.create, ResourceType.project)
2✔
173
    @dispatch_message(avro_schema_v2.ProjectCreated)
2✔
174
    async def insert_project(
2✔
175
        self,
176
        user: base_models.APIUser,
177
        project: models.UnsavedProject,
178
        *,
179
        session: AsyncSession | None = None,
180
    ) -> models.Project:
181
        """Insert a new project entry."""
182
        if not session:
2✔
183
            raise errors.ProgrammingError(message="A database session is required")
×
184
        ns = await session.scalar(
2✔
185
            select(ns_schemas.NamespaceORM).where(ns_schemas.NamespaceORM.slug == project.namespace.lower())
186
        )
187
        if not ns:
2✔
188
            raise errors.MissingResourceError(
1✔
189
                message=f"The project cannot be created because the namespace {project.namespace} does not exist"
190
            )
191
        if not ns.group_id and not ns.user_id:
1✔
192
            raise errors.ProgrammingError(message="Found a namespace that has no group or user associated with it.")
×
193

194
        if user.id is None:
1✔
195
            raise errors.UnauthorizedError(message="You do not have the required permissions for this operation.")
×
196

197
        resource_type, resource_id = (
1✔
198
            (ResourceType.group, ns.group_id) if ns.group and ns.group_id else (ResourceType.user_namespace, ns.id)
199
        )
200
        has_permission = await self.authz.has_permission(user, resource_type, resource_id, Scope.WRITE)
1✔
201
        if not has_permission:
1✔
202
            raise errors.ForbiddenError(
1✔
203
                message=f"The project cannot be created because you do not have sufficient permissions with the namespace {project.namespace}"  # noqa: E501
204
            )
205

206
        slug = project.slug or base_models.Slug.from_name(project.name).value
1✔
207

208
        existing_slug = await session.scalar(
1✔
209
            select(ns_schemas.EntitySlugORM)
210
            .where(ns_schemas.EntitySlugORM.namespace_id == ns.id)
211
            .where(ns_schemas.EntitySlugORM.slug == slug)
212
        )
213
        if existing_slug is not None:
1✔
214
            raise errors.ConflictError(message=f"An entity with the slug '{ns.slug}/{slug}' already exists.")
1✔
215

216
        repositories = [schemas.ProjectRepositoryORM(url) for url in (project.repositories or [])]
1✔
217
        project_orm = schemas.ProjectORM(
1✔
218
            name=project.name,
219
            visibility=(
220
                project_apispec.Visibility(project.visibility)
221
                if isinstance(project.visibility, str)
222
                else project_apispec.Visibility(project.visibility.value)
223
            ),
224
            created_by_id=user.id,
225
            description=project.description,
226
            repositories=repositories,
227
            creation_date=datetime.now(UTC).replace(microsecond=0),
228
            keywords=project.keywords,
229
            documentation=project.documentation,
230
            template_id=project.template_id,
231
        )
232
        project_slug = ns_schemas.EntitySlugORM.create_project_slug(slug, project_id=project_orm.id, namespace_id=ns.id)
1✔
233

234
        session.add(project_orm)
1✔
235
        session.add(project_slug)
1✔
236
        await session.flush()
1✔
237
        await session.refresh(project_orm)
1✔
238

239
        return project_orm.dump()
1✔
240

241
    @with_db_transaction
2✔
242
    @Authz.authz_change(AuthzOperation.update, ResourceType.project)
2✔
243
    @dispatch_message(avro_schema_v2.ProjectUpdated)
2✔
244
    async def update_project(
2✔
245
        self,
246
        user: base_models.APIUser,
247
        project_id: ULID,
248
        patch: models.ProjectPatch,
249
        etag: str | None = None,
250
        *,
251
        session: AsyncSession | None = None,
252
    ) -> models.ProjectUpdate:
253
        """Update a project entry."""
254
        if not session:
2✔
255
            raise errors.ProgrammingError(message="A database session is required")
×
256
        result = await session.scalars(select(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id))
2✔
257
        project = result.one_or_none()
2✔
258
        if project is None:
2✔
259
            raise errors.MissingResourceError(message=f"The project with id '{project_id}' cannot be found")
1✔
260
        old_project = project.dump()
1✔
261

262
        required_scope = Scope.WRITE
1✔
263
        if patch.visibility is not None and patch.visibility != old_project.visibility:
1✔
264
            # NOTE: changing the visibility requires the user to be owner which means they should have DELETE permission
265
            required_scope = Scope.DELETE
1✔
266
        if patch.namespace is not None and patch.namespace != old_project.namespace.slug:
1✔
267
            # NOTE: changing the namespace requires the user to be owner which means they should have DELETE permission
268
            required_scope = Scope.DELETE
1✔
269
        authorized = await self.authz.has_permission(user, ResourceType.project, project_id, required_scope)
1✔
270
        if not authorized:
1✔
271
            raise errors.MissingResourceError(
×
272
                message=f"Project with id '{project_id}' does not exist or you do not have access to it."
273
            )
274

275
        current_etag = project.dump().etag
1✔
276
        if etag is not None and current_etag != etag:
1✔
277
            raise errors.ConflictError(message=f"Current ETag is {current_etag}, not {etag}.")
×
278

279
        if patch.name is not None:
1✔
280
            project.name = patch.name
1✔
281
        if patch.namespace is not None and patch.namespace != old_project.namespace.slug:
1✔
282
            ns = await session.scalar(
1✔
283
                select(ns_schemas.NamespaceORM).where(ns_schemas.NamespaceORM.slug == patch.namespace.lower())
284
            )
285
            if not ns:
1✔
286
                raise errors.MissingResourceError(message=f"The namespace with slug {patch.namespace} does not exist")
×
287
            if not ns.group_id and not ns.user_id:
1✔
288
                raise errors.ProgrammingError(message="Found a namespace that has no group or user associated with it.")
×
289
            resource_type, resource_id = (
1✔
290
                (ResourceType.group, ns.group_id) if ns.group and ns.group_id else (ResourceType.user_namespace, ns.id)
291
            )
292
            has_permission = await self.authz.has_permission(user, resource_type, resource_id, Scope.WRITE)
1✔
293
            if not has_permission:
1✔
294
                raise errors.ForbiddenError(
1✔
295
                    message=f"The project cannot be moved because you do not have sufficient permissions with the namespace {patch.namespace}"  # noqa: E501
296
                )
297
            project.slug.namespace_id = ns.id
1✔
298
        if patch.visibility is not None:
1✔
299
            visibility_orm = (
1✔
300
                project_apispec.Visibility(patch.visibility)
301
                if isinstance(patch.visibility, str)
302
                else project_apispec.Visibility(patch.visibility.value)
303
            )
304
            project.visibility = visibility_orm
1✔
305
        if patch.repositories is not None:
1✔
306
            project.repositories = [
1✔
307
                schemas.ProjectRepositoryORM(url=r, project_id=project.id, project=project) for r in patch.repositories
308
            ]
309
            # Trigger update for ``updated_at`` column
310
            await session.execute(update(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id).values())
1✔
311
        if patch.description is not None:
1✔
312
            project.description = patch.description if patch.description else None
1✔
313
        if patch.keywords is not None:
1✔
314
            project.keywords = patch.keywords if patch.keywords else None
1✔
315
        if patch.documentation is not None:
1✔
316
            project.documentation = patch.documentation
1✔
317

318
        if patch.template_id is not None:
1✔
319
            project.template_id = None
1✔
320
        if patch.is_template is not None:
1✔
321
            project.is_template = patch.is_template
1✔
322

323
        await session.flush()
1✔
324
        await session.refresh(project)
1✔
325

326
        return models.ProjectUpdate(
1✔
327
            old=old_project,
328
            new=project.dump(),  # NOTE: Triggers validation before the transaction saves data
329
        )
330

331
    @with_db_transaction
2✔
332
    @Authz.authz_change(AuthzOperation.delete, ResourceType.project)
2✔
333
    @dispatch_message(avro_schema_v2.ProjectRemoved)
2✔
334
    async def delete_project(
2✔
335
        self, user: base_models.APIUser, project_id: ULID, *, session: AsyncSession | None = None
336
    ) -> models.DeletedProject | None:
337
        """Delete a project."""
338
        if not session:
2✔
339
            raise errors.ProgrammingError(message="A database session is required")
×
340
        authorized = await self.authz.has_permission(user, ResourceType.project, project_id, Scope.DELETE)
2✔
341
        if not authorized:
2✔
342
            raise errors.MissingResourceError(
1✔
343
                message=f"Project with id '{project_id}' does not exist or you do not have access to it."
344
            )
345

346
        result = await session.execute(select(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id))
1✔
347
        project = result.scalar_one_or_none()
1✔
348

349
        if project is None:
1✔
350
            return None
×
351

352
        await session.execute(delete(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id))
1✔
353

354
        await session.execute(
1✔
355
            delete(storage_schemas.CloudStorageORM).where(storage_schemas.CloudStorageORM.project_id == str(project_id))
356
        )
357

358
        return models.DeletedProject(id=project.id)
1✔
359

360
    async def get_project_permissions(self, user: base_models.APIUser, project_id: ULID) -> models.ProjectPermissions:
2✔
361
        """Get the permissions of the user on a given project."""
362
        # Get the project first, it will check if the user can view it.
363
        await self.get_project(user=user, project_id=project_id)
2✔
364

365
        scopes = [Scope.WRITE, Scope.DELETE, Scope.CHANGE_MEMBERSHIP]
1✔
366
        items = [
1✔
367
            CheckPermissionItem(resource_type=ResourceType.project, resource_id=project_id, scope=scope)
368
            for scope in scopes
369
        ]
370
        responses = await self.authz.has_permissions(user=user, items=items)
1✔
371
        permissions = models.ProjectPermissions(write=False, delete=False, change_membership=False)
1✔
372
        for item, has_permission in responses:
1✔
373
            if not has_permission:
1✔
374
                continue
1✔
375
            match item.scope:
1✔
376
                case Scope.WRITE:
1✔
377
                    permissions.write = True
1✔
378
                case Scope.DELETE:
1✔
379
                    permissions.delete = True
1✔
380
                case Scope.CHANGE_MEMBERSHIP:
1✔
381
                    permissions.change_membership = True
1✔
382
        return permissions
1✔
383

384

385
_P = ParamSpec("_P")
2✔
386
_T = TypeVar("_T")
2✔
387

388

389
def _filter_by_namespace_slug(statement: Select[tuple[_T]], namespace: str) -> Select[tuple[_T]]:
2✔
390
    """Filters a select query on projects to a given namespace."""
391
    return (
2✔
392
        statement.where(ns_schemas.NamespaceORM.slug == namespace.lower())
393
        .where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
394
        .where(schemas.ProjectORM.id == ns_schemas.EntitySlugORM.project_id)
395
    )
396

397

398
def _project_exists(
2✔
399
    f: Callable[Concatenate[ProjectMemberRepository, base_models.APIUser, ULID, _P], Awaitable[_T]],
400
) -> Callable[Concatenate[ProjectMemberRepository, base_models.APIUser, ULID, _P], Awaitable[_T]]:
401
    """Checks if the project exists when adding or modifying project members."""
402

403
    @functools.wraps(f)
2✔
404
    async def decorated_func(
2✔
405
        self: ProjectMemberRepository,
406
        user: base_models.APIUser,
407
        project_id: ULID,
408
        *args: _P.args,
409
        **kwargs: _P.kwargs,
410
    ) -> _T:
411
        session = kwargs.get("session")
2✔
412
        if not isinstance(session, AsyncSession):
2✔
413
            raise errors.ProgrammingError(
×
414
                message="The decorator that checks if a project exists requires a database session in the "
415
                f"keyword arguments, but instead it got {type(session)}"
416
            )
417
        stmt = select(schemas.ProjectORM.id).where(schemas.ProjectORM.id == project_id)
2✔
418
        res = await session.scalar(stmt)
2✔
419
        if not res:
2✔
420
            raise errors.MissingResourceError(
2✔
421
                message=f"Project with ID {project_id} does not exist or you do not have access to it."
422
            )
423
        return await f(self, user, project_id, *args, **kwargs)
1✔
424

425
    return decorated_func
2✔
426

427

428
class ProjectMemberRepository:
2✔
429
    """Repository for project members."""
430

431
    def __init__(
2✔
432
        self,
433
        session_maker: Callable[..., AsyncSession],
434
        event_repo: EventRepository,
435
        authz: Authz,
436
        message_queue: IMessageQueue,
437
    ) -> None:
438
        self.session_maker = session_maker
2✔
439
        self.event_repo = event_repo
2✔
440
        self.authz = authz
2✔
441
        self.message_queue = message_queue
2✔
442

443
    @with_db_transaction
2✔
444
    @_project_exists
2✔
445
    async def get_members(
2✔
446
        self, user: base_models.APIUser, project_id: ULID, *, session: AsyncSession | None = None
447
    ) -> list[Member]:
448
        """Get all members of a project."""
449
        members = await self.authz.members(user, ResourceType.project, project_id)
1✔
450
        members = [member for member in members if member.user_id and member.user_id != "*"]
1✔
451
        return members
1✔
452

453
    @with_db_transaction
2✔
454
    @_project_exists
2✔
455
    @dispatch_message(events.ProjectMembershipChanged)
2✔
456
    async def update_members(
2✔
457
        self,
458
        user: base_models.APIUser,
459
        project_id: ULID,
460
        members: list[Member],
461
        *,
462
        session: AsyncSession | None = None,
463
    ) -> list[MembershipChange]:
464
        """Update project's members."""
465
        if not session:
1✔
466
            raise errors.ProgrammingError(message="A database session is required")
×
467
        if len(members) == 0:
1✔
468
            raise errors.ValidationError(message="Please request at least 1 member to be added to the project")
×
469

470
        requested_member_ids = [member.user_id for member in members]
1✔
471
        requested_member_ids_set = set(requested_member_ids)
1✔
472
        stmt = select(UserORM.keycloak_id).where(UserORM.keycloak_id.in_(requested_member_ids))
1✔
473
        res = await session.scalars(stmt)
1✔
474
        existing_member_ids = set(res)
1✔
475
        if len(existing_member_ids) != len(requested_member_ids_set):
1✔
476
            raise errors.MissingResourceError(
1✔
477
                message="You are trying to add users to the project, but the users with ids "
478
                f"{requested_member_ids_set.difference(existing_member_ids)} cannot be found"
479
            )
480

481
        output = await self.authz.upsert_project_members(user, ResourceType.project, project_id, members)
1✔
482
        return output
1✔
483

484
    @with_db_transaction
2✔
485
    @_project_exists
2✔
486
    @dispatch_message(events.ProjectMembershipChanged)
2✔
487
    async def delete_members(
2✔
488
        self, user: base_models.APIUser, project_id: ULID, user_ids: list[str], *, session: AsyncSession | None = None
489
    ) -> list[MembershipChange]:
490
        """Delete members from a project."""
491
        if len(user_ids) == 0:
1✔
492
            raise errors.ValidationError(message="Please request at least 1 member to be removed from the project")
×
493

494
        members = await self.authz.remove_project_members(user, ResourceType.project, project_id, user_ids)
1✔
495
        return members
1✔
496

497

498
class ProjectSessionSecretRepository:
2✔
499
    """Repository for session secrets."""
500

501
    def __init__(
2✔
502
        self,
503
        session_maker: Callable[..., AsyncSession],
504
        authz: Authz,
505
    ) -> None:
506
        self.session_maker = session_maker
2✔
507
        self.authz = authz
2✔
508

509
    async def get_all_session_secret_slots_from_project(
2✔
510
        self,
511
        user: base_models.APIUser,
512
        project_id: ULID,
513
    ) -> list[models.SessionSecretSlot]:
514
        """Get all session secret slots from a project."""
515
        # Check that the user is allowed to access the project
516
        authorized = await self.authz.has_permission(user, ResourceType.project, project_id, Scope.READ)
2✔
517
        if not authorized:
2✔
518
            raise errors.MissingResourceError(
1✔
519
                message=f"Project with id '{project_id}' does not exist or you do not have access to it."
520
            )
521
        async with self.session_maker() as session:
1✔
522
            result = await session.scalars(
1✔
523
                select(schemas.SessionSecretSlotORM).where(schemas.SessionSecretSlotORM.project_id == project_id)
524
            )
525
            secret_slots = result.all()
1✔
526
            return [s.dump() for s in secret_slots]
1✔
527

528
    async def get_session_secret_slot(
2✔
529
        self,
530
        user: base_models.APIUser,
531
        slot_id: ULID,
532
    ) -> models.SessionSecretSlot:
533
        """Get one session secret slot from the database."""
534
        async with self.session_maker() as session, session.begin():
2✔
535
            result = await session.scalars(
2✔
536
                select(schemas.SessionSecretSlotORM).where(schemas.SessionSecretSlotORM.id == slot_id)
537
            )
538
            secret_slot = result.one_or_none()
2✔
539

540
            authorized = (
2✔
541
                await self.authz.has_permission(user, ResourceType.project, secret_slot.project_id, Scope.READ)
542
                if secret_slot is not None
543
                else False
544
            )
545
            if not authorized or secret_slot is None:
2✔
546
                raise errors.MissingResourceError(
2✔
547
                    message=f"Session secret slot with id '{slot_id}' does not exist or you do not have access to it."
548
                )
549

550
            return secret_slot.dump()
1✔
551

552
    async def insert_session_secret_slot(
2✔
553
        self, user: base_models.APIUser, secret_slot: models.UnsavedSessionSecretSlot
554
    ) -> models.SessionSecretSlot:
555
        """Insert a new session secret slot entry."""
556
        if user.id is None:
2✔
NEW
557
            raise errors.UnauthorizedError(message="You do not have the required permissions for this operation.")
×
558

559
        # Check that the user is allowed to access the project
560
        authorized = await self.authz.has_permission(user, ResourceType.project, secret_slot.project_id, Scope.WRITE)
2✔
561
        if not authorized:
2✔
562
            raise errors.MissingResourceError(
2✔
563
                message=f"Project with id '{secret_slot.project_id}' does not exist or you do not have access to it."
564
            )
565

566
        async with self.session_maker() as session, session.begin():
1✔
567
            existing_secret_slot = await session.scalar(
1✔
568
                select(schemas.SessionSecretSlotORM)
569
                .where(schemas.SessionSecretSlotORM.project_id == secret_slot.project_id)
570
                .where(schemas.SessionSecretSlotORM.filename == secret_slot.filename)
571
            )
572
            if existing_secret_slot is not None:
1✔
573
                raise errors.ConflictError(
1✔
574
                    message=f"A session secret slot with the filename '{secret_slot.filename}' already exists."
575
                )
576

577
            secret_slot_orm = schemas.SessionSecretSlotORM(
1✔
578
                project_id=secret_slot.project_id,
579
                name=secret_slot.name or secret_slot.filename,
580
                description=secret_slot.description if secret_slot.description else None,
581
                filename=secret_slot.filename,
582
                created_by_id=user.id,
583
            )
584

585
            session.add(secret_slot_orm)
1✔
586
            await session.flush()
1✔
587
            await session.refresh(secret_slot_orm)
1✔
588

589
            return secret_slot_orm.dump()
1✔
590

591
    async def update_session_secret_slot(
2✔
592
        self, user: base_models.APIUser, slot_id: ULID, patch: models.SessionSecretSlotPatch, etag: str
593
    ) -> models.SessionSecretSlot:
594
        """Update a session secret slot entry."""
595
        not_found_msg = f"Session secret slot with id '{slot_id}' does not exist or you do not have access to it."
2✔
596

597
        async with self.session_maker() as session, session.begin():
2✔
598
            result = await session.scalars(
2✔
599
                select(schemas.SessionSecretSlotORM).where(schemas.SessionSecretSlotORM.id == slot_id)
600
            )
601
            secret_slot = result.one_or_none()
2✔
602
            if secret_slot is None:
2✔
603
                raise errors.MissingResourceError(message=not_found_msg)
1✔
604

605
            authorized = await self.authz.has_permission(
1✔
606
                user, ResourceType.project, secret_slot.project_id, Scope.WRITE
607
            )
608
            if not authorized:
1✔
NEW
609
                raise errors.MissingResourceError(message=not_found_msg)
×
610

611
            current_etag = secret_slot.dump().etag
1✔
612
            if current_etag != etag:
1✔
NEW
613
                raise errors.ConflictError(message=f"Current ETag is {current_etag}, not {etag}.")
×
614

615
            if patch.name is not None:
1✔
616
                secret_slot.name = patch.name
1✔
617
            if patch.description is not None:
1✔
618
                secret_slot.description = patch.description if patch.description else None
1✔
619
            if patch.filename is not None and patch.filename != secret_slot.filename:
1✔
620
                existing_secret_slot = await session.scalar(
1✔
621
                    select(schemas.SessionSecretSlotORM)
622
                    .where(schemas.SessionSecretSlotORM.project_id == secret_slot.project_id)
623
                    .where(schemas.SessionSecretSlotORM.filename == patch.filename)
624
                )
625
                if existing_secret_slot is not None:
1✔
626
                    raise errors.ConflictError(
1✔
627
                        message=f"A session secret slot with the filename '{secret_slot.filename}' already exists."
628
                    )
629
                secret_slot.filename = patch.filename
1✔
630

631
            await session.flush()
1✔
632
            await session.refresh(secret_slot)
1✔
633

634
            return secret_slot.dump()
1✔
635

636
    async def delete_session_secret_slot(
2✔
637
        self,
638
        user: base_models.APIUser,
639
        slot_id: ULID,
640
    ) -> None:
641
        """Delete a session secret slot."""
642
        async with self.session_maker() as session, session.begin():
2✔
643
            result = await session.scalars(
2✔
644
                select(schemas.SessionSecretSlotORM).where(schemas.SessionSecretSlotORM.id == slot_id)
645
            )
646
            secret_slot = result.one_or_none()
2✔
647
            if secret_slot is None:
2✔
648
                return None
1✔
649

650
            authorized = await self.authz.has_permission(
1✔
651
                user, ResourceType.project, secret_slot.project_id, Scope.WRITE
652
            )
653
            if not authorized:
1✔
NEW
654
                raise errors.MissingResourceError(
×
655
                    message=f"Session secret slot with id '{slot_id}' does not exist or you do not have access to it."
656
                )
657

658
            await session.delete(secret_slot)
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

© 2026 Coveralls, Inc