Skip to content
Closed
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
13 changes: 7 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
35 changes: 24 additions & 11 deletions context_intelligence_server/authz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions context_intelligence_server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
# -------------------------------------------------------------------------
Expand Down
31 changes: 28 additions & 3 deletions context_intelligence_server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__,
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions context_intelligence_server/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])


Expand Down
25 changes: 16 additions & 9 deletions context_intelligence_server/routers/queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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``.

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion context_intelligence_server/web/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ <h2 style="margin:0;font-size:1.25rem;">Context Intelligence</h2>
<div class="table-scroll">
<table class="data-table">
<thead><tr>
<th>Worker</th><th>Count</th><th>Last error</th><th>Actions</th>
<th>Worker</th><th>Count</th><th>Last error</th>
</tr></thead>
<tbody id="dead-letter-body"></tbody>
</table>
Expand Down
6 changes: 4 additions & 2 deletions context_intelligence_server/web/static/js/dashboard.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { fetchStatus, postCypher } from './api.js';
import { renderQueues, fetchDeadLetters, renderDeadLetters, renderDeadLetterError, wireDeadLetterActions } from './queues-panel.js';
import { renderQueues, fetchDeadLetters, renderDeadLetters, renderDeadLetterError } from './queues-panel.js';

function timeAgo(ts) {
if (!ts) return '-';
Expand Down Expand Up @@ -101,7 +101,9 @@ function setTab(name) {
document.getElementById('tab-overview')?.addEventListener('click', () => setTab('overview'));
document.getElementById('tab-queues')?.addEventListener('click', () => setTab('queues'));
document.getElementById('hint-go-queues')?.addEventListener('click', () => setTab('queues'));
wireDeadLetterActions({ onAuthLost });
// Dead-letter drain (replay/purge) is admin-only and lives on the admin surface
// (doc 04 §3); the general dashboard renders the dead-letter list read-only, so
// there are no action handlers to wire here.

async function refresh() {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,10 +272,12 @@ describe('dashboard.js Queues tab wiring (C2 re-arch)', () => {
);
});

test('wires dead-letter actions and the hint-go-queues shortcut', () => {
test('does NOT wire dead-letter drain actions (admin-only, doc 04 §3) and keeps the hint-go-queues shortcut', () => {
// Dead-letter drain (replay/purge) is admin-only and lives on the admin
// surface (doc 04 §3). The general dashboard must NOT wire drain controls.
assert.ok(
jsSource.includes('wireDeadLetterActions('),
'dashboard.js should call wireDeadLetterActions(...)'
!jsSource.includes('wireDeadLetterActions('),
'dashboard.js must NOT call wireDeadLetterActions(...) — drain is admin-only (doc 04 §3)'
);
assert.ok(
jsSource.includes('hint-go-queues'),
Expand Down
Loading