diff --git a/src/ai/backend/manager/models/acl.py b/src/ai/backend/manager/models/acl.py index 4b64b14e7b9..3cd20834847 100644 --- a/src/ai/backend/manager/models/acl.py +++ b/src/ai/backend/manager/models/acl.py @@ -1,11 +1,15 @@ from __future__ import annotations +import uuid +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, List, Mapping, Sequence import graphene from ai.backend.common.types import VFolderHostPermission +from .user import UserRole + if TYPE_CHECKING: from .gql import GraphQueryContext @@ -16,6 +20,65 @@ ) +@dataclass(frozen=True) +class RequesterContext: + domain_name: str + user_id: uuid.UUID + user_role: UserRole + + # Key: vfolder id / Value: a set of actions the user can perform on. This will be replaced with new `Permission` + # overriden_perm_ctx is not nullable since it is always needed + # overriden_perm_ctx: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] + # project_ctx: Mapping[uuid.UUID, UserRoleInProject] | None + + # vfolder host permission. 이건 phase 2로 가자... + # vfolder는 user/project 소유이며, storage host에 속할 수 있다. + # domain, project, user 별로 storage host에 대해 permission 부여 가능. + # 이 기능이 많이 쓰이지는 않으므로 storage host - {domain, project, user} 중계 테이블을 두고 + # 여기에 action block list를 넣어두는 방식은 어떨지. + # 중계 테이블을 둬도 쿼리만 잘 짜면 pagination도 어렵지 않을 것으로 보이는데..이건 해봐야 알듯. + # vfolders.storage_host 컬럼이 storage_host 테이블 id가 되는데... 옵션 + # 1번. 세 중계 테이블에 모두 left outer join 걸어서 조건 걸기. vfolders pagination이 가능하지만 join이 불필요하게 많아진다는 단점 + # 2번. 세 중계 테이블에 먼저 쿼리하고 action blocklist 확인 후 vfolders는 따로 쿼리하기. 애초에 중계 테이블이 blocklist 방식이라 쿼리결과도 그리 크진 않을 것 + # Storage host permisions --- + # 이 호스트에서 vfolder에 대해 (혹은 호스트에 속한 vfolder에 대해서) 어떤 액션을 할 수 있는지를 정의. 어떤 권한이 더 있을지는 생각해보자.. + # CREATE_VFOLDERS, READ_ATTR, READ_CONTENT, MOUNT ... + # storage_host: str + + # async def init_project_ctx(self, db_session: SASession) -> None: + # stmt = sa.select(AssocGroupUserRow).where(AssocGroupUserRow.user_id == self.user_id) + # rows = cast(list[AssocGroupUserRow], await db_session.scalars(stmt)) + + # def from_row(row: AssocGroupUserRow) -> UserRoleInProject: + # # TODO: get user_role_in_project from the row + # # For now, assume it is a project admin if admin is associated with a group + # if self.user_role in (UserRole.SUPERADMIN, UserRole.ADMIN): + # return UserRoleInProject.ADMIN + # else: + # return UserRoleInProject.USER + + # self.project_ctx = {row.group_id: from_row(row) for row in rows} + + +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 + + 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..53a58869f6d 100644 --- a/src/ai/backend/manager/models/group.py +++ b/src/ai/backend/manager/models/group.py @@ -94,6 +94,14 @@ 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 + UNKNOWN = "unknown" + NONE = "none" # User is not associated + + association_groups_users = sa.Table( "association_groups_users", mapper_registry.metadata, @@ -197,6 +205,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..856bc454e4a 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 ( + RequestedDomainScope, + RequestedProjectScope, + RequestedScope, + RequestedUserScope, + 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 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,10 @@ "SOFT_DELETED_VFOLDER_STATUSES", "HARD_DELETED_VFOLDER_STATUSES", "VFolderPermissionSetAlias", + "get_vfolders", + "VFolderACLObject", + "OWNER_PERMISSIONS", + "ScopePermissionMap", ) @@ -294,6 +308,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 +318,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 +418,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 +430,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 +448,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 +644,575 @@ async def _append_entries(_query, _is_owner=True): return entries +class VFolderACLPermission(enum.StrEnum): + # 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_PROJECT_VFOLDERS: frozenset[VFolderACLPermission] = frozenset([ + VFolderACLPermission.READ_ATTRIBUTE, + VFolderACLPermission.UPDATE_ATTRIBUTE, + VFolderACLPermission.DELETE_VFOLDER, +]) +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: + # Should be built for every request fetching vfolders + user: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] # Owned + project: Mapping[uuid.UUID, frozenset[VFolderACLPermission]] + domain: Mapping[str, frozenset[VFolderACLPermission]] + 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) + + def determine_permission(self, vfolder: VFolderRow) -> VFolderACLObject: + permissions: set[VFolderACLPermission] = set() + if vfolder.id in self.overriden: + return VFolderACLObject(vfolder, self.overriden[vfolder.id]) + if vfolder.id in self.additional: + permissions |= self.additional[vfolder.id] + if vfolder.user in self.user: + permissions |= self.user[vfolder.user] + if vfolder.group in self.project: + permissions |= self.project[vfolder.group] + if vfolder.domain_name in self.domain: + permissions |= self.domain[vfolder.domain_name] + return VFolderACLObject(vfolder, frozenset(permissions)) + + @classmethod + async def build( + cls, + db_session: SASession, + ctx: RequesterContext, + requested_scope: RequestedScope, + ) -> ScopePermissionMap: + match ctx.user_role: + case UserRole.SUPERADMIN: + return await cls._for_superadmin(db_session, ctx, requested_scope) + case UserRole.ADMIN: + return await cls._for_admin(db_session, ctx, requested_scope) + case UserRole.USER: + return await cls._for_user(db_session, ctx, requested_scope) + case UserRole.MONITOR: + pass + case _: + pass + raise RuntimeError(f"invalid user role {ctx.user_role}") + + @classmethod + async def _for_superadmin( + cls, db_session: SASession, ctx: RequesterContext, requested_scope: RequestedScope + ) -> 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 requested_scope: + case RequestedUserScope(user_id=user_id): + 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: + user = {user_id: DEFAULT_ADMIN_PERMISSIONS_ON_USER_VFOLDERS} + case RequestedProjectScope(project_id=project_id): + project_stmt = sa.select(AssocGroupUserRow).where( + (AssocGroupUserRow.user_id == ctx.user_id) + & (AssocGroupUserRow.group_id == project_id) + ) + # TODO: user_role에 따라 어떻게 scope별 action(permission)이 정해지는지 보기 쉽게, 이해하기 쉽게 정리할 것 + associated_project = { + row.group_id: OWNER_PERMISSIONS + for row in await db_session.scalars(project_stmt) + } + if associated_project: + # 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 + pass + project = { + project_id: DEFAULT_ADMIN_PERMISSIONS_ON_PROJECT_VFOLDERS, + **associated_project, + } + + case RequestedDomainScope(domain_name=domain_name): + 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, + ) + 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) + ) + ) + # TODO: user_role에 따라 어떻게 scope별 action(permission)이 정해지는지 보기 쉽게, 이해하기 쉽게 정리할 것 + project = { + row.group_id: OWNER_PERMISSIONS + for row in await db_session.scalars(project_stmt) + } + + case _: + raise RuntimeError(f"invalid ACL scope {requested_scope}") + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + + @classmethod + async def _for_admin( + cls, db_session: SASession, ctx: RequesterContext, requested_scope: RequestedScope + ) -> 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 requested_scope: + case RequestedUserScope(user_id=user_id): + 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: + user = {user_id: DEFAULT_ADMIN_PERMISSIONS_ON_USER_VFOLDERS} + case RequestedProjectScope(project_id=project_id): + project_stmt = sa.select(AssocGroupUserRow).where( + (AssocGroupUserRow.user_id == ctx.user_id) + & (AssocGroupUserRow.group_id == project_id) + ) + # TODO: user_role에 따라 어떻게 scope별 action(permission)이 정해지는지 보기 쉽게, 이해하기 쉽게 정리할 것 + associated_project = { + row.group_id: OWNER_PERMISSIONS + for row in await db_session.scalars(project_stmt) + } + if associated_project: + # 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 + pass + project = { + project_id: DEFAULT_ADMIN_PERMISSIONS_ON_PROJECT_VFOLDERS, + **associated_project, + } + + case RequestedDomainScope(domain_name=domain_name): + 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, + ) + 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) + ) + ) + # TODO: user_role에 따라 어떻게 scope별 action(permission)이 정해지는지 보기 쉽게, 이해하기 쉽게 정리할 것 + 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 _: + raise RuntimeError(f"invalid ACL scope {requested_scope}") + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + + @classmethod + async def _for_user( + cls, db_session: SASession, ctx: RequesterContext, requested_scope: RequestedScope + ) -> 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 requested_scope: + case RequestedUserScope(user_id=user_id): + 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 RequestedProjectScope(project_id=project_id): + project_stmt = sa.select(AssocGroupUserRow).where( + (AssocGroupUserRow.user_id == ctx.user_id) + & (AssocGroupUserRow.group_id == project_id) + ) + # TODO: user_role에 따라 어떻게 scope별 action(permission)이 정해지는지 보기 쉽게, 이해하기 쉽게 정리할 것 + associated_project = { + row.group_id: DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + for row in await db_session.scalars(project_stmt) + } + if associated_project: + # 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 + project = {**associated_project} + + case RequestedDomainScope(domain_name=domain_name): + 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, + ) + 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} + + project_stmt = ( + sa.select(AssocGroupUserRow) + .select_from(sa.join(AssocGroupUserRow, GroupRow)) + .where( + (AssocGroupUserRow.user_id == ctx.user_id) + & (GroupRow.domain_name == domain_name) + ) + ) + # TODO: user_role에 따라 어떻게 scope별 action(permission)이 정해지는지 보기 쉽게, 이해하기 쉽게 정리할 것 + project = { + row.group_id: DEFAULT_USER_PERMISSIONS_ON_ASSOCIATED_PROJECT_VFOLDERS + for row in await db_session.scalars(project_stmt) + } + case _: + raise RuntimeError(f"invalid ACL scope {requested_scope}") + return ScopePermissionMap( + user=user, + project=project, + domain=domain, + additional=additional, + overriden=overriden, + ) + + +# Factors that determines permission on vfolder --- +# User side +# - user role, user domain, user role in project +# VFolder side +# - owner(user,project) domain, owner user associated project - 소유한 사용자가 속한 프로젝트?(-> 이건 보류), owner project - 소유한 프로젝트 +# Etc +# - overriden permission by vfolder_permissions +class VFolderACLObject(NamedTuple): + vfolder_row: VFolderRow + permissions: frozenset[VFolderACLPermission] + + +async def get_vfolders( + db_session: SASession, + 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]: + scope_ctx = await ScopePermissionMap.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): + vf = scope_ctx.determine_permission(row) + if requested_permission is None or requested_permission in vf.permissions: + result.append(vf) + return result + + async def get_allowed_vfolder_hosts_by_group( conn: SAConnection, resource_policy,