"""Software permission domain models.""" from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import ( JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base from app.core.sql_types import GUID as Uuid from app.core.sql_types import PreciseDateTime # Permission status # # ``pending_approval`` (migration 606): the request is waiting for the # software owner / IT contact / a global admin to decide IN Helix (2026-09-05 # decision: permission catalog approvals do not go through Feishu). It is # deliberately distinct from ``pending``, which has always meant "approved, # vendor-side grant in flight" — the provisioning hook flips ``pending`` to # ``active`` and must never see an undecided row. PERM_PENDING_APPROVAL = "pending_approval" PERM_PENDING = "pending" PERM_ACTIVE = "active" PERM_REVOKED = "revoked" PERM_REJECTED = "rejected" PERM_EXPIRED = "expired" # Requester withdrew a still-pending request (lifecycle-verb parity with # sso / vpn / domain / cloud-scope / asset requests, which all支持撤回). PERM_CANCELLED = "cancelled" class SoftwarePermission(Base): """Catalog entry — a granted-or-revocable permission within a software.""" __tablename__ = "software_permissions" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) software_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("asset_software_catalog.id"), nullable=False, ) permission_key: Mapped[str] = mapped_column(String(128), nullable=False) display_name: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text) requires_approval: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) # Soft-delete column. Hard-delete would orphan FK references on # historical UserPermission rows whose status is ``revoked`` — # the catalog id is the only handle for "what permission did # Jane hold in 2026-Q1?". Soft delete preserves the history # while hiding the row from the catalog UI. NULL = active row. deleted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) # Statuses that mean "this request/grant is still in play". A cycle stays # open while the row sits in one of these; anything else closes it. PERM_OPEN_STATUSES = frozenset({PERM_PENDING, PERM_ACTIVE}) class UserPermission(Base): """Record of one permission granted to one user. A user has at most ONE row per ``(user_id, permission_id)`` across ALL statuses. When a user re-requests a permission after revoke / expire, the service UPDATEs the existing row in place — see ``PermissionService.request_permission``. The unique constraint closes the SELECT-then-INSERT race that allowed double-click double- grants pre-2026-05-05 (Wave 1D pre-launch hardening). Because the row is reused, it only ever describes the CURRENT holding. "Which permissions did Jane hold in 2026-Q1" is answered by :class:`UserPermissionCycle`, the append-only interval ledger that ``app.core.access_cycle_listener`` writes on every transition. """ __tablename__ = "user_permissions" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( UniqueConstraint("user_id", "permission_id", name="uq_user_perm"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) permission_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("software_permissions.id"), nullable=False, ) status: Mapped[str] = mapped_column(String(32), nullable=False, default=PERM_PENDING) reason: Mapped[str | None] = mapped_column(Text) approval_request_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("approval_requests.id"), ) provisioning_task_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("provisioning_tasks.id"), ) external_ref: Mapped[str | None] = mapped_column(String(255)) granted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # ── In-app approval + validity (migration 606) ──────────────── # ``expires_at`` is the validity the approver / admin gave the grant # (NULL = open-ended). The ``permission.lifecycle_sweep`` cron issues a # REVOKE provisioning task when it passes; the task's success hook lands # the row on ``expired`` (never ``revoked``) so the ledger tells the two # apart. expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) approver_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) decision_note: Mapped[str | None] = mapped_column(Text) approval_reminder_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) approval_last_reminded_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class UserPermissionCycle(Base): """One holding interval of one permission by one person (append-only). ``user_permissions`` keeps at most one row per (user, permission) and resets it in place on re-request, so the live row cannot answer "who held this permission on 2026-03-01". This ledger can: WHERE permission_id = :p AND granted_at IS NOT NULL AND granted_at <= :d AND (closed_at IS NULL OR closed_at > :d) ``granted_at IS NOT NULL`` is load-bearing. A cycle opens when the request row leaves a terminal state, which happens *before* the vendor-side grant lands; a request that was later rejected or cancelled has an ``opened_at`` but never a ``granted_at`` and must not appear in an access roster. No FK columns — see the migration docstring (483). Rows must outlive soft-deleted catalog entries and HRIS-purged people, and FK child inserts would put shared locks on parent rows in the grant/revoke hot path. """ __tablename__ = "user_permission_cycles" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) user_permission_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True) user_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) permission_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) # Microsecond precision — see ``PreciseDateTime``. Two cycles opened # in the same second must still be orderable, or the evidence bundle # is not reproducible. opened_at: Mapped[datetime] = mapped_column(PreciseDateTime, nullable=False) granted_at: Mapped[datetime | None] = mapped_column(PreciseDateTime) closed_at: Mapped[datetime | None] = mapped_column(PreciseDateTime) open_status: Mapped[str] = mapped_column(String(32), nullable=False) close_status: Mapped[str | None] = mapped_column(String(32)) opened_by_person_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) closed_by_person_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) approval_request_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) provisioning_task_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) reason: Mapped[str | None] = mapped_column(Text) open_trace_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) close_trace_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) detail: Mapped[dict | None] = mapped_column(JSON) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), )