Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/12931.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Materialize a project's virtual scope on creation so it can own child entities through the virtual-scope chain.
34 changes: 34 additions & 0 deletions src/ai/backend/manager/repositories/group/creators.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from typing import override
from uuid import UUID

from ai.backend.common.data.permission.types import EntityType, RelationType, ScopeType
from ai.backend.common.exception import InvalidAPIParameters
from ai.backend.common.types import ResourceSlot, VFolderHostPermissionMap
from ai.backend.manager.errors.repository import (
ForeignKeyViolationError,
UniqueConstraintViolationError,
)
from ai.backend.manager.models.group import GroupRow, ProjectType
from ai.backend.manager.models.rbac_models.association_scopes_entities import (
AssociationScopesEntitiesRow,
)
from ai.backend.manager.repositories.base import CreatorSpec
from ai.backend.manager.repositories.base.types import IntegrityErrorCheck


@dataclass
Expand All @@ -31,6 +38,33 @@ class GroupCreatorSpec(CreatorSpec[GroupRow]):
container_registry: dict[str, str] | None = None
dotfiles: bytes | None = None

@property
@override
def integrity_error_checks(self) -> Sequence[IntegrityErrorCheck]:
return (
IntegrityErrorCheck(
violation_type=UniqueConstraintViolationError,
error=InvalidAPIParameters(
f"Group with name '{self.name}' already exists in domain '{self.domain_name}'"
),
constraint_name="uq_groups_name_domain_name",
),
IntegrityErrorCheck(
violation_type=ForeignKeyViolationError,
error=InvalidAPIParameters(
f"Cannot create group: Domain '{self.domain_name}' does not exist"
),
constraint_name="fk_groups_domain_name_domains",
),
IntegrityErrorCheck(
violation_type=ForeignKeyViolationError,
error=InvalidAPIParameters(
f"Cannot create group: Resource policy '{self.resource_policy}' does not exist"
),
constraint_name="fk_groups_resource_policy_project_resource_policies",
),
)

@override
def build_row(self) -> GroupRow:
return GroupRow(
Expand Down
143 changes: 69 additions & 74 deletions src/ai/backend/manager/repositories/group/db_source/db_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import copy
import logging
import uuid
from collections.abc import Sequence
from collections.abc import Collection, Sequence
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any, cast
from typing import Any, cast, override
from uuid import UUID

import aiotools
Expand All @@ -16,6 +17,7 @@
from sqlalchemy.ext.asyncio import AsyncSession as SASession

from ai.backend.common.clients.valkey_client.valkey_stat.client import ValkeyStatClient
from ai.backend.common.data.entity.types import ScopeRef, ScopeType
from ai.backend.common.data.permission.types import RBACElementType
from ai.backend.common.exception import InvalidAPIParameters
from ai.backend.common.identifier.project import ProjectID
Expand All @@ -31,15 +33,20 @@
UnassignUsersResult,
)
from ai.backend.manager.data.permission.id import ScopeId
from ai.backend.manager.data.permission.types import EntityType, RBACElementRef, ScopeType
from ai.backend.manager.data.permission.types import (
EntityType,
RBACElementRef,
)
from ai.backend.manager.data.permission.types import (
ScopeType as LegacyScopeType,
)
from ai.backend.manager.data.user.types import UserData
from ai.backend.manager.errors.resource import (
ProjectHasActiveEndpointsError,
ProjectHasActiveKernelsError,
ProjectHasVFoldersMountedError,
ProjectNotFound,
)
from ai.backend.manager.models.domain import domains
from ai.backend.manager.models.endpoint import EndpointLifecycle, EndpointRow
from ai.backend.manager.models.group import groups
from ai.backend.manager.models.group.row import (
Expand All @@ -57,7 +64,6 @@
)
from ai.backend.manager.models.rbac_models.role import RoleRow
from ai.backend.manager.models.rbac_models.user_role import UserRoleRow
from ai.backend.manager.models.resource_policy import project_resource_policies
from ai.backend.manager.models.resource_usage import fetch_resource_usage
from ai.backend.manager.models.routing import RoutingRow
from ai.backend.manager.models.user import UserRow, users
Expand All @@ -74,7 +80,6 @@
from ai.backend.manager.repositories.base.querier import BatchQuerier, execute_batch_querier
from ai.backend.manager.repositories.base.rbac.entity_creator import (
RBACEntityCreator,
execute_rbac_entity_creator,
)
from ai.backend.manager.repositories.base.rbac.entity_purger import (
RBACEntityBatchPurger,
Expand Down Expand Up @@ -106,81 +111,71 @@
GroupSearchResult,
UserProjectSearchScope,
)
from ai.backend.manager.repositories.ops.rbac.provider import (
RBACOpsProvider,
ScopeCreation,
)
from ai.backend.manager.repositories.permission_controller.creators import UserRoleCreatorSpec
from ai.backend.manager.repositories.permission_controller.role_manager import RoleManager
from ai.backend.manager.repositories.permission_controller.role_manager import (
RoleManager,
ScopeSystemRoleData,
)
from ai.backend.manager.repositories.vfolder.deletion import initiate_vfolder_deletion

