"""ORM models for identity consumers + their service tokens (migration 277). Three tables: * ``identity_consumers`` — one row per registered external system. Carries the authorization config (scopes, person filter, rate limit) so every protocol surface (Directory REST, SCIM) enforces the same grants from the same row. * ``identity_consumer_tokens`` — ``helix_svc_`` credentials. A consumer may hold several live tokens at once so rotation has an overlap window (mint new → switch client → revoke old) with zero downtime. * ``identity_consumer_usage_daily`` — per-(consumer, day) request counter the admin console charts. Request-level detail goes to stdout structured logs; this table only keeps the cheap aggregate. """ from __future__ import annotations import uuid from datetime import date as date_type from datetime import datetime from sqlalchemy import ( JSON, Date, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, ) from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func from app.core.database import Base from app.core.sql_types import GUID as Uuid #: Scopes a consumer can be granted. Field-level semantics: #: ``directory.persons:read`` — person records with PII masked; #: ``directory.org:read`` — org nodes + person→org memberships; #: ``directory.pii:read`` — widens persons:read to plaintext PII #: (mobile_phone, personal_email). Never #: useful alone; the UI enforces it as an #: add-on to persons:read and the API #: endpoints all gate on the base scope. SCOPE_PERSONS_READ = "directory.persons:read" SCOPE_ORG_READ = "directory.org:read" SCOPE_PII_READ = "directory.pii:read" KNOWN_SCOPES: frozenset[str] = frozenset({ SCOPE_PERSONS_READ, SCOPE_ORG_READ, SCOPE_PII_READ, }) CONSUMER_STATUS_ACTIVE = "active" CONSUMER_STATUS_DISABLED = "disabled" #: Lifecycle of a self-service access REQUEST (distinct from the live #: consumer's status). A request is the application that, once approved #: in Feishu, materialises an ``active`` consumer + mints its first token. REQUEST_STATUS_PENDING = "pending_approval" REQUEST_STATUS_APPROVED = "approved" REQUEST_STATUS_REJECTED = "rejected" REQUEST_STATUS_CANCELLED = "cancelled" #: Approved in Feishu but provisioning failed (e.g. slug taken meanwhile) #: — terminal, surfaced to the requester with a reason so they re-apply. REQUEST_STATUS_FAILED = "failed" #: The Feishu approval template (flow_key → external_code seeded by #: migration 286). The applicant fills a rich Helix form; Helix creates #: the approval instance pre-filled and the approver decides in Feishu. CONSUMER_REQUEST_FLOW_KEY = "identity_consumer_request" class IdentityConsumer(Base): __tablename__ = "identity_consumers" __table_args__ = ( UniqueConstraint("slug", name="uq_identity_consumers_slug"), Index("idx_identity_consumers_status", "status"), ) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) #: Human-readable display name ("汇联易报销平台"). name: Mapped[str] = mapped_column(String(128), nullable=False) #: Stable machine identifier (``huilianyi``) — lands in audit rows, #: structured logs and rate-limit keys, so it must never change #: after creation (enforced at the service layer). slug: Mapped[str] = mapped_column(String(64), nullable=False) description: Mapped[str | None] = mapped_column(Text) #: The accountable human. Every credential must have an owner who #: answers "why does this system pull our people data" in an audit. owner_person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) #: ``active`` | ``disabled``. Disabled is the admin kill switch — #: every token of a disabled consumer fails auth immediately, #: without having to revoke them one by one (re-enable restores #: service with the same credentials). status: Mapped[str] = mapped_column( String(16), nullable=False, default=CONSUMER_STATUS_ACTIVE, server_default=CONSUMER_STATUS_ACTIVE, ) #: JSON list[str] ⊆ KNOWN_SCOPES — the coarse bundles. Endpoints #: still gate on these (persons/org/pii); ``granted_fields`` narrows #: *which* person fields the persons bundle actually exposes. scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False) #: JSON list[str] ⊆ field_catalog person field keys — the #: least-privilege projection the ``/persons`` serializer applies. #: Every key's bundle must be in ``scopes``. Backfilled from #: ``scopes`` for pre-field-level consumers (migration 285), so a #: consumer that only ever ticked bundles gets the full bundle set. granted_fields: Mapped[list[str]] = mapped_column( JSON, nullable=False, default=list, ) #: Person-population filter, same shape as the Identity Sync #: Center's ``FilterConfig`` (``app.domains.identity_sync.filters``) #: so admins configure pull targets with the exact semantics they #: already know from push targets. ``{}`` = active employees only #: (the FilterConfig default-safe behaviour). filter_config: Mapped[dict] = mapped_column(JSON, nullable=False) #: Per-consumer request cap per 60s fixed window. NULL = platform #: default (``settings.DIRECTORY_API_RATE_LIMIT_PER_MINUTE``). rate_limit_per_minute: Mapped[int | None] = mapped_column(Integer) #: Periodic re-certification (migration 287). ``last_certified_at`` is #: stamped when an auditor decides ``keep`` on this consumer's recert #: item; ``recert_lapsed_at`` is set when the lapse sweep auto-disables #: a consumer whose recert went undecided past the grace window, and #: cleared (consumer re-enabled) on a later ``keep`` — so a keep can #: distinguish a lapse-disable (re-enable) from an admin kill switch #: (leave disabled). last_certified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) recert_lapsed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_by: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) 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(), ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) tokens: Mapped[list[IdentityConsumerToken]] = relationship( back_populates="consumer", lazy="selectin", order_by="IdentityConsumerToken.created_at.desc()", ) def __repr__(self) -> str: return f"" class IdentityConsumerToken(Base): __tablename__ = "identity_consumer_tokens" __table_args__ = ( UniqueConstraint("token_hash", name="uq_identity_consumer_tokens_hash"), Index("idx_identity_consumer_tokens_consumer", "consumer_id"), ) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) # ondelete matches migration 277 — create_all (CI) and alembic # (prod) must build identical FK behaviour. consumer_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_consumers.id", ondelete="CASCADE"), nullable=False, ) #: Label so a consumer's token list reads like a rotation history #: ("initial", "rotation 2026-07"). name: Mapped[str] = mapped_column(String(120), nullable=False) #: SHA-256(plaintext) hex — same trade-off as PATs (160-bit random #: secret ⇒ no offline-dictionary risk; lookup must be O(1)). token_hash: Mapped[str] = mapped_column(String(64), nullable=False) #: ``helix_svc_`` + first 2 secret chars — safe to display. token_prefix: Mapped[str] = mapped_column(String(16), nullable=False) #: NULL = never expires. expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) revoked_by_user_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_used_ip: Mapped[str | None] = mapped_column(String(64)) created_by: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_persons.id"), ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) consumer: Mapped[IdentityConsumer] = relationship(back_populates="tokens") def __repr__(self) -> str: return f"" class IdentityConsumerUsageDaily(Base): __tablename__ = "identity_consumer_usage_daily" __table_args__ = ( UniqueConstraint( "consumer_id", "usage_date", name="uq_identity_consumer_usage_day", ), ) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) consumer_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_consumers.id", ondelete="CASCADE"), nullable=False, ) #: UTC calendar day — consumers are machines, no per-person TZ here. usage_date: Mapped[date_type] = mapped_column(Date, nullable=False) request_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default="0", ) last_request_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) class IdentityConsumerRequest(Base): """A self-service application to register an identity consumer. The applicant (a system owner) fills the Helix form; on submit Helix creates a Feishu approval instance pre-filled with the request and stores the correlation. The security/data-governance approver decides in Feishu; the callback (``request_service.handle_feishu_callback``) materialises the live ``IdentityConsumer`` + first token on approval. Kept separate from ``IdentityConsumer`` so the request history (who applied, when, decided by whom, why rejected) survives independently of the live credential config, and so a rejected/cancelled request never leaves a half-built consumer behind. """ __tablename__ = "identity_consumer_requests" __table_args__ = ( Index("idx_identity_consumer_requests_requester", "requester_person_id"), Index("idx_identity_consumer_requests_status", "status"), ) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) requester_person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) # The proposed consumer config — copied onto the live consumer on # approval. slug uniqueness is validated at submit (best-effort) and # again at provision time (authoritative). name: Mapped[str] = mapped_column(String(128), nullable=False) slug: Mapped[str] = mapped_column(String(64), nullable=False) description: Mapped[str | None] = mapped_column(Text) owner_person_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("identity_persons.id"), nullable=False, ) scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False) granted_fields: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) filter_config: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) rate_limit_per_minute: Mapped[int | None] = mapped_column(Integer) #: Chosen token-expiry preset in days; NULL = never expires. token_validity_days: Mapped[int | None] = mapped_column(Integer) #: Required when the grant includes any PII field — the approver's #: headline review item. pii_justification: Mapped[str | None] = mapped_column(Text) status: Mapped[str] = mapped_column( String(24), nullable=False, default=REQUEST_STATUS_PENDING, server_default=REQUEST_STATUS_PENDING, ) reject_reason: Mapped[str | None] = mapped_column(Text) #: Feishu correlation — instance_code links the approval callback #: back to this row; approval_request_id is the dispatcher's row. feishu_instance_code: Mapped[str | None] = mapped_column(String(128)) approval_request_id: Mapped[uuid.UUID | None] = mapped_column(Uuid) #: Set when provisioning succeeds — the live consumer this produced. consumer_id: Mapped[uuid.UUID | None] = mapped_column( Uuid, ForeignKey("identity_consumers.id"), ) #: Stamped when the one-time token-issue link mints the first token #: (migration 288). This is the AUTHORITATIVE single-use guard for the #: no-login issue link — independent of whether the minted token is #: later revoked, so an admin revoking the token does NOT re-open the #: no-login mint path, and concurrent POSTs can't both mint (the row #: is locked FOR UPDATE around the check+stamp). token_issued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) decided_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(), ) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) def __repr__(self) -> str: return f"" __all__ = [ "CONSUMER_REQUEST_FLOW_KEY", "CONSUMER_STATUS_ACTIVE", "CONSUMER_STATUS_DISABLED", "IdentityConsumer", "IdentityConsumerRequest", "IdentityConsumerToken", "IdentityConsumerUsageDaily", "KNOWN_SCOPES", "REQUEST_STATUS_APPROVED", "REQUEST_STATUS_CANCELLED", "REQUEST_STATUS_FAILED", "REQUEST_STATUS_PENDING", "REQUEST_STATUS_REJECTED", "SCOPE_ORG_READ", "SCOPE_PERSONS_READ", "SCOPE_PII_READ", ]