Ctrl K
ComunidadGuías…

appdb/models.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# SPDX-License-Identifier: BUSL-1.1
# Copyright (c) 2024-2026 Ali Sadeghi Aghili
"""The application database's schema — SQLAlchemy Core ``Table`` objects.

Core, not the ORM: this codebase's existing SQLAlchemy usage
(``database/connection.py``, ``database/executor.py``) is Core-only
(``conn.exec_driver_sql``/``text()``), and the key store's access pattern
is simple row lookups and updates with no object graph worth mapping.
:data:`metadata` is what ``appdb/migrations/env.py`` points Alembic's
autogenerate at, and what a fresh SQLite deployment's tables are created
from directly (:func:`create_all`) without a migration tool in the loop at
all, matching ``session/persistence.py``'s own "create tables if missing,
unconditionally" behaviour for the zero-configuration path.

Two tables this phase needs:

``admin_api_keys``
    The key store (§3). One row per issued key, keyed by
    ``key_sha256`` — never the raw key (see ``security/auth.py``'s module
    docstring for why only the digest is ever persisted). Revocation is a
    tombstone (``revoked_at``, never a ``DELETE`` — §3.4): restoring this
    database to a point before a leaked key was revoked must not silently
    un-revoke it.

``admin_principal_roles``
    Role grants (§2) — ``(principal_id, capability)`` pairs. Presence of a
    row means the principal currently holds that capability; a revoke
    deletes the row. This table holds *current state* only — the
    who/when/why history of each grant and revoke lives in the separate,
    append-only admin-action log (:mod:`appdb.admin_audit`), not here,
    for the same reason that log is a file and not a database table (see
    this package's docstring).

Neither table names this deployment's warehouse in any way — both are
pure key/role bookkeeping, unconditionally the same shape regardless of
which warehouse this deployment queries.
"""

from __future__ import annotations

from sqlalchemy import (
    Column,
    Engine,
    Integer,
    MetaData,
    String,
    Table,
    Text,
    UniqueConstraint,
)

metadata = MetaData()

#: One row per issued API key. ``key_sha256`` is the primary key (lookups
#: are always by presented-key hash, and it is unique by construction --
#: two principals sharing a hash is exactly the ambiguity
#: ``security.auth._parse_api_keys`` already refuses for the environment
#: bootstrap path). ``principal_id`` is deliberately NOT unique: a
#: principal may hold more than one live key at once (rotation), and a
#: revoked row is never deleted (§3.4), so a principal issued a new key
#: after an old one was revoked would otherwise collide on a uniqueness
#: constraint that has nothing to do with the actual security question.
admin_api_keys = Table(
    "admin_api_keys",
    metadata,
    Column("key_sha256", String(64), primary_key=True),
    Column("principal_id", String(255), nullable=False),
    Column("name", String(255), nullable=False),
    #: JSON-encoded array of column names, matching
    #: ``API_KEYS_JSON``'s own ``denied_columns`` shape. Populated with
    #: *every* column this deployment's schema.yaml knows about at issue
    #: time (:mod:`appdb.key_store`'s restrictive default — see its
    #: docstring for why), never empty for a freshly issued key.
    Column("denied_columns_json", String, nullable=False),
    #: ``"issued"`` for a key minted via ``POST /admin/keys``, or
    #: ``"imported_from_env"`` for a row created once, at start-up, from
    #: an ``API_KEYS_JSON`` entry that predates this table (§3.3).
    #: Bookkeeping only -- both kinds behave identically once resolved.
    Column("source", String(32), nullable=False),
    Column("created_at", String(64), nullable=False),
    Column("updated_at", String(64), nullable=False),
    #: NULL while enabled. Set/cleared by ``POST /admin/keys/{id}/disable``
    #: and ``.../enable`` -- reversible, unlike ``revoked_at`` below.
    Column("disabled_at", String(64), nullable=True),
    #: NULL until revoked, then never cleared again (§3.4's tombstone --
    #: "never delete a key row"). A restore of this database to a point
    #: before this column was set must not un-revoke a key that leaked.
    Column("revoked_at", String(64), nullable=True),
)

