feat(frontends,security,api,ui): a frontend can hold a token scope of its own
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: ...
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user