"""SQLAlchemy 2 user / API-key / session models for the auth subsystem. V1 uses SQLite (via aiosqlite) for zero-config local deployments. UUIDs are stored as ``String(36)`` so the same schema works on both SQLite and PostgreSQL without dialect-specific types. Use :func:`init_auth_db` to create the tables on startup. Schema versioning ----------------- The :data:`_SCHEMA_VERSION` constant tracks the current auth DB schema. The :func:`_backfill_user_sessions` one-time migration is gated on this version to ensure idempotency. After a successful backfill the version is stored in the ``auth_meta`` table so subsequent restarts are no-ops. V2 additions (2026-06-20, Centralized Auth & Token Persistence): - New ``auth_sessions`` table with full device/IP/audit metadata and an ``auth_provider`` column for future IdP integration traceability. - New ``auth_meta`` table for storing schema version + migration state. """ from __future__ import annotations import json import logging import os from collections.abc import Mapping from datetime import datetime, timezone from pathlib import Path import aiosqlite from sqlalchemy import String from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column logger = logging.getLogger(__name__) # Default auth DB path: project-local data/auth.db # (mirrors portal.py's pattern of using Path(__file__).parents[4] / "data" / ...) _PROJECT_ROOT = Path(__file__).parents[4] DEFAULT_AUTH_DB_PATH = Path(os.environ.get("AGENTKIT_AUTH_DB", _PROJECT_ROOT / "data" / "auth.db")) def _now_iso() -> str: """Return current UTC time as ISO 8601 string.""" return datetime.now(timezone.utc).isoformat() # --------------------------------------------------------------------------- # SQLAlchemy 2 declarative models # --------------------------------------------------------------------------- class Base(DeclarativeBase): """Declarative base for auth models.""" class UserModel(Base): """User account record. Attributes mirror the V1 user schema: UUID id, unique username/email, bcrypt password hash, role, active flag, terminal-authorization flags, and audit timestamps. ``created_by`` is a self-reference (nullable) used when an admin creates another user. """ __tablename__ = "users" id: Mapped[str] = mapped_column(String(36), primary_key=True) username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) password_hash: Mapped[str] = mapped_column(String(255), nullable=False) role: Mapped[str] = mapped_column(String(32), nullable=False, default="member") is_active: Mapped[bool] = mapped_column(default=True, nullable=False) is_terminal_authorized: Mapped[bool] = mapped_column(default=False, nullable=False) is_server_terminal_authorized: Mapped[bool] = mapped_column(default=False, nullable=False) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) updated_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) last_login_at: Mapped[str | None] = mapped_column(String(64), nullable=True) created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) class UserApiKeyModel(Base): """Per-user API key (issued via ``agentkit pair`` or UI). The full key is never stored — only its SHA-256 hash. ``key_prefix`` is the first 16 chars of the plaintext key, shown to users for identification. """ __tablename__ = "user_api_keys" id: Mapped[str] = mapped_column(String(36), primary_key=True) user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) key_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True) key_prefix: Mapped[str] = mapped_column(String(16), nullable=False) name: Mapped[str | None] = mapped_column(String(64), nullable=True) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) last_used_at: Mapped[str | None] = mapped_column(String(64), nullable=True) expires_at: Mapped[str | None] = mapped_column(String(64), nullable=True) is_revoked: Mapped[bool] = mapped_column(default=False, nullable=False) class UserSessionModel(Base): """Refresh-token session record (V1, deprecated — see :class:`AuthSessionModel`). Stores the SHA-256 hash of the refresh token (never the plaintext). ``revoked_at`` is set on logout / forced revocation. .. deprecated:: Kept for one minor version (per U10 back-compat shim) so legacy clients holding JWTs without ``sid`` claim can still validate. New code should use :class:`AuthSessionModel` (table ``auth_sessions``) which carries full device/IP/audit metadata. """ __tablename__ = "user_sessions" id: Mapped[str] = mapped_column(String(36), primary_key=True) user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) refresh_token_hash: Mapped[str] = mapped_column( String(64), unique=True, nullable=False, index=True ) device_info: Mapped[str] = mapped_column(String(2048), nullable=False, default="{}") created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) expires_at: Mapped[str] = mapped_column(String(64), nullable=False) revoked_at: Mapped[str | None] = mapped_column(String(64), nullable=True) class AuthSessionModel(Base): """Server-side session record (V2, the primary session table going forward). Each row corresponds to a single refresh-token issuance. The full JWT session id (``sid`` claim) is the row's ``id`` (UUID string). V2 fields (vs V1 ``user_sessions``): - ``device_fingerprint`` / ``device_label``: surfaces "which device is this session" in the admin UI. - ``ip`` / ``user_agent``: audit trail. - ``last_active_at``: updated on every successful refresh, used to display "last seen" in the sessions list. - ``revoked`` (0/1) + ``revoked_reason``: explicit revoked-state machine with machine-readable reasons (``user_terminated``, ``password_changed``, ``admin_revoked``, ``reuse_detected``, ``session_cap_eviction``). - ``previous_session_id``: back-pointer to the previous session id, written on refresh rotation, for audit trail. - ``auth_provider``: the AuthProvider that issued this session (``local`` / ``oidc-stub`` / future ``oidc-keycloak`` / ``saml`` / ``ldap``). Enables admin "list sessions by provider" queries and audit traceability. """ __tablename__ = "auth_sessions" id: Mapped[str] = mapped_column(String(36), primary_key=True) user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) refresh_token_hash: Mapped[str] = mapped_column( String(64), unique=True, nullable=False, index=True ) device_fingerprint: Mapped[str] = mapped_column(String(128), nullable=False, default="unknown") device_label: Mapped[str] = mapped_column(String(256), nullable=False, default="Unknown device") ip: Mapped[str] = mapped_column(String(64), nullable=False, default="") user_agent: Mapped[str] = mapped_column(String(512), nullable=False, default="") auth_provider: Mapped[str] = mapped_column( String(32), nullable=False, default="local", index=True ) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) last_active_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) expires_at: Mapped[str] = mapped_column(String(64), nullable=False) revoked: Mapped[bool] = mapped_column(default=False, nullable=False, index=True) revoked_reason: Mapped[str | None] = mapped_column(String(64), nullable=True) previous_session_id: Mapped[str | None] = mapped_column(String(36), nullable=True) class TerminalWhitelistUserModel(Base): """Per-user terminal command whitelist. Each row is a command prefix (e.g. ``"docker"``, ``"git push"``) that the user has approved for direct execution without confirmation. ``scope`` is ``"user"`` for the user-managed list. (Reserved for future ``"global"`` scope managed by admins — see U8.) """ __tablename__ = "terminal_whitelist_user" id: Mapped[str] = mapped_column(String(36), primary_key=True) user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) command_pattern: Mapped[str] = mapped_column(String(512), nullable=False) scope: Mapped[str] = mapped_column(String(16), nullable=False, default="user") created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) class TerminalBlocklistModel(Base): """Admin-managed terminal command blocklist. Commands matching any pattern here are always rejected, regardless of other whitelist membership. Patterns are matched as command-prefix (case-insensitive). """ __tablename__ = "terminal_blocklist" id: Mapped[str] = mapped_column(String(36), primary_key=True) command_pattern: Mapped[str] = mapped_column(String(512), nullable=False, unique=True) reason: Mapped[str | None] = mapped_column(String(512), nullable=True) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) created_by: Mapped[str | None] = mapped_column(String(36), nullable=True) class TerminalAuditLogModel(Base): """Audit log for every terminal command decision. ``decision`` is one of: ``executed`` (ran without confirmation), ``confirmed`` (ran after user confirmation), ``rejected`` (blocked by user or blocklist), ``denied`` (blocked by safety check). """ __tablename__ = "terminal_audit_logs" id: Mapped[str] = mapped_column(String(36), primary_key=True) user_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) username: Mapped[str | None] = mapped_column(String(64), nullable=True) session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) command: Mapped[str] = mapped_column(String(4096), nullable=False) decision: Mapped[str] = mapped_column(String(16), nullable=False) reason: Mapped[str | None] = mapped_column(String(512), nullable=True) cwd: Mapped[str | None] = mapped_column(String(1024), nullable=True) exit_code: Mapped[int | None] = mapped_column(nullable=True) terminal_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="local") created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) class TerminalApprovalModel(Base): """Approval request for a non-whitelisted server-terminal command. Lifecycle: - ``pending``: Created when a user runs a non-whitelisted command on the server terminal. Admins see it in their approval queue. - ``approved``: Admin approved → command executes. - ``rejected``: Admin rejected → command cancelled. - ``expired``: 5-minute timeout reached → auto-rejected. - ``cancelled``: User cancelled the request before admin acted. """ __tablename__ = "terminal_approvals" id: Mapped[str] = mapped_column(String(36), primary_key=True) user_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) username: Mapped[str] = mapped_column(String(64), nullable=False) session_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) command: Mapped[str] = mapped_column(String(4096), nullable=False) reason: Mapped[str | None] = mapped_column(String(512), nullable=True) status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending") reviewer_id: Mapped[str | None] = mapped_column(String(36), nullable=True) reviewer_username: Mapped[str | None] = mapped_column(String(64), nullable=True) review_note: Mapped[str | None] = mapped_column(String(512), nullable=True) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) reviewed_at: Mapped[str | None] = mapped_column(String(64), nullable=True) expires_at: Mapped[str] = mapped_column(String(64), nullable=False) # --------------------------------------------------------------------------- # V3: Department-scoped admin models (U1 — Admin Console) # --------------------------------------------------------------------------- class DepartmentModel(Base): """Department record (V3 — Admin Console). A department is the unit of resource isolation: skills, KB sources, and LLM quotas can be bound to a department. Users belong to one or more departments via :class:`UserDepartmentModel` (many-to-many); their effective permissions are the union of all departments they belong to. ``is_active=0`` disables a department: its users can still log in but cannot access department-bound resources. """ __tablename__ = "departments" id: Mapped[str] = mapped_column(String(36), primary_key=True) name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True) description: Mapped[str | None] = mapped_column(String(1024), nullable=True) is_active: Mapped[bool] = mapped_column(default=True, nullable=False) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) class UserDepartmentModel(Base): """Many-to-many association between users and departments (V3). Composite primary key ``(user_id, department_id)`` enforces uniqueness per (user, department) pair. A user with no rows here is a "global" user with no department-scoped permissions. """ __tablename__ = "user_departments" user_id: Mapped[str] = mapped_column(String(36), primary_key=True) department_id: Mapped[str] = mapped_column(String(36), primary_key=True) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) class DepartmentSkillBindingModel(Base): """Skill binding for a department (V3). Each row grants the department access to a named skill. ``skill_name`` references the skill registry identifier (not a DB FK — skills are defined in YAML configs). """ __tablename__ = "department_skill_bindings" id: Mapped[str] = mapped_column(String(36), primary_key=True) department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) skill_name: Mapped[str] = mapped_column(String(128), nullable=False) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) class DepartmentKbBindingModel(Base): """Knowledge-base source binding for a department (V3). Each row grants the department access to a KB source. ``kb_source_id`` references the KB source identifier (not a DB FK — KB sources are managed by the KB subsystem). """ __tablename__ = "department_kb_bindings" id: Mapped[str] = mapped_column(String(36), primary_key=True) department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) kb_source_id: Mapped[str] = mapped_column(String(128), nullable=False) created_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) class DepartmentQuotaModel(Base): """Quota configuration for a department (V3). Each row defines a single quota for a department. ``quota_type`` is one of ``token_limit`` / ``cost_limit`` / ``model_whitelist``. ``limit_value`` is stored as TEXT (JSON-encoded for complex types like model_whitelist, plain integer-as-string for simple limits). ``period`` is ``daily`` or ``monthly``. """ __tablename__ = "department_quotas" id: Mapped[str] = mapped_column(String(36), primary_key=True) department_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) quota_type: Mapped[str] = mapped_column(String(32), nullable=False) limit_value: Mapped[str] = mapped_column(String(1024), nullable=False) period: Mapped[str] = mapped_column(String(16), nullable=False, default="daily") updated_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) class SkillStateModel(Base): """Skill enable/disable state (V4 — Admin Console U6). Each row records whether a named skill has been disabled by an admin via the ``/admin/skills/{name}/disable`` endpoint. Skills with no row here are considered enabled (the default). ``skill_name`` references the skill registry identifier (not a DB FK — skills are defined in YAML configs). """ __tablename__ = "skill_states" skill_name: Mapped[str] = mapped_column(String(128), primary_key=True) is_disabled: Mapped[bool] = mapped_column(default=True, nullable=False) disabled_at: Mapped[str] = mapped_column(String(64), nullable=False, default=_now_iso) disabled_by: Mapped[str | None] = mapped_column(String(36), nullable=True) # --------------------------------------------------------------------------- # Schema DDL (kept in sync with the models above for aiosqlite bootstrap) # --------------------------------------------------------------------------- _SCHEMA_SQL = """ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', is_active INTEGER NOT NULL DEFAULT 1, is_terminal_authorized INTEGER NOT NULL DEFAULT 0, is_server_terminal_authorized INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, last_login_at TEXT, created_by TEXT ); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); CREATE TABLE IF NOT EXISTS user_api_keys ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, key_hash TEXT NOT NULL UNIQUE, key_prefix TEXT NOT NULL, name TEXT, created_at TEXT NOT NULL, last_used_at TEXT, expires_at TEXT, is_revoked INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_user_api_keys_user_id ON user_api_keys(user_id); CREATE INDEX IF NOT EXISTS idx_user_api_keys_key_hash ON user_api_keys(key_hash); CREATE TABLE IF NOT EXISTS user_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, refresh_token_hash TEXT NOT NULL UNIQUE, device_info TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, expires_at TEXT NOT NULL, revoked_at TEXT ); CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); CREATE INDEX IF NOT EXISTS idx_user_sessions_refresh_token_hash ON user_sessions(refresh_token_hash); -- V2: auth_sessions replaces user_sessions as the primary session table. -- Stores device/IP/audit metadata and auth_provider for IdP traceability. -- Per-row indexes are sized for the most common access patterns: -- * (user_id, revoked, expires_at) — cap-count, list-active, refresh-validate -- * expires_at — cleanup sweeps -- * refresh_token_hash — uniqueness + fast lookup on /auth/refresh -- * auth_provider — admin "list sessions by provider" queries CREATE TABLE IF NOT EXISTS auth_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, refresh_token_hash TEXT NOT NULL UNIQUE, device_fingerprint TEXT NOT NULL DEFAULT 'unknown', device_label TEXT NOT NULL DEFAULT 'Unknown device', ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '', auth_provider TEXT NOT NULL DEFAULT 'local', created_at TEXT NOT NULL, last_active_at TEXT NOT NULL, expires_at TEXT NOT NULL, revoked INTEGER NOT NULL DEFAULT 0, revoked_reason TEXT, previous_session_id TEXT ); CREATE INDEX IF NOT EXISTS idx_auth_sessions_user_id_active ON auth_sessions(user_id, revoked, expires_at); CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires_at ON auth_sessions(expires_at); CREATE INDEX IF NOT EXISTS idx_auth_sessions_auth_provider ON auth_sessions(auth_provider); -- V2: auth_meta stores schema version + migration completion markers. -- Used by init_auth_db to gate one-time migrations (idempotency). CREATE TABLE IF NOT EXISTS auth_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS terminal_whitelist_user ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, command_pattern TEXT NOT NULL, scope TEXT NOT NULL DEFAULT 'user', created_at TEXT NOT NULL, created_by TEXT ); CREATE INDEX IF NOT EXISTS idx_terminal_whitelist_user_user_id ON terminal_whitelist_user(user_id); CREATE TABLE IF NOT EXISTS terminal_blocklist ( id TEXT PRIMARY KEY, command_pattern TEXT NOT NULL UNIQUE, reason TEXT, created_at TEXT NOT NULL, created_by TEXT ); CREATE TABLE IF NOT EXISTS terminal_audit_logs ( id TEXT PRIMARY KEY, user_id TEXT, username TEXT, session_id TEXT NOT NULL, command TEXT NOT NULL, decision TEXT NOT NULL, reason TEXT, cwd TEXT, exit_code INTEGER, terminal_mode TEXT NOT NULL DEFAULT 'local', created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_user_id ON terminal_audit_logs(user_id); CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_session_id ON terminal_audit_logs(session_id); CREATE INDEX IF NOT EXISTS idx_terminal_audit_logs_created_at ON terminal_audit_logs(created_at); CREATE TABLE IF NOT EXISTS terminal_approvals ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, username TEXT NOT NULL, session_id TEXT NOT NULL, command TEXT NOT NULL, reason TEXT, status TEXT NOT NULL DEFAULT 'pending', reviewer_id TEXT, reviewer_username TEXT, review_note TEXT, created_at TEXT NOT NULL, reviewed_at TEXT, expires_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_terminal_approvals_user_id ON terminal_approvals(user_id); CREATE INDEX IF NOT EXISTS idx_terminal_approvals_session_id ON terminal_approvals(session_id); CREATE INDEX IF NOT EXISTS idx_terminal_approvals_status ON terminal_approvals(status); -- V3: Department-scoped admin tables (U1 — Admin Console). -- departments: top-level isolation unit. name is UNIQUE so admin UI can -- reference departments by name. is_active=0 disables a department without -- deleting it (users keep their user_departments rows but lose access to -- department-bound resources). CREATE TABLE IF NOT EXISTS departments ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT, is_active INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL ); -- V3: user_departments many-to-many. Composite PK (user_id, department_id) -- enforces uniqueness per pair. A user with no rows here is a "global" user. CREATE TABLE IF NOT EXISTS user_departments ( user_id TEXT NOT NULL, department_id TEXT NOT NULL, created_at TEXT NOT NULL, PRIMARY KEY (user_id, department_id) ); CREATE INDEX IF NOT EXISTS idx_user_departments_user_id ON user_departments(user_id); CREATE INDEX IF NOT EXISTS idx_user_departments_department_id ON user_departments(department_id); -- V3: department_skill_bindings grants a department access to a named skill. -- UNIQUE (department_id, skill_name) prevents duplicate bindings. CREATE TABLE IF NOT EXISTS department_skill_bindings ( id TEXT PRIMARY KEY, department_id TEXT NOT NULL, skill_name TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (department_id, skill_name) ); CREATE INDEX IF NOT EXISTS idx_department_skill_bindings_department_id ON department_skill_bindings(department_id); -- V3: department_kb_bindings grants a department access to a KB source. -- UNIQUE (department_id, kb_source_id) prevents duplicate bindings. CREATE TABLE IF NOT EXISTS department_kb_bindings ( id TEXT PRIMARY KEY, department_id TEXT NOT NULL, kb_source_id TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE (department_id, kb_source_id) ); CREATE INDEX IF NOT EXISTS idx_department_kb_bindings_department_id ON department_kb_bindings(department_id); -- V3: department_quotas stores per-department quota configuration. -- quota_type ∈ {token_limit, cost_limit, model_whitelist}. -- limit_value is TEXT (JSON for model_whitelist, integer-as-string for -- simple limits). period ∈ {daily, monthly}. UNIQUE (department_id, -- quota_type, period) ensures one row per quota type per period. CREATE TABLE IF NOT EXISTS department_quotas ( id TEXT PRIMARY KEY, department_id TEXT NOT NULL, quota_type TEXT NOT NULL, limit_value TEXT NOT NULL, period TEXT NOT NULL DEFAULT 'daily', updated_at TEXT NOT NULL, UNIQUE (department_id, quota_type, period) ); CREATE INDEX IF NOT EXISTS idx_department_quotas_department_id ON department_quotas(department_id); -- V4: skill_states records admin-disabled skills (U6 — Admin Console). -- A skill with no row here is considered enabled (the default). Only -- disabled skills have a row, with is_disabled=1. disabled_by records -- the admin user id who disabled the skill (audit trail). CREATE TABLE IF NOT EXISTS skill_states ( skill_name TEXT PRIMARY KEY, is_disabled INTEGER NOT NULL DEFAULT 1, disabled_at TEXT NOT NULL, disabled_by TEXT ); """ # --------------------------------------------------------------------------- # Schema versioning + one-time migrations # --------------------------------------------------------------------------- # Current auth DB schema version. Bump this when adding new tables/columns # that require data backfill or migration. The :func:`init_auth_db` function # uses this together with the ``auth_meta.schema_version`` row to decide # which migrations to run. # # V3 (2026-06-21, Admin Console): added departments, user_departments, # department_skill_bindings, department_kb_bindings, department_quotas. # No backfill needed — all new tables are additive. # # V4 (2026-06-21, Admin Console U6): added skill_states table for # admin-driven skill enable/disable. No backfill needed — additive. _SCHEMA_VERSION = 4 _META_SCHEMA_VERSION_KEY = "schema_version" async def _get_meta_value(db: aiosqlite.Connection, key: str) -> str | None: """Read a key from the ``auth_meta`` table. Returns ``None`` if missing.""" cursor = await db.execute("SELECT value FROM auth_meta WHERE key = ?", (key,)) row = await cursor.fetchone() return row["value"] if row else None async def _set_meta_value(db: aiosqlite.Connection, key: str, value: str) -> None: """Upsert a key in the ``auth_meta`` table.""" await db.execute( "INSERT INTO auth_meta (key, value, updated_at) VALUES (?, ?, ?) " "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at", (key, value, _now_iso()), ) async def _backfill_user_sessions(db: aiosqlite.Connection) -> int: """One-time backfill from ``user_sessions`` (V1) to ``auth_sessions`` (V2). Runs only when ``auth_sessions`` is empty AND ``user_sessions`` has rows. Idempotent: subsequent restarts are no-ops because we mark the backfill as completed in ``auth_meta``. For each non-revoked V1 session, copies: - id (reused — see note below) - user_id - refresh_token_hash - device_fingerprint / device_label / ip / user_agent from the legacy ``device_info`` JSON blob (best-effort) - created_at / expires_at - last_active_at defaults to created_at - revoked=0 (already filtered) - revoked_reason=None - auth_provider='local' (default; backfilled rows are pre-IdP) The original ``id`` is preserved so that legacy clients holding the old refresh_token_hash still match a row in the new table — this is what the back-compat path in U10 (``get_current_user`` for legacy JWTs) relies on. Returns: Number of rows backfilled (0 if already done or nothing to backfill). """ # Idempotency: check the marker if await _get_meta_value(db, "backfill_user_sessions_v1_to_v2") == "done": return 0 cursor = await db.execute("SELECT COUNT(*) FROM auth_sessions") (count,) = await cursor.fetchone() if count > 0: # auth_sessions already has data — this is a fresh V2 install, not an # upgrade. Mark the backfill done so we never re-check. await _set_meta_value(db, "backfill_user_sessions_v1_to_v2", "done") await db.commit() return 0 cursor = await db.execute( "SELECT id, user_id, refresh_token_hash, device_info, created_at, expires_at, revoked_at " "FROM user_sessions WHERE revoked_at IS NULL" ) rows = await cursor.fetchall() backfilled = 0 for row in rows: try: device_info = json.loads(row["device_info"]) if row["device_info"] else {} except (json.JSONDecodeError, TypeError): device_info = {} await db.execute( "INSERT OR IGNORE INTO auth_sessions " "(id, user_id, refresh_token_hash, device_fingerprint, device_label, " " ip, user_agent, auth_provider, created_at, last_active_at, expires_at, " " revoked, revoked_reason, previous_session_id) " "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( row["id"], # reuse legacy id for back-compat with old clients row["user_id"], row["refresh_token_hash"], device_info.get("fingerprint", "unknown"), device_info.get("label", "Unknown device"), device_info.get("ip", ""), device_info.get("user_agent", ""), "local", # backfilled rows are pre-IdP by definition row["created_at"], row["created_at"], # last_active_at defaults to created_at row["expires_at"], 0, # not revoked (already filtered) None, None, ), ) backfilled += 1 if backfilled: logger.info( f"Backfilled {backfilled} user_sessions rows to auth_sessions " f"(schema v{_SCHEMA_VERSION})" ) # Mark the backfill as completed regardless of how many rows were moved. # (idempotency: even a 0-row backfill is "done".) await _set_meta_value(db, "backfill_user_sessions_v1_to_v2", "done") await db.commit() return backfilled async def init_auth_db(db_path: str | Path | None = None) -> Path: """Create auth tables if they do not exist. Uses aiosqlite directly (no SQLAlchemy engine) for a lightweight, zero-config bootstrap that mirrors :class:`SqliteConversationStore`. On startup, this function: 1. Creates all tables and indexes from :data:`_SCHEMA_SQL` (idempotent). 2. Records the current :data:`_SCHEMA_VERSION` in ``auth_meta``. 3. Runs any pending one-time migrations (currently: V1 → V2 backfill from ``user_sessions`` to ``auth_sessions``). Args: db_path: Path to the SQLite file. Defaults to :data:`DEFAULT_AUTH_DB_PATH` (``data/auth.db`` under the project root, overridable via ``AGENTKIT_AUTH_DB`` env var). Returns: The resolved :class:`Path` to the auth DB file. """ path = Path(db_path) if db_path is not None else DEFAULT_AUTH_DB_PATH path.parent.mkdir(parents=True, exist_ok=True) async with aiosqlite.connect(str(path)) as db: db.row_factory = aiosqlite.Row await db.execute("PRAGMA journal_mode=WAL") # Set busy_timeout to prevent "database is locked" errors on # concurrent writes (waits up to 5 seconds before failing). await db.execute("PRAGMA busy_timeout = 5000") await db.executescript(_SCHEMA_SQL) # Record the current schema version (idempotent upsert). current = await _get_meta_value(db, _META_SCHEMA_VERSION_KEY) if current != str(_SCHEMA_VERSION): await _set_meta_value(db, _META_SCHEMA_VERSION_KEY, str(_SCHEMA_VERSION)) logger.info(f"Auth DB schema version set to {_SCHEMA_VERSION}") # Run pending migrations (each is internally idempotent). await _backfill_user_sessions(db) await db.commit() logger.info(f"Auth DB initialized at {path}") return path # --------------------------------------------------------------------------- # Row → dict helpers (used by routes to avoid exposing ORM internals) # --------------------------------------------------------------------------- def user_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, object]: """Convert a ``users`` row into a JSON-safe dict.""" return { "id": row["id"], "username": row["username"], "email": row["email"], "role": row["role"], "is_active": bool(row["is_active"]), "is_terminal_authorized": bool(row["is_terminal_authorized"]), "is_server_terminal_authorized": bool(row["is_server_terminal_authorized"]), "created_at": row["created_at"], "updated_at": row["updated_at"], "last_login_at": row["last_login_at"], "created_by": row["created_by"], } def auth_session_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, object]: """Convert an ``auth_sessions`` row into a JSON-safe dict. The ``revoked`` field is normalized to a Python ``bool`` (the DB stores 0/1). The full set of audit fields is included so the admin UI and API responses can surface device/IP/last-active information without a separate lookup. """ return { "id": row["id"], "user_id": row["user_id"], "device_fingerprint": row["device_fingerprint"], "device_label": row["device_label"], "ip": row["ip"], "user_agent": row["user_agent"], "auth_provider": row["auth_provider"], "created_at": row["created_at"], "last_active_at": row["last_active_at"], "expires_at": row["expires_at"], "revoked": bool(row["revoked"]), "revoked_reason": row["revoked_reason"], "previous_session_id": row["previous_session_id"], } def department_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, object]: """Convert a ``departments`` row into a JSON-safe dict. The ``is_active`` field is normalized to a Python ``bool`` (the DB stores 0/1). """ return { "id": row["id"], "name": row["name"], "description": row["description"], "is_active": bool(row["is_active"]), "created_at": row["created_at"], } def user_department_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, object]: """Convert a ``user_departments`` row into a JSON-safe dict.""" return { "user_id": row["user_id"], "department_id": row["department_id"], "created_at": row["created_at"], } def skill_state_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, object]: """Convert a ``skill_states`` row into a JSON-safe dict. The ``is_disabled`` field is normalized to a Python ``bool`` (the DB stores 0/1). ``disabled_by`` is ``None`` when the disabling admin is not recorded (e.g. legacy rows or system-initiated disables). """ return { "skill_name": row["skill_name"], "is_disabled": bool(row["is_disabled"]), "disabled_at": row["disabled_at"], "disabled_by": row["disabled_by"], }