log = BraceStyleAdapter(logging.getLogger(__spec__.name))


@dataclass
class ProjectScopeCreation(ScopeCreation[GroupRow]):
"""Creates a project row under its domain, and the scope the project becomes."""

spec: GroupCreatorSpec

@override
def creator(self) -> RBACEntityCreator[GroupRow]:
return RBACEntityCreator(
spec=self.spec,
element_type=RBACElementType.PROJECT,
scope_ref=RBACElementRef(
element_type=RBACElementType.DOMAIN, element_id=self.spec.domain_name
),
)

@override
def scope_of(self, row: GroupRow) -> ScopeRef:
return ScopeRef(
scope_type=ScopeType(RBACElementType.PROJECT.value),
scope_id=row.id,
)

@override
def system_roles_of(self, row: GroupRow) -> Collection[ScopeSystemRoleData]:
"""A project starts with an admin role (via GroupData) and a member role
(read-only access for project members)."""
data = row.to_data()
return (data, ProjectMemberRoleSpec(project_id=data.id))


class GroupDBSource:
_db: ExtendedAsyncSAEngine
_role_manager: RoleManager
_rbac_ops_provider: RBACOpsProvider

def __init__(self, db: ExtendedAsyncSAEngine) -> None:
self._db = db
self._role_manager = RoleManager()
self._rbac_ops_provider = RBACOpsProvider(db)

async def create(self, creator: Creator[GroupRow]) -> GroupData:
"""Create a new group."""
spec = cast(GroupCreatorSpec, creator.spec)
async with self._db.begin_session() as db_session:
# Validate domain exists
domain_exists = await db_session.scalar(
sa.select(sa.exists().where(domains.c.name == spec.domain_name))
)
if not domain_exists:
raise InvalidAPIParameters(
f"Cannot create group: Domain '{spec.domain_name}' does not exist"
)
"""Create a new group.

# Validate resource policy exists
policy_exists = await db_session.scalar(
sa.select(
sa.exists().where(project_resource_policies.c.name == spec.resource_policy)
)
)
if not policy_exists:
raise InvalidAPIParameters(
f"Cannot create group: Resource policy '{spec.resource_policy}' does not exist"
)

# Check if group already exists
check_stmt = sa.select(GroupRow).where(
sa.and_(
GroupRow.name == spec.name,
GroupRow.domain_name == spec.domain_name,
)
)
existing_group = await db_session.scalar(check_stmt)
if existing_group is not None:
raise InvalidAPIParameters(
f"Group with name '{spec.name}' already exists in domain '{spec.domain_name}'"
)

# Create the group with RBAC scope association
rbac_creator = RBACEntityCreator(
spec=creator.spec,
element_type=RBACElementType.PROJECT,
scope_ref=RBACElementRef(
element_type=RBACElementType.DOMAIN, element_id=spec.domain_name
),
additional_scope_refs=[],
)
result = await execute_rbac_entity_creator(db_session, rbac_creator)
row: GroupRow = result.row
data = row.to_data()
# Create RBAC roles and permissions for the group.
# Each project gets two SYSTEM-sourced roles at its scope: an admin role
# (via GroupData) and a member role (read-only access for project members).
await self._role_manager.create_system_role(db_session, data)
await self._role_manager.create_system_role(
db_session, ProjectMemberRoleSpec(project_id=data.id)
)
# Provision roles from active presets matching the project scope.
await self._role_manager.create_preset_roles(db_session, data.scope_id())

return data
Domain/resource-policy existence and name-uniqueness are enforced by the group
row's DB constraints, mapped to domain errors via the spec's
integrity_error_checks.
"""
creation = ProjectScopeCreation(spec=cast(GroupCreatorSpec, creator.spec))
async with self._rbac_ops_provider.write_ops() as w:
return (await w.create_scope(creation)).row.to_data()

async def modify_validated(
self,
Expand Down Expand Up @@ -237,7 +232,7 @@ async def _add_users_to_project_in_session(
AssociationScopesEntitiesRow,
sa.and_(
sa.cast(UserRow.uuid, sa.String) == AssociationScopesEntitiesRow.entity_id,
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_id == str(project_id),
AssociationScopesEntitiesRow.entity_type == EntityType.USER,
),
Expand Down Expand Up @@ -266,7 +261,7 @@ async def _add_users_to_project_in_session(
await self._role_manager.assign_auto_assign_roles(
session,
[row.uuid for row in new_user_rows],
ScopeId(scope_type=ScopeType.PROJECT, scope_id=str(project_id)),
ScopeId(scope_type=LegacyScopeType.PROJECT, scope_id=str(project_id)),
)

async def _remove_users_from_project_in_session(
Expand All @@ -285,7 +280,7 @@ async def _remove_users_from_project_in_session(
assigned_entity_ids = (
await session.scalars(
sa.select(AssociationScopesEntitiesRow.entity_id).where(
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_id == str(project_id),
AssociationScopesEntitiesRow.entity_type == EntityType.USER,
AssociationScopesEntitiesRow.entity_id.in_(target_entity_ids),
Expand All @@ -305,7 +300,7 @@ async def _remove_users_from_project_in_session(
project_role_ids_subq = sa.select(
AssociationScopesEntitiesRow.entity_id,
).where(
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_id == str(project_id),
AssociationScopesEntitiesRow.entity_type == EntityType.ROLE,
)
Expand Down Expand Up @@ -744,7 +739,7 @@ async def assign_users_to_project(
sa.and_(
sa.cast(UserRow.uuid, sa.String)
== AssociationScopesEntitiesRow.entity_id,
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_id == str(project_id),
AssociationScopesEntitiesRow.entity_type == EntityType.USER,
),
Expand Down Expand Up @@ -804,7 +799,7 @@ async def unassign_users_from_project(
actual_assoc_query = sa.select(UserRow).where(
sa.cast(UserRow.uuid, sa.String).in_(
sa.select(AssociationScopesEntitiesRow.entity_id).where(
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_id == str(unbinder.project_id),
AssociationScopesEntitiesRow.entity_type == EntityType.USER,
AssociationScopesEntitiesRow.entity_id.in_(target_entity_ids),
Expand Down Expand Up @@ -841,7 +836,7 @@ async def bind_user_to_project(self, user_id: UUID, project_id: UUID) -> None:
async with self._db.begin_session() as session:
already_bound = await session.scalar(
sa.select(AssociationScopesEntitiesRow.id).where(
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_id == str(project_id),
AssociationScopesEntitiesRow.entity_type == EntityType.USER,
AssociationScopesEntitiesRow.entity_id == str(user_id),
Expand All @@ -863,7 +858,7 @@ async def unbind_user_from_project(self, user_id: UUID, project_id: UUID) -> Non
async with self._db.begin_session() as session:
await session.execute(
sa.delete(AssociationScopesEntitiesRow).where(
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_id == str(project_id),
AssociationScopesEntitiesRow.entity_type == EntityType.USER,
AssociationScopesEntitiesRow.entity_id == str(user_id),
Expand Down Expand Up @@ -993,7 +988,7 @@ async def search_projects_by_user(
AssociationScopesEntitiesRow,
sa.and_(
sa.cast(GroupRow.id, sa.String) == AssociationScopesEntitiesRow.scope_id,
AssociationScopesEntitiesRow.scope_type == ScopeType.PROJECT,
AssociationScopesEntitiesRow.scope_type == LegacyScopeType.PROJECT,
AssociationScopesEntitiesRow.entity_type == EntityType.USER,
),
)
Expand Down
Loading
Loading