| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- """UserGroup management — Org admin+ or platform admin.
- Groups are GROUP-kind ``Principal`` rows whose ``parent_principal_id``
- points at their owning ORG-principal. Group memberships live in the
- unified ``principal_memberships`` table with ``role=NULL`` (groups
- don't have role tiers).
- """
- from datetime import datetime, timezone
- from typing import List, Optional
- from fastapi import APIRouter, Depends
- from pydantic import BaseModel
- from sqlmodel import select
- from gpustack.api.exceptions import (
- AlreadyExistsException,
- ForbiddenException,
- InvalidException,
- NotFoundException,
- )
- from gpustack.schemas.principals import (
- OrgRole,
- Principal,
- PrincipalMembership,
- PrincipalType,
- )
- from gpustack.schemas.user_groups import (
- UserGroupCreate,
- UserGroupListParams,
- UserGroupMembershipPublic,
- UserGroupPublic,
- UserGroupUpdate,
- UserGroupsPublic,
- )
- from gpustack.schemas.users import User
- from gpustack.server.deps import SessionDep, TenantContextDep
- router = APIRouter()
- class GroupMembershipCreate(BaseModel):
- user_id: int
- def _can_manage_groups(ctx, org_id: int) -> bool:
- if ctx.is_platform_admin:
- return True
- if ctx.current_principal_id != org_id:
- return False
- return ctx.org_role == OrgRole.ADMIN
- async def _load_org(session, org_id: int) -> Principal:
- org = await Principal.one_by_id(session, org_id)
- if not org or org.deleted_at is not None or org.kind != PrincipalType.ORG:
- raise NotFoundException(message="Organization not found")
- return org
- async def _load_group(session, org_id: int, group_id: int) -> Principal:
- group = await Principal.one_by_id(session, group_id)
- if (
- not group
- or group.deleted_at is not None
- or group.kind != PrincipalType.GROUP
- or group.parent_principal_id != org_id
- ):
- raise NotFoundException(message="Group not found")
- return group
- def _group_to_public(group: Principal) -> UserGroupPublic:
- return UserGroupPublic.from_principal(group)
- # ---- groups ----------------------------------------------------------------
- @router.get("/organizations/{org_id}/groups", response_model=UserGroupsPublic)
- async def list_groups(
- session: SessionDep,
- ctx: TenantContextDep,
- org_id: int,
- params: UserGroupListParams = Depends(),
- search: Optional[str] = None,
- ):
- await _load_org(session, org_id)
- if not ctx.is_platform_admin and ctx.current_principal_id != org_id:
- raise ForbiddenException(message="Not a member of this organization")
- fuzzy_fields = {"name": search} if search else {}
- page = await Principal.paginated_by_query(
- session=session,
- fields={
- "kind": PrincipalType.GROUP,
- "parent_principal_id": org_id,
- "deleted_at": None,
- },
- fuzzy_fields=fuzzy_fields,
- page=params.page,
- per_page=params.perPage,
- order_by=params.order_by,
- )
- page.items = [_group_to_public(g) for g in page.items]
- return page
- @router.post("/organizations/{org_id}/groups", response_model=UserGroupPublic)
- async def create_group(
- session: SessionDep,
- ctx: TenantContextDep,
- org_id: int,
- body: UserGroupCreate,
- ):
- await _load_org(session, org_id)
- if not _can_manage_groups(ctx, org_id):
- raise ForbiddenException(message="Insufficient permission to manage groups")
- existing = await Principal.one_by_fields(
- session,
- {
- "kind": PrincipalType.GROUP,
- "parent_principal_id": org_id,
- "name": body.name,
- "deleted_at": None,
- },
- )
- if existing:
- raise AlreadyExistsException(
- message=f"Group '{body.name}' already exists in this organization"
- )
- try:
- group = Principal(
- kind=PrincipalType.GROUP,
- parent_principal_id=org_id,
- name=body.name,
- description=body.description,
- )
- created = await Principal.create(session, group)
- except Exception as e:
- raise InvalidException(message=f"Failed to create group: {e}")
- return _group_to_public(created)
- @router.put("/organizations/{org_id}/groups/{group_id}", response_model=UserGroupPublic)
- async def update_group(
- session: SessionDep,
- ctx: TenantContextDep,
- org_id: int,
- group_id: int,
- body: UserGroupUpdate,
- ):
- group = await _load_group(session, org_id, group_id)
- if not _can_manage_groups(ctx, org_id):
- raise ForbiddenException(message="Insufficient permission to manage groups")
- try:
- await group.update(session, body.model_dump(exclude_unset=True))
- except Exception as e:
- raise InvalidException(message=f"Failed to update group: {e}")
- return _group_to_public(group)
- @router.delete("/organizations/{org_id}/groups/{group_id}")
- async def delete_group(
- session: SessionDep, ctx: TenantContextDep, org_id: int, group_id: int
- ):
- group = await _load_group(session, org_id, group_id)
- if not _can_manage_groups(ctx, org_id):
- raise ForbiddenException(message="Insufficient permission to manage groups")
- try:
- await group.delete(session)
- except Exception as e:
- raise InvalidException(message=f"Failed to delete group: {e}")
- # ---- group members ---------------------------------------------------------
- async def _resolve_user(session, user_id: int) -> Optional[User]:
- user = await User.one_by_id(session, user_id)
- if not user or user.is_system or user.deleted_at is not None:
- return None
- return user
- @router.get(
- "/organizations/{org_id}/groups/{group_id}/members",
- response_model=List[UserGroupMembershipPublic],
- )
- async def list_group_members(
- session: SessionDep,
- ctx: TenantContextDep,
- org_id: int,
- group_id: int,
- ):
- await _load_group(session, org_id, group_id)
- if not ctx.is_platform_admin and ctx.current_principal_id != org_id:
- raise ForbiddenException(message="Not a member of this organization")
- stmt = select(PrincipalMembership).where(
- PrincipalMembership.parent_principal_id == group_id,
- PrincipalMembership.deleted_at.is_(None),
- )
- rows = list((await session.exec(stmt)).all())
- member_ids = {r.member_principal_id for r in rows}
- user_by_principal: dict[int, User] = {}
- if member_ids:
- result = await session.exec(
- select(User).where(User.principal_id.in_(member_ids))
- )
- user_by_principal = {u.principal_id: u for u in result.all()}
- out: List[UserGroupMembershipPublic] = []
- for r in rows:
- u = user_by_principal.get(r.member_principal_id)
- out.append(
- UserGroupMembershipPublic(
- user_id=getattr(u, "id", 0),
- group_id=group_id,
- created_at=r.created_at,
- username=getattr(u, "username", None),
- full_name=getattr(u, "full_name", None),
- )
- )
- return out
- @router.post(
- "/organizations/{org_id}/groups/{group_id}/members",
- response_model=UserGroupMembershipPublic,
- )
- async def add_group_member(
- session: SessionDep,
- ctx: TenantContextDep,
- org_id: int,
- group_id: int,
- body: GroupMembershipCreate,
- ):
- await _load_group(session, org_id, group_id)
- if not _can_manage_groups(ctx, org_id):
- raise ForbiddenException(message="Insufficient permission to manage groups")
- user = await _resolve_user(session, body.user_id)
- if not user:
- raise NotFoundException(message="User not found")
- # User must be an active member of the group's org first.
- org_membership_stmt = select(PrincipalMembership.id).where(
- PrincipalMembership.parent_principal_id == org_id,
- PrincipalMembership.member_principal_id == user.principal_id,
- PrincipalMembership.deleted_at.is_(None),
- )
- if (await session.exec(org_membership_stmt)).first() is None:
- raise InvalidException(
- message=(
- f"User {body.user_id} is not a member of " f"organization {org_id}"
- )
- )
- existing_stmt = select(PrincipalMembership).where(
- PrincipalMembership.parent_principal_id == group_id,
- PrincipalMembership.member_principal_id == user.principal_id,
- PrincipalMembership.deleted_at.is_(None),
- )
- if (await session.exec(existing_stmt)).first() is not None:
- raise AlreadyExistsException(
- message=f"User {body.user_id} is already in group {group_id}"
- )
- try:
- now = datetime.now(timezone.utc).replace(tzinfo=None)
- link = PrincipalMembership(
- parent_principal_id=group_id,
- member_principal_id=user.principal_id,
- role=None,
- created_at=now,
- updated_at=now,
- )
- session.add(link)
- await session.commit()
- await session.refresh(link)
- except Exception as e:
- await session.rollback()
- raise InvalidException(message=f"Failed to add group member: {e}")
- return UserGroupMembershipPublic(
- user_id=user.id,
- group_id=group_id,
- created_at=link.created_at,
- username=user.username,
- full_name=user.full_name,
- )
- @router.delete("/organizations/{org_id}/groups/{group_id}/members/{user_id}")
- async def remove_group_member(
- session: SessionDep,
- ctx: TenantContextDep,
- org_id: int,
- group_id: int,
- user_id: int,
- ):
- await _load_group(session, org_id, group_id)
- if not _can_manage_groups(ctx, org_id):
- raise ForbiddenException(message="Insufficient permission to manage groups")
- user = await _resolve_user(session, user_id)
- if not user:
- raise NotFoundException(message="Group membership not found")
- stmt = select(PrincipalMembership).where(
- PrincipalMembership.parent_principal_id == group_id,
- PrincipalMembership.member_principal_id == user.principal_id,
- PrincipalMembership.deleted_at.is_(None),
- )
- link = (await session.exec(stmt)).first()
- if not link:
- raise NotFoundException(message="Group membership not found")
- try:
- await link.delete(session, soft=True)
- except Exception as e:
- await session.rollback()
- raise InvalidException(message=f"Failed to remove group member: {e}")
|