feat: add admin panel

This commit is contained in:
hh
2026-05-20 13:00:08 +02:00
parent 7970d4be9b
commit 0128191ac3
26 changed files with 2985 additions and 115 deletions
+40
View File
@@ -0,0 +1,40 @@
"""SQLModel-backed persistence (Phase 4.1).
The storage layer carries three tables — :class:`Token`, :class:`Session`,
:class:`AuditLog` — and a thin :class:`Database` wrapper around a sync
SQLAlchemy engine. Phase 4.2 (auth migration) and Phase 4.3 (admin UI)
build on this; Phase 4.1 itself only schemas the data and exposes the
``Database`` on :class:`GatewayRuntime` so later phases can reach it.
"""
from beaver_gateway.storage.db import (
Database,
append_audit,
close_session,
create_token,
list_active_tokens,
list_audit_records,
list_tokens,
revoke_token,
touch_session,
touch_token,
upsert_session,
)
from beaver_gateway.storage.models import AuditLog, Session, Token
__all__ = [
"AuditLog",
"Database",
"Session",
"Token",
"append_audit",
"close_session",
"create_token",
"list_active_tokens",
"list_audit_records",
"list_tokens",
"revoke_token",
"touch_session",
"touch_token",
"upsert_session",
]
+237
View File
@@ -0,0 +1,237 @@
"""Async ``Database`` wrapper + the bare-minimum CRUD helpers.
Async to match the rest of the stack (aiohttp, uvicorn, claude-code-api).
psycopg3 has native async support — ``postgresql+psycopg://...`` works
with ``create_async_engine`` directly. SQLite goes through ``aiosqlite``
(``sqlite+aiosqlite://...``); user-facing config still uses the plain
``sqlite:///`` form and we normalise the URL here, so nothing leaks into
``.env`` / docker-compose.
No repository layer (PLAN §4.1 explicitly waives it). Helpers take an
``AsyncSession`` so callers can batch operations into one transaction
(e.g. touch ``last_used_at`` + write an audit line on the same request).
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from sqlalchemy.ext.asyncio import create_async_engine
from sqlmodel import SQLModel, select
from sqlmodel.ext.asyncio.session import AsyncSession
from beaver_gateway.storage.models import AuditLog, Session, Token
if TYPE_CHECKING:
from collections.abc import Sequence
from sqlalchemy.ext.asyncio import AsyncEngine
def _utcnow() -> datetime:
return datetime.now(UTC)
def _to_async_url(url: str) -> str:
"""Promote the user-facing sync URL to its async driver variant.
Users write ``sqlite:///gw.db`` or ``postgresql://...`` in ``.env``;
we translate to ``sqlite+aiosqlite://`` / ``postgresql+psycopg://``
so they don't have to know which driver we use internally.
"""
if url.startswith(("sqlite+aiosqlite://", "postgresql+psycopg://")):
return url
if url.startswith("sqlite://"):
return "sqlite+aiosqlite://" + url[len("sqlite://") :]
if url.startswith("postgresql://"):
return "postgresql+psycopg://" + url[len("postgresql://") :]
if url.startswith("postgres://"):
return "postgresql+psycopg://" + url[len("postgres://") :]
return url
class Database:
"""Owner of the async SQLAlchemy engine.
Construct once in ``cli`` from ``settings.database_url``, ``await``
:meth:`create_all` at startup, hand the instance to
:class:`GatewayRuntime`. Frontends grab sessions via
:meth:`session` (an async context manager).
``echo=False`` keeps SQL out of INFO logs (admin UI is the
user-facing view); ``connect_args={"check_same_thread": False}``
isn't needed under async sqlite — aiosqlite already runs each
connection on a dedicated thread.
"""
__slots__ = ("_engine",)
def __init__(self, url: str) -> None:
self._engine: AsyncEngine = create_async_engine(_to_async_url(url), echo=False)
async def create_all(self) -> None:
"""Idempotent ``CREATE TABLE IF NOT EXISTS`` for every model."""
async with self._engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
def session(self) -> AsyncSession:
"""Open a fresh :class:`AsyncSession` (use as ``async with``).
Callers commit explicitly; we don't auto-commit on exit so a
request that fails mid-flight rolls back by simply not
committing.
"""
return AsyncSession(self._engine, expire_on_commit=False)
async def dispose(self) -> None:
"""Close the engine's connection pool. Idempotent."""
await self._engine.dispose()
# ---- Token CRUD ---------------------------------------------------------
async def list_active_tokens(session: AsyncSession) -> Sequence[Token]:
"""Return every non-revoked token (Phase 4.2 seeds the cache from this)."""
stmt = select(Token).where(Token.revoked_at.is_(None)) # ty: ignore[unresolved-attribute]
result = await session.exec(stmt)
return result.all()
async def list_tokens(
session: AsyncSession, *, include_revoked: bool = False
) -> Sequence[Token]:
"""Return tokens ordered newest-first (Phase 4.3 admin table)."""
stmt = select(Token).order_by(Token.created_at.desc()) # ty: ignore[unresolved-attribute]
if not include_revoked:
stmt = stmt.where(Token.revoked_at.is_(None)) # ty: ignore[unresolved-attribute]
result = await session.exec(stmt)
return result.all()
async def create_token(
session: AsyncSession, *, name: str, scope: str, hashed_value: str
) -> Token:
"""Persist a new token. Caller hashes the plaintext before passing it in."""
row = Token(name=name, scope=scope, hashed_value=hashed_value)
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def revoke_token(session: AsyncSession, *, token_id: int) -> bool:
"""Mark a token revoked. Returns ``False`` if no such row."""
row = await session.get(Token, token_id)
if row is None or row.revoked_at is not None:
return False
row.revoked_at = _utcnow()
session.add(row)
await session.commit()
return True
async def touch_token(session: AsyncSession, *, token_id: int) -> None:
"""Bump ``last_used_at``. Phase 4.2 batches these — not per-request."""
row = await session.get(Token, token_id)
if row is None:
return
row.last_used_at = _utcnow()
session.add(row)
await session.commit()
# ---- Session bookkeeping ------------------------------------------------
async def upsert_session(
session: AsyncSession, *, session_id: str, agent_name: str, fingerprint: str
) -> Session:
"""Insert-or-bump a Session row. Agent/fingerprint never change for an id."""
row = await session.get(Session, session_id)
if row is None:
row = Session(id=session_id, agent_name=agent_name, fingerprint=fingerprint)
else:
row.last_active_at = _utcnow()
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def touch_session(session: AsyncSession, *, session_id: str) -> None:
"""Bump ``last_active_at`` without changing fingerprint/agent."""
row = await session.get(Session, session_id)
if row is None:
return
row.last_active_at = _utcnow()
session.add(row)
await session.commit()
async def close_session(session: AsyncSession, *, session_id: str) -> bool:
"""Mark a session closed. Returns ``False`` if no such row."""
row = await session.get(Session, session_id)
if row is None or row.closed_at is not None:
return False
row.closed_at = _utcnow()
session.add(row)
await session.commit()
return True
# ---- Audit --------------------------------------------------------------
async def append_audit(
session: AsyncSession,
*,
actor: str,
kind: str,
agent_name: str | None = None,
detail: dict[str, Any] | None = None,
) -> AuditLog:
"""Append-only insert. ``detail`` JSON-serialised here, not by callers."""
row = AuditLog(
actor=actor,
kind=kind,
agent_name=agent_name,
detail_json=json.dumps(detail or {}, separators=(",", ":")),
)
session.add(row)
await session.commit()
await session.refresh(row)
return row
async def list_audit_records(
session: AsyncSession, *, limit: int = 50, before_id: int | None = None
) -> Sequence[AuditLog]:
"""Return audit entries newest-first, optionally paginated by id cursor.
``before_id`` is a forward-only cursor: pass the smallest id from the
current page to fetch the next slice. Cheap because ``id`` is the
primary key (ordered insert).
"""
stmt = select(AuditLog).order_by(AuditLog.id.desc()).limit(limit) # ty: ignore[unresolved-attribute]
if before_id is not None:
stmt = stmt.where(AuditLog.id < before_id) # ty: ignore[unsupported-operator]
result = await session.exec(stmt)
return result.all()
__all__ = [
"Database",
"append_audit",
"close_session",
"create_token",
"list_active_tokens",
"list_audit_records",
"list_tokens",
"revoke_token",
"touch_session",
"touch_token",
"upsert_session",
]
+80
View File
@@ -0,0 +1,80 @@
"""SQLModel tables — see PRD §9.
Three tables, all flat, no relationships modelled yet (Phase 4 talks
about ``actor`` and ``agent_name`` as strings — joining audit→token by
name is fine at this volume; we'll introduce FKs when the admin UI
actually demands them).
Datetimes are stored UTC; we set ``default_factory`` rather than relying
on DB defaults so SQLite + Postgres behave identically. Every row that
needs an id uses ``Optional[int]`` so SQLAlchemy can autoincrement.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlmodel import Field, SQLModel
def _utcnow() -> datetime:
return datetime.now(UTC)
class Token(SQLModel, table=True):
"""Bearer token issued to an external caller.
``hashed_value`` holds the Argon2 hash (Phase 4.2 — until then,
rows are written by tests / the admin UI, not by ``TokenStore``).
Plaintext is shown to the user **once** at creation and then
discarded.
"""
id: int | None = Field(default=None, primary_key=True)
name: str = Field(index=True, unique=True)
scope: str = Field(default="*")
hashed_value: str
created_at: datetime = Field(default_factory=_utcnow)
last_used_at: datetime | None = Field(default=None)
revoked_at: datetime | None = Field(default=None)
class Session(SQLModel, table=True):
"""Mirror of one live ``claude-code-api`` session.
The id is the ``session_id`` claude itself assigns on the first
turn; we don't generate it. Rows here are for admin observability
(live count, last activity) — the actual pool lives in
``claude_code_api.ClaudeCodeBackend`` and is the source of truth.
"""
id: str = Field(primary_key=True)
agent_name: str = Field(index=True)
fingerprint: str = Field(index=True)
created_at: datetime = Field(default_factory=_utcnow)
last_active_at: datetime = Field(default_factory=_utcnow)
closed_at: datetime | None = Field(default=None)
class AuditLog(SQLModel, table=True):
"""Append-only record of who-did-what.
``actor`` is ``"token:<name>"`` for inbound traffic or
``"admin:<user>"`` for admin-UI actions. ``kind`` is a short tag
(``"messages"`` / ``"mcp_call"`` / ``"token_create"`` / …);
free-form rather than enum so we can add new kinds without a
schema migration. ``detail_json`` is a JSON-encoded blob — keep
it small (paths, method, status), not full bodies.
"""
__tablename__ = "audit_log"
id: int | None = Field(default=None, primary_key=True)
ts: datetime = Field(default_factory=_utcnow, index=True)
actor: str = Field(index=True)
kind: str = Field(index=True)
agent_name: str | None = Field(default=None, index=True)
detail_json: str = Field(default="{}")
__all__ = ["AuditLog", "Session", "Token"]