principals.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. """Principal — the unified owner-identity model.
  2. Every namespaced actor in the system (a User, an Organization, a User
  3. Group) is a row in ``principals``. The kind-specific extension lives in
  4. its own table:
  5. - ``users`` — credentials, role, system flags, cluster/worker FKs
  6. - (no extension for ORG / GROUP — every column they need is on
  7. ``principals`` itself)
  8. Resources (``models``, ``model_routes``, ``clusters``, ...) record their
  9. owner via ``owner_principal_id``. Memberships connect principals to
  10. principals (a user-principal joining an org-principal, or a user-
  11. principal joining a group-principal). ACLs reference principals
  12. directly.
  13. """
  14. from datetime import datetime
  15. from enum import Enum
  16. from typing import ClassVar, List, Optional
  17. from sqlalchemy import Enum as SQLEnum
  18. from sqlmodel import (
  19. Column,
  20. Field,
  21. ForeignKey,
  22. Integer,
  23. SQLModel,
  24. UniqueConstraint,
  25. )
  26. from sqlalchemy import Text
  27. from gpustack.mixins import BaseModelMixin
  28. from gpustack.schemas.common import ListParams, PaginatedList
  29. # Canonical slug of the built-in platform Org-principal. Created by the
  30. # multi-tenancy foundation migration; system / infrastructure resources
  31. # default to it.
  32. #
  33. # ``PLATFORM_PRINCIPAL_ID`` is the id we *seed* it with in a fresh DB.
  34. # It happens to be ``1`` today, but anywhere we don't have to bake the
  35. # integer into the SQL — primarily migrations and any future bootstrap
  36. # code that runs against a populated DB — we look it up by slug instead.
  37. # That's the form that survives any future renumbering (e.g. when
  38. # ``users`` and ``principals`` get unified and USER-kind principals
  39. # inherit ``users.id``, the platform Org will get a new id above
  40. # ``max(users.id)``).
  41. PLATFORM_PRINCIPAL_SLUG = 'default'
  42. PLATFORM_PRINCIPAL_ID = 1
  43. class PrincipalType(str, Enum):
  44. """Discriminator for the kind of principal a row represents.
  45. Kept named ``PrincipalType`` (rather than ``PrincipalKind``) for
  46. continuity with the cluster-access / model-route ACL APIs that
  47. already accepted this enum on the wire.
  48. """
  49. USER = "user"
  50. ORG = "org"
  51. GROUP = "group"
  52. class OrgRole(str, Enum):
  53. # Two-tier Org membership model: ADMIN can manage the Org's infra
  54. # (resources, members, settings); USER is a plain consumer. The
  55. # platform-wide superuser lives on `users.is_admin` and is distinct
  56. # from `OrgRole.ADMIN` — always disambiguate with `is_platform_admin`
  57. # vs `org_role == OrgRole.ADMIN` in code.
  58. ADMIN = "admin"
  59. USER = "user"
  60. class PrincipalBase(SQLModel):
  61. kind: PrincipalType = Field(
  62. sa_column=Column(SQLEnum(PrincipalType), nullable=False),
  63. )
  64. # User-facing identifier used in URLs and ``effective_route_name``.
  65. # Globally unique among non-NULL values. Auto-set to ``user-{user.id}``
  66. # for USER principals; user-supplied for ORG; NULL for GROUP (groups
  67. # never appear in URL prefixes).
  68. slug: Optional[str] = Field(default=None, nullable=True)
  69. name: str = Field(nullable=False)
  70. description: Optional[str] = Field(
  71. default=None, sa_column=Column(Text, nullable=True)
  72. )
  73. # Structural parent. NULL for USER and ORG; for GROUP, points at the
  74. # owning ORG-principal so the group lives inside it.
  75. parent_principal_id: Optional[int] = Field(
  76. default=None,
  77. sa_column=Column(
  78. Integer,
  79. ForeignKey("principals.id", ondelete="CASCADE"),
  80. nullable=True,
  81. ),
  82. )
  83. class Principal(PrincipalBase, BaseModelMixin, table=True):
  84. __tablename__ = 'principals'
  85. __table_args__ = (
  86. UniqueConstraint('slug', name='uix_principals_slug'),
  87. # Group names must be unique within their parent org. NULL parent
  88. # (USER / ORG) doesn't participate — UNIQUE treats NULL as
  89. # distinct, so users and orgs can share names freely.
  90. UniqueConstraint(
  91. 'parent_principal_id', 'name', name='uix_principals_parent_name'
  92. ),
  93. )
  94. id: Optional[int] = Field(default=None, primary_key=True)
  95. class PrincipalListParams(ListParams):
  96. kind: Optional[PrincipalType] = None
  97. parent_principal_id: Optional[int] = None
  98. sortable_fields: ClassVar[List[str]] = [
  99. "name",
  100. "slug",
  101. "kind",
  102. "created_at",
  103. "updated_at",
  104. ]
  105. class PrincipalPublic(SQLModel):
  106. id: int
  107. kind: PrincipalType
  108. slug: Optional[str] = None
  109. name: str
  110. description: Optional[str] = None
  111. parent_principal_id: Optional[int] = None
  112. created_at: datetime
  113. updated_at: datetime
  114. PrincipalsPublic = PaginatedList[PrincipalPublic]
  115. class PrincipalMembershipBase(SQLModel):
  116. parent_principal_id: int = Field(
  117. sa_column=Column(
  118. Integer,
  119. ForeignKey("principals.id", ondelete="CASCADE"),
  120. nullable=False,
  121. ),
  122. )
  123. member_principal_id: int = Field(
  124. sa_column=Column(
  125. Integer,
  126. ForeignKey("principals.id", ondelete="CASCADE"),
  127. nullable=False,
  128. ),
  129. )
  130. # Only meaningful when parent is an ORG; NULL for GROUP memberships
  131. # (groups don't have role tiers — you're either in or out).
  132. role: Optional[OrgRole] = Field(
  133. default=None,
  134. sa_column=Column(SQLEnum(OrgRole), nullable=True),
  135. )
  136. class PrincipalMembership(PrincipalMembershipBase, BaseModelMixin, table=True):
  137. """Membership of one principal inside another.
  138. Surrogate ``id`` PK so soft-delete + re-add doesn't collide on a
  139. composite key. At any point in time at most one row per
  140. ``(parent_principal_id, member_principal_id)`` may have
  141. ``deleted_at IS NULL``; that invariant is enforced in the route
  142. handlers (the add path looks up an active row first and re-uses
  143. any soft-deleted row by clearing ``deleted_at`` and updating
  144. ``role``).
  145. """
  146. __tablename__ = 'principal_memberships'
  147. id: Optional[int] = Field(default=None, primary_key=True)