Skip to main content

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).

#ThreatVector (4.4)Verification
1SpoofingAny Google account signs in and reaches admintests/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"
2SpoofingTest-login mints an admin session in a deployed envtests/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)
3TamperingAdmin edits bloom_runs.state and corrupts the orchestratortests/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
4TamperingCSRF from a malicious site drives admin mutationstests/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
5TamperingAudit-trail erasure to cover trackstests/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
6Repudiation"Who deleted that user?" unanswerabletests/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
7Info. disclosureAdmin surface leaks credential valuestests/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
8Info. disclosureXSS on the admin origin exfiltrates data / rides the sessionCSP: 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
9Info. disclosureCompromised user-dashboard origin pivots to admintest_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)
10DoSBulk actions or unbounded list queries hammer the DBtests/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
11EoPviewer invokes an operator verb; operator self-escalatestests/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
12EoPJob-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 until m27 merges to main, 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).