#: Current role-grant state (§2). One row per ``(principal_id,
#: capability)`` pair a principal currently holds; a revoke deletes the
#: row rather than tombstoning it -- see the module docstring for why the
#: history lives in the admin-action log instead.
admin_principal_roles = Table(
    "admin_principal_roles",
    metadata,
    Column("principal_id", String(255), primary_key=True),
    Column("capability", String(32), primary_key=True),
    Column("granted_at", String(64), nullable=False),
    #: The principal id that authorised this grant, or ``"bootstrap"`` for
    #: a role that predates any grant (there is no such row today -- every
    #: environment-bootstrapped role is resolved fresh from
    #: ``API_KEYS_JSON`` on every request, per
    #: ``docs/admin-panel-architecture.md`` §2.3's "never from a web
    #: flow" -- this column exists for the day a migration tool backfills
    #: one, not because this phase writes it).
    Column("granted_by", String(255), nullable=False),
)

#: Phase 3: the ``project_config/`` bundle's version history (§1, §6 of
#: ``docs/admin-panel-architecture.md`` and the phase 3 spec). One row per
#: bundle version -- a full snapshot of all nine ``project_config/*.yaml``
#: files, never a diff, so restoring any depth is a read rather than a
#: replay (spec §1). ``version_id`` is a plain autoincrementing integer:
#: the *order* versions were created in is exactly the information a
#: rollback and an audit trail both need, and an integer sequence is the
#: simplest thing that can never disagree with itself about that order
#: (unlike a timestamp, which two versions created within the same clock
#: tick could tie on).
#:
#: ``status`` is one of ``"applied"``, ``"draft"``, or ``"rejected"``
#: (spec §3.1's propose-and-approve flow). The *active* version is always
#: the highest ``version_id`` whose ``status`` is ``"applied"`` -- there is
#: deliberately no separate "is this the active one" flag to keep in sync;
#: see :mod:`appdb.config_versions` for why that single rule is sufficient
#: even across restores and approvals.
#:
#: ``content_json`` holds the full bundle as a JSON object
#: (``{filename: yaml_text}``, one entry per file) -- the same "JSON text
#: in a String/Text column" shape :data:`admin_api_keys`'s own
#: ``denied_columns_json`` already uses, chosen for the same reason: every
#: backend this application supports (SQLite, PostgreSQL, SQL Server,
#: MySQL) has a text column, and a bespoke JSON column type would be one
#: more thing :mod:`appdb.migrations` would need a backend-specific
#: mapping for (see the architecture's §5.4 on why type mapping across
#: backends must be explicit rather than incidental).
config_bundle_versions = Table(
    "config_bundle_versions",
    metadata,
    Column("version_id", Integer, primary_key=True, autoincrement=True),
    Column("status", String(16), nullable=False),
    Column("content_json", Text, nullable=False),
    #: sha256 hex digest of the canonical (sorted-key) JSON form of the
    #: content -- a cheap identity check (e.g. "did this restore actually
    #: change anything") without re-parsing every file.
    Column("content_hash", String(64), nullable=False),
    #: The version this row was optimistically based on (spec §8) -- the
    #: version the editor/reviewer had open when they saved. NULL only for
    #: the very first, bootstrap version, which has no prior version to be
    #: based on.
    Column("based_on_version", Integer, nullable=True),
    #: Set when this version's content is a revert of an earlier one
    #: (spec §2/§6.1's "revert, never reset") -- the version *number* that
    #: was restored from, never mutated itself.
    Column("restored_from_version", Integer, nullable=True),
    #: The single filename that was restored, when this version is a
    #: *per-file* restore (spec §1's "a single file may be restored from
    #: any version") rather than a restore of the whole bundle. NULL for a
    #: whole-bundle restore or an ordinary multi-file edit.
    Column("restored_file", String(64), nullable=True),
    Column("created_at", String(64), nullable=False),
    Column("created_by", String(255), nullable=False),
    #: Which capability authorised creating this version -- "operations"
    #: or "security" (never omitted, mirroring
    #: :attr:`appdb.admin_audit.AdminActionRecord.authorised_by`'s own
    #: reasoning: one principal may hold both roles at once).
    Column("created_by_capability", String(32), nullable=False),
    #: NULL until a draft is approved or rejected; the security admin who
    #: did so, thereafter (spec §3.1).
    Column("reviewed_by", String(255), nullable=True),
    Column("reviewed_at", String(64), nullable=True),
    Column("review_note", Text, nullable=True),
    #: Precomputed at creation time (never recomputed later, so a
    #: subsequent version's own diff/dry-run can never retroactively
    #: change what an earlier draft showed its reviewer): the structural
    #: diff (spec §4.3) and the offline dry-run summary (spec §5), each as
    #: a JSON object. NULL only for the bootstrap version, which has no
    #: prior version to diff or dry-run against.
    Column("diff_json", Text, nullable=True),
    Column("dry_run_json", Text, nullable=True),
)


