956 lines
39 KiB
Python
956 lines
39 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 Awaitable, Callable, 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()
|
||
|
||
|
||
def resolve_auth_db_path() -> Path:
|
||
"""Lazily resolve the auth DB path from env var at call time.
|
||
|
||
``DEFAULT_AUTH_DB_PATH`` is captured at module-import time and cannot
|
||
see test-time ``AGENTKIT_AUTH_DB`` mutations. This helper re-reads the
|
||
env var on every call so tests can ``monkeypatch.setenv`` before
|
||
constructing a provider/service.
|
||
"""
|
||
env = os.environ.get("AGENTKIT_AUTH_DB")
|
||
return Path(env) if env else DEFAULT_AUTH_DB_PATH
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
);
|
||
|
||
-- V5: user_idp_links — 多 IDP 信任边界 (R1a, U4 SSO 集成)。
|
||
-- 按 (idp_name, idp_subject) 主键关联本地用户,禁止邮箱合并 (R1a)。
|
||
-- 单用户可关联多个 IDP(如飞书 + Azure AD 并存),但每个 IDP sub 仅映射一行。
|
||
CREATE TABLE IF NOT EXISTS user_idp_links (
|
||
idp_name TEXT NOT NULL,
|
||
idp_subject TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
idp_type TEXT NOT NULL DEFAULT 'oidc',
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY (idp_name, idp_subject)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_user_idp_links_user_id ON user_idp_links(user_id);
|
||
|
||
-- V5: auth_audit_log — RBAC 角色变更 + deprovisioning 审计 (KTD-8, U4)。
|
||
-- 记录 actor/target/role_before/role_after/reason/source/timestamp。
|
||
-- 接入 OTel 审计通道(audit.py 写入本表 + 发 OTel span event)。
|
||
CREATE TABLE IF NOT EXISTS auth_audit_log (
|
||
id TEXT PRIMARY KEY,
|
||
event_type TEXT NOT NULL,
|
||
actor_user_id TEXT,
|
||
actor_username TEXT,
|
||
target_user_id TEXT,
|
||
target_username TEXT,
|
||
role_before TEXT,
|
||
role_after TEXT,
|
||
reason TEXT,
|
||
source TEXT NOT NULL DEFAULT 'manual',
|
||
created_at TEXT NOT NULL
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_auth_audit_log_target ON auth_audit_log(target_user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_auth_audit_log_created_at ON auth_audit_log(created_at);
|
||
"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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.
|
||
#
|
||
# V5 (2026-07-06, U4 SSO 集成): added user_idp_links (多 IDP 信任边界 R1a)
|
||
# + auth_audit_log (RBAC 角色变更 + deprovisioning 审计 KTD-8).
|
||
# No backfill needed — additive.
|
||
_SCHEMA_VERSION = 5
|
||
|
||
_META_SCHEMA_VERSION_KEY = "schema_version"
|
||
|
||
|
||
# MIG-2: 显式 schema 迁移注册表 — 按 schema_version 差值执行 ALTER TABLE。
|
||
# 新增列/修改列时在此追加 migration 函数(每个必须 idempotent)。
|
||
# 当前 V5 全为 additive(CREATE TABLE IF NOT EXISTS),无 ALTER TABLE 迁移。
|
||
# 升级路径示例:V5→V6 新增列 → 在此注册 `6: _migrate_v5_to_v6`。
|
||
# type: ignore[misc] — dict value 类型异构(不同 migration 签名一致)
|
||
_SCHEMA_MIGRATIONS: dict[int, "Callable[[aiosqlite.Connection], Awaitable[None]]"] = {}
|
||
|
||
|
||
async def _run_schema_migrations(db: aiosqlite.Connection, current_version: int) -> None:
|
||
"""MIG-2: 按 schema_version 差值顺序执行迁移。
|
||
|
||
每个 migration 函数必须 idempotent(可重复执行不报错)。
|
||
升级路径:current_version+1 → ... → _SCHEMA_VERSION。
|
||
"""
|
||
for v in range(current_version + 1, _SCHEMA_VERSION + 1):
|
||
migration = _SCHEMA_MIGRATIONS.get(v)
|
||
if migration is not None:
|
||
await migration(db)
|
||
logger.info(f"Auth DB migration V{v - 1}→V{v} applied")
|
||
|
||
|
||
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):
|
||
# MIG-2: 显式运行 schema_version 差值迁移(ALTER TABLE 等)
|
||
current_int = int(current) if current else 0
|
||
if current_int < _SCHEMA_VERSION:
|
||
await _run_schema_migrations(db, current_int)
|
||
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"],
|
||
}
|
||
|
||
|
||
def idp_link_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, object]:
|
||
"""Convert a ``user_idp_links`` row into a JSON-safe dict (U4 SSO)."""
|
||
return {
|
||
"idp_name": row["idp_name"],
|
||
"idp_subject": row["idp_subject"],
|
||
"user_id": row["user_id"],
|
||
"idp_type": row["idp_type"],
|
||
"created_at": row["created_at"],
|
||
}
|
||
|
||
|
||
def audit_log_row_to_dict(row: aiosqlite.Row | Mapping[str, object]) -> dict[str, object]:
|
||
"""Convert an ``auth_audit_log`` row into a JSON-safe dict (U4 审计)."""
|
||
return {
|
||
"id": row["id"],
|
||
"event_type": row["event_type"],
|
||
"actor_user_id": row["actor_user_id"],
|
||
"actor_username": row["actor_username"],
|
||
"target_user_id": row["target_user_id"],
|
||
"target_username": row["target_username"],
|
||
"role_before": row["role_before"],
|
||
"role_after": row["role_after"],
|
||
"reason": row["reason"],
|
||
"source": row["source"],
|
||
"created_at": row["created_at"],
|
||
}
|