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