#: Phase 4: wrong-answer feedback and its triage (spec §2, §3;
#: ``docs/admin-panel-architecture.md`` §3's tier-1 "closes a loop nothing
#: else closes"). One row per flag an analyst raised on one of their own
#: turns.
#:
#: Deliberately does NOT carry the question, the generated SQL, or any
#: result data -- spec §2.2: those already exist, keyed by the same
#: ``session_id``/``turn_id``, in the analyst audit log
#: (``observability/audit.py``, whose ``session_id``/``turn_id`` fields
#: exist precisely so this join is possible). Duplicating them here would
#: create a second place for the same content to leak from and a second
#: thing to keep consistent, and would put row-adjacent question text in
#: yet another store. ``request_id`` and ``config_version_id`` are resolved
#: from that same audit record at flag time (see :mod:`appdb.feedback`) and
#: stored here only as identifiers -- the same "an id, never the content it
#: names" category ``observability.audit.AuditRecord.session_id`` already
#: is.
turn_feedback = Table(
    "turn_feedback",
    metadata,
    Column("feedback_id", Integer, primary_key=True, autoincrement=True),
    Column("session_id", String(64), nullable=False),
    Column("turn_id", String(64), nullable=False),
    #: The audit record's own request id, resolved by joining on
    #: (session_id, turn_id) at flag time -- never supplied by the client.
    #: NULL only if no audit record could be found for this turn (e.g. the
    #: audit log has since rotated past it).
    Column("request_id", String(64), nullable=True),
    Column("reporter_principal_id", String(255), nullable=False),
    #: One of :data:`appdb.feedback.FEEDBACK_CATEGORIES`.
    Column("category", String(32), nullable=False),
    #: The analyst's optional free-text note (spec §2.1) -- never the
    #: question or the SQL, which the join above already supplies.
    Column("note", Text, nullable=True),
    #: The configuration version (:mod:`appdb.config_versions`) active when
    #: this turn's answer was produced, resolved from the audit record --
    #: never the version active at flag time, which may already differ.
    #: NULL when the audit record predates this field or carries no value.
    Column("config_version_id", Integer, nullable=True),
    Column("created_at", String(64), nullable=False),
    #: ``"open"`` until triaged, then ``"resolved"`` -- spec §3's "a triage
    #: queue that lets items vanish without a decision becomes an inbox
    #: nobody opens": there is no third state and no delete.
    Column("status", String(16), nullable=False),
    #: One of :data:`appdb.feedback.RESOLUTION_OUTCOMES`, set only once
    #: ``status`` becomes ``"resolved"``.
    Column("resolution_outcome", String(32), nullable=True),
    #: Free-text reason -- required for ``"not_a_defect"`` (spec §3.1:
    #: "recorded with a reason, not silently dropped").
    Column("resolution_note", Text, nullable=True),
    #: For ``"alias_fix"``/``"rule_fix"`` outcomes: the id of an existing
    #: ``config_bundle_versions`` row the admin made the actual edit
    #: through (``POST /admin/config/versions`` -- phase 3, unchanged by
    #: this phase) and is linking here for provenance. This table never
    #: creates or applies a configuration version itself -- see
    #: :mod:`appdb.feedback`'s module docstring for why that is what makes
    #: "nothing auto-applies" (spec §3.2) trivially true rather than merely
    #: asserted.
    Column("resolution_config_version_id", Integer, nullable=True),
    #: For the ``"golden_case"`` outcome: the id assigned to the case
    #: written to the golden-set file (spec §4). NULL for every other
    #: outcome.
    Column("resolution_golden_case_id", String(255), nullable=True),
    Column("resolved_by", String(255), nullable=True),
    Column("resolved_at", String(64), nullable=True),
)


#: ADR-004 part 1: "Request access" from a denied-column guard rejection
#: (``docs/design/DESIGN-INVARIANTS.md`` §8's failure-anatomy table). One
#: row per request an analyst raised for one denied column.
#:
#: Deliberately does NOT carry the question or the SQL -- the same "an id,
#: never the content it names" reasoning :data:`turn_feedback` already
#: documents above: ``session_id``/``turn_id`` let a reviewer join back to
#: the audit record (``observability/audit.py``) for the question, and
#: ``column_name`` is resolved from that same audit record's
#: ``guard.subject`` at submit time (:mod:`appdb.access_requests`) --
#: never accepted from the client, so this column can never be forged into
#: naming a column the turn was never actually denied.
access_requests = Table(
    "access_requests",
    metadata,
    Column("request_id", Integer, primary_key=True, autoincrement=True),
    Column("session_id", String(64), nullable=False),
    Column("turn_id", String(64), nullable=False),
    #: Stamped server-side from the authenticated principal -- never
    #: accepted from the request body (see :mod:`appdb.access_requests`).
    Column("requester_principal_id", String(255), nullable=False),
    #: Resolved server-side from the joined audit record's
    #: ``guard.subject`` -- see the table comment above.
    Column("column_name", String(255), nullable=False),
    Column("created_at", String(64), nullable=False),
    #: ``"open"`` until a security admin decides it, then ``"approved"``
    #: or ``"denied"`` -- no third state, and no delete, mirroring
    #: :data:`turn_feedback.status`'s own "no dismiss without a decision".
    Column("status", String(16), nullable=False),
    #: Free-text reason. Required (non-blank) for a ``"denied"``
    #: resolution -- visible to the requester, never to another analyst.
    #: NULL for an open or approved request.
    Column("resolution_note", Text, nullable=True),
    Column("resolved_by", String(255), nullable=True),
    Column("resolved_at", String(64), nullable=True),
)


#: "This answer is correct" (پاسخ درست است): one analyst's confirmation of
#: one turn's answer. The positive counterpart of :data:`turn_feedback`,
#: and bound by the same rule: **identifiers only**. A row never carries
#: the question, the SQL or any result data -- those already exist, keyed
#: by the same ``session_id``/``turn_id``, in the analyst audit log
#: (``observability/audit.py``), and ``request_id`` is resolved from that
#: audit record at confirmation time (:mod:`appdb.confirmations`), never
#: accepted from the client.
#:
#: At most one row per ``(session_id, turn_id, principal_id)`` -- enforced
#: by the unique constraint below, not only by the module's
#: select-then-insert, so two concurrent clicks cannot store two rows.
#: Withdrawing a confirmation deletes the row (current state only, like
#: :data:`admin_principal_roles`): there is no tombstone, because nothing
#: downstream needs the history of a click that was taken back.
turn_confirmations = Table(
    "turn_confirmations",
    metadata,
    Column("confirmation_id", Integer, primary_key=True, autoincrement=True),
    Column("session_id", String(64), nullable=False),
    Column("turn_id", String(64), nullable=False),
    #: Stamped server-side from the authenticated principal -- never
    #: accepted from the request body.
    Column("principal_id", String(255), nullable=False),
    #: The audit record's own request id, resolved by joining on
    #: (session_id, turn_id) when the confirmation was stored.
    Column("request_id", String(64), nullable=True),
    Column("created_at", String(64), nullable=False),
    UniqueConstraint(
        "session_id", "turn_id", "principal_id", name="uq_turn_confirmations_turn_principal",
    ),
)


def create_all(engine: Engine) -> None:
    """Create every table in :data:`metadata` that does not already exist.

    ``checkfirst=True`` (the default) makes this the SQLAlchemy-Core
    equivalent of ``session/persistence.py``'s pre-Alembic ``CREATE TABLE
    IF NOT EXISTS`` -- the zero-configuration path (unset ``APP_DB_URL``,
    a SQLite file) needs no migration tool at all. A deployment on a
    managed backend that goes through Alembic instead
    (``appdb/migrations/``) never calls this directly; Alembic's own
    ``env.py`` points at this same :data:`metadata` so the two paths can
    never define the schema differently.
    """
    metadata.create_all(engine)