diff --git a/README.md b/README.md index 0bacce2..95295d4 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,10 @@ is a full setup built on them. the subscription budget. - **frontends/** - the windows: Telegram (master = General, topic = branch), markdown files in a vault, `/api` + the admin SPA, an Anthropic-compatible - `/anthropic/v1/messages`, MCP re-exposure at `/mcp/`. + `/anthropic/v1/messages`, MCP re-exposure at `/mcp/`, and + `WebhookFrontend` for a window a setup declares itself - its own request + and response schema, its own agent, its own token scope + (`docs/FRONTEND-PLUGINS.md`). - **mcp/** - MCP servers a setup declares (stdio, http, python tools), aggregated in-process and handed to agents by name. - **vault/** - watching a directory of notes for the envelope. diff --git a/src/beaver_gateway/app.py b/src/beaver_gateway/app.py index ead5135..255d897 100644 --- a/src/beaver_gateway/app.py +++ b/src/beaver_gateway/app.py @@ -33,7 +33,7 @@ from beaver_gateway.frontends.bearer import require_token from beaver_gateway.frontends.root import build_root_app from beaver_gateway.jobs.scheduler import Scheduler from beaver_gateway.mcp.internal_app import build_internal_app -from beaver_gateway.security.auth import TokenStore +from beaver_gateway.security.auth import TokenStore, scopes_with from beaver_gateway.storage import ( Database, PostgresSessionStore, @@ -194,6 +194,7 @@ async def run(gateway: Gateway, settings: Settings) -> None: pool=pool, scheduler=scheduler, public_url=gateway.public_url.rstrip("/") if gateway.public_url else None, + scopes=scopes_with(fe.scope for fe in gateway.frontends), ) for fe in gateway.frontends: fe.configure(runtime) diff --git a/src/beaver_gateway/config.py b/src/beaver_gateway/config.py index 4799fb4..750a579 100644 --- a/src/beaver_gateway/config.py +++ b/src/beaver_gateway/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re import sys from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -30,6 +31,8 @@ if TYPE_CHECKING: __all__ = ["ConfigError", "Gateway", "load"] +_SCOPE = re.compile(r"[a-z][a-z0-9_-]*") + @dataclass(slots=True) class Gateway: @@ -135,6 +138,12 @@ def _validate(gw: Gateway, path: Path) -> None: f"got {type(f).__name__}" ) raise ConfigError(msg) + if f.scope is not None and not _SCOPE.fullmatch(f.scope): + msg = ( + f"{path}: gateway.frontends[{i}] has scope {f.scope!r}; " + f"a frontend scope is a lowercase name like 'voice', never '*'" + ) + raise ConfigError(msg) names: set[str] = set() for i, j in enumerate(gw.jobs): if not isinstance(j, Job): diff --git a/src/beaver_gateway/frontends/api/frontend.py b/src/beaver_gateway/frontends/api/frontend.py index d31d882..78a8339 100644 --- a/src/beaver_gateway/frontends/api/frontend.py +++ b/src/beaver_gateway/frontends/api/frontend.py @@ -35,7 +35,7 @@ from beaver_gateway.frontends.sse import ( ) from beaver_gateway.frontends.urls import frontend_url from beaver_gateway.security import audit -from beaver_gateway.security.auth import VALID_SCOPES, hash_token +from beaver_gateway.security.auth import hash_token from beaver_gateway.storage import ( create_token, list_audit_records, @@ -217,6 +217,7 @@ def build_app( # noqa: PLR0915 k: fe.agent_for(k) for k in fe.kinds if fe.agent_for(k) }, "path": fe.path, + "scope": fe.scope, "url": frontend_url(request, runtime, fe), } for fe in runtime.frontends @@ -814,6 +815,19 @@ def build_app( # noqa: PLR0915 "content": content, } + @app.get("/scopes") + async def scopes(request: Request) -> dict[str, Any]: + await require_token(request, runtime, scope=ADMIN_SCOPE) + by_frontend = { + fe.scope: fe.name or fe.path or "" for fe in runtime.frontends if fe.scope + } + return { + "scopes": [ + {"scope": s, "frontend": by_frontend.get(s)} + for s in sorted(runtime.scopes) + ] + } + @app.get("/tokens") async def tokens(request: Request) -> dict[str, Any]: await require_token(request, runtime, scope=ADMIN_SCOPE) @@ -828,7 +842,7 @@ def build_app( # noqa: PLR0915 data = await body_of(request) name = text_of(data, "name").strip() scope = str(data.get("scope") or "*") - if scope not in VALID_SCOPES: + if scope not in runtime.scopes: raise HTTPException(status.HTTP_400_BAD_REQUEST, f"invalid scope {scope!r}") plaintext = secrets.token_urlsafe(32) async with runtime.db.session() as session: diff --git a/src/beaver_gateway/frontends/base.py b/src/beaver_gateway/frontends/base.py index 302492f..3b90486 100644 --- a/src/beaver_gateway/frontends/base.py +++ b/src/beaver_gateway/frontends/base.py @@ -10,6 +10,8 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from beaver_gateway.security.auth import BUILTIN_SCOPES + if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Mapping, Sequence @@ -54,6 +56,8 @@ class GatewayRuntime: scheduler: Any = None public_url: str | None = None """``Gateway.public_url``; ``None`` derives the origin from the request.""" + scopes: frozenset[str] = BUILTIN_SCOPES + """Every token scope this gateway knows: the builtins plus each frontend's.""" class Frontend(ABC): @@ -64,6 +68,10 @@ class Frontend(ABC): is for non-HTTP work (polling, vault mirrors) and defaults to nothing. ``landing`` marks the app that ``/`` redirects to. + ``scope`` is the token scope this frontend's routes are gated by; a + frontend that names its own scope gets keys nobody else's token opens, + and ``POST /api/tokens`` will mint them (see ``security/auth.py``). + A frontend that shows conversations declares ``name`` (the binding key) and ``kinds`` (which conversation kinds it shows). The first frontend whose ``materialize`` returns a binding is the *home* of @@ -76,6 +84,7 @@ class Frontend(ABC): kinds: tuple[Kind, ...] = () path: str | None = None landing: bool = False + scope: str | None = None @abstractmethod def configure(self, runtime: GatewayRuntime) -> None: ... diff --git a/src/beaver_gateway/security/auth.py b/src/beaver_gateway/security/auth.py index 83ca814..837a094 100644 --- a/src/beaver_gateway/security/auth.py +++ b/src/beaver_gateway/security/auth.py @@ -21,7 +21,7 @@ from argon2.exceptions import InvalidHashError, VerifyMismatchError from beaver_gateway.storage import list_active_tokens, touch_token if TYPE_CHECKING: - from collections.abc import Mapping + from collections.abc import Iterable, Mapping from beaver_gateway.storage import Database @@ -35,8 +35,8 @@ class TokenStoreError(ValueError): """Malformed ``BOOTSTRAP_TOKENS`` value or duplicate token.""" -VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"}) -"""The scopes a ``Token.scope`` may hold. +BUILTIN_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api"}) +"""The scopes the gateway's own frontends define. * ``*`` — wildcard, may use any frontend * ``messages`` — Anthropic Messages frontend only @@ -45,16 +45,24 @@ VALID_SCOPES: frozenset[str] = frozenset({"*", "messages", "mcp", "admin", "api" * ``admin`` — reserved for programmatic admin access; the AdminFrontend itself authenticates via session cookies, not bearer tokens, so this scope is unused today. + +A setup's own frontend adds its scope on top; see :func:`scopes_with` and +``GatewayRuntime.scopes``, which is what ``POST /api/tokens`` validates. """ +def scopes_with(extra: Iterable[str | None]) -> frozenset[str]: + """The builtin scopes plus every frontend's own; ``None`` entries drop out.""" + return BUILTIN_SCOPES | {s for s in extra if s} + + @dataclass(frozen=True, slots=True) class TokenIdentity: """What :meth:`TokenStore.verify` resolves to on success. ``token_id`` is the DB row id for persisted tokens, or ``None`` for an env-bootstrap match (those have no DB row to touch). ``scope`` - gates which frontend the token may hit (see :data:`VALID_SCOPES`); + gates which frontend the token may hit (see :data:`BUILTIN_SCOPES`); bootstrap tokens implicitly get ``"*"``. """ @@ -67,8 +75,8 @@ class TokenIdentity: ``"*"`` is the wildcard; an exact match satisfies a single scope. Unknown ``required`` values intentionally fall through to a - strict equality check — callers should pass one of - :data:`VALID_SCOPES`. + strict equality check — callers should pass a scope the runtime + knows (:data:`BUILTIN_SCOPES` plus every frontend's own). """ return self.scope in ("*", required) @@ -325,11 +333,12 @@ class TokenStore: __all__ = [ - "VALID_SCOPES", + "BUILTIN_SCOPES", "TokenIdentity", "TokenStore", "TokenStoreError", "hash_token", + "scopes_with", ] diff --git a/tests/test_scopes.py b/tests/test_scopes.py new file mode 100644 index 0000000..88c8981 --- /dev/null +++ b/tests/test_scopes.py @@ -0,0 +1,133 @@ +import tempfile +from pathlib import Path + +import httpx +import pytest +from fastapi import FastAPI, Request + +from beaver_gateway.app import AgentRegistry, McpRegistry +from beaver_gateway.frontends.api.frontend import build_app as build_api +from beaver_gateway.frontends.base import Frontend, GatewayRuntime +from beaver_gateway.frontends.bearer import require_token +from beaver_gateway.security.auth import BUILTIN_SCOPES, TokenStore, scopes_with +from beaver_gateway.storage import Database + +ROOT = {"Authorization": "Bearer root-key"} +VOICE = {"Authorization": "Bearer voice-key"} +OPS = {"Authorization": "Bearer ops-key"} + + +class Speaker(Frontend): + """A frontend a setup declares, gated by a scope of its own.""" + + name = "voice" + path = "/voice" + scope = "voice" + + def configure(self, runtime: GatewayRuntime) -> None: + self.runtime = runtime + + def app(self) -> FastAPI: + app = FastAPI() + + @app.post("/") + async def ask(request: Request) -> dict[str, str]: + return {"by": await require_token(request, self.runtime, scope=self.scope)} + + return app + + +@pytest.fixture +async def runtime() -> GatewayRuntime: + root = Path(tempfile.mkdtemp(prefix="beaver-scopes-")) + db = Database(f"sqlite:///{root / 's.db'}") + await db.create_all() + speaker = Speaker() + rt = GatewayRuntime( + agents=AgentRegistry([]), + mcps=McpRegistry([]), + backends={}, + token_store=TokenStore( + db, + bootstrap={"root": "root-key", "voice": "voice-key", "ops": "ops-key"}, + bootstrap_scopes={"voice": "voice", "ops": "api"}, + ), + db=db, + frontends=(speaker,), + scopes=scopes_with(fe.scope for fe in (speaker,)), + ) + speaker.configure(rt) + yield rt + await db.dispose() + + +def api(runtime: GatewayRuntime) -> httpx.AsyncClient: + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=build_api(runtime)), base_url="http://t" + ) + + +def frontend(runtime: GatewayRuntime) -> httpx.AsyncClient: + fe = runtime.frontends[0] + return httpx.AsyncClient( + transport=httpx.ASGITransport(app=fe.app()), base_url="http://t" + ) + + +def test_a_frontend_scope_joins_the_builtin_vocabulary() -> None: + assert "voice" not in BUILTIN_SCOPES + assert scopes_with(["voice", None, "api"]) == BUILTIN_SCOPES | {"voice"} + + +async def test_the_scope_of_a_frontend_is_listed_with_its_name( + runtime: GatewayRuntime, +) -> None: + async with api(runtime) as client: + listed = await client.get("/scopes", headers=ROOT) + scopes = listed.json()["scopes"] + assert {"scope": "voice", "frontend": "voice"} in scopes + assert {"scope": "api", "frontend": None} in scopes + + +async def test_tokens_mint_for_a_declared_scope_and_not_for_an_unknown_one( + runtime: GatewayRuntime, +) -> None: + async with api(runtime) as client: + minted = await client.post( + "/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT + ) + unknown = await client.post( + "/tokens", json={"name": "nope", "scope": "kitchen"}, headers=ROOT + ) + assert minted.status_code == 201 + assert minted.json()["token"]["scope"] == "voice" + assert unknown.status_code == 400 + assert "kitchen" in unknown.json()["error"] + + +async def test_the_route_opens_for_its_own_scope_only(runtime: GatewayRuntime) -> None: + async with frontend(runtime) as client: + own = await client.post("/", headers=VOICE) + other = await client.post("/", headers=OPS) + wildcard = await client.post("/", headers=ROOT) + anonymous = await client.post("/") + assert own.status_code == 200 + assert own.json()["by"] == "voice" + assert other.status_code == 403 + assert wildcard.status_code == 200 + assert anonymous.status_code == 401 + + +async def test_a_minted_token_reaches_the_frontend_it_was_minted_for( + runtime: GatewayRuntime, +) -> None: + async with api(runtime) as client: + plaintext = ( + await client.post( + "/tokens", json={"name": "esp32-hall", "scope": "voice"}, headers=ROOT + ) + ).json()["plaintext"] + async with frontend(runtime) as client: + opened = await client.post("/", headers={"X-Api-Key": plaintext}) + assert opened.status_code == 200 + assert opened.json()["by"] == "esp32-hall" diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index e24cf67..0084b3d 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -12,6 +12,7 @@ import type { LimitsResponse, MemoryFile, MemoryTree, + ScopeRow, SearchResponse, SessionsResponse, TokenRow, @@ -351,6 +352,10 @@ export class ApiClient { }); } + scopes(): Promise<{ scopes: ScopeRow[] }> { + return this.get("/api/scopes"); + } + createToken( name: string, scope: string diff --git a/ui/src/lib/api/types.ts b/ui/src/lib/api/types.ts index 6a2d3ee..9ba5bc9 100644 --- a/ui/src/lib/api/types.ts +++ b/ui/src/lib/api/types.ts @@ -132,6 +132,7 @@ export interface FrontendInfo { kinds: Kind[]; name: string; path: string | null; + scope: string | null; type: string; url: string | null; } @@ -245,6 +246,11 @@ export interface MemoryFile { size: number; } +export interface ScopeRow { + frontend: string | null; + scope: string; +} + export interface TokenRow { created_at: string; id: number; diff --git a/ui/src/lib/components/endpoints.svelte b/ui/src/lib/components/endpoints.svelte index 414d86e..423f0ee 100644 --- a/ui/src/lib/components/endpoints.svelte +++ b/ui/src/lib/components/endpoints.svelte @@ -117,7 +117,17 @@ }; default: return { - endpoints: base ? [{ hint: "", label: "base", url: base }] : [], + endpoints: base + ? [ + { + hint: fe.scope + ? `POST the frontend's own payload; bearer with scope ${fe.scope}` + : "", + label: "base", + url: base, + }, + ] + : [], frontend: fe, note: "", }; diff --git a/ui/src/routes/system/tokens/+page.svelte b/ui/src/routes/system/tokens/+page.svelte index 6b8d29b..39f4635 100644 --- a/ui/src/routes/system/tokens/+page.svelte +++ b/ui/src/routes/system/tokens/+page.svelte @@ -2,7 +2,7 @@ import CopyIcon from "@lucide/svelte/icons/copy"; import PlusIcon from "@lucide/svelte/icons/plus"; import { toast } from "svelte-sonner"; - import type { TokenRow } from "$lib/api/types"; + import type { ScopeRow, TokenRow } from "$lib/api/types"; import EmptyState from "$lib/components/empty-state.svelte"; import ErrorNote from "$lib/components/error-note.svelte"; import { Button } from "$lib/components/ui/button"; @@ -15,13 +15,23 @@ import { session } from "$lib/session.svelte"; import { cn } from "$lib/utils"; - const SCOPES = [ - { hint: "every frontend", value: "*" }, - { hint: "conversations API and SSE (panel, plugin)", value: "api" }, - { hint: "Anthropic /v1/messages", value: "messages" }, - { hint: "MCP server", value: "mcp" }, - { hint: "tokens and audit over the API", value: "admin" }, - ]; + const HINTS: Record = { + "*": "every frontend", + admin: "tokens and audit over the API", + api: "conversations API and SSE (panel, plugin)", + mcp: "MCP server", + messages: "Anthropic /v1/messages", + }; + + let scopes = $state([]); + const options = $derived( + scopes.map((row) => ({ + hint: row.frontend + ? `the ${row.frontend} frontend` + : (HINTS[row.scope] ?? ""), + value: row.scope, + })) + ); let tokens = $state(null); let includeRevoked = $state(false); @@ -49,6 +59,21 @@ load(includeRevoked); }); + $effect(() => { + const { client } = session; + if (!client) { + return; + } + client + .scopes() + .then(({ scopes: rows }) => { + scopes = rows; + }) + .catch(() => { + scopes = []; + }); + }); + async function create() { const { client } = session; if (!client) { @@ -254,7 +279,7 @@ > {scope} - {#each SCOPES as option (option.value)} + {#each options as option (option.value)} {option.value}