organizations.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """Organization management — platform admin only.
  2. "Organizations" in the API surface are ORG-kind ``Principal`` rows.
  3. This file is the CRUD adapter that maps the legacy Organization shape
  4. on/off the unified principals table.
  5. """
  6. from typing import Optional
  7. from fastapi import APIRouter, Depends
  8. from fastapi.responses import StreamingResponse
  9. from sqlmodel import select
  10. from gpustack.api.exceptions import (
  11. AlreadyExistsException,
  12. ConflictException,
  13. InternalServerErrorException,
  14. InvalidException,
  15. NotFoundException,
  16. )
  17. from gpustack.schemas.organizations import (
  18. OrganizationCreate,
  19. OrganizationListParams,
  20. OrganizationPublic,
  21. OrganizationUpdate,
  22. OrganizationsPublic,
  23. validate_org_input,
  24. )
  25. from gpustack.schemas.principals import (
  26. PLATFORM_PRINCIPAL_ID,
  27. Principal,
  28. PrincipalType,
  29. )
  30. from gpustack.server.deps import SessionDep
  31. router = APIRouter()
  32. def _to_public(p: Principal) -> OrganizationPublic:
  33. return OrganizationPublic.from_principal(p)
  34. @router.get("", response_model=OrganizationsPublic)
  35. async def get_organizations(
  36. session: SessionDep,
  37. params: OrganizationListParams = Depends(),
  38. search: Optional[str] = None,
  39. ):
  40. fuzzy_fields = {}
  41. if search:
  42. fuzzy_fields = {"name": search, "slug": search}
  43. fields = {"deleted_at": None, "kind": PrincipalType.ORG}
  44. if params.watch:
  45. return StreamingResponse(
  46. Principal.streaming(fields=fields, fuzzy_fields=fuzzy_fields),
  47. media_type="text/event-stream",
  48. )
  49. page = await Principal.paginated_by_query(
  50. session=session,
  51. fields=fields,
  52. fuzzy_fields=fuzzy_fields,
  53. page=params.page,
  54. per_page=params.perPage,
  55. order_by=params.order_by,
  56. )
  57. page.items = [_to_public(p) for p in page.items]
  58. return page
  59. @router.get("/{id}", response_model=OrganizationPublic)
  60. async def get_organization(session: SessionDep, id: int):
  61. org = await Principal.one_by_id(session, id)
  62. if not org or org.deleted_at is not None or org.kind != PrincipalType.ORG:
  63. raise NotFoundException(message="Organization not found")
  64. return _to_public(org)
  65. @router.post("", response_model=OrganizationPublic)
  66. async def create_organization(session: SessionDep, org_in: OrganizationCreate):
  67. # Block reserved names ("Personal" / "Global") and slug patterns
  68. # ("user-N") on the input side. Validation lives in the route, not
  69. # the schema, so the same model can serialize already-existing
  70. # auto-created USER-principals without rejecting them.
  71. try:
  72. validate_org_input(name=org_in.name, slug=org_in.slug)
  73. except ValueError as e:
  74. raise InvalidException(message=str(e))
  75. existing = await Principal.one_by_fields(
  76. session, {"slug": org_in.slug, "deleted_at": None}
  77. )
  78. if existing:
  79. raise AlreadyExistsException(
  80. message=f"Organization with slug '{org_in.slug}' already exists"
  81. )
  82. try:
  83. to_create = Principal(
  84. kind=PrincipalType.ORG,
  85. name=org_in.name,
  86. slug=org_in.slug,
  87. description=org_in.description,
  88. )
  89. created = await Principal.create(session, to_create)
  90. return _to_public(created)
  91. except Exception as e:
  92. raise InternalServerErrorException(
  93. message=f"Failed to create organization: {e}"
  94. )
  95. @router.put("/{id}", response_model=OrganizationPublic)
  96. async def update_organization(session: SessionDep, id: int, org_in: OrganizationUpdate):
  97. org = await Principal.one_by_id(session, id)
  98. if not org or org.deleted_at is not None or org.kind != PrincipalType.ORG:
  99. raise NotFoundException(message="Organization not found")
  100. try:
  101. validate_org_input(name=org_in.name)
  102. except ValueError as e:
  103. raise InvalidException(message=str(e))
  104. try:
  105. await org.update(session, org_in.model_dump(exclude_unset=True))
  106. except Exception as e:
  107. raise InternalServerErrorException(
  108. message=f"Failed to update organization: {e}"
  109. )
  110. return _to_public(org)
  111. @router.delete("/{id}")
  112. async def delete_organization(session: SessionDep, id: int):
  113. org = await Principal.one_by_id(session, id)
  114. if not org or org.deleted_at is not None or org.kind != PrincipalType.ORG:
  115. raise NotFoundException(message="Organization not found")
  116. if org.id == PLATFORM_PRINCIPAL_ID:
  117. raise ConflictException(
  118. message="The built-in platform organization cannot be deleted"
  119. )
  120. # Block delete when any tenant-owned resource still references this org.
  121. # FK CASCADE would silently destroy users' resources; surfacing the
  122. # conflict lets the operator decide.
  123. blockers = await _has_resources(session, id)
  124. if blockers:
  125. raise ConflictException(
  126. message=(
  127. "Organization still owns resources: "
  128. f"{', '.join(blockers)}. Remove them before deleting."
  129. )
  130. )
  131. try:
  132. await org.delete(session)
  133. except Exception as e:
  134. raise InternalServerErrorException(
  135. message=f"Failed to delete organization: {e}"
  136. )
  137. async def _has_resources(session, owner_principal_id: int) -> list[str]:
  138. """Return resource types that still belong to this principal.
  139. Cover every tenant-scoped resource (anything carrying an
  140. ``owner_principal_id``) so an admin who deletes an Org can't
  141. silently orphan or destroy clusters, worker pools, cloud
  142. credentials, user groups, benchmarks, or backend overrides. The
  143. check matches the spirit of FK CASCADE — but surfaces the
  144. conflict so the operator can decide what to do.
  145. """
  146. from gpustack.schemas.api_keys import ApiKey
  147. from gpustack.schemas.benchmark import Benchmark
  148. from gpustack.schemas.clusters import Cluster, CloudCredential, WorkerPool
  149. from gpustack.schemas.inference_backend import InferenceBackend
  150. from gpustack.schemas.model_routes import ModelRoute
  151. from gpustack.schemas.models import Model, ModelInstance
  152. blockers: list[str] = []
  153. for resource_cls, label in (
  154. (ApiKey, "api_keys"),
  155. (Model, "models"),
  156. (ModelInstance, "model_instances"),
  157. (ModelRoute, "model_routes"),
  158. (Cluster, "clusters"),
  159. (WorkerPool, "worker_pools"),
  160. (CloudCredential, "cloud_credentials"),
  161. (Benchmark, "benchmarks"),
  162. (InferenceBackend, "inference_backends"),
  163. ):
  164. stmt = (
  165. select(resource_cls.id)
  166. .where(
  167. resource_cls.owner_principal_id == owner_principal_id,
  168. resource_cls.deleted_at.is_(None),
  169. )
  170. .limit(1)
  171. )
  172. if (await session.exec(stmt)).first() is not None:
  173. blockers.append(label)
  174. # Child principals (groups belonging to this org).
  175. group_stmt = (
  176. select(Principal.id)
  177. .where(
  178. Principal.kind == PrincipalType.GROUP,
  179. Principal.parent_principal_id == owner_principal_id,
  180. Principal.deleted_at.is_(None),
  181. )
  182. .limit(1)
  183. )
  184. if (await session.exec(group_stmt)).first() is not None:
  185. blockers.append("user_groups")
  186. return blockers