r"""Provisioning domain models. The provisioning system is a unified work queue for grant/revoke actions across all resource domains: software seats, software permissions, API keys, and (eventually) hardware returns. The queue decouples "what was decided" (approval, policy match, offboarding scan) from "how it's executed" (API call, human task, SSO auto-lifecycle). Task lifecycle: pending -> in_progress -> completed | failed \-> notified (manual_notify only, waits for human) \-> completed (sso_auto, resolved immediately) A task can be retried (attempt_count increments) by an admin if it failed. """ from __future__ import annotations import uuid from datetime import datetime from typing import TYPE_CHECKING, Any from sqlalchemy import ( JSON, Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base from app.core.sql_types import GUID as Uuid if TYPE_CHECKING: from app.domains.asset.models import AssetSoftwareCatalog # ── Enumerations (kept as strings for migration friendliness) ── # action ACTION_GRANT = "grant" ACTION_REVOKE = "revoke" # target_type TARGET_SEAT = "seat" TARGET_PERMISSION = "permission" TARGET_API_KEY = "api_key" TARGET_HARDWARE = "hardware" # A revoked SSO grant whose IdP-side removal is a human action in a vendor # console (Google admin / Atlassian / Okta). Always ``manual_notify``; # completion REQUIRES evidence even on the one-click token page (the task's # ``context_data.requires_evidence`` flag). ``target_id`` is the # ``sso_grants.id``; ``software_id`` is NULL by design. See # ``app.domains.sso.removal`` (migration 603). TARGET_SSO_GRANT = "sso_grant" # source SOURCE_ONBOARDING = "onboarding" SOURCE_OFFBOARDING = "offboarding" SOURCE_ROLE_CHANGE = "role_change" SOURCE_REQUEST = "request" SOURCE_MANUAL = "manual" # Scheduled audit handler ('jira.leaver_account_audit', # 'jira.recently_revoked_audit') auto-remediating a drift it detected — # either a leaver whose offboarding revoke missed a group, or an # active employee whose admin-revoke was bounced back by SCIM / # default-group. Distinct from SOURCE_OFFBOARDING (intended human- # initiated revoke) so dashboards / dedup counters can tell the two # apart and a SCIM-driven persistent bouncer becomes self-evident in # the task history. SOURCE_AUDIT = "audit" # PR-2 of symmetric-onboarding (2026-06-01): admin applied a # ``ProvisioningBundle`` to a single onboarding process via the cockpit # detail page. Distinct from SOURCE_ONBOARDING (HRIS confirm fan-out) # so dashboards can tell bundle-driven catch-up from regular hire-day # provisioning, and so per-source quotas / dedup keys stay separable. SOURCE_BUNDLE_APPLY = "bundle_apply" # ``software_seat.idle_reclaim`` (2026-06-11): seat unused past # threshold + buffer, holder warned and didn't keep → auto-enqueued # ``product_access`` revoke. Distinct from SOURCE_AUDIT (drift # remediation of an already-revoked seat) and SOURCE_OFFBOARDING # (leaver) so the task history says WHY the seat went away. SOURCE_IDLE_RECLAIM = "idle_reclaim" # An asset left company ownership (buyback / sale / write-off) while a # device carrying its serial was still enrolled in an MDM, so the record # has to come out of the tenant. Distinct from SOURCE_OFFBOARDING on # purpose: MDM enrolment follows the DEVICE, and a departure on its own # never produces one of these — a returned laptop stays enrolled through # the handover. See ``mdm_device_retirement_coordinator``. SOURCE_ASSET_DISPOSAL = "asset_disposal" # execution_mode EXEC_AUTO_API = "auto_api" EXEC_MANUAL_NOTIFY = "manual_notify" EXEC_SSO_AUTO = "sso_auto" # status STATUS_PENDING = "pending" STATUS_IN_PROGRESS = "in_progress" STATUS_NOTIFIED = "notified" STATUS_COMPLETED = "completed" STATUS_FAILED = "failed" STATUS_CANCELLED = "cancelled" # Statuses a task never leaves. Lives here rather than in the service so # cross-domain callers (the MDM retirement coordinator, which must know # whether a cleanup todo is still open) can ask the question without # importing ProvisioningService and dragging its dependency graph — and # so there is exactly one definition of "done" to keep in step. TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({ STATUS_COMPLETED, STATUS_CANCELLED, }) # Signed one-click action-token purpose for the no-login "我已处理, # 标记完成" button in manual_notify notifications (see # ``app.core.security.create_action_token`` + the public # ``/provisioning/quick-complete`` route). ACTION_TOKEN_PURPOSE_MANUAL_COMPLETE = "provisioning.manual_complete" class ProvisioningTask(Base): """A single grant or revoke operation against an external system. The task is the atomic unit of work — if five software bundles are auto-provisioned at onboarding, five tasks are created. The scheduler or admin-triggered executor walks pending tasks and dispatches them. """ __tablename__ = "provisioning_tasks" __mapper_args__ = {"eager_defaults": True} 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, ) software_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("asset_software_catalog.id"), nullable=True, ) action: Mapped[str] = mapped_column(String(16), nullable=False) target_type: Mapped[str] = mapped_column(String(32), nullable=False) target_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True) source: Mapped[str] = mapped_column(String(32), nullable=False) execution_mode: Mapped[str] = mapped_column(String(16), nullable=False) status: Mapped[str] = mapped_column(String(16), nullable=False, default=STATUS_PENDING) assignee_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=True, ) external_ref: Mapped[str | None] = mapped_column(String(255)) error_message: Mapped[str | None] = mapped_column(Text) approval_request_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("approval_requests.id"), nullable=True, ) context_data: Mapped[dict[str, Any] | None] = mapped_column(JSON) attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # Defer execution until this UTC moment. NULL = no gate, run ASAP. # Offboarding writes ``leaver_date + 1 day at 00:00 local TZ → UTC`` # so the revoke fires AFTER the leaver's last working day ends in # their wall-clock zone. Onboarding writes ``hire_date at 00:00 local # TZ → UTC`` so account creation lands on day-1. The dispatcher's # SELECT filters ``not_before IS NULL OR not_before <= NOW()``; # ``execute_task`` defensively re-checks before dispatching to a # connector so a manually-pulled task can't bypass the gate either. # Indexed jointly with ``status`` for the dispatcher hot path # (migration 221, ix_provisioning_tasks_status_not_before). not_before: 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 ProvisioningBundle(Base): """Named group of ``ProvisionPolicy`` rows — "SDE Standard Pack" / "Sales Standard Pack" / etc. Bundles let IT apply a coherent kit to a single onboarding process in one click (cockpit detail page) instead of waiting for HRIS attribute-matching to fan-out N policies individually. Schema (migration 230, 2026-06-01 PR-2 symmetric-onboarding): * ``name`` UNIQUE * ``target_role_hint`` free-text — the role / job-title this bundle is designed for (e.g. "Software Engineer"). NOT a matching key — ``ProvisionPolicy``'s ``match_job_title`` is still the matching gate; this is operator-facing context. * ``is_active`` — inactive bundles hide from the apply dropdown but stay queryable for audit. Apply semantics: ``BundleService.apply_to_person`` creates a ``ProvisioningTask`` for every policy in this bundle that the person doesn't already have (target_type + target_id dedup). Does NOT auto-attach the policies as ProvisionPolicy rows on the person — that's a separate (rarely needed) flow. """ __tablename__ = "provisioning_bundles" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) description: Mapped[str | None] = mapped_column(Text) target_role_hint: Mapped[str | None] = mapped_column(String(128)) is_active: Mapped[bool] = mapped_column( Boolean, nullable=False, default=True, server_default="1", ) created_by_person_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id", ondelete="SET NULL"), ) 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 ProvisioningBundleMember(Base): """``provisioning_bundle_members`` — N×M join of bundle → policy. A policy may belong to multiple bundles (e.g. Slack policy is in every standard pack). Cascade-deletes when either parent is removed. """ __tablename__ = "provisioning_bundle_members" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( UniqueConstraint( "bundle_id", "policy_id", name="uq_pbm_bundle_policy", ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) bundle_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("provisioning_bundles.id", ondelete="CASCADE"), nullable=False, ) policy_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("provision_policies.id", ondelete="CASCADE"), nullable=False, ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) class ProvisionPolicy(Base): """Rule that says "everyone matching criteria (department, title, location) should have target Y". Evaluated when a new employee is onboarded (HRIS confirm_batch) or when the IT Admin manually triggers a reconcile. A match key set to NULL means "any value" (wildcard), so ``department_name=NULL, match_job_title=NULL, match_location=NULL`` matches every employee. Matching semantics: a rule matches the employee iff, for each of the three criteria, the rule's column is NULL OR equals the employee's corresponding attribute. Multiple rules may match one employee — the grant set is the UNION (dedup on target_type + target_id). Uniqueness: (department_name, match_job_title, match_location, target_type, target_id). """ __tablename__ = "provision_policies" __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) department_name: Mapped[str | None] = mapped_column(String(128)) match_job_title: Mapped[str | None] = mapped_column(String(128)) match_location: Mapped[str | None] = mapped_column(String(64)) target_type: Mapped[str] = mapped_column(String(32), nullable=False) target_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) notes: Mapped[str | None] = mapped_column(Text) created_by: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) # 2026-06-01 PR-2: optional pointer back to the bundle this policy # was cloned from. NULL on policies created before bundles existed # or on policies created standalone. ``ondelete=SET NULL`` so # retiring a bundle leaves the individual policies intact. bundle_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("provisioning_bundles.id", ondelete="SET NULL"), ) 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 SoftwareConnector(Base): """Binding between a software catalog entry and a provisioning integration. Schema as of migration 028: credentials live on the ``integrations`` row pointed to by ``integration_id``; this table only holds the *per-software* parameters that shape how the driver operates on behalf of this specific software (Google Group email, Feishu user group chat id, operation selector, ...) in ``binding_config``. One software has at most one binding; the driver is derived from the linked integration's ``(kind, provider)`` pair. """ __tablename__ = "software_connectors" __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, unique=True, ) integration_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("integrations.id"), nullable=False, ) # Per-software params rendered from the driver's ``binding_schema`` # on the admin form. Passed to the driver via # ``ProvisioningContext.extra`` at grant/revoke time. binding_config: Mapped[dict[str, Any] | None] = mapped_column(JSON) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) # NB: offboarding_policy moved to ``asset_software_catalog`` in # migration 033 — the policy is a property of the software, not of # the connector binding. SSO_CASCADE software doesn't even need a # connector row. last_success_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_error_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_error_message: Mapped[str | None] = mapped_column(Text) 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(), ) software: Mapped[AssetSoftwareCatalog] = relationship(lazy="joined")