diff --git a/README.md b/README.md index bf61ee64..010f7ee6 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ The server looks for `server-config.yaml` in the **working directory** by defaul ```bash AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_CONFIG_FILE=/etc/ci-server/config.yaml \ - uvicorn context_intelligence_server.main:app + uvicorn context_intelligence_server.main:asgi_app ``` #### Option B — Environment variables @@ -333,7 +333,7 @@ AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_NEO4J_BROWSER_URL=http://localhost:7474 \ AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_NEO4J_PASSWORD="" \ AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_BLOB_PATH=/tmp/ci-blobs \ AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_LOG_PATH=/tmp/ci-logs/server.jsonl \ - uvicorn context_intelligence_server.main:app --reload + uvicorn context_intelligence_server.main:asgi_app --reload ``` #### Option C — Mix both @@ -350,17 +350,18 @@ log_path: /data/ci-logs/server.jsonl ```bash # Override only the password at runtime (e.g. from a secrets manager) AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_NEO4J_PASSWORD=hunter2 \ - uvicorn context_intelligence_server.main:app + uvicorn context_intelligence_server.main:asgi_app ``` ### 4. Start the server ```bash # With auto-reload (development) -uvicorn context_intelligence_server.main:app --reload +uvicorn context_intelligence_server.main:asgi_app --reload -# Production — bind explicitly -uvicorn context_intelligence_server.main:app \ +# Production — bind explicitly (MUST serve asgi_app: the auth-wrapped ASGI app; +# main:app is the bare app with NO auth middleware) +uvicorn context_intelligence_server.main:asgi_app \ --host 0.0.0.0 \ --port 8000 \ --workers 1 diff --git a/context_intelligence_server/authz.py b/context_intelligence_server/authz.py index dcafef53..cb2b321b 100644 --- a/context_intelligence_server/authz.py +++ b/context_intelligence_server/authz.py @@ -21,21 +21,34 @@ def _is_write_capable(request: Request) -> bool: """True for any human/static principal; for a service iff it holds Contributor. - When ``is_service`` is absent from scope state the principal defaults to - False (human-like), making it write-capable. This default is ONLY - reachable in two safe situations: - - 1. ``allow_unauthenticated=True`` (dev/test mode, no credential required). - 2. Auth-exempt paths (/status, /version, /skills/*) — none of which carry - a capability gate, so this function is never called for them. + Fail-closed on unpopulated scope state (Step 3, doc 16 W1): when + ``is_service`` is absent from scope state entirely, the request never + passed through BearerTokenMiddleware's identity-setting path. That is + only a safe situation when the server is explicitly in the + ``allow_unauthenticated=True`` dev/test opt-out (read from + ``app.state.allow_unauthenticated``) — otherwise this denies. In auth-enabled production mode BearerTokenMiddleware ALWAYS sets - ``is_service`` on scope state before any route handler or dependency runs, - so the default is never exercised in that path. + ``is_service`` on scope state before any route handler or dependency runs + (auth.py), and the boot guard (``_assert_capability_routes_not_exempt``, + main.py) keeps every capability-gated route non-exempt, so the + absent-``is_service`` branch is unreachable in production. + + Once ``is_service`` IS present, ``False`` (human / static / easyauth) + remains always write-capable — unchanged. """ state: dict = request.scope.get("state", {}) - if not state.get("is_service", False): - return True # human / static — always write-capable, unchanged + if "is_service" not in state: + # Unpopulated scope state → fail CLOSED (Step 3, doc 16 W1). + # In auth-enabled mode this branch is unreachable: middleware always + # sets is_service (auth.py) and the boot guard keeps capability + # routes non-exempt (main.py). It is reachable ONLY in the explicit + # allow_unauthenticated dev/test opt-out, where the middleware + # short-circuits without populating state. Honour that opt-out; + # otherwise deny. + return bool(getattr(request.app.state, "allow_unauthenticated", False)) + if not state["is_service"]: + return True # human / static / easyauth — write-capable, unchanged roles: list[str] = state.get("roles", []) role: str = getattr(request.app.state, "service_data_role", "") return bool(role) and role in roles diff --git a/context_intelligence_server/config.py b/context_intelligence_server/config.py index 0afb9f6b..158fdd0c 100644 --- a/context_intelligence_server/config.py +++ b/context_intelligence_server/config.py @@ -805,6 +805,20 @@ def _normalize_service_role_fields(cls, v: object) -> str: neo4j_password: str = "password" neo4j_browser_url: str = "http://localhost:7474" + # Development mode. True reveals local-only surfaces (e.g. the Neo4j browser + # URL on /status); default False (production) hides them. Scoped bool, NOT a + # general "environment" string: this is the only consumer today, and a bool + # cannot sprout `if environment == "..."` sprawl or a case-sensitivity foot-gun + # (council W4). Widen to an environment enum later IF a real second consumer + # appears. Env: AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_IS_DEVELOPMENT=true + is_development: bool = False + + # Explicit opt-in to expose the Neo4j Browser (7474 HTTP) URL on /status even + # outside development. Default False -> hidden (fail-safe). In Azure this stays + # False: the browser URL points into a private VNet and must not be surfaced. + # Env: AMPLIFIER_CONTEXT_INTELLIGENCE_SERVER_SHOW_NEO4J_BROWSER_URL=true + show_neo4j_browser_url: bool = False + # Structured two-client config (doc 11). OPTIONAL for backward-compat: when # absent, BOTH clients fall back to the legacy flat neo4j_* fields above. # The real amplifier-online.yaml MUST set this explicitly (see @@ -841,6 +855,13 @@ def resolve_neo4j_query(self) -> Neo4jClientConfig: access_mode="READ", ) + def neo4j_browser_url_visible(self) -> bool: + """True iff the Neo4j Browser (7474 HTTP) URL may be surfaced on /status. + + Fail-safe hidden: only development OR the explicit opt-in reveals it. + """ + return self.is_development or self.show_neo4j_browser_url + # ------------------------------------------------------------------------- # Storage paths # ------------------------------------------------------------------------- diff --git a/context_intelligence_server/main.py b/context_intelligence_server/main.py index cba29c0a..21e4147e 100644 --- a/context_intelligence_server/main.py +++ b/context_intelligence_server/main.py @@ -42,7 +42,10 @@ from context_intelligence_server.identity_store import IdentityStore from context_intelligence_server.dashboard import build_status_response from context_intelligence_server.routers.admin import router as admin_router -from context_intelligence_server.routers.queues import router as queues_router +from context_intelligence_server.routers.queues import ( + dead_letter_admin_router, + router as queues_router, +) from context_intelligence_server.routers.skills import SkillRegistry from context_intelligence_server.routers.skills import router as skills_router from context_intelligence_server.routers.version import router as version_router @@ -219,6 +222,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: await app.state.neo4j_query_driver.close() +# NOTE (doc 16 §10.2): `app` is the BARE FastAPI app — NO auth middleware and +# NO fail-closed startup gate. It exists only for internal wiring and tests. +# The ONLY deployable entrypoint is `asgi_app` (defined below): serving `main:app` +# directly ships an unauthenticated server. Deploy `main:asgi_app`, never `main:app`. app = FastAPI( title="Context Intelligence Server", version=__version__, @@ -233,6 +240,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: app.include_router(skills_router) app.include_router(version_router) app.include_router(queues_router) +# doc 16 W2: dead-letter purge/replay live under /admin so the static admin-key +# fast-path (auth._is_admin_route) can reach them; list stays on queues_router. +app.include_router(dead_letter_admin_router) _start_time = time.time() registry = SessionRegistry() # Expose the registry singleton on app.state so routers can read it via @@ -352,8 +362,13 @@ def _assert_capability_routes_not_exempt(settings: Settings) -> None: require_read, require_write, ) + from context_intelligence_server.routers.admin import require_admin # noqa: PLC0415 - _capability_deps = {require_read: "require_read", require_write: "require_write"} + _capability_deps = { + require_read: "require_read", + require_write: "require_write", + require_admin: "require_admin", + } def _collect_calls(dependant: Any) -> list[Any]: """Recursively collect every ``.call`` in a FastAPI dependant tree.""" @@ -487,6 +502,10 @@ def create_asgi_app( # M2: service capability role names for require_write / require_read deps. app.state.service_data_role = s.service_data_role app.state.reader_role = s.reader_role + # Step 3 (doc 16 W1): expose the dev/test opt-out to the capability + # predicate. _is_write_capable() fails closed on unpopulated scope state + # UNLESS this flag is set — the only sanctioned empty-state write path. + app.state.allow_unauthenticated = s.allow_unauthenticated # Compute the admin-key digest for the middleware (static mode only). # The middleware checks the bearer token's sha256 against this digest BEFORE @@ -685,7 +704,13 @@ async def get_status(request: Request) -> dict[str, Any]: request.app, "neo4j_query_driver" ) response["neo4j_url"] = _settings.resolve_neo4j_admin().url - response["neo4j_browser_url"] = _settings.neo4j_browser_url + # doc 16 W4: hide the 7474 HTTP browser URL unless dev / explicit opt-in. + # Key is ALWAYS present (stable /status shape) but null when hidden so the + # dashboard treats absence-of-value as "not available". Bolt url (neo4j_url) + # and health stay visible. + response["neo4j_browser_url"] = ( + _settings.neo4j_browser_url if _settings.neo4j_browser_url_visible() else None + ) # Additive, aggregate-only conservation metrics (D3). /status is # unauthenticated, so this block must NOT carry the per-key table or the # dead-letter listing — both are authenticated-only. diff --git a/context_intelligence_server/routers/admin.py b/context_intelligence_server/routers/admin.py index 24f4d9f3..353f30fa 100644 --- a/context_intelligence_server/routers/admin.py +++ b/context_intelligence_server/routers/admin.py @@ -348,6 +348,14 @@ def _require_key_store(request: Request) -> IdentityStore: # Router # --------------------------------------------------------------------------- +# NOTE (doc 16 W2, council crusty F-W2b): this is NOT the only ``/admin``-prefixed +# router. A SECOND one — ``dead_letter_admin_router`` in ``routers/queues.py`` — +# also mounts under ``/admin`` for the dead-letter drain routes +# (``POST /admin/queues/dead-letter/{worker_key}/{purge,replay}``). The path +# spaces are disjoint (``/admin/identities`` + ``/admin/keys`` here vs +# ``/admin/queues/dead-letter/*`` there), and both share this same +# ``require_admin`` dependency object so the admin gate + test override identity +# apply uniformly. Both must be ``include_router``'d in ``main.py``. router = APIRouter(prefix="/admin", dependencies=[Depends(require_admin)]) diff --git a/context_intelligence_server/routers/queues.py b/context_intelligence_server/routers/queues.py index 184319fb..4e2d886d 100644 --- a/context_intelligence_server/routers/queues.py +++ b/context_intelligence_server/routers/queues.py @@ -15,12 +15,25 @@ from fastapi import APIRouter, Depends, HTTPException # noqa: F401 (HTTPException per spec) from fastapi.requests import Request -from context_intelligence_server.authz import require_read, require_write +from context_intelligence_server.authz import require_read +from context_intelligence_server.routers.admin import require_admin logger = logging.getLogger(__name__) router = APIRouter() +# Second router: dead-letter admin mutations (purge/replay) live under /admin so +# the static admin-key fast-path (auth._is_admin_route, /admin/* ONLY) can reach +# them (council TB-1 fix, doc 16 W2). require_admin in static mode allows only +# when scope state has is_admin=True, and that flag is set ONLY on /admin/* paths; +# a require_admin route OFF /admin/* would be unreachable by the static admin key. +# require_admin is applied router-wide here (mirrors admin.py) — do NOT add a +# per-route Depends(require_admin). +dead_letter_admin_router = APIRouter( + prefix="/admin", + dependencies=[Depends(require_admin)], +) + def _decode_payload(record: dict[str, Any]) -> bytes: """Return the original payload bytes from a dead-letter record. @@ -63,10 +76,7 @@ async def list_dead_letters(request: Request) -> dict[str, Any]: return {"dead_letters": entries} -@router.post( - "/queues/dead-letter/{worker_key:path}/purge", - dependencies=[Depends(require_write)], -) +@dead_letter_admin_router.post("/queues/dead-letter/{worker_key:path}/purge") async def purge_dead_letters(worker_key: str, request: Request) -> dict[str, Any]: """Purge all dead-letter records for ``worker_key``. @@ -83,10 +93,7 @@ async def purge_dead_letters(worker_key: str, request: Request) -> dict[str, Any return {"worker_key": worker_key, "purged": purged} -@router.post( - "/queues/dead-letter/{worker_key:path}/replay", - dependencies=[Depends(require_write)], -) +@dead_letter_admin_router.post("/queues/dead-letter/{worker_key:path}/replay") async def replay_dead_letters(worker_key: str, request: Request) -> dict[str, Any]: """Re-enqueue every dead-letter record for ``worker_key`` then purge them. diff --git a/context_intelligence_server/web/dashboard.html b/context_intelligence_server/web/dashboard.html index a1e3a350..46940c8f 100644 --- a/context_intelligence_server/web/dashboard.html +++ b/context_intelligence_server/web/dashboard.html @@ -147,7 +147,7 @@
| Worker | Count | Last error | Actions | +Worker | Count | Last error |
|---|