prismal-server 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- prismal_server/__init__.py +15 -0
- prismal_server/app.py +163 -0
- prismal_server/auth/__init__.py +125 -0
- prismal_server/auth/base.py +42 -0
- prismal_server/auth/bearer.py +45 -0
- prismal_server/auth/noauth.py +24 -0
- prismal_server/auth/oidc.py +49 -0
- prismal_server/config.py +106 -0
- prismal_server/deps.py +305 -0
- prismal_server/errors.py +148 -0
- prismal_server/fake/__init__.py +35 -0
- prismal_server/fake/graph.py +151 -0
- prismal_server/fake/middleware.py +30 -0
- prismal_server/metrics.py +33 -0
- prismal_server/openapi_examples.py +370 -0
- prismal_server/py.typed +0 -0
- prismal_server/routes/__init__.py +1 -0
- prismal_server/routes/a2a.py +249 -0
- prismal_server/routes/admin.py +221 -0
- prismal_server/routes/agui.py +81 -0
- prismal_server/routes/health.py +38 -0
- prismal_server/routes/threads.py +343 -0
- prismal_server/routes/usage.py +164 -0
- prismal_server/routes/ws.py +262 -0
- prismal_server/streaming.py +361 -0
- prismal_server/tenancy/__init__.py +7 -0
- prismal_server/tenancy/config_source.py +315 -0
- prismal_server/tenancy/secrets.py +132 -0
- prismal_server/usage/__init__.py +6 -0
- prismal_server/usage/aggregate.py +63 -0
- prismal_server/usage/models.py +120 -0
- prismal_server/usage/store.py +239 -0
- prismal_server/usage/summary.py +66 -0
- prismal_server-0.2.0.dist-info/METADATA +173 -0
- prismal_server-0.2.0.dist-info/RECORD +37 -0
- prismal_server-0.2.0.dist-info/WHEEL +4 -0
- prismal_server-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""prismal-server — reference host for the prismal engine.
|
|
2
|
+
|
|
3
|
+
A thin FastAPI/ASGI process that boots the engine once and serves it over
|
|
4
|
+
REST/SSE plus the inbound A2A endpoints. It orchestrates the engine through
|
|
5
|
+
exactly four seams and reimplements none of its logic (see ``CLAUDE.md``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = version("prismal-server")
|
|
12
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
13
|
+
__version__ = "0.0.0.dev0"
|
|
14
|
+
|
|
15
|
+
__all__ = ["__version__"]
|
prismal_server/app.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""ASGI application factory and lifespan (RHB-01-03).
|
|
2
|
+
|
|
3
|
+
``create_app()`` wires the ``RuntimeRegistry`` into the app, registers the
|
|
4
|
+
centralized error mapping, and mounts the routers. The lifespan warms nothing by
|
|
5
|
+
default (readiness composes on demand) and, on shutdown, ``aclose()``s every
|
|
6
|
+
cached ``RuntimeContext`` (SPEC-RHB-LIF-001/002). The registry is injectable so
|
|
7
|
+
tests can supply a fake built from ``build_test_runtime`` (SPEC-RHB-LIF-003).
|
|
8
|
+
|
|
9
|
+
The module-level ``app = create_app()`` is the entry point for
|
|
10
|
+
``uvicorn prismal_server.app:app``.
|
|
11
|
+
|
|
12
|
+
Seam: composition — ``build_runtime`` (via ``deps.RuntimeRegistry``).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import AsyncIterator
|
|
18
|
+
from contextlib import asynccontextmanager
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from prismal.agents.graph import get_async_compiled_graph
|
|
23
|
+
|
|
24
|
+
from prismal_server import __version__, openapi_examples
|
|
25
|
+
from prismal_server.auth import AuthBackend, build_auth_backend
|
|
26
|
+
from prismal_server.config import HostSettings, get_settings
|
|
27
|
+
from prismal_server.deps import AgentCardBuilder, GraphFactory, RuntimeRegistry
|
|
28
|
+
from prismal_server.errors import register_exception_handlers
|
|
29
|
+
from prismal_server.fake import (
|
|
30
|
+
FakeGraph,
|
|
31
|
+
FakeHeaderMiddleware,
|
|
32
|
+
FakeSummaries,
|
|
33
|
+
fake_builder,
|
|
34
|
+
)
|
|
35
|
+
from prismal_server.routes import a2a, admin, agui, health, threads, usage, ws
|
|
36
|
+
from prismal_server.usage.store import UsageStore, build_usage_store
|
|
37
|
+
from prismal_server.usage.summary import RunSummaryLookup, default_run_summary
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def _default_graph_factory(*, tool_provider: Any = None) -> Any:
|
|
41
|
+
"""Obtain the compiled graph via the Execution seam."""
|
|
42
|
+
return await get_async_compiled_graph(tool_provider=tool_provider)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _default_agent_card_builder(
|
|
46
|
+
*,
|
|
47
|
+
org_id: str | None = None,
|
|
48
|
+
protocol_version: str | None = None,
|
|
49
|
+
settings: Any = None,
|
|
50
|
+
) -> dict[str, Any]:
|
|
51
|
+
"""Build the Agent Card via the A2A seam (camelCase wire form).
|
|
52
|
+
|
|
53
|
+
Imports are deferred and sourced only from ``prismal.a2a`` (the seam
|
|
54
|
+
re-exports the engine settings + capability map), so the host never reaches
|
|
55
|
+
past the A2A seam and module import stays independent of that re-export.
|
|
56
|
+
"""
|
|
57
|
+
from prismal.a2a import ( # noqa: PLC0415 - deferred to keep import off the base path
|
|
58
|
+
DEFAULT_CAPABILITY_MAP,
|
|
59
|
+
build_agent_card,
|
|
60
|
+
)
|
|
61
|
+
from prismal.a2a import (
|
|
62
|
+
get_settings as get_engine_settings,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
card = build_agent_card(
|
|
66
|
+
settings or get_engine_settings(),
|
|
67
|
+
DEFAULT_CAPABILITY_MAP,
|
|
68
|
+
org_id=org_id,
|
|
69
|
+
protocol_version=protocol_version,
|
|
70
|
+
)
|
|
71
|
+
return cast("dict[str, Any]", card.model_dump(by_alias=True))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _default_a2a_engine_settings() -> Any:
|
|
75
|
+
"""Process engine settings for A2A routing (base URL, version, legacy)."""
|
|
76
|
+
from prismal.a2a import get_settings as get_engine_settings # noqa: PLC0415
|
|
77
|
+
|
|
78
|
+
return get_engine_settings()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def create_app(
|
|
82
|
+
*,
|
|
83
|
+
registry: RuntimeRegistry | None = None,
|
|
84
|
+
settings: HostSettings | None = None,
|
|
85
|
+
graph_factory: GraphFactory | None = None,
|
|
86
|
+
agent_card_builder: AgentCardBuilder | None = None,
|
|
87
|
+
auth_backend: AuthBackend | None = None,
|
|
88
|
+
usage_store: UsageStore | None = None,
|
|
89
|
+
run_summary: RunSummaryLookup | None = None,
|
|
90
|
+
a2a_engine_settings: Any = None,
|
|
91
|
+
) -> FastAPI:
|
|
92
|
+
"""Build the FastAPI app, optionally with injected seams (for tests)."""
|
|
93
|
+
settings = settings or get_settings()
|
|
94
|
+
if settings.host_fake_llm_mode:
|
|
95
|
+
# SPEC-RH2-FAK-001: engine test runtimes + the deterministic script.
|
|
96
|
+
summaries = FakeSummaries()
|
|
97
|
+
|
|
98
|
+
async def fake_graph_factory(*, tool_provider: Any = None) -> Any:
|
|
99
|
+
return FakeGraph(summaries)
|
|
100
|
+
|
|
101
|
+
registry = registry or RuntimeRegistry(settings, builder=fake_builder)
|
|
102
|
+
graph_factory = graph_factory or fake_graph_factory
|
|
103
|
+
run_summary = run_summary or summaries
|
|
104
|
+
registry = registry or RuntimeRegistry(settings)
|
|
105
|
+
graph_factory = graph_factory or _default_graph_factory
|
|
106
|
+
agent_card_builder = agent_card_builder or _default_agent_card_builder
|
|
107
|
+
auth_backend = auth_backend or build_auth_backend(settings)
|
|
108
|
+
usage_store = usage_store or build_usage_store(settings)
|
|
109
|
+
run_summary = run_summary or default_run_summary
|
|
110
|
+
a2a_engine_settings = a2a_engine_settings or _default_a2a_engine_settings()
|
|
111
|
+
|
|
112
|
+
@asynccontextmanager
|
|
113
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
114
|
+
# Startup: registry is already on app.state (set below). Warming the
|
|
115
|
+
# default tenant is optional (SPEC-RHB-LIF-001 MAY) and left off so boot
|
|
116
|
+
# stays resilient; /readyz composes on demand.
|
|
117
|
+
try:
|
|
118
|
+
yield
|
|
119
|
+
finally:
|
|
120
|
+
# Shutdown: release every tenant runtime (SPEC-RHB-LIF-002).
|
|
121
|
+
await registry.aclose_all()
|
|
122
|
+
await usage_store.aclose()
|
|
123
|
+
|
|
124
|
+
app = FastAPI(
|
|
125
|
+
title="prismal-server",
|
|
126
|
+
version=__version__,
|
|
127
|
+
summary="Reference host for the prismal engine (REST · SSE · A2A).",
|
|
128
|
+
lifespan=lifespan,
|
|
129
|
+
)
|
|
130
|
+
# Eagerly available so ASGI clients that don't run lifespan still resolve it.
|
|
131
|
+
app.state.settings = settings
|
|
132
|
+
app.state.registry = registry
|
|
133
|
+
app.state.graph_factory = graph_factory
|
|
134
|
+
app.state.agent_card_builder = agent_card_builder
|
|
135
|
+
app.state.agent_card_cache = {}
|
|
136
|
+
app.state.auth_backend = auth_backend
|
|
137
|
+
app.state.usage_store = usage_store
|
|
138
|
+
app.state.run_summary = run_summary
|
|
139
|
+
app.state.a2a_engine_settings = a2a_engine_settings
|
|
140
|
+
|
|
141
|
+
register_exception_handlers(app)
|
|
142
|
+
openapi_examples.install(app) # RH2-07-03: examples served verbatim
|
|
143
|
+
if settings.host_fake_llm_mode:
|
|
144
|
+
app.add_middleware(FakeHeaderMiddleware) # SPEC-RH2-FAK-003
|
|
145
|
+
app.include_router(health.router)
|
|
146
|
+
app.include_router(threads.router)
|
|
147
|
+
app.include_router(a2a.router)
|
|
148
|
+
app.include_router(usage.router)
|
|
149
|
+
app.include_router(agui.router)
|
|
150
|
+
app.include_router(admin.router) # 404 unless host_admin_enabled (ADM-001)
|
|
151
|
+
if settings.host_ws_enabled: # SPEC-RH2-WSK-001
|
|
152
|
+
app.include_router(ws.router)
|
|
153
|
+
# Tenant-scoped A2A routes match `{base}/{tenant}`: registered last so every
|
|
154
|
+
# fixed route above wins (SPEC-RH2-A2A-003).
|
|
155
|
+
app.include_router(
|
|
156
|
+
a2a.tenant_router(
|
|
157
|
+
a2a.base_path(getattr(a2a_engine_settings, "a2a_base_url", None))
|
|
158
|
+
)
|
|
159
|
+
)
|
|
160
|
+
return app
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
app = create_app()
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Authentication seam (RHB-04-*).
|
|
2
|
+
|
|
3
|
+
The host owns only *where the credential comes from on the wire*; the identity
|
|
4
|
+
*mechanism* is engine-side. Backends implement :class:`AuthBackend` and resolve a
|
|
5
|
+
request to an engine ``AgentIdentity`` (or ``None``). The backend is selected by
|
|
6
|
+
``host_auth_backend`` (``none`` | ``bearer`` | ``oidc``) via
|
|
7
|
+
:func:`build_auth_backend`, pluggable without touching the routes
|
|
8
|
+
(SPEC-RHB-AUT-006).
|
|
9
|
+
|
|
10
|
+
:func:`authenticate` applies the host's strict-mode policy (SPEC-RHB-AUT-005) and
|
|
11
|
+
:func:`resolve_org_id` resolves the tenant (SPEC-RHB-TEN-001).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING, Any
|
|
17
|
+
|
|
18
|
+
from fastapi import HTTPException, Request
|
|
19
|
+
|
|
20
|
+
from prismal_server.auth.base import AuthBackend, extract_bearer_token
|
|
21
|
+
from prismal_server.auth.bearer import BearerTokenBackend
|
|
22
|
+
from prismal_server.auth.noauth import NoAuthBackend
|
|
23
|
+
from prismal_server.auth.oidc import OidcAuthBackend
|
|
24
|
+
from prismal_server.config import get_settings
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from prismal.identity import AgentIdentity
|
|
28
|
+
|
|
29
|
+
from prismal_server.config import HostSettings
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AuthBackend",
|
|
33
|
+
"BearerTokenBackend",
|
|
34
|
+
"NoAuthBackend",
|
|
35
|
+
"OidcAuthBackend",
|
|
36
|
+
"authenticate",
|
|
37
|
+
"build_auth_backend",
|
|
38
|
+
"extract_bearer_token",
|
|
39
|
+
"resolve_org_id",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_auth_backend(settings: HostSettings) -> AuthBackend:
|
|
44
|
+
"""Select the auth backend by config (SPEC-RHB-AUT-006).
|
|
45
|
+
|
|
46
|
+
The engine ``OidcIdentityProvider`` is constructed only when the ``oidc``
|
|
47
|
+
backend is chosen, so ``none``/``bearer`` hosts pay no OIDC setup cost.
|
|
48
|
+
"""
|
|
49
|
+
if settings.host_auth_backend == "bearer":
|
|
50
|
+
return BearerTokenBackend(
|
|
51
|
+
static_tokens=settings.bearer_static_tokens,
|
|
52
|
+
agent_name=settings.dev_identity_agent_name,
|
|
53
|
+
)
|
|
54
|
+
if settings.host_auth_backend == "oidc":
|
|
55
|
+
return OidcAuthBackend()
|
|
56
|
+
return NoAuthBackend(
|
|
57
|
+
did=settings.dev_identity_did,
|
|
58
|
+
agent_name=settings.dev_identity_agent_name,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def authenticate(request: Request) -> AgentIdentity | None:
|
|
63
|
+
"""Resolve the request identity, enforcing strict mode on protected routes.
|
|
64
|
+
|
|
65
|
+
Returns the resolved ``AgentIdentity`` (or ``None`` in non-strict mode). When
|
|
66
|
+
``host_auth_strict`` and the request is unauthenticated, raises ``401``
|
|
67
|
+
(SPEC-RHB-AUT-005). Health routes never call this, so they stay public.
|
|
68
|
+
"""
|
|
69
|
+
backend: AuthBackend = request.app.state.auth_backend
|
|
70
|
+
identity = await backend.resolve(request)
|
|
71
|
+
if identity is None and request.app.state.settings.host_auth_strict:
|
|
72
|
+
raise HTTPException(status_code=401, detail="authentication required")
|
|
73
|
+
return identity
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ForbiddenTenant(Exception):
|
|
77
|
+
"""The request names a tenant it may not use (HTTP 403 ``forbidden_tenant``)."""
|
|
78
|
+
|
|
79
|
+
def __init__(self) -> None:
|
|
80
|
+
super().__init__("the requested tenant is not available to this caller")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def resolve_org_id(
|
|
84
|
+
request: Any, identity: AgentIdentity | None, *, for_read: bool = False
|
|
85
|
+
) -> str | None:
|
|
86
|
+
"""Resolve the tenant ``org_id`` (SPEC-RHB-TEN-001).
|
|
87
|
+
|
|
88
|
+
Order: the identity's ``org_id`` if present, else the ``X-Org-Id`` header,
|
|
89
|
+
else ``None`` (single-tenant).
|
|
90
|
+
|
|
91
|
+
WHERE per-tenant config is enabled (``host_tenant_config_dir``), tenants
|
|
92
|
+
carry their own gateway credentials, so tenant selection fails closed:
|
|
93
|
+
|
|
94
|
+
* an ``X-Org-Id`` that differs from the identity's ``org_id`` ⇒
|
|
95
|
+
:class:`ForbiddenTenant`;
|
|
96
|
+
* an ``X-Org-Id`` without an identity ``org_id`` is honoured only in local
|
|
97
|
+
development (``host_auth_backend="none"`` outside ``production``);
|
|
98
|
+
otherwise :class:`ForbiddenTenant` — never a silent fallback to the
|
|
99
|
+
process (operator) tenant.
|
|
100
|
+
|
|
101
|
+
``for_read=True`` (usage, budget, admin reads) applies those fail-closed
|
|
102
|
+
rules even without tenant config: reading another org's records must never
|
|
103
|
+
be possible by just sending a header.
|
|
104
|
+
"""
|
|
105
|
+
header: str | None = request.headers.get("X-Org-Id")
|
|
106
|
+
identity_org: str | None = identity.org_id if identity is not None else None
|
|
107
|
+
state = getattr(getattr(request, "app", None), "state", None)
|
|
108
|
+
settings = getattr(state, "settings", None) or get_settings()
|
|
109
|
+
|
|
110
|
+
if settings.host_tenant_config_dir is None and not for_read:
|
|
111
|
+
# v0.1 behaviour (SPEC-RHB-TEN-001).
|
|
112
|
+
return identity_org or header
|
|
113
|
+
|
|
114
|
+
if identity_org:
|
|
115
|
+
if header is not None and header != identity_org:
|
|
116
|
+
raise ForbiddenTenant()
|
|
117
|
+
return identity_org
|
|
118
|
+
if header is None:
|
|
119
|
+
return None
|
|
120
|
+
if (
|
|
121
|
+
settings.host_auth_backend == "none"
|
|
122
|
+
and settings.host_environment != "production"
|
|
123
|
+
):
|
|
124
|
+
return header
|
|
125
|
+
raise ForbiddenTenant()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""``AuthBackend`` protocol and shared credential parsing (RHB-04-01).
|
|
2
|
+
|
|
3
|
+
A backend resolves an inbound request to an engine ``AgentIdentity`` (or ``None``
|
|
4
|
+
when the request is unauthenticated). Per SPEC-RHB-AUT-001 ``resolve`` MUST NOT
|
|
5
|
+
raise for an unauthenticated request — it returns ``None`` so the caller decides
|
|
6
|
+
policy (public vs. strict).
|
|
7
|
+
|
|
8
|
+
The host owns only *where the credential comes from on the wire*; the identity
|
|
9
|
+
*mechanism* stays engine-side.
|
|
10
|
+
|
|
11
|
+
Seam: identity — ``prismal.identity`` (``AgentIdentity`` / ``OidcIdentityProvider``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from prismal.identity import AgentIdentity
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@runtime_checkable
|
|
23
|
+
class AuthBackend(Protocol):
|
|
24
|
+
"""Resolve a request to an ``AgentIdentity`` or ``None`` (never raising)."""
|
|
25
|
+
|
|
26
|
+
async def resolve(self, request: Any) -> AgentIdentity | None: ...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_bearer_token(request: Any) -> str | None:
|
|
30
|
+
"""Return the ``Authorization: Bearer <token>`` value, or ``None``.
|
|
31
|
+
|
|
32
|
+
Tolerant by construction (SPEC-RHB-AUT-001): a missing header, a non-Bearer
|
|
33
|
+
scheme, or an empty token all yield ``None`` rather than raising.
|
|
34
|
+
"""
|
|
35
|
+
header: str | None = request.headers.get("Authorization")
|
|
36
|
+
if not header:
|
|
37
|
+
return None
|
|
38
|
+
scheme, _, token = header.partition(" ")
|
|
39
|
+
token = token.strip()
|
|
40
|
+
if scheme.lower() != "bearer" or not token:
|
|
41
|
+
return None
|
|
42
|
+
return token
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""``BearerTokenBackend`` (RHB-04-03).
|
|
2
|
+
|
|
3
|
+
Resolves an ``Authorization: Bearer <token>`` credential to an ``AgentIdentity``
|
|
4
|
+
via a static token→DID map (SPEC-RHB-AUT-003). The configured tokens are
|
|
5
|
+
``SecretStr`` so a raw token never surfaces in logs or responses; an unknown or
|
|
6
|
+
absent token resolves to ``None`` (never raises, SPEC-RHB-AUT-001).
|
|
7
|
+
|
|
8
|
+
The identity carries no ``org_id`` — tenant selection falls back to the
|
|
9
|
+
``X-Org-Id`` header per SPEC-RHB-TEN-001.
|
|
10
|
+
|
|
11
|
+
Seam: identity — ``prismal.identity.AgentIdentity``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from prismal.identity import DID, AgentIdentity
|
|
19
|
+
from pydantic import SecretStr
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BearerTokenBackend:
|
|
23
|
+
"""Resolve a static bearer token to a fixed-DID identity."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
static_tokens: dict[str, SecretStr],
|
|
29
|
+
agent_name: str = "bearer-client",
|
|
30
|
+
) -> None:
|
|
31
|
+
self._static_tokens = static_tokens
|
|
32
|
+
self._agent_name = agent_name
|
|
33
|
+
|
|
34
|
+
async def resolve(self, request: Any) -> AgentIdentity | None:
|
|
35
|
+
from prismal_server.auth.base import extract_bearer_token
|
|
36
|
+
|
|
37
|
+
token = extract_bearer_token(request)
|
|
38
|
+
if token is None:
|
|
39
|
+
return None
|
|
40
|
+
did = self._static_tokens.get(token)
|
|
41
|
+
if did is None:
|
|
42
|
+
return None
|
|
43
|
+
return AgentIdentity(
|
|
44
|
+
did=DID(did.get_secret_value()), agent_name=self._agent_name
|
|
45
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""``NoAuthBackend`` — dev default (RHB-04-02).
|
|
2
|
+
|
|
3
|
+
Returns a fixed local ``AgentIdentity`` (configurable DID / agent_name) for every
|
|
4
|
+
request, so a fresh clone boots usable with no credentials (SPEC-RHB-AUT-002).
|
|
5
|
+
This is a development convenience; production hosts select ``bearer`` or ``oidc``.
|
|
6
|
+
|
|
7
|
+
Seam: identity — ``prismal.identity.AgentIdentity``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from prismal.identity import DID, AgentIdentity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class NoAuthBackend:
|
|
18
|
+
"""Resolve every request to one fixed, configured identity."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *, did: str, agent_name: str) -> None:
|
|
21
|
+
self._identity = AgentIdentity(did=DID(did), agent_name=agent_name)
|
|
22
|
+
|
|
23
|
+
async def resolve(self, request: Any) -> AgentIdentity | None:
|
|
24
|
+
return self._identity
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""``OidcAuthBackend`` (RHB-04-04).
|
|
2
|
+
|
|
3
|
+
Delegates token validation and identity resolution to the engine's
|
|
4
|
+
``OidcIdentityProvider`` — the host performs no token crypto (SPEC-RHB-AUT-004).
|
|
5
|
+
The bearer value is treated as the caller's DID; the engine provider verifies it
|
|
6
|
+
and resolves the backing ``AgentIdentity``. Any provider error (or an
|
|
7
|
+
unverifiable / unknown DID) resolves to ``None`` rather than raising
|
|
8
|
+
(SPEC-RHB-AUT-001).
|
|
9
|
+
|
|
10
|
+
The synchronous provider calls run in a worker thread so DID resolution never
|
|
11
|
+
blocks the event loop.
|
|
12
|
+
|
|
13
|
+
Seam: identity — ``prismal.identity.OidcIdentityProvider``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from prismal.identity import DID, AgentIdentity, OidcIdentityProvider
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OidcAuthBackend:
|
|
28
|
+
"""Resolve a bearer DID via the engine OIDC identity provider."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, *, provider: Any | None = None) -> None:
|
|
31
|
+
# Constructed lazily by the caller/factory; ``provider`` is injectable so
|
|
32
|
+
# tests supply a fake and never do real DID resolution.
|
|
33
|
+
self._provider = provider if provider is not None else OidcIdentityProvider()
|
|
34
|
+
|
|
35
|
+
async def resolve(self, request: Any) -> AgentIdentity | None:
|
|
36
|
+
from prismal_server.auth.base import extract_bearer_token
|
|
37
|
+
|
|
38
|
+
token = extract_bearer_token(request)
|
|
39
|
+
if token is None:
|
|
40
|
+
return None
|
|
41
|
+
did = DID(token)
|
|
42
|
+
try:
|
|
43
|
+
verified = await asyncio.to_thread(self._provider.verify, did)
|
|
44
|
+
if not verified:
|
|
45
|
+
return None
|
|
46
|
+
return await asyncio.to_thread(self._provider.resolve, did)
|
|
47
|
+
except Exception: # never propagate — an auth failure is just ``None``
|
|
48
|
+
logger.warning("OIDC identity resolution failed", exc_info=True)
|
|
49
|
+
return None
|
prismal_server/config.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Host configuration (RHB-01-01).
|
|
2
|
+
|
|
3
|
+
``HostSettings`` (pydantic-settings, prefix ``PRISMAL_SERVER_``) governs *host*
|
|
4
|
+
concerns only — bind host/port, CORS, auth mode, SSE heartbeat, tenant cap.
|
|
5
|
+
Engine configuration stays engine-side and is read by the engine via its own
|
|
6
|
+
``ConfigSourcePort`` (SPEC-RHB-CFG-001); this class MUST NOT duplicate it.
|
|
7
|
+
Secret-bearing values are ``SecretStr`` (SPEC-RHB-CFG-002).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import Field, SecretStr, model_validator
|
|
17
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
18
|
+
|
|
19
|
+
AuthBackend = Literal["none", "bearer", "oidc"]
|
|
20
|
+
HostEnvironment = Literal["development", "staging", "production"]
|
|
21
|
+
SecretsBackend = Literal["file", "env"]
|
|
22
|
+
UsageStoreKind = Literal["memory", "sqlite"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class HostSettings(BaseSettings):
|
|
26
|
+
"""Host-only settings, populated from ``PRISMAL_SERVER_*`` env vars.
|
|
27
|
+
|
|
28
|
+
See ``SPEC.md`` §7 for the authoritative key table.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
model_config = SettingsConfigDict(
|
|
32
|
+
env_prefix="PRISMAL_SERVER_",
|
|
33
|
+
env_file=".env",
|
|
34
|
+
env_file_encoding="utf-8",
|
|
35
|
+
extra="ignore",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Bind
|
|
39
|
+
host: str = "0.0.0.0"
|
|
40
|
+
port: int = 8000
|
|
41
|
+
cors_origins: list[str] = []
|
|
42
|
+
|
|
43
|
+
# Auth seam (backends wired in Phase 4)
|
|
44
|
+
host_auth_backend: AuthBackend = "none"
|
|
45
|
+
host_auth_strict: bool = False
|
|
46
|
+
dev_identity_did: str = "did:key:zLocalDev"
|
|
47
|
+
dev_identity_agent_name: str = "local-dev"
|
|
48
|
+
# Static bearer tokens (token → DID) for the dev/bearer backend. Secret by
|
|
49
|
+
# construction so raw tokens never surface in logs or responses.
|
|
50
|
+
bearer_static_tokens: dict[str, SecretStr] = {}
|
|
51
|
+
|
|
52
|
+
# Streaming
|
|
53
|
+
sse_heartbeat_s: int = 15
|
|
54
|
+
|
|
55
|
+
# Multi-tenancy
|
|
56
|
+
host_max_tenants: int = 32
|
|
57
|
+
|
|
58
|
+
# Inbound A2A: gates route presence on the host (404 when off). The engine's
|
|
59
|
+
# own a2a_inbound_enabled / a2a_strict govern the handler and strictness.
|
|
60
|
+
a2a_enabled: bool = True
|
|
61
|
+
|
|
62
|
+
# ── Host v0.2 (SPEC-RH2 §10) ────────────────────────────────────────────
|
|
63
|
+
host_environment: HostEnvironment = "development"
|
|
64
|
+
|
|
65
|
+
# Per-tenant engine config (RH2-01): `<dir>/<org_id>/tenant.yaml` plus
|
|
66
|
+
# secrets from the selected backend. None ⇒ every tenant uses process config.
|
|
67
|
+
host_tenant_config_dir: Path | None = None
|
|
68
|
+
host_secrets_backend: SecretsBackend = "file"
|
|
69
|
+
# File backend root (`<dir>/<org_id>/<key>`). None ⇒
|
|
70
|
+
# `<host_tenant_config_dir>/<org_id>/secrets/<key>`.
|
|
71
|
+
host_secrets_dir: Path | None = None
|
|
72
|
+
|
|
73
|
+
# Usage persistence (RH2-02).
|
|
74
|
+
host_usage_store: UsageStoreKind = "memory"
|
|
75
|
+
host_usage_db_path: Path = Path("usage.db")
|
|
76
|
+
host_usage_retention_days: int = Field(default=90, ge=1)
|
|
77
|
+
|
|
78
|
+
# AG-UI host gate (RH2-03); the engine's agui_events_enabled is also required.
|
|
79
|
+
host_agui_enabled: bool = True
|
|
80
|
+
|
|
81
|
+
# WebSocket transport (RH2-05).
|
|
82
|
+
host_ws_enabled: bool = False
|
|
83
|
+
host_ws_max_message_bytes: int = Field(default=1_048_576, ge=1)
|
|
84
|
+
|
|
85
|
+
# Deterministic fake runtime for credential-free contract tests (RH2-06).
|
|
86
|
+
host_fake_llm_mode: bool = False
|
|
87
|
+
|
|
88
|
+
# Read-only admin endpoints (RH2-06).
|
|
89
|
+
host_admin_enabled: bool = False
|
|
90
|
+
host_admin_scope: str = Field(default="prismal:admin", min_length=1)
|
|
91
|
+
|
|
92
|
+
@model_validator(mode="after")
|
|
93
|
+
def _no_fake_llm_in_production(self) -> HostSettings:
|
|
94
|
+
# SPEC-RH2-FAK-001: a fake model must never answer production traffic.
|
|
95
|
+
if self.host_fake_llm_mode and self.host_environment == "production":
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"host_fake_llm_mode cannot be enabled when "
|
|
98
|
+
"host_environment='production'"
|
|
99
|
+
)
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@lru_cache
|
|
104
|
+
def get_settings() -> HostSettings:
|
|
105
|
+
"""Return a process-wide cached ``HostSettings`` instance."""
|
|
106
|
+
return HostSettings()
|