Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions contextual_orchestrator/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ def main() -> None:
parser.add_argument("--allow-public-bind", action="store_true")
parser.add_argument("--insecure-disable-auth", action="store_true", help="Deprecated; API auth is always required.")
parser.add_argument("--expose-trace-by-default", action="store_true")
parser.add_argument(
"--insecure-admin-session-cookie",
action="store_true",
help="Allow the admin session cookie over local HTTP; use only for isolated development.",
)
parser.add_argument("--clearfolio-url", default=os.environ.get("CONTEXTUAL_ORCHESTRATOR_CLEARFOLIO_URL") or None,
help="Base URL of a Clearfolio deployment to use as the admin document viewer (default: disabled).")
parser.add_argument("--agents-db", default=os.environ.get("CONTEXTUAL_ORCHESTRATOR_AGENTS_DB") or None,
Expand Down Expand Up @@ -420,6 +425,7 @@ def main() -> None:
max_concurrent_runs=args.max_concurrent_runs,
allow_public_bind=args.allow_public_bind,
expose_trace_by_default=args.expose_trace_by_default,
admin_session_secure_cookie=not args.insecure_admin_session_cookie,
),
clearfolio_url=args.clearfolio_url,
)
Expand Down
130 changes: 97 additions & 33 deletions contextual_orchestrator/admin.py

Large diffs are not rendered by default.

185 changes: 175 additions & 10 deletions contextual_orchestrator/server.py

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions docs/doctoring/admin-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Browser admin session doctoring

## Customer next action

Run the admin console behind HTTPS, establish a session once with the admin
bearer, and then use the console without placing that bearer in browser
storage. Configure `admin_session_secure_cookie=False` (or the CLI's
`--insecure-admin-session-cookie`) only for an explicitly isolated local HTTP
test; production and reverse-proxy deployments keep the secure cookie default.

## Contract

- `POST /admin/session` accepts a bearer in the JSON body or `Authorization`
header, validates it, and returns only `session_status=established`.
- The `contextual_orchestrator_session` cookie is an opaque server-side id;
the raw bearer is never stored in the session map or returned in a response.
- The cookie is `HttpOnly`, `SameSite=Strict`, bounded by TTL and maximum live
sessions, and `Secure` by default.
- `DELETE /admin/session` revokes the current cookie and clears it.
- Admin session cookies authorize admin scope only. They cannot call inference
endpoints as bearer credentials.
- Cookie-authenticated state-changing requests require an `Origin` whose
network location equals `Host`; API bearer clients remain compatible.
- Session state is process-local. A multi-process or durable session backend is
a future deployment boundary, not an unstated reliability claim.

## Evidence

`tests/test_security_hardening.py` proves opacity, admin-only scope, cross-origin
state-change rejection, logout revocation, and cookie clearing. The admin
surface contract proves the UI uses same-origin credentials and clears the
entered token. Focused verification for this change is recorded in the PR;
hosted security and full-suite checks remain authoritative.

## Design provenance

The implementation uses a server-side opaque session id instead of masking or
transforming business PII. Authorization, purpose, expiry, revocation, and
audit boundaries protect access while preserving authorized data usability.
The existing admin design source is Figma file `vsZMd8WAv42HDRgcZuNcWk`.
Storybook is not introduced because this repository has no standalone frontend
package; the embedded admin shell remains covered by its HTML contract test.

## References

National Institute of Standards and Technology. (2022). *Digital identity
guidelines: Authentication and lifecycle management (NIST Special Publication
800-63B)*. https://doi.org/10.6028/NIST.SP.800-63b

OWASP Foundation. (n.d.). *Session management cheat sheet*. Retrieved August
20, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html

Barth, A. (2011). *HTTP state management mechanism* (RFC 6265). Internet
Engineering Task Force. https://doi.org/10.17487/RFC6265
54 changes: 54 additions & 0 deletions docs/planning/adrs/0022-opaque-admin-browser-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# ADR 0022: Opaque admin browser sessions

- Status: Proposed implementation
- Date: 2026-08-20
- Figma file: `vsZMd8WAv42HDRgcZuNcWk` (existing embedded admin source)
- Related issue: #116

## Decision

The embedded admin console establishes a short-lived, bounded, opaque
server-side session after one admin bearer validation. Browser requests carry
only the `HttpOnly; SameSite=Strict; Secure` cookie by default. API clients
continue to use bearer authentication, and a session cookie never authorizes
inference scope.

State-changing requests authenticated by the session also require same-origin
`Origin`/`Host` evidence. Missing, `null`, or mismatched origins fail closed.
The session store has a TTL and a maximum size with deterministic earliest
expiry eviction, preventing unbounded process memory growth.

## Why

Reusing a long-lived bearer as a browser cookie increases replay impact and
couples browser lifetime to API credential lifetime. Blanket PII masking would
also make authorized operator work unusable; this boundary protects the
credential and access purpose instead of altering authorized business data.

## Consequences

The current store is process-local and therefore suitable for a single gateway
process or explicit sticky-session deployment. A multi-process or durable
session backend requires a separate ADR with rotation, revocation replication,
key lifecycle, and failover evidence. HTTPS is required when the secure cookie
default is enabled; local HTTP tests must opt into an insecure cookie explicitly.

## Verification

The security suite covers valid establishment, raw-bearer non-reflection,
admin-only scope, cross-origin rejection, revocation, and clearing. The admin
contract covers the session form, same-origin credential mode, and token field.
Hosted Security, Strix, dependency, and full-suite Checks remain required for
release.

## References (APA 7th)

Barth, A. (2011). *HTTP state management mechanism* (RFC 6265). Internet
Engineering Task Force. https://doi.org/10.17487/RFC6265

National Institute of Standards and Technology. (2022). *Digital identity
guidelines: Authentication and lifecycle management (NIST Special Publication
800-63B)*. https://doi.org/10.6028/NIST.SP.800-63b

OWASP Foundation. (n.d.). *Session management cheat sheet*. Retrieved August
20, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
9 changes: 9 additions & 0 deletions tests/test_admin_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ def test_admin_surface_exists_for_enterprise_operations() -> None:
assert '|| `<tr><td colspan="3" class="empty" data-i18n="no_agents_configured">${t("no_agents_configured")}</td></tr>`' in ADMIN_HTML
assert ADMIN_TRANSLATIONS["en"]["no_audit_events"] == "No audit events yet."
assert '|| `<tr><td colspan="3" class="empty" data-i18n="no_audit_events">${t("no_audit_events")}</td></tr>`' in ADMIN_HTML
assert 'id="sessionForm"' in ADMIN_HTML
assert 'id="sessionToken"' in ADMIN_HTML
assert 'credentials: "same-origin"' in ADMIN_HTML
assert '"/admin/session"' in ADMIN_HTML
assert 'finally {\n els.sessionToken.value = "";' in ADMIN_HTML
assert 'headers: {"origin": window.location.origin}' not in ADMIN_HTML
assert ADMIN_TRANSLATIONS["en"]["session_title"] == "Operator session"
assert "Mask email, phone" not in ADMIN_HTML
assert "Field encryption and audited release" in ADMIN_HTML


def test_admin_state_exposes_agents_without_secrets() -> None:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_cli_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,18 @@ def test_server_concurrency_is_explicit_and_bounded() -> None:
assert serve.call_args.kwargs["security"].max_concurrent_runs == 16


def test_local_http_session_cookie_requires_explicit_opt_in() -> None:
with (
patch.object(sys, "argv", ["contextual-orchestrator", "--serve", "--auth-token", "token", "--insecure-admin-session-cookie"]),
patch("contextual_orchestrator.__main__.load_agents", return_value=[]),
patch("contextual_orchestrator.__main__.ModelClient"),
patch("contextual_orchestrator.__main__.TaskOrchestrator"),
patch("contextual_orchestrator.__main__.serve") as serve,
):
main()
assert serve.call_args.kwargs["security"].admin_session_secure_cookie is False


def test_sampling_temperature_uses_descriptive_name_and_legacy_alias() -> None:
for option in ("--sampling-temperature", "--temperature"):
with (
Expand Down
84 changes: 84 additions & 0 deletions tests/test_security_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,90 @@ def post_json(url: str, payload: dict[str, object], token: str | None = None) ->
return exc.code, json.loads(exc.read().decode("utf-8"))


def request_json(
url: str,
method: str,
*,
body: dict[str, object] | None = None,
headers: dict[str, str] | None = None,
) -> tuple[int, dict[str, object], object]:
"""Make a JSON request and retain response headers for cookie assertions."""
request_headers = {"content-type": "application/json", "connection": "close", **(headers or {})}
request = urllib.request.Request(
url,
data=json.dumps(body).encode("utf-8") if body is not None else None,
headers=request_headers,
method=method,
)
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read().decode("utf-8")), response.headers
except urllib.error.HTTPError as exc:
return exc.code, json.loads(exc.read().decode("utf-8")), exc.headers


def test_admin_session_is_opaque_scoped_and_revocable() -> None:
"""A browser cookie replaces, but never becomes, the long-lived bearer."""
server = build_server(build(), port=0, security=SecurityConfig(auth_token="secret_token"))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
base = f"http://127.0.0.1:{server.server_address[1]}"
try:
status, body, headers = request_json(
f"{base}/admin/session",
"POST",
body={"token": "secret_token"},
)
set_cookie = headers.get("set-cookie") or headers.get("Set-Cookie") or ""
cookie_pair = set_cookie.split(";", 1)[0]
session_id = cookie_pair.split("=", 1)[1]
assert status == 200
assert body == {"session_status": "established"}
assert "secret_token" not in set_cookie
assert "HttpOnly" in set_cookie and "SameSite=Strict" in set_cookie and "Secure" in set_cookie
assert session_id != "secret_token"

status, state, _ = request_json(f"{base}/admin/state", "GET", headers={"cookie": cookie_pair})
assert status == 200 and "agents" in state
status, evaluation, _ = request_json(
f"{base}/api/v1/evaluation_runs",
"POST",
body={"prompts": ["evaluate this"]},
headers={"cookie": cookie_pair, "origin": base},
)
assert status == 201 and evaluation["prompt_count"] == 1
status, _, _ = request_json(f"{base}/v1/models", "GET", headers={"cookie": cookie_pair})
assert status == 401
status, body, _ = request_json(
f"{base}/admin/simulate",
"POST",
body={"prompt": "cross-origin must fail"},
headers={"cookie": cookie_pair, "origin": "https://evil.example"},
)
assert status == 403 and body["error"]["code"] == "csrf_origin_rejected"

status, body, _ = request_json(
f"{base}/admin/session",
"DELETE",
headers={"cookie": cookie_pair, "origin": "https://evil.example"},
)
assert status == 403 and body["error"]["code"] == "csrf_origin_rejected"

status, body, clear_headers = request_json(
f"{base}/admin/session",
"DELETE",
headers={"cookie": cookie_pair, "origin": base},
)
clear_cookie = clear_headers.get("set-cookie") or clear_headers.get("Set-Cookie") or ""
assert status == 200 and body == {"session_status": "cleared", "session_revoked": True}
assert "Max-Age=0" in clear_cookie
status, _, _ = request_json(f"{base}/admin/state", "GET", headers={"cookie": cookie_pair})
assert status == 401
finally:
server.shutdown()
thread.join(timeout=5)


def test_http_api_requires_bearer_token_and_hides_trace_by_default() -> None:
server = build_server(build(), port=0, security=SecurityConfig(auth_token="secret_token"))
thread = threading.Thread(target=server.serve_forever, daemon=True)
Expand Down
Loading