From dcb2e943bdef7b1d4d24352870f3cdd5dd3def66 Mon Sep 17 00:00:00 2001 From: Sanghun Lee Date: Sun, 12 May 2024 15:32:53 +0900 Subject: [PATCH] refactor: impl vfolder RBAC APIs --- src/ai/backend/manager/models/acl.py | 136 +++- ...2be0ce116c35_add_vfolder_permissions_id.py | 31 + .../37410c773b8c_add_vfolders_domain_name.py | 43 ++ src/ai/backend/manager/models/group.py | 7 + src/ai/backend/manager/models/user.py | 2 + src/ai/backend/manager/models/vfolder.py | 611 +++++++++++++++++- 6 files changed, 825 insertions(+), 5 deletions(-) create mode 100644 src/ai/backend/manager/models/alembic/versions/2be0ce116c35_add_vfolder_permissions_id.py create mode 100644 src/ai/backend/manager/models/alembic/versions/37410c773b8c_add_vfolders_domain_name.py diff --git a/src/ai/backend/manager/models/acl.py b/src/ai/backend/manager/models/acl.py index 4b64b14e7b9..46855d448de 100644 --- a/src/ai/backend/manager/models/acl.py +++ b/src/ai/backend/manager/models/acl.py @@ -1,11 +1,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, List, Mapping, Sequence +import enum +import uuid +from abc import ABCMeta, abstractmethod +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Generic, List, Sequence, TypeVar import graphene +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import AsyncConnection, AsyncSession from ai.backend.common.types import VFolderHostPermission +from .group import AssocGroupUserRow, GroupRow, UserRoleInProject +from .user import UserRole + if TYPE_CHECKING: from .gql import GraphQueryContext @@ -16,6 +26,130 @@ ) +class AbstractACLPermission(enum.StrEnum): + pass + + +ACLPermissionType = TypeVar("ACLPermissionType", bound=AbstractACLPermission) + + +@dataclass +class RequesterContext: + db_conn: AsyncConnection + + domain_name: str + user_id: uuid.UUID + user_role: UserRole + + project_ctx: Mapping[uuid.UUID, UserRoleInProject] | None = None + + async def get_or_init_project_ctx(self) -> Mapping[uuid.UUID, UserRoleInProject]: + if self.project_ctx is None: + if self.user_role in (UserRole.SUPERADMIN, UserRole.ADMIN): + role_in_project = UserRoleInProject.ADMIN + else: + role_in_project = UserRoleInProject.USER + stmt = ( + sa.select(AssocGroupUserRow) + .select_from(sa.join(AssocGroupUserRow, GroupRow)) + .where( + (AssocGroupUserRow.user_id == self.user_id) + & (GroupRow.domain_name == self.domain_name) + ) + ) + async with AsyncSession(self.db_conn) as db_session: + self.project_ctx = { + row.group_id: role_in_project for row in await db_session.scalars(stmt) + } + return self.project_ctx + + +class RequestedScope: + pass + + +@dataclass(frozen=True) +class RequestedDomainScope(RequestedScope): + domain_name: str + + +@dataclass(frozen=True) +class RequestedProjectScope(RequestedScope): + project_id: uuid.UUID + + +@dataclass(frozen=True) +class RequestedUserScope(RequestedScope): + user_id: uuid.UUID + + +ACLObjectType = TypeVar("ACLObjectType") + + +@dataclass +class AbstractScopePermissionMap(Generic[ACLPermissionType, ACLObjectType], metaclass=ABCMeta): + user: Mapping[uuid.UUID, frozenset[ACLPermissionType]] + project: Mapping[uuid.UUID, frozenset[ACLPermissionType]] + domain: Mapping[str, frozenset[ACLPermissionType]] + + @abstractmethod + async def determine_permission(self, acl_obj: ACLObjectType) -> frozenset[ACLPermissionType]: + pass + + +ScopePermissionMapType = TypeVar("ScopePermissionMapType", bound=AbstractScopePermissionMap) + + +class AbstractScopePermissionMapBuilder(Generic[ScopePermissionMapType], metaclass=ABCMeta): + @classmethod + async def build( + cls, + db_session: AsyncSession, + ctx: RequesterContext, + requested_scope: RequestedScope, + ) -> ScopePermissionMapType: + match requested_scope: + case RequestedUserScope(user_id=user_id): + return await cls._build_in_user_scope(db_session, ctx, user_id) + case RequestedProjectScope(project_id=project_id): + return await cls._build_in_project_scope(db_session, ctx, project_id) + case RequestedDomainScope(domain_name=domain_name): + return await cls._build_in_domain_scope(db_session, ctx, domain_name) + case _: + pass + raise RuntimeError(f"invalid request scope {requested_scope}") + + @classmethod + @abstractmethod + async def _build_in_user_scope( + cls, + db_session: AsyncSession, + ctx: RequesterContext, + user_id: uuid.UUID, + ) -> ScopePermissionMapType: + pass + + @classmethod + @abstractmethod + async def _build_in_project_scope( + cls, + db_session: AsyncSession, + ctx: RequesterContext, + project_id: uuid.UUID, + ) -> ScopePermissionMapType: + pass + + @classmethod + @abstractmethod + async def _build_in_domain_scope( + cls, + db_session: AsyncSession, + ctx: RequesterContext, + domain_name: str, + ) -> ScopePermissionMapType: + pass + + def get_all_vfolder_host_permissions() -> List[str]: return [perm.value for perm in VFolderHostPermission] diff --git a/src/ai/backend/manager/models/alembic/versions/2be0ce116c35_add_vfolder_permissions_id.py b/src/ai/backend/manager/models/alembic/versions/2be0ce116c35_add_vfolder_permissions_id.py new file mode 100644 index 00000000000..f6f67840172 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/2be0ce116c35_add_vfolder_permissions_id.py @@ -0,0 +1,31 @@ +"""add_id_to_vfolder_permissions + +Revision ID: 2be0ce116c35 +Revises: 37410c773b8c +Create Date: 2024-05-11 00:53:28.387238 + +""" + +import sqlalchemy as sa +from alembic import op + +from ai.backend.manager.models.base import GUID + +# revision identifiers, used by Alembic. +revision = "2be0ce116c35" +down_revision = "37410c773b8c" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "vfolder_permissions", + sa.Column("id", GUID(), server_default=sa.text("uuid_generate_v4()"), nullable=False), + ) + op.create_primary_key("pk_vfolder_permissions", "vfolder_permissions", ["id"]) + + +def downgrade(): + op.drop_constraint("pk_vfolder_permissions", "vfolder_permissions", type_="primary") + op.drop_column("vfolder_permissions", "id") diff --git a/src/ai/backend/manager/models/alembic/versions/37410c773b8c_add_vfolders_domain_name.py b/src/ai/backend/manager/models/alembic/versions/37410c773b8c_add_vfolders_domain_name.py new file mode 100644 index 00000000000..6a4ded87af5 --- /dev/null +++ b/src/ai/backend/manager/models/alembic/versions/37410c773b8c_add_vfolders_domain_name.py @@ -0,0 +1,43 @@ +"""add_vfolders_domain_name + +Revision ID: 37410c773b8c +Revises: dddf9be580f5 +Create Date: 2024-05-06 20:51:54.658829 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.sql import text + +# revision identifiers, used by Alembic. +revision = "37410c773b8c" +down_revision = "dddf9be580f5" +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + op.add_column("vfolders", sa.Column("domain_name", sa.String(length=64), nullable=True)) + + conn.execute( + text( + """\ + UPDATE vfolders + SET domain_name = COALESCE( + (SELECT domain_name FROM users WHERE vfolders.user = users.uuid), + (SELECT domain_name FROM groups WHERE vfolders.group = groups.id) + ) + WHERE domain_name IS NULL; + """ + ) + ) + + op.alter_column("vfolders", column_name="domain_name", nullable=False) + op.create_index(op.f("ix_vfolders_domain_name"), "vfolders", ["domain_name"], unique=False) + + +def downgrade(): + op.drop_index(op.f("ix_vfolders_domain_name"), table_name="vfolders") + op.drop_column("vfolders", "domain_name") diff --git a/src/ai/backend/manager/models/group.py b/src/ai/backend/manager/models/group.py index 3ed98d0bd30..17fefd24020 100644 --- a/src/ai/backend/manager/models/group.py +++ b/src/ai/backend/manager/models/group.py @@ -94,6 +94,12 @@ MAXIMUM_DOTFILE_SIZE = 64 * 1024 # 61 KiB _rx_slug = re.compile(r"^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$") + +class UserRoleInProject(enum.StrEnum): + ADMIN = "admin" # User is associated as admin. TODO: impl project admin + USER = "user" # User is associated as user + + association_groups_users = sa.Table( "association_groups_users", mapper_registry.metadata, @@ -197,6 +203,7 @@ class GroupRow(Base): users = relationship("AssocGroupUserRow", back_populates="group") resource_policy_row = relationship("ProjectResourcePolicyRow", back_populates="projects") kernels = relationship("KernelRow", back_populates="group_row") + vfolders = relationship("VFolderRow", back_populates="project_row") def _build_group_query(cond: sa.sql.BinaryExpression, domain_name: str) -> sa.sql.Select: diff --git a/src/ai/backend/manager/models/user.py b/src/ai/backend/manager/models/user.py index 3685b6e6cb9..9036ed14f63 100644 --- a/src/ai/backend/manager/models/user.py +++ b/src/ai/backend/manager/models/user.py @@ -186,6 +186,8 @@ class UserRow(Base): main_keypair = relationship("KeyPairRow", foreign_keys=users.c.main_access_key) + vfolders = relationship("VFolderRow", back_populates="user_row") + class UserGroup(graphene.ObjectType): id = graphene.UUID() diff --git a/src/ai/backend/manager/models/vfolder.py b/src/ai/backend/manager/models/vfolder.py index e9ee821afa8..a505c47aa03 100644 --- a/src/ai/backend/manager/models/vfolder.py +++ b/src/ai/backend/manager/models/vfolder.py @@ -4,9 +4,11 @@ import logging import os.path import uuid +from collections.abc import Container, Mapping +from dataclasses import dataclass from datetime import datetime from pathlib import PurePosixPath -from typing import TYPE_CHECKING, Any, Final, List, Mapping, NamedTuple, Optional, Sequence +from typing import TYPE_CHECKING, Any, Final, List, NamedTuple, Optional, Sequence, TypeAlias import aiohttp import aiotools @@ -23,7 +25,7 @@ from sqlalchemy.engine.row import Row from sqlalchemy.ext.asyncio import AsyncConnection as SAConnection from sqlalchemy.ext.asyncio import AsyncSession as SASession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import relationship, selectinload from ai.backend.common.bgtask import ProgressReporter from ai.backend.common.config import model_definition_iv @@ -52,6 +54,13 @@ VFOLDER_DSTPATHS_MAP, ) from ..types import UserScope +from .acl import ( + AbstractACLPermission, + AbstractScopePermissionMap, + AbstractScopePermissionMapBuilder, + RequestedScope, + RequesterContext, +) from .base import ( GUID, Base, @@ -69,7 +78,7 @@ metadata, ) from .gql_relay import AsyncNode, Connection, ConnectionResolverResult -from .group import GroupRow, ProjectType +from .group import AssocGroupUserRow, GroupRow, ProjectType, UserRoleInProject from .minilang.ordering import OrderSpecItem, QueryOrderParser from .minilang.queryfilter import FieldSpecItem, QueryFilterParser, enum_field_getter from .user import UserRole @@ -85,6 +94,7 @@ "vfolder_invitations", "vfolder_permissions", "VirtualFolder", + "VFolderACLPermission", "VFolderOwnershipType", "VFolderInvitationState", "VFolderPermission", @@ -113,6 +123,11 @@ "SOFT_DELETED_VFOLDER_STATUSES", "HARD_DELETED_VFOLDER_STATUSES", "VFolderPermissionSetAlias", + "get_vfolders", + "VFolderACLObject", + "OWNER_PERMISSIONS", + "ScopePermissionMap", + "ScopePermissionMapBuilder", ) @@ -294,6 +309,7 @@ class VFolderCloneInfo(NamedTuple): IDColumn("id"), # host will be '' if vFolder is unmanaged sa.Column("host", sa.String(length=128), nullable=False, index=True), + sa.Column("domain_name", sa.String(length=64), nullable=False, index=True), sa.Column("quota_scope_id", QuotaScopeIDType, nullable=False), sa.Column("name", sa.String(length=64), nullable=False, index=True), sa.Column( @@ -303,7 +319,9 @@ class VFolderCloneInfo(NamedTuple): nullable=False, index=True, ), - sa.Column("permission", EnumValueType(VFolderPermission), default=VFolderPermission.READ_WRITE), + sa.Column( + "permission", EnumValueType(VFolderPermission), default=VFolderPermission.READ_WRITE + ), # legacy sa.Column("max_files", sa.Integer(), default=1000), sa.Column("max_size", sa.Integer(), default=None), # in MBytes sa.Column("num_files", sa.Integer(), default=0), @@ -401,6 +419,7 @@ class VFolderCloneInfo(NamedTuple): vfolder_permissions = sa.Table( "vfolder_permissions", metadata, + IDColumn(), sa.Column("permission", EnumValueType(VFolderPermission), default=VFolderPermission.READ_WRITE), sa.Column( "vfolder", @@ -412,8 +431,14 @@ class VFolderCloneInfo(NamedTuple): ) +class VFolderPermissionRow(Base): + __table__ = vfolder_permissions + + class VFolderRow(Base): __table__ = vfolders + user_row = relationship("UserRow", back_populates="vfolders") + project_row = relationship("GroupRow", back_populates="vfolders") def __contains__(self, key): return key in self.__dir__() @@ -424,6 +449,10 @@ def __getitem__(self, item): except AttributeError: raise KeyError(item) + @property + def vfid(self) -> VFolderID: + return VFolderID(quota_scope_id=self.quota_scope_id, folder_id=self.id) + def verify_vfolder_name(folder: str) -> bool: if folder in RESERVED_VFOLDERS: @@ -616,6 +645,580 @@ async def _append_entries(_query, _is_owner=True): return entries +class VFolderACLPermission(AbstractACLPermission): + # Only owners can do + CLONE = "clone" + OVERRIDE_PERMISSION_TO_OTHERS = "override-permission" # Invite, share + + # `create_vfolder` action should be in {Domain, Project, or User} permissions, not here + READ_ATTRIBUTE = "read-attribute" + UPDATE_ATTRIBUTE = "update-attribute" + DELETE_VFOLDER = "delete-vfolder" + + READ_CONTENT = "read-content" + WRITE_CONTENT = "write-content" + DELETE_CONTENT = "delete-content" + + MOUNT_RO = "mount-ro" + MOUNT_RW = "mount-rw" + MOUNT_WD = "mount-wd" + + +WhereClauseType: TypeAlias = ( + sa.sql.expression.BinaryExpression | sa.sql.expression.BooleanClauseList +) +# TypeAlias is deprecated since 3.12 + +OWNER_PERMISSIONS: frozenset[VFolderACLPermission] = frozenset([ + perm for perm in VFolderACLPermission +]) +DEFAULT_ADDED_PERMISSIONS_BY_VF_PERM: frozenset[VFolderACLPermission] = frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.READ_CONTENT, + VFolderACLPermission.WRITE_CONTENT, + VFolderACLPermission.DELETE_CONTENT, + VFolderACLPermission.MOUNT_RO, + VFolderACLPermission.MOUNT_RW, + VFolderACLPermission.MOUNT_WD, +]) +DEFAULT_ADMIN_PERMISSIONS_ON_USER_VFOLDERS: frozenset[VFolderACLPermission] = frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.UPDATE_ATTRIBUTE, + VFolderACLPermission.DELETE_VFOLDER, +]) +DEFAULT_ADMIN_PERMISSIONS_ON_UNASSOCIATED_PROJECT_VFOLDERS: frozenset[VFolderACLPermission] = ( + frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.UPDATE_ATTRIBUTE, + VFolderACLPermission.DELETE_VFOLDER, + ]) +) +DEFAULT_ADMIN_PERMISSIONS_ON_USER_INVITED_VFOLDERS: frozenset[VFolderACLPermission] = frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, +]) +DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS: frozenset[VFolderACLPermission] = ( + frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.READ_CONTENT, + VFolderACLPermission.WRITE_CONTENT, + VFolderACLPermission.DELETE_CONTENT, + VFolderACLPermission.MOUNT_RO, + VFolderACLPermission.MOUNT_RW, + VFolderACLPermission.MOUNT_WD, + ]) +) +PERMISSION_TO_ACL_PERMISSION_MAP: Mapping[VFolderPermission, frozenset[VFolderACLPermission]] = { + VFolderPermission.READ_ONLY: frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.READ_CONTENT, + ]), + VFolderPermission.READ_WRITE: frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.UPDATE_ATTRIBUTE, + VFolderACLPermission.DELETE_VFOLDER, + VFolderACLPermission.READ_CONTENT, + VFolderACLPermission.WRITE_CONTENT, + VFolderACLPermission.DELETE_CONTENT, + VFolderACLPermission.MOUNT_RO, + VFolderACLPermission.MOUNT_RW, + ]), + VFolderPermission.RW_DELETE: frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.UPDATE_ATTRIBUTE, + VFolderACLPermission.DELETE_VFOLDER, + VFolderACLPermission.READ_CONTENT, + VFolderACLPermission.WRITE_CONTENT, + VFolderACLPermission.DELETE_CONTENT, + VFolderACLPermission.MOUNT_RO, + VFolderACLPermission.MOUNT_RW, + VFolderACLPermission.MOUNT_WD, + ]), + VFolderPermission.OWNER_PERM: OWNER_PERMISSIONS, +} + + +@dataclass +class ScopePermissionMap(AbstractScopePermissionMap[VFolderACLPermission, VFolderRow]): + additional: Mapping[ + uuid.UUID, frozenset[VFolderACLPermission] + ] # Key: vfolder id / Value: set of VFolderACLPermissions + overriden: Mapping[ + uuid.UUID, frozenset[VFolderACLPermission] + ] # Key: vfolder id / Value: set of VFolderACLPermissions + + @property + def query_condition(self) -> WhereClauseType | None: + cond: WhereClauseType | None = None + + def _coalesce( + base_cond: WhereClauseType | None, + or_cond: sa.sql.expression.BinaryExpression, + ) -> WhereClauseType: + return base_cond | or_cond if base_cond is not None else or_cond + + if self.user: + cond = _coalesce(cond, VFolderRow.user.in_(self.user.keys())) + if self.project: + cond = _coalesce(cond, VFolderRow.group.in_(self.project.keys())) + if self.domain: + cond = _coalesce(cond, VFolderRow.domain_name.in_(self.domain.keys())) + if self.additional: + cond = _coalesce(cond, VFolderRow.id.in_(self.additional.keys())) + if self.overriden: + cond = _coalesce(cond, VFolderRow.id.in_(self.overriden.keys())) + return cond + + @property + def query_stmt(self) -> sa.sql.Select | None: + cond = self.query_condition + if cond is None: + return None + return sa.select(VFolderRow).where(cond) + + async def determine_permission(self, acl_obj: VFolderRow) -> frozenset[VFolderACLPermission]: + vfolder_row = acl_obj + permissions: set[VFolderACLPermission] = set() + if vfolder_row.id in self.overriden: + return self.overriden[vfolder_row.id] + if vfolder_row.id in self.additional: + permissions |= self.additional[vfolder_row.id] + if vfolder_row.user in self.user: + permissions |= self.user[vfolder_row.user] + if vfolder_row.group in self.project: + permissions |= self.project[vfolder_row.group] + if vfolder_row.domain_name in self.domain: + permissions |= self.domain[vfolder_row.domain_name] + return frozenset(permissions) + + +class ScopePermissionMapBuilder(AbstractScopePermissionMapBuilder[ScopePermissionMap]): + @classmethod + async def _build_in_user_scope( + cls, + db_session: SASession, + ctx: RequesterContext, + user_id: uuid.UUID, + ) -> ScopePermissionMap: + user: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + project: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + domain: Mapping[str, frozenset[VFolderACLPermission]] = {} + additional: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + overriden: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + match ctx.user_role: + case UserRole.SUPERADMIN: + if ctx.user_id == user_id: + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + VFolderRow.ownership_type == VFolderOwnershipType.USER + ) # filter out project vfolders + ) + ) + additional = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(additional_stmt) + } + + user = {user_id: OWNER_PERMISSIONS} + else: + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + VFolderRow.ownership_type == VFolderOwnershipType.USER + ) # filter out project vfolders + ) + ) + additional = { + row.vfolder: DEFAULT_ADMIN_PERMISSIONS_ON_USER_INVITED_VFOLDERS + for row in await db_session.scalars(additional_stmt) + } + user = {user_id: DEFAULT_ADMIN_PERMISSIONS_ON_USER_VFOLDERS} + case UserRole.ADMIN: + if ctx.user_id == user_id: + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + VFolderRow.ownership_type == VFolderOwnershipType.USER + ) # filter out project vfolders + ) + ) + additional = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(additional_stmt) + } + + user = {user_id: OWNER_PERMISSIONS} + else: + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + VFolderRow.ownership_type == VFolderOwnershipType.USER + ) # filter out project vfolders + ) + ) + additional = { + row.vfolder: DEFAULT_ADMIN_PERMISSIONS_ON_USER_INVITED_VFOLDERS + for row in await db_session.scalars(additional_stmt) + } + user = {user_id: DEFAULT_ADMIN_PERMISSIONS_ON_USER_VFOLDERS} + case UserRole.USER: + if ctx.user_id == user_id: + overriden_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + VFolderRow.ownership_type == VFolderOwnershipType.USER + ) # filter out project vfolders + ) + ) + overriden = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(overriden_stmt) + } + + user = {ctx.user_id: OWNER_PERMISSIONS} + case UserRole.MONITOR: + raise RuntimeError("Moditor users cannot fetch vfolders") + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + + @classmethod + async def _build_in_project_scope( + cls, + db_session: SASession, + ctx: RequesterContext, + project_id: uuid.UUID, + ) -> ScopePermissionMap: + user: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + project: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + domain: Mapping[str, frozenset[VFolderACLPermission]] = {} + additional: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + overriden: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + + project_ctx = await ctx.get_or_init_project_ctx() + role_in_project = project_ctx.get(project_id) + match ctx.user_role: + case UserRole.SUPERADMIN: + if role_in_project is not None: + project = { + project_id: OWNER_PERMISSIONS + if role_in_project == UserRoleInProject.ADMIN + else DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + } + + # Admin/user is associated with the project, fetch owned/invited vfolders + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + (VFolderRow.group == project_id) + | (VFolderRow.ownership_type == VFolderOwnershipType.USER) + ) + # project vfolders or invited user vfolders + ) + ) + additional = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(additional_stmt) + } + + user = {ctx.user_id: OWNER_PERMISSIONS} + else: + # Admin/user is NOT associated with the project, do not fetch owned/invited vfolders + project = { + project_id: DEFAULT_ADMIN_PERMISSIONS_ON_UNASSOCIATED_PROJECT_VFOLDERS, + } + case UserRole.ADMIN: + if role_in_project is not None: + project = { + project_id: OWNER_PERMISSIONS + if role_in_project == UserRoleInProject.ADMIN + else DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + } + + # Admin/user is associated with the project, fetch owned/invited vfolders + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + (VFolderRow.group == project_id) + | (VFolderRow.ownership_type == VFolderOwnershipType.USER) + ) + # project vfolders or invited user vfolders + ) + ) + additional = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(additional_stmt) + } + + user = {ctx.user_id: OWNER_PERMISSIONS} + else: + # Admin/user is NOT associated with the project, do not fetch owned/invited vfolders + project = { + project_id: DEFAULT_ADMIN_PERMISSIONS_ON_UNASSOCIATED_PROJECT_VFOLDERS, + } + case UserRole.USER: + if role_in_project is not None: + project = { + project_id: OWNER_PERMISSIONS + if role_in_project == UserRoleInProject.ADMIN + else DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + } + + # Admin/user is associated with the project, fetch owned/invited vfolders + overriden_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & ( + (VFolderRow.group == project_id) + | (VFolderRow.ownership_type == VFolderOwnershipType.USER) + ) + # project vfolders or invited user vfolders + ) + ) + overriden = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(overriden_stmt) + } + + user = {ctx.user_id: OWNER_PERMISSIONS} + else: + # Admin/user is NOT associated with the project, do not fetch owned/invited vfolders + pass + case UserRole.MONITOR: + raise RuntimeError("Moditor users cannot fetch vfolders") + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + + @classmethod + async def _build_in_domain_scope( + cls, + db_session: SASession, + ctx: RequesterContext, + domain_name: str, + ) -> ScopePermissionMap: + user: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + project: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + domain: Mapping[str, frozenset[VFolderACLPermission]] = {} + additional: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + overriden: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] = {} + match ctx.user_role: + case UserRole.SUPERADMIN: + domain = { + domain_name: frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.UPDATE_ATTRIBUTE, + VFolderACLPermission.DELETE_VFOLDER, + ]) + } + if ctx.domain_name != domain_name: + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + project_ctx = await ctx.get_or_init_project_ctx() + project = { + project_id: OWNER_PERMISSIONS + if role == UserRoleInProject.ADMIN + else DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + for project_id, role in project_ctx.items() + } + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & (VFolderRow.domain_name == domain_name) + ) + ) + additional = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(additional_stmt) + } + + user = {ctx.user_id: OWNER_PERMISSIONS} + + project_stmt = ( + sa.select(AssocGroupUserRow) + .select_from(sa.join(AssocGroupUserRow, GroupRow)) + .where( + (AssocGroupUserRow.user_id == ctx.user_id) + & (GroupRow.domain_name == domain_name) + ) + ) + project = { + row.group_id: OWNER_PERMISSIONS + for row in await db_session.scalars(project_stmt) + } + case UserRole.ADMIN: + if ctx.domain_name != domain_name: + # Only superadmin can access to another domains + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + project_ctx = await ctx.get_or_init_project_ctx() + project = { + project_id: OWNER_PERMISSIONS + if role == UserRoleInProject.ADMIN + else DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + for project_id, role in project_ctx.items() + } + additional_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & (VFolderRow.domain_name == domain_name) + ) + ) + additional = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(additional_stmt) + } + + user = {ctx.user_id: OWNER_PERMISSIONS} + + project_stmt = ( + sa.select(AssocGroupUserRow) + .select_from(sa.join(AssocGroupUserRow, GroupRow)) + .where( + (AssocGroupUserRow.user_id == ctx.user_id) + & (GroupRow.domain_name == domain_name) + ) + ) + project = { + row.group_id: OWNER_PERMISSIONS + for row in await db_session.scalars(project_stmt) + } + + domain = { + domain_name: frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.UPDATE_ATTRIBUTE, + VFolderACLPermission.DELETE_VFOLDER, + ]) + } + case UserRole.USER: + if ctx.domain_name != domain_name: + # Only superadmin can access to another domains + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + project_ctx = await ctx.get_or_init_project_ctx() + project = { + project_id: DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + if role == UserRoleInProject.USER + else OWNER_PERMISSIONS + for project_id, role in project_ctx.items() + } + overriden_stmt = ( + sa.select(VFolderPermissionRow) + .select_from(sa.join(VFolderPermissionRow, VFolderRow)) + .where( + (VFolderPermissionRow.user == ctx.user_id) + & (VFolderRow.domain_name == domain_name) + ) + ) + overriden = { + row.vfolder: PERMISSION_TO_ACL_PERMISSION_MAP[row.permission] + for row in await db_session.scalars(overriden_stmt) + } + + user = {ctx.user_id: OWNER_PERMISSIONS} + + case UserRole.MONITOR: + raise RuntimeError("Moditor users cannot fetch vfolders") + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + + +class VFolderACLObject(NamedTuple): + vfolder_row: VFolderRow + permissions: frozenset[VFolderACLPermission] + + +async def get_vfolders( + ctx: RequesterContext, + requested_scope: RequestedScope, + requested_permission: VFolderACLPermission | None = None, + *, + vfolder_id: uuid.UUID | None = None, + vfolder_name: str | None = None, + usage_mode: VFolderUsageMode | None = None, + allowed_status: Container[VFolderOperationStatus] | None = None, + blocked_status: Container[VFolderOperationStatus] | None = None, +) -> list[VFolderACLObject]: + async with SASession(ctx.db_conn) as db_session: + scope_ctx = await ScopePermissionMapBuilder.build(db_session, ctx, requested_scope) + stmt = scope_ctx.query_stmt + if stmt is None: + return [] + if vfolder_id is not None: + stmt = stmt.where(VFolderRow.id == vfolder_id) + if vfolder_name is not None: + stmt = stmt.where(VFolderRow.name == vfolder_name) + if usage_mode is not None: + stmt = stmt.where(VFolderRow.usage_mode == usage_mode) + if allowed_status is not None: + stmt = stmt.where(VFolderRow.status.in_(allowed_status)) + if blocked_status is not None: + stmt = stmt.where(VFolderRow.status.not_in(blocked_status)) + + result: list[VFolderACLObject] = [] + for row in await db_session.scalars(stmt): + permissions = await scope_ctx.determine_permission(row) + if requested_permission is None or requested_permission in permissions: + result.append(VFolderACLObject(row, permissions)) + return result + + async def get_allowed_vfolder_hosts_by_group( conn: SAConnection, resource_policy,