855 lines
35 KiB
Python
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"],
|
|
}
|