organization_members.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. """Organization membership management.
  2. These routes are nested under /organizations/{org_id}/members. Both the
  3. platform admin and any Org admin can manage memberships. The last admin
  4. of an Org cannot be demoted or removed — that would leave the Org
  5. without anyone able to manage members or infra.
  6. Storage is the unified ``principal_memberships`` table. Each row links
  7. two principals: the parent (an ORG-principal here, but the same table
  8. also carries GROUP memberships) and the member (a USER-principal). The
  9. URL path's ``user_id`` is the legacy ``users.id``, which we resolve to
  10. the user's ``principal_id`` for storage.
  11. """
  12. from datetime import datetime, timezone
  13. from typing import List, Optional
  14. from fastapi import APIRouter
  15. from pydantic import BaseModel
  16. from sqlmodel import select
  17. from gpustack.api.exceptions import (
  18. AlreadyExistsException,
  19. ConflictException,
  20. ForbiddenException,
  21. InvalidException,
  22. NotFoundException,
  23. )
  24. from gpustack.schemas.organizations import OrganizationMembershipPublic
  25. from gpustack.schemas.principals import (
  26. OrgRole,
  27. Principal,
  28. PrincipalMembership,
  29. PrincipalType,
  30. )
  31. from gpustack.schemas.users import User
  32. from gpustack.server.deps import SessionDep, TenantContextDep
  33. router = APIRouter()
  34. class MembershipCreate(BaseModel):
  35. user_id: int
  36. role: OrgRole = OrgRole.USER
  37. class MembershipUpdate(BaseModel):
  38. role: OrgRole
  39. def _can_manage(ctx, org_id: int) -> bool:
  40. """Platform admin can manage any Org's memberships; an Org admin
  41. can only manage their own Org. The role check is bound to the
  42. target Org from the URL path — ``ctx.org_role`` reflects the
  43. caller's *current* Org context, which may not match the path when
  44. a savvy client crafts the URL directly. Anchoring on ``org_id``
  45. closes that cross-Org escalation.
  46. """
  47. if ctx.is_platform_admin:
  48. return True
  49. if ctx.current_principal_id != org_id:
  50. return False
  51. return ctx.org_role == OrgRole.ADMIN
  52. async def _load_org(session, org_id: int) -> Principal:
  53. org = await Principal.one_by_id(session, org_id)
  54. if not org or org.deleted_at is not None or org.kind != PrincipalType.ORG:
  55. raise NotFoundException(message="Organization not found")
  56. return org
  57. async def _resolve_user(session, user_id: int) -> Optional[User]:
  58. user = await User.one_by_id(session, user_id)
  59. if not user or user.is_system or user.deleted_at is not None:
  60. return None
  61. return user
  62. async def _list_memberships(
  63. session, org_principal_id: int
  64. ) -> List[PrincipalMembership]:
  65. stmt = select(PrincipalMembership).where(
  66. PrincipalMembership.parent_principal_id == org_principal_id,
  67. PrincipalMembership.deleted_at.is_(None),
  68. )
  69. return list((await session.exec(stmt)).all())
  70. async def _find_membership(
  71. session,
  72. org_principal_id: int,
  73. member_principal_id: int,
  74. *,
  75. include_deleted: bool = False,
  76. ) -> Optional[PrincipalMembership]:
  77. """Return the (optionally soft-deleted) membership row.
  78. Used by the add path with ``include_deleted=True`` so a soft-deleted
  79. row can be resurrected instead of producing a duplicate.
  80. """
  81. conditions = [
  82. PrincipalMembership.parent_principal_id == org_principal_id,
  83. PrincipalMembership.member_principal_id == member_principal_id,
  84. ]
  85. if not include_deleted:
  86. conditions.append(PrincipalMembership.deleted_at.is_(None))
  87. stmt = select(PrincipalMembership).where(*conditions)
  88. return (await session.exec(stmt)).first()
  89. async def _has_other_admin(
  90. session, org_principal_id: int, exclude_member_principal_id: int
  91. ) -> bool:
  92. stmt = select(PrincipalMembership.id).where(
  93. PrincipalMembership.parent_principal_id == org_principal_id,
  94. PrincipalMembership.role == OrgRole.ADMIN,
  95. PrincipalMembership.member_principal_id != exclude_member_principal_id,
  96. PrincipalMembership.deleted_at.is_(None),
  97. )
  98. return (await session.exec(stmt)).first() is not None
  99. async def _enrich_with_user_labels(
  100. session,
  101. org_principal_id: int,
  102. rows: List[PrincipalMembership],
  103. ) -> List[OrganizationMembershipPublic]:
  104. """Bulk-resolve username / full_name / users.id for each membership.
  105. Membership rows reference users via ``member_principal_id``
  106. (= ``users.principal_id``). We join back to ``users`` so the
  107. response can carry the legacy ``user_id`` (= ``users.id``) plus
  108. display labels, in a single query — no per-row round trip from
  109. the client.
  110. """
  111. member_ids = {r.member_principal_id for r in rows}
  112. user_by_principal: dict[int, User] = {}
  113. if member_ids:
  114. result = await session.exec(
  115. select(User).where(User.principal_id.in_(member_ids))
  116. )
  117. user_by_principal = {u.principal_id: u for u in result.all()}
  118. out: List[OrganizationMembershipPublic] = []
  119. for r in rows:
  120. u = user_by_principal.get(r.member_principal_id)
  121. out.append(
  122. OrganizationMembershipPublic(
  123. user_id=getattr(u, "id", 0),
  124. organization_id=org_principal_id,
  125. role=r.role,
  126. created_at=r.created_at,
  127. username=getattr(u, "username", None),
  128. full_name=getattr(u, "full_name", None),
  129. )
  130. )
  131. return out
  132. @router.get(
  133. "/organizations/{org_id}/members",
  134. response_model=List[OrganizationMembershipPublic],
  135. )
  136. async def list_org_members(session: SessionDep, ctx: TenantContextDep, org_id: int):
  137. await _load_org(session, org_id)
  138. if not ctx.is_platform_admin and ctx.current_principal_id != org_id:
  139. raise ForbiddenException(message="Not a member of this organization")
  140. rows = await _list_memberships(session, org_id)
  141. return await _enrich_with_user_labels(session, org_id, rows)
  142. @router.post(
  143. "/organizations/{org_id}/members",
  144. response_model=OrganizationMembershipPublic,
  145. )
  146. async def add_org_member(
  147. session: SessionDep,
  148. ctx: TenantContextDep,
  149. org_id: int,
  150. body: MembershipCreate,
  151. ):
  152. org = await _load_org(session, org_id)
  153. if not _can_manage(ctx, org_id):
  154. raise ForbiddenException(message="Insufficient permission to add member")
  155. user = await _resolve_user(session, body.user_id)
  156. if not user:
  157. raise NotFoundException(message="User not found")
  158. existing = await _find_membership(
  159. session, org.id, user.principal_id, include_deleted=True
  160. )
  161. if existing is not None and existing.deleted_at is None:
  162. raise AlreadyExistsException(
  163. message=(
  164. f"User {body.user_id} is already a member of " f"organization {org_id}"
  165. )
  166. )
  167. try:
  168. if existing is not None:
  169. # Resurrect a soft-deleted row so the membership timeline
  170. # stays on a single row.
  171. existing.deleted_at = None
  172. existing.role = body.role
  173. session.add(existing)
  174. await session.commit()
  175. await session.refresh(existing)
  176. stored = existing
  177. else:
  178. now = datetime.now(timezone.utc).replace(tzinfo=None)
  179. stored = PrincipalMembership(
  180. parent_principal_id=org.id,
  181. member_principal_id=user.principal_id,
  182. role=body.role,
  183. created_at=now,
  184. updated_at=now,
  185. )
  186. session.add(stored)
  187. await session.commit()
  188. await session.refresh(stored)
  189. except Exception as e:
  190. await session.rollback()
  191. raise InvalidException(message=f"Failed to add member: {e}")
  192. return OrganizationMembershipPublic(
  193. user_id=user.id,
  194. organization_id=org.id,
  195. role=stored.role,
  196. created_at=stored.created_at,
  197. username=user.username,
  198. full_name=user.full_name,
  199. )
  200. @router.put(
  201. "/organizations/{org_id}/members/{user_id}",
  202. response_model=OrganizationMembershipPublic,
  203. )
  204. async def update_org_member(
  205. session: SessionDep,
  206. ctx: TenantContextDep,
  207. org_id: int,
  208. user_id: int,
  209. body: MembershipUpdate,
  210. ):
  211. await _load_org(session, org_id)
  212. user = await _resolve_user(session, user_id)
  213. if not user:
  214. raise NotFoundException(message="Membership not found")
  215. membership = await _find_membership(session, org_id, user.principal_id)
  216. if not membership:
  217. raise NotFoundException(message="Membership not found")
  218. if not _can_manage(ctx, org_id):
  219. raise ForbiddenException(message="Insufficient permission to change role")
  220. if membership.role == OrgRole.ADMIN and body.role != OrgRole.ADMIN:
  221. if not await _has_other_admin(
  222. session, org_id, exclude_member_principal_id=user.principal_id
  223. ):
  224. raise ConflictException(
  225. message="Cannot demote the only admin of this organization"
  226. )
  227. try:
  228. membership.role = body.role
  229. session.add(membership)
  230. await session.commit()
  231. await session.refresh(membership)
  232. except Exception as e:
  233. await session.rollback()
  234. raise InvalidException(message=f"Failed to update member: {e}")
  235. return OrganizationMembershipPublic(
  236. user_id=user.id,
  237. organization_id=org_id,
  238. role=membership.role,
  239. created_at=membership.created_at,
  240. username=user.username,
  241. full_name=user.full_name,
  242. )
  243. @router.delete("/organizations/{org_id}/members/{user_id}")
  244. async def remove_org_member(
  245. session: SessionDep,
  246. ctx: TenantContextDep,
  247. org_id: int,
  248. user_id: int,
  249. ):
  250. await _load_org(session, org_id)
  251. user = await _resolve_user(session, user_id)
  252. if not user:
  253. raise NotFoundException(message="Membership not found")
  254. membership = await _find_membership(session, org_id, user.principal_id)
  255. if not membership:
  256. raise NotFoundException(message="Membership not found")
  257. if not _can_manage(ctx, org_id):
  258. raise ForbiddenException(message="Insufficient permission to remove member")
  259. if membership.role == OrgRole.ADMIN:
  260. if not await _has_other_admin(
  261. session, org_id, exclude_member_principal_id=user.principal_id
  262. ):
  263. raise ConflictException(
  264. message="Cannot remove the only admin of this organization"
  265. )
  266. try:
  267. await membership.delete(session, soft=True)
  268. except Exception as e:
  269. await session.rollback()
  270. raise InvalidException(message=f"Failed to remove member: {e}")