Skip to content

Step 3 isolation-safe hardening (W1–W6): fail-closed authz, dead-letter tiering, /status behind auth, Neo4j browser-URL flag#45

Draft
colombod wants to merge 4 commits into
step2-auth-spikefrom
step3-isolation-safe
Draft

Step 3 isolation-safe hardening (W1–W6): fail-closed authz, dead-letter tiering, /status behind auth, Neo4j browser-URL flag#45
colombod wants to merge 4 commits into
step2-auth-spikefrom
step3-isolation-safe

Conversation

@colombod

@colombod colombod commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Stacked on step2-auth-spike (PR #42, draft). Review after / alongside #42; the diff shown here is step3 vs step2.

Three council-reviewed local commits, all provable under pytest — no Azure, no live EasyAuth, no browser. Spec: workspace docs/16.

Commits

  • 3aeb581 Commit 1 (coupled slice W1+W2+W3+W5+W6)
  • 74e330e W4 — Neo4j browser-URL hide flag
  • 7c91abe Commit 3 — conftest client fixture → asgi_app (real middleware) + TB-N1 guard

What changed

  • W1 fail-closed authz_is_write_capable denies on unpopulated request scope state unless app.state.allow_unauthenticated (explicit dev/test opt-out). Prod empty-state now fails closed; in auth-enabled prod the branch is unreachable.
  • W2 dead-letter tieringGET /queues/dead-letter (list) → require_read (any authenticated principal). purge/replay RELOCATED to POST /admin/queues/dead-letter/{worker}/{purge,replay} under a new /admin-prefixed require_admin router. Relocation is load-bearing: the static admin key is recognized ONLY on /admin/*, so admin-gated mutations must live there. Closes council TB-1 (static-admin lockout), pinned by positive tests.
  • W3 /status behind auth — removed from both auth-exempt sets; /version stays as the unauthenticated liveness carve-out.
  • W4 Neo4j browser-URL hide flag/status.neo4j_browser_url gated by is_development bool; value when visible, null when hidden (stable shape). Bolt URL + health stay visible. Fail-safe = hidden (zero-config prod hides).
  • W5 deploy-surface coherence (product's own files) — README main:appmain:asgi_app; docker-compose healthcheck /status/version; docs/service-setup.md stale "/status always unauthenticated" claims fixed.
  • W6 admin controls off the general dashboard (doc 04 §3) — removed dead-letter DRAIN (replay/purge) buttons + fetch wrappers + confirm flow from the general dashboard queues-panel; kept the read-only LIST. This is the REMOVAL half only — the /admin UI that will HOUSE these controls is deferred (Azure-gated, doc 04).

⚠ BREAKING CHANGES

  • GET /status now requires authentication (was unauthenticated). Repoint external health/liveness monitors to /version (config-free, unauthenticated, unchanged).
  • Dead-letter purge/replay moved: /queues/dead-letter/{w}/{purge,replay}/admin/queues/dead-letter/{w}/{purge,replay}. Old paths now 404.

Interim admin drain (until the doc-04 /admin UI ships — API is reachable now)

curl -X POST -H "Authorization: Bearer <admin_key_or_admin_role_token>" \
  http://<host>/admin/queues/dead-letter/<worker_key>/purge
curl -X POST -H "Authorization: Bearer <admin_key_or_admin_role_token>" \
  http://<host>/admin/queues/dead-letter/<worker_key>/replay

Verification

Isolated suite pytest -m "not neo4j and not integration" = 1843 passed, 2 skipped, 0 failed (independently re-run). Web JS suite 101/101. Council-reviewed across the slices; no FAIL.

Tracked follow-ups (non-blocking)

  • Deeper root smell: create_asgi_app mutates a shared module-level singleton app with no teardown; the Commit-3 guard patches only app.state.allow_unauthenticated. Real fix = fixture-scoped snapshot/restore or fresh-app factory (needs owner).
  • W4: /status.neo4j_browser_url contract is now str|null; browser URL reads the FLAT field while Bolt URL resolves via the STRUCTURED neo4j block.
  • UX copy: in-UI "drain is admin-only" hint on the general dashboard; actionable onboarding/contact for identity_unbound 403 + no-header 401.
  • Changelog/release-note the moved dead-letter purge/replay paths for external callers.

Not in this PR (deferred)

The /admin browser UI (doc 04/06), live EasyAuth in Azure + Test B deploy gate (doc 15), and the Step 4 coherence+test gate (doc 07) are designed but NOT built here.

colombod and others added 4 commits July 9, 2026 00:32
…-letter tiering, /status behind auth

Coupled behavior slice (W1+W2+W3+W5+W6), all provable under pytest. No Azure,
no live EasyAuth, no browser. Spec: workspace docs/16.

W1 fail-closed authz: _is_write_capable now denies on unpopulated request scope
state unless app.state.allow_unauthenticated (the explicit dev/test opt-out).
Converts an accidental implicit default into a named flag; prod empty-state now
fails closed. In auth-enabled prod the branch is unreachable (middleware always
sets is_service; boot guard keeps capability routes non-exempt).

W2 dead-letter tiering (per product decision + council TB-1):
- GET /queues/dead-letter (list) -> require_read: any authenticated principal.
- purge/replay RELOCATED to POST /admin/queues/dead-letter/{worker}/{purge,replay}
  under a new /admin-prefixed require_admin router. Relocation is load-bearing:
  the static admin key is recognized ONLY on /admin/* (auth._is_admin_route), so
  admin-gated mutations must live under /admin to be reachable by the static
  (local/dev/test) admin credential. Boot-guard watch-list extended to require_admin.

W3 /status behind auth: removed from both auth-exempt sets; /version stays as the
unauthenticated liveness carve-out. get_status carries no capability dep.

W5 deploy-surface coherence (product's own files, corrected): README production
command main:app -> main:asgi_app (bare app has no auth middleware); docker-compose
healthcheck /status -> /version; docs/service-setup.md stale "/status always
unauthenticated" claims fixed.

W6 admin controls off the general dashboard (doc 04 §3): removed the dead-letter
DRAIN (replay/purge) buttons + fetch wrappers + confirm flow from the general
dashboard queues-panel; kept the read-only dead-letter LIST. Admin drain
functionality lives only behind the admin surface. NOTE: this is the REMOVAL half
of doc 04 §3 — the /admin browser UI that will house these controls is deferred
(Azure-gated, doc 04); the endpoints exist and are admin-gated now.

BREAKING CHANGES:
- GET /status now requires authentication (was unauthenticated). Repoint external
  health/liveness monitors to /version (config-free, unauthenticated, unchanged).
- Dead-letter purge/replay moved: /queues/dead-letter/{w}/{purge,replay} ->
  /admin/queues/dead-letter/{w}/{purge,replay}. Old paths now 404.

Interim admin drain path (until the doc-04 /admin UI ships) — API is reachable now:
  curl -X POST -H "Authorization: Bearer <admin_key_or_admin_role_token>" \
    http://<host>/admin/queues/dead-letter/<worker_key>/purge
  curl -X POST -H "Authorization: Bearer <admin_key_or_admin_role_token>" \
    http://<host>/admin/queues/dead-letter/<worker_key>/replay

Verification: isolated suite `pytest -m "not neo4j and not integration"` = 1838
passed, 2 skipped, 0 failed (independently re-run). Web JS suite 101/101. The two
strict-xfail authz tripwires flipped XFAIL->XPASS and their markers were removed.
Council-reviewed (two rounds); no FAIL; TB-1 static-admin-lockout closed by the
relocation and pinned by positive tests (static admin key + entra admin role reach
the handler through the real gate). Tracked non-blocking follow-ups: conftest
client->asgi_app fixture swap (Commit 3), the /admin UI (doc 04), and error-copy UX.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…t — W4

doc 16 W4, independent of the Step 3 auth-coupled slice. The Neo4j 7474 HTTP
browser URL points into a private Azure VNet (doc 05 requirement 5b) and must not
be surfaced on /status. The Bolt URL (neo4j_url) and health flags stay visible.

- config.py: add `is_development: bool = False` and `show_neo4j_browser_url: bool
  = False`, plus a `neo4j_browser_url_visible()` predicate (fail-safe hidden: only
  development OR the explicit opt-in reveals the URL).
- main.py /status: gate `neo4j_browser_url` behind the predicate — value when
  visible, null when hidden. Key is always present so the /status JSON shape stays
  stable (hidden is a value state, not a schema change).

Design note (council W4): the dev-detection field is a scoped `is_development: bool`,
NOT a general `environment: str`. The panel (cranky-old-sam + tester-breaker) flagged
the open string as speculative generality for a single yes/no decision — one consumer
today — and as a case-sensitivity foot-gun ("Development" silently hiding). A bool
eliminates both. Widen to an environment enum later IF a real second consumer appears.

Fail-safe verified: a zero-config fresh deploy (production, no opt-in) hides the URL;
garbage/unset values hide; only `is_development=true` or `show_neo4j_browser_url=true`
reveal. The sole emit path is the gated /status line — no other endpoint, template, or
log surfaces the URL.

Tracked (non-blocking, from review): the /status `neo4j_browser_url` key type changes
from always-str to str|null (in-tree consumers are null-safe); and the browser URL
still resolves via the flat `neo4j_browser_url` field while the adjacent Bolt URL
resolves via the structured neo4j block — a latent display-correctness mismatch to
reconcile if a structured browser-url field is ever added.

Verification: isolated suite `pytest -m "not neo4j and not integration"` = 1843
passed, 2 skipped, 0 failed (independently re-run); 5 new W4 tests pass. python_check
clean.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
… middleware) — Commit 3

doc 16 §9 Commit 3 (tracked, not a blocker in Commit 1). The default `client`
fixture routed through the bare, unwrapped `app` — so the ~146 tests using it never
traversed the auth middleware and passed gated routes via "no middleware ran". Route
it through `asgi_app` (the auth-wrapped ASGI app) so the default suite exercises the
real wired stack. This closes the recorded crusty-old-engineer dissent from Commit 1.

Under the suite's ALLOW_UNAUTHENTICATED=true opt-out the middleware short-circuits
(no scope state populated) and W1's _is_write_capable honours the flag, so these tests
stay green while now traversing the real middleware.

TB-N1 guard (proven load-bearing): there is a single module-level FastAPI `app`;
create_asgi_app(settings=...) mutates its shared app.state on every call (173 calls in
the suite). A sibling building an auth-enabled app (allow_unauthenticated=False) leaves
app.state.allow_unauthenticated=False on the singleton; because `client` now wraps that
singleton and W1 reads the flag live, a later gated-route `client` test would fail-close
(403). The fixture resets app.state.allow_unauthenticated=True before each yield.
Proof: adversarial ordering (flipper files then test_main.py client/gated tests) = 0
failures WITH the guard, 28 failures WITHOUT it (guard temporarily disabled).

Also removes a now-redundant function-local `import asgi_app` in auth_client (module-level now).

Council-reviewed (crusty-old-engineer / tester-breaker / cranky-old-sam): no FAIL.
cranky-old-sam PASS (minimal, net-subtractive, the long comment earns its place as
non-obvious why-not-delete rationale). crusty dissent CLOSED, merge. The fixture comment
was narrowed after review to NOT overclaim full order-independence: the guard resets ONLY
allow_unauthenticated; other create_asgi_app-leaked app.state (auth_mode, roles, stores)
is not reset and is not currently observed by any `client` test.

Tracked follow-ups (deeper, not patched here — SCRATCH):
- create_asgi_app mutates a shared module-level singleton with no teardown (design smell;
  candidate: fixture-scoped app.state snapshot/restore, or a fresh-app factory).
- Latent TB-N1 siblings: auth_mode / reader_role / service_data_role leak onto the same
  singleton and are read live by /status; not live today (client /status tests assert only
  status_code + neo4j fields), but the same class of hazard.
- asgi_app.resolver is the true auth-enforcement lever (auth.py short-circuit), protected
  today only by the monkeypatch/separate-instance conventions of sibling tests.

Verification: isolated suite `pytest -m "not neo4j and not integration"` = 1843 passed,
2 skipped, 0 failed (independently re-run after the comment narrowing).

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
…/status-behind-auth

W3 (commit 3aeb581) moved GET /status behind authentication. This is WRONG and
breaks the Azure Container Apps deployment: ACA uses /status as its UNAUTHENTICATED
liveness/health probe. An authenticated /status makes every liveness check 401 →
the revision never goes healthy → deployment failure. It also broke the existing
dashboard (its api.js fetchStatus() sends no token), which surfaced this in live
browser testing.

/status MUST be a public, unauthenticated route at every level and must NEVER be
gated again.

Reverted (W3 + its W5 coherence artifacts only — W1 fail-closed authz, W2
dead-letter tiering, W6 dashboard removal are UNTOUCHED):
- auth.py: restored "/status" to both _EXEMPT_PATHS and _EXEMPT_PATHS_API_ONLY.
- docker-compose.yml: healthcheck reverted /version -> /status.
- docs/service-setup.md: reverted the "/status requires auth" edits (verify curl
  drops the bearer; exempt-list re-includes /status; health example unauthenticated).
- Tests that asserted /status was gated (test_auth, test_web_ui_switch,
  test_entra_integration, test_docker_infrastructure, test_m2_service_auth,
  test_main) reverted to assert /status is exempt / 200 without a token.
- test_status_requires_auth.py (a W3 tripwire enforcing the wrong policy) DELETED;
  replaced by test_status_stays_public.py — an INVERTED tripwire that fails loudly
  if /status is ever removed from either exempt set. Protects against re-regression.

Note: /version remains an additional unauthenticated liveness carve-out. The
neo4j_browser_url hide flag (W4) still hides the browser URL on the public /status.

Verification (independently re-run): isolated pytest -m "not neo4j and not
integration" = 1845 passed, 2 skipped, 0 failed. Targeted status/auth suite 114 passed.

🤖 Generated with [Amplifier](https://github.com/microsoft/amplifier)

Co-Authored-By: Amplifier <240397093+microsoft-amplifier@users.noreply.github.com>
@colombod colombod force-pushed the step2-auth-spike branch from af46484 to f53b9a9 Compare July 9, 2026 00:34
@colombod colombod force-pushed the step3-isolation-safe branch from b342dcc to d8cbb92 Compare July 9, 2026 00:36
@colombod colombod marked this pull request as draft July 9, 2026 13:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant