"""ORM models for the External API feature (migration 289). Three tables, deliberately parallel to (but independent of) the identity-consumer credential store — the two are different products and must not share a registry: * ``api_clients`` — one row per registered external system. Carries the authorization config (scopes, rate limit) so every gated route enforces the same grant from one row. * ``api_client_tokens`` — ``helix_api_`` credentials (keyed SHA-256 fingerprints at rest; legacy unkeyed rows upgrade on successful use). A client may hold several live tokens for overlap rotation. * ``api_client_usage_daily`` — per-(client, UTC day) request counter for the admin console. """ 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 CLIENT_STATUS_ACTIVE = "active" CLIENT_STATUS_DISABLED = "disabled" class ApiClient(Base): __tablename__ = "api_clients" __table_args__ = ( UniqueConstraint("slug", name="uq_api_clients_slug"), Index("idx_api_clients_status", "status"), ) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) #: Human-readable display name ("BI 报表平台"). name: Mapped[str] = mapped_column(String(128), nullable=False) #: Stable machine identifier — lands in audit rows / logs / rate-limit #: keys, so it must never change after creation (service-layer guard). slug: Mapped[str] = mapped_column(String(64), nullable=False) description: Mapped[str | None] = mapped_column(Text) #: The accountable human. Every credential answers to an in-office #: owner who can be asked "why does this system call our API". 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 client fails auth immediately. status: Mapped[str] = mapped_column( String(16), nullable=False, default=CLIENT_STATUS_ACTIVE, server_default=CLIENT_STATUS_ACTIVE, ) #: JSON list[str] ⊆ scopes.KNOWN_SCOPES — the granted capabilities. scopes: Mapped[list[str]] = mapped_column(JSON, nullable=False) #: Per-client request cap per 60s window. NULL = platform default #: (``settings.EXTERNAL_API_RATE_LIMIT_PER_MINUTE``). rate_limit_per_minute: Mapped[int | None] = mapped_column(Integer) #: Cloud resource whitelist — ``{"providers": [...], #: "subscription_ids": [...]}``, the two lists forming a UNION. #: ``NULL`` = unrestricted (the behaviour every client had before #: migration 470, so the column is additive and non-breaking). #: Orthogonal to ``scopes``: the scope says WHAT KIND of cloud data #: may be read, this says WHICH cloud accounts. Enforced by every #: ``/open/cloud/...`` route via #: :mod:`app.domains.cmdb.open_visibility`; shape validation lives in #: :mod:`app.api_clients.visibility`. cloud_visibility: Mapped[dict | None] = mapped_column(JSON) #: Person-population ceiling — a ``FilterConfig`` payload (the same #: vocabulary the identity-consumer pull surface uses: #: departments / countries / employment_statuses / person_ids / …). #: ``NULL`` = unrestricted (the behaviour every client had before #: migration 513, so the column is additive and non-breaking). #: Orthogonal to ``scopes`` exactly like ``cloud_visibility``: the #: scope says WHAT KIND of people data may be read, this says WHICH #: people. Enforced by the people routes; shape validation lives in #: :mod:`app.api_clients.person_visibility`. person_visibility: Mapped[dict | None] = mapped_column(JSON) 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[ApiClientToken]] = relationship( back_populates="client", lazy="selectin", order_by="ApiClientToken.created_at.desc()", ) def __repr__(self) -> str: return f"" class ApiClientToken(Base): __tablename__ = "api_client_tokens" __table_args__ = ( UniqueConstraint("token_hash", name="uq_api_client_tokens_hash"), Index("idx_api_client_tokens_client", "client_id"), ) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) client_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False, ) name: Mapped[str] = mapped_column(String(120), nullable=False) #: Domain-separated HMAC-SHA-256(plaintext) hex. Legacy SHA-256 rows are #: accepted once and upgraded by ``ApiClientTokenService.authenticate``. token_hash: Mapped[str] = mapped_column(String(64), nullable=False) #: ``helix_api_`` + first 2 secret chars — safe to display. token_prefix: Mapped[str] = mapped_column(String(16), nullable=False) 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(), ) client: Mapped[ApiClient] = relationship(back_populates="tokens") def __repr__(self) -> str: return f"" class ApiClientUsageDaily(Base): __tablename__ = "api_client_usage_daily" __table_args__ = ( UniqueConstraint( "client_id", "usage_date", name="uq_api_client_usage_day", ), ) __mapper_args__ = {"eager_defaults": True} id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) client_id: Mapped[uuid.UUID] = mapped_column( Uuid, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False, ) 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)) __all__ = [ "CLIENT_STATUS_ACTIVE", "CLIENT_STATUS_DISABLED", "ApiClient", "ApiClientToken", "ApiClientUsageDaily", ]