fischer-agentkit/src/agentkit/server/auth/models.py

855 lines
35 KiB
Python

"""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"],
}