Admin surface threat model - verification (M27-14)
The STRIDE threat model for the admin dashboard lives in the design of record:
docs/design/admin-dashboard-milestone-plan-fable.md
section 4.4. This companion is the verification ledger #491 requires: every row of that
table cites the test(s) that prove its mitigations, or - where a mitigation is not
mechanically testable - the documented manual check that stands in. Web-test paths are relative
to apps/admin. Python test paths cite the retired apps/api suite (deleted in #808): those
suites and their TS ports under apps/server/src were kept in lockstep through the migration,
and the Python originals run at git d69f789. When a cited test moves, update this file in the
same PR; the row-per-mitigation contract is the point.
How to read the table: Threat / Vector mirror 4.4 verbatim (abbreviated); Verification
is the pointer. E2E means apps/admin/e2e/admin-journey.e2e.ts (the browser journey CI runs
per PR against the local stack - see the deployed-dev variant in
docs/deployment.md).
| # | Threat | Vector (4.4) | Verification |
|---|---|---|---|
| 1 | Spoofing | Any Google account signs in and reaches admin | tests/integration/test_admin_auth.py::test_ungranted_sign_in_redirects_to_access_denied_and_mints_nothing, ::test_first_time_ungranted_visitor_is_denied_too (sign-in mints nothing without a grant); ::test_revoked_grant_denies_the_next_request_on_a_live_session (per-request re-check); tests/integration/test_admin_api.py::test_no_grant_user_gets_a_bare_403; bootstrap-only-via-env: tests/unit/test_admin_grants.py::test_bootstrap_seeds_superadmin_exactly_once, ::test_bootstrap_never_clobbers_an_existing_grant; E2E "sign-in without a grant lands on access-denied and mints nothing" |
| 2 | Spoofing | Test-login mints an admin session in a deployed env | tests/integration/test_admin_auth.py::test_test_login_can_never_mint_an_admin_session (structurally user-surface-only); tests/unit/test_config.py::test_test_login_is_forbidden_in_production, ::test_admin_test_grant_is_forbidden_in_production, ::test_admin_test_oidc_is_forbidden_in_production (the validators); test_admin_auth.py::test_loopback_oidc_is_absent_without_its_flag, ::test_loopback_oidc_still_denies_without_a_grant (the M27-14 loopback IdP stays grant-gated and off by default) |
| 3 | Tampering | Admin edits bloom_runs.state and corrupts the orchestrator | tests/integration/test_admin_projects_api.py::test_edit_state_rejects_invalid_shapes_with_field_level_errors (schema validation), ::test_edit_state_rejects_stale_updated_at (optimistic concurrency), ::test_edit_state_happy_path_writes_full_before_after_audit (full diff in the trail); superadmin-only: ::test_projects_routes_deny_by_default |
| 4 | Tampering | CSRF from a malicious site drives admin mutations | tests/integration/test_admin_hardening_api.py (the whole module: foreign/absent-origin 403 before auth, Referer fallback, config-driven allowlist, JSON-only mutations, safe GETs unaffected); cookie policy: test_admin_auth.py::test_admin_cookie_is_host_only_secure_lax_with_the_12h_ttl; admin origin absent from CORS: manual check - create_app registers CORSMiddleware for settings.dashboard_url alone (apps/api/src/bloom/api/app.py), grep CORSMiddleware to confirm a single registration |
| 5 | Tampering | Audit-trail erasure to cover tracks | tests/unit/test_admin_audit.py::test_no_update_or_delete_against_the_audit_table_exists_anywhere (grep-guard), ::test_store_surface_is_append_and_read_only; DB-level REVOKE UPDATE, DELETE remains a documented hardening option for the only-mode role (design 4.3), not yet provisioned |
| 6 | Repudiation | "Who deleted that user?" unanswerable | tests/integration/test_admin_audit_api.py::test_success_error_and_denied_paths_each_land_a_row; per-verb coverage in tests/integration/test_admin_actions_api.py (e.g. ::test_update_name_validates_audits_and_persists, ::test_grant_change_and_revoke_admin_round_trip_with_audit, ::test_bulk_session_revoke_is_capped_deduped_and_audited_per_item); E2E asserts the mutation's audit row renders with its actor |
| 7 | Info. disclosure | Admin surface leaks credential values | tests/integration/test_admin_resources_api.py::test_global_credentials_are_metadata_only (no value-read endpoint exists); tests/integration/test_admin_actions_api.py::test_delete_global_credential_needs_the_store_and_scrubs_metadata; snapshot redaction: tests/unit/test_admin_audit.py::test_redaction_scrubs_secret_shaped_keys_recursively_and_keeps_names, tests/integration/test_admin_audit_api.py::test_snapshots_come_back_redacted_never_the_raw_session_id |
| 8 | Info. disclosure | XSS on the admin origin exfiltrates data / rides the session | CSP: src/test/csp-parity.test.ts (static _headers == Function proxy headers == security-headers.ts, where every directive is justified - script/connect/frame/worker all locked to 'self' or 'none'); no-escape-hatch guard: src/test/no-danger.test.ts (no dangerouslySetInnerHTML in the admin render path); HttpOnly cookie: test_admin_auth.py::test_admin_cookie_is_host_only_secure_lax_with_the_12h_ttl |
| 9 | Info. disclosure | Compromised user-dashboard origin pivots to admin | test_admin_auth.py::test_user_surface_cookie_domain_never_widens_the_admin_cookie (host-only, no Domain attribute ever), ::test_surface_confusion_matrix (user cookie/session authorizes nothing on admin routes, and vice versa) |
| 10 | DoS | Bulk actions or unbounded list queries hammer the DB | tests/integration/test_admin_resources_api.py::test_every_list_is_hard_capped_at_100; tests/integration/test_admin_actions_api.py::test_bulk_session_revoke_is_capped_deduped_and_audited_per_item, tests/integration/test_admin_operational_api.py::test_bulk_cancel_audits_per_item_and_caps_targets; auth rate limit (M27-14): tests/integration/test_admin_hardening_api.py::test_auth_routes_rate_limit_the_nth_request_with_retry_after, ::test_rate_limit_covers_the_whole_auth_family_but_nothing_else, bucket math in tests/unit/test_admin_hardening.py |
| 11 | EoP | viewer invokes an operator verb; operator self-escalates | tests/integration/test_admin_actions_api.py::test_permission_matrix_no_cookie_no_grant_and_every_role (every verb x every role, from the in-code map), ::test_viewer_denials_land_one_audit_row_per_attempt; grant management superadmin-only + lockout: ::test_last_superadmin_guard_holds_on_demote_revoke_and_delete, tests/unit/test_admin_grants.py::test_last_superadmin_cannot_be_revoked, ::test_last_superadmin_cannot_be_downgraded |
| 12 | EoP | Job-queue mutation escalates to code execution (F18) | tests/integration/test_admin_operational_api.py::test_job_verbs_reject_any_request_payload (payloads are never parseable - EmptyBody 422s everything), ::test_retry_reenqueues_the_byte_identical_stored_payload, ::test_job_verbs_are_operator_gated |
Residual risks (4.4, restated with their M27-14 disposition)
- Compromised superadmin Google account: Google-side 2FA remains the real control. The
edge-level second factor - Cloudflare Access in front of the admin origin - is delivered
as reproducible configuration in
docs/deployment.md(the admin origin is not deployed untilm27merges tomain, so live enforcement is the operator's post-deploy step documented there). - Shared Postgres role: a server-side RCE in either surface still reaches all data (pre-existing, unchanged). The distinct admin-mode DB role with table-level grants stays a recorded hardening option (design 3.6/4.3).
- Admin reads are not row-audited (v1 scope): reads remain visible in the structured HTTP
log stream with actor binding (
request_id), verified by the telemetry suite (tests/integration/test_security_telemetry.py).