Admin Dashboard - milestone plan (independent Fable pass)
Status: independent design proposal (Fable). Produced from the repository source only, as a deliberately independent second pass; it has not read any parallel proposal. Bare
apps/apimodule paths below live underapps/api/src/bloom/; bareapps/webpaths underapps/web/src/. All file:line references were verified againstmainat the time of writing (2026-08-14,31f53aa).
0. TL;DR
- New
apps/adminSPA (strict TS + React + Vite + TanStack Query + Tailwind + shadcn), plus a root pnpm workspace (none exists today) with three deliberately small shared packages:packages/ui,packages/api-client,packages/config. Feature code is duplicated on purpose; only the proven-stable 15% is extracted. - Deploy where the user dashboard actually deploys: a second Cloudflare Pages project
(
bloom-admin) with the same same-origin/api/*proxy Function, giving the admin surface its own origin (its own subdomain once a production domain exists) and its own first-party cookie jar. Helmadminblocks and an OpenTofuadminDNS record are added behind flags, aligned with the in-flight M24 k3s cutover - not as a prerequisite. - No separate admin backend service. The admin API is a router family inside
apps/api(/api/admin/*) behind arequire_admindependency chain, with a deploy-timeBLOOM_ADMIN_API_MODE = disabled | mounted | onlyswitch.onlylets operations later run a dedicated admin deployment of the same image with the user surface off and vice versa, so blast-radius isolation is a configuration decision, not a codebase split. Rationale in 3.3: the credential that matters (the Postgres DSN) is identical in every option, and ~all of the code a separate service would need already lives in thebloompackage. - Admin rights are a grants table, not a column:
bloom_admin_grants(user_id, role, granted_by, granted_at)with rolesviewer | operator | superadmin; absence of a row means no rights. Sign-in to the admin origin runs the existing Google OIDC flow but only mints a session when a grant exists; admin sessions are a separate surface (own cookie name, host-only, 12h TTL vs the user surface's 14 days), and the grant is re-checked on every request so revocation is immediate. - Framework:
shadcn-admin-kit(MIT, by Marmelab) - react-admin's battle-testedra-coreengine with shadcn/ui presentation, vendored into the repo via the shadcn registry. It matches the existing stack exactly and delivers the Django-admin mechanics (list/filter/sort/paginate, relations, optimistic CRUD, auth/canAccess, bulk actions) without adopting MUI or Ant. The Python admin-panel route (sqladmin, starlette-admin) is structurally impossible here: they require SQLAlchemy models, and this backend is raw asyncpg with hand-written SQL. - Writes are guarded actions, not generic row edits. Most domain state is one JSONB blob per
project (
bloom_runs.state); letting an admin PATCH it freely is a foot-gun aimed at the orchestrator. Mutations are a whitelist of audited verbs (revoke session, unlink Telegram, delete user with cascade preview, retry/cancel job, archive project, ...), with a superadmin-only raw-state editor as the schema-validated escape hatch. - New append-only
bloom_audit_logtable records every admin action (actor, verb, resource, before/after, request id, outcome), complementing the four existing trails (credential audit log-stream,bloom_project_events, outbound ledger, runtime correlations). - App-layer authz owns the security boundary, not RLS: the API connects to Postgres as a
single privileged role, so row-level security demonstrably does not apply
(
persistence/supabase_store.py:5-6says exactly this). Section 3.6. - Proposed as milestone M27 - "Admin Dashboard" (next free number after M26; confirm against GitHub when the milestone is created), broken into 14 dependency-ordered issues in section 6.
1. The repository as it actually is
Facts that drive every decision below. Where the brief's framing and the repo differ, the repo wins.
| # | Fact | Evidence |
|---|---|---|
| F1 | Monorepo is apps/api (FastAPI, uv) + apps/web (bloom-web); there is no root package.json, no pnpm-workspace.yaml, no packages/ - apps/web is a standalone pnpm package with its own lockfile | repo root listing; apps/web/package.json |
| F2 | Web stack: React 18.3, react-router-dom 6 (not TanStack Router), TanStack Query 5, Tailwind 3, shadcn (new-york, components.json), lucide; no table lib, no form lib | apps/web/package.json:21-59, apps/web/components.json |
| F3 | Auth is server-driven Google OIDC (auth-code + PKCE, JWKS-verified id_token) minting an opaque server-side session in an httpOnly cookie; no tokens ever reach JS | auth/oidc.py:102-189, api/routes/auth.py:59-165, persistence/account_store.py:41-43 |
| F4 | Any Google account can self-register; the "registered user registry" gates inbound Telegram, not the dashboard | api/routes/projects.py:120-121, persistence/supabase_account_store.py:111-145, api/routes/telegram.py:110-131 |
| F5 | No admin/is_admin/authz-role concept exists; bloom_users is exactly id, email, name, created_at; every "role" in code is a chat/collaborator/specialist role, not authz | grep audit; domain/accounts.py:15-21, supabase_account_store.py:27-32 |
| F6 | Authorization is ownership-only, failure is a 404 (resource-hiding), never 403 | api/routes/projects.py:80-87,145-164 |
| F7 | Data layer is raw asyncpg + hand-written SQL constants; no SQLAlchemy, no Alembic, no supabase-py; schema is CREATE TABLE IF NOT EXISTS at boot | pyproject.toml:20, persistence/supabase_store.py, scripts/provision_preview_db.py:5-10 |
| F8 | The API connects as one privileged Postgres role; the code itself documents that RLS does not apply | persistence/supabase_store.py:5-6 |
| F9 | 11 tables; the project domain lives in bloom_runs.state, one JSONB blob per thread driven by the workflow engine; "project" is a virtual model | persistence/supabase_store.py:18-24, workflow/engine.py:29-45 |
| F10 | Four audit-adjacent trails exist: credential audit (log-stream only), durable bloom_project_events, outbound-message ledger (has list_recent, no HTTP route), runtime correlations. No admin-action or user-CRUD audit exists | credential_audit.py, persistence/project_events.py, persistence/outbound_messages.py:97-130, persistence/runtime_correlations.py |
| F11 | CORS allows exactly one origin (BLOOM_DASHBOARD_URL) and only GET/POST - the dashboard's PUT/DELETE calls work only because the SPA is same-origin via a proxy | api/app.py:407-415 |
| F12 | The user SPA deploys to Cloudflare Pages (bloom-dashboard / bloom-dashboard-prod) with a Pages Function reverse-proxying /api/* so the session cookie is first-party; per-PR previews rewrite preview-api-origin.ts | apps/web/functions/api/[[path]].ts, .github/workflows/deploy-web-dev.yml, preview.yml:344-374 |
| F13 | Helm chart has api/web/swarm deployables, but the web image is unpublished and dev deploys run --set web.enabled=false; k3s cutover (M24) is in flight; OpenTofu is authored but never applied, and prod domains are RFC-6761 placeholders (bloom.example) | deploy/helm/bloom/values.yaml:109-119, scripts/deploy/helm-dev.sh:73, deploy/tofu/main.tf:1-5, values-prod.yaml:43-54 |
| F14 | A server-reported capability-boolean pattern already exists for conditional UI (GET /api/auth/config -> {google, testLogin, ...}) | hooks/use-auth.ts:51-63, routes/login.tsx:30-41 |
| F15 | The sidebar footer is where global nav lives; FooterLink wraps react-router NavLink only, so an external admin URL needs a sibling anchor variant | components/workspace-sidebar.tsx:88-105,145-172 |
| F16 | A non-production test-login path exists (POST /api/auth/test-login), forbidden in production by a config validator; E2E and previews depend on it | api/routes/auth.py:180-209, config.py:517-531 |
| F17 | BLOOM_SESSION_COOKIE_DOMAIN exists specifically to widen the session cookie to a parent domain so one cookie can cover SPA+API subdomains | config.py:166-168 |
| F18 | The jobs queue includes engine_implement / engine_rework kinds that trigger Claude Code execution on the worker VM - job mutations are adjacent to remote code execution | persistence/jobs.py:29-48 |
| F19 | AGENTS.md gates UX-heavy work behind Penpot design studies (desktop and mobile frames) approved before UI code | AGENTS.md ("UX design studies") |
2. Requirements
Al's requirements, and where each is addressed:
- Separate deployable at a different subdomain -> 3.2.
- Separate app + shared
packages/*, extract-vs-duplicate decided -> 3.1. - Admin nav link in the user dashboard, which forces an admin-rights claim -> 3.4, issue M27-6.
- Django-admin parity with modern UX -> 3.5 (framework), 5 (parity matrix).
- Tight security: authN/authZ, RBAC, audit logging, session/CSRF/origin isolation, threat model -> 3.4, 4.
- Separate backend API: evaluated with recommendation -> 3.3.
3. Architecture decisions
3.1 App and package structure
Decision: apps/admin as a sibling SPA, plus a root pnpm workspace with three small packages.
There is no workspace today (F1), so "extract shared code into packages/*" starts with creating
the workspace itself: root package.json + pnpm-workspace.yaml covering apps/* and
packages/*, one root lockfile, and CI updated to install from the root. That is issue M27-4 and
it is deliberately its own PR: it moves apps/web's lockfile and CI cache keys and should not be
entangled with feature work.
The web explorer pass measured the coupling of every candidate module. The extraction rule applied: extract what is provably app-agnostic today; duplicate what is still evolving.
Extract now:
| Package | Contents | Why safe |
|---|---|---|
packages/ui | the 8 components/ui/* primitives (313 lines), bloom-mark.tsx, theme-provider + theme-toggle (storage key becomes a prop), the Tailwind preset + CSS design tokens (index.css:5-50, tailwind.config.js theme block) | every file imports only react, radix/cva, and lib/utils; zero app-state imports (verified) |
packages/api-client | apiFetch/ApiError/apiUrl (lib/api.ts, 33 lines; base URL becomes injectable instead of reading VITE_API_BASE_URL at module scope), use-auth.ts (93 lines: useCurrentUser, useAuthConfig, useTestLogin, useLogout), the CurrentUser/AuthConfig types, and the test utilities (test/render.tsx, mock-event-source.ts) | lib/api.ts has zero imports; use-auth imports only query + the fetch core |
packages/config | shared tsconfig base, eslint flat-config base, prettier config, Tailwind preset export | pure config; the two apps must not drift on strictness |
Explicitly not extracted (duplicated or app-local on purpose):
- The ~665 lines of
use-project-*feature hooks and their hand-mirrored Pydantic types. The admin app consumes different endpoints (/api/admin/*) with different shapes; sharing the user-app read-model hooks would couple the two release cadences for zero reuse. If a concrete shared need appears later (e.g. the SSE reconciliation helper), extract it then. - App shells, routing, sidebar. Twenty lines of duplicated layout is cheaper than a premature
packages/shellabstraction serving two very different information architectures. - The shadcn-admin-kit components themselves: the shadcn registry model vendors them into
apps/admin(that is its design); they are admin-only and stay in the app.
Scored comparison (1-5, higher better):
| Criterion (weight) | A: standalone app, copy-paste shared bits | B: workspace + minimal packages (chosen) | C: big-bang extraction (ui + api hooks + types + shell) |
|---|---|---|---|
| Delivery speed to first admin screen (x2) | 4 | 4 | 2 |
| Drift risk between apps (x2) | 1 | 4 | 5 |
| Blast radius of a shared-code change (x1) | 5 | 4 | 2 |
| Honors "DRY where proven" without speculative generality (x1) | 2 | 5 | 2 |
| Weighted total | 17 | 25 | 18 |
3.2 Subdomain and deployment
Decision: a second Cloudflare Pages project (bloom-admin) with its own same-origin /api/*
proxy Function, i.e. exactly the topology the user dashboard actually ships with today. Helm
admin.* blocks and an OpenTofu admin DNS record are authored behind flags in the same
milestone, activated when the M24 k3s web cutover lands - the admin milestone does not block on
it.
The brief assumed "deploy is k8s (helm) + OpenTofu; subdomains via ingress". For the API that is
becoming true (M24 in flight); for SPAs it is not the running reality: there is no web Docker
image, dev helm deploys explicitly disable the web deployable, OpenTofu has never been applied,
and production domains are placeholders (F13). An admin dashboard that only exists behind a
not-yet-real ingress would ship never; one that ships on Pages today and gains its ingress/DNS
blocks in the same PR series as the web cutover ships now and converges later. This is the main
place this plan diverges from the obvious reading of the requirements, and it is a divergence
about sequencing, not destination: the target state remains admin.<domain> beside
app.<domain> and api.<domain>.
Concretely:
- Origins. Dev:
bloom-admin.pages.dev(Pages projectbloom-admin), user dev dashboard stays onbloom-dashboard.pages.dev/bloom-server.exe.xyz. Production (when a real domain exists):admin.<domain>as a custom domain on the Pages project, or the helmadminingress host if the SPA has moved into the cluster by then. Either way the admin surface is a different registrable origin from the user dashboard at every stage, which is what the security model needs (section 4). - Same-origin proxy.
apps/admin/functions/api/[[path]].tsmirrors the existing web proxy (F12), pointing at the same API origin. This keeps the admin session cookie first-party, avoids touching the single-origin GET/POST-only CORS allowlist at all (F11), and inherits the per-PR preview mechanism (preview-api-origin.tspattern) unchanged. - Helm/Tofu (flag-gated, follow the cutover).
values.yamlgains anadmin:block mirroringweb:(values.yaml:101-150),ingress.admin.host,bloom.adminImage+bloom.adminTLSSecretNamehelpers, anadmin-{deployment,service,ingress}.yamltemplate trio, a third cert-managerCertificate, helm-unittest coverage, and in Tofu a thirddigitalocean_record(admin_record_name, default"admin") beside the existingapi/apprecords (modules/k8s-bootstrap/main.tf:48-66). All disabled by default; dev keepsadmin.enabled=falseuntil the web deployable itself is real. - CI/CD.
deploy-admin-dev.ymlmirrorsdeploy-web-dev.yml(workflow_run on CI success onmain,wrangler pages deploy). The admin app joinsci-test.yml(lint/typecheck/vitest via the workspace) andci-build.yml(bundle build). Preview stacks optionally add the admin SPA later; not in scope for M27.
| Criterion (weight) | A: k8s ingress first | B: Cloudflare Pages now + flag-gated helm/tofu (chosen) | C: path-based /admin on the user dashboard |
|---|---|---|---|
| Shippable against today's infra (x2) | 1 | 5 | 5 |
| Meets "separate subdomain/origin" isolation (x2) | 5 | 5 | 1 |
| Converges with M24 target state (x1) | 5 | 4 | 2 |
| Operational cost added now (x1) | 2 | 4 | 5 |
| Weighted total | 19 | 28 | 19 |
Option C is listed because it is the classic shortcut; it fails requirement 1 outright and collapses the origin-isolation story (one cookie jar, one XSS domain), so it was never really in play.
3.3 Separate admin backend?
Decision: no separate service. Admin endpoints live in apps/api as a router family
(api/routes/admin/*.py, prefix /api/admin) behind a require_admin dependency chain, plus a
deploy-time posture switch BLOOM_ADMIN_API_MODE = disabled | mounted | only (default mounted
in dev, so one process serves both surfaces; disabled strips admin routes from the public API;
only runs an admin-only instance of the same image).
The honest analysis of "blast radius" for this system:
- The database credential is the blast radius that matters, and it is invariant. Any admin backend - router or separate service - must hold a Postgres DSN with read/write over every table (F7, F8: single privileged role, no RLS). A separate service does not shrink what a compromised admin backend can do; it only moves which process holds the same key.
- Code reuse is near-total. The stores (
SupabaseAccountStore,SupabaseStateStore, jobs, events, outbound, correlations), the domain models,Settings, telemetry middleware, logging and metrics - a separate FastAPI service would import essentially the wholebloompackage or re-implement it. There is nopackages/-style seam on the Python side, and inventing one for a second service is a large refactor with no security payoff (see previous point). - What a separate deployable does buy is exposure control: the user-facing API host not serving admin routes at all, and the admin instance being reachable only via the admin origin's proxy (plus, later, network policy in k8s). The mode switch buys exactly that benefit for the cost of one env var, because the deployable is the same image with a different flag - the same pattern the repo already uses for the swarm deployable (same image, different command/config).
- Operational cost of a real second service in this repo is concrete and high: a second Dockerfile or entrypoint, a second helm deployable + secrets + sealed-secrets split + CI build + deploy workflow + rollback path + monitoring, on a team where the API deliberately runs as a single replica with in-process scheduling.
Recommended production posture once a production environment exists: user-facing API deployment
runs BLOOM_ADMIN_API_MODE=disabled; a second small deployment of the same image runs only,
targeted exclusively by the admin origin's proxy. Until then (dev), one process runs mounted
and the boundary is the require_admin chain - which must be airtight anyway, because in every
option it is the last line of defense.
| Criterion (weight) | A: admin router, same process (chosen baseline) | B: separate admin codebase/service | C: same code, mode-switched deployable (chosen prod posture) |
|---|---|---|---|
| Auth/blast-radius isolation (x2) | 3 | 4 | 4 |
| Code reuse / no duplication (x2) | 5 | 1 | 5 |
| Operational cost (x1) | 5 | 1 | 4 |
| Deployment simplicity today (x1) | 5 | 2 | 4 |
| Future exposure control (x1) | 3 | 5 | 5 |
| Weighted total | 29 | 18 | 31 |
A and C are the same code; the recommendation is "A now, C when production exists", and B is rejected.
Implementation notes:
- Admin routers mount from a single
build_admin_router(settings)factory sodisabledgenuinely omits the routes (404, indistinguishable from absent - matching the repo's resource-hiding convention, F6/F16) rather than answering 403. - In
onlymode the instance skips starting the scheduler/job-runner (those belong to the user API's single replica) - the lifespan already conditionalizes subsystem startup, this adds one more condition. - No API versioning is introduced; the repo has none (
/api/...throughout) and an internal admin surface consumed by one first-party SPA does not need it.
3.4 The admin-rights model and admin sessions
Decision: an explicit grants table + a separate session surface.
CREATE TABLE IF NOT EXISTS bloom_admin_grants (
user_id text PRIMARY KEY REFERENCES bloom_users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('viewer', 'operator', 'superadmin')),
granted_by text, -- user id or 'bootstrap'
granted_at timestamptz NOT NULL DEFAULT now()
);
Why a table and not an is_admin column on bloom_users:
- Absence-as-default. No row means no rights; there is no default-false flag to accidentally flip in a generic user-update path (and the users resource in the admin UI itself will have an update verb - the grant living in a separate table keeps privilege escalation out of that verb's reach entirely).
- The grant is itself auditable data (who granted, when), which Django models as scattered LogEntry rows; here it is intrinsic.
- Word-collision hygiene. "role" already means collaborator/specialist/chat roles everywhere
in this codebase (F5); a
bloom_admin_grants.rolescoped to one table is unambiguous in a waybloom_users.rolewould not be. - Full RBAC schema (roles/permissions/role_permissions tables) is rejected for v1: three fixed
roles with a permission map in code (
ADMIN_PERMISSIONS: dict[AdminRole, frozenset[str]]) is least-privilege enough for a surface with a handful of operators, is mypy-checkable, and can graduate to tables if roles ever become user-defined.
Roles:
| Role | Grants |
|---|---|
viewer | read every resource (minus redacted fields); no mutations |
operator | viewer + the guarded action whitelist (section 5.2) |
superadmin | operator + grant management, destructive verbs (user delete), raw-state editor |
Bootstrap: BLOOM_ADMIN_EMAILS (comma-separated) seeds superadmin grants idempotently at
startup for matching existing/future users, recorded with granted_by='bootstrap'. Thereafter
grants are managed in the admin UI (superadmin only, audited). Guard: the last remaining
superadmin grant cannot be revoked or downgraded (a self-lockout check in the store, not the UI).
Admin sessions are a separate surface, not a widened user session:
bloom_sessionsgainssurface text NOT NULL DEFAULT 'user'('user' | 'admin'). Existing rows read asuservia the default; boot-timeALTER TABLE ... ADD COLUMN IF NOT EXISTSmatches the repo's idempotent-schema convention (F7).- Sign-in on the admin origin uses the same Google OIDC provider with an
/api/admin/auth/google/login+/callbackpair (thin wrappers over the existing flow helpers). The callback resolves the user, then requires a grant before creating any session; no grant -> redirect to an access-denied page, nothing minted. This means "logged into the user dashboard" and "logged into the admin dashboard" are unrelated states by construction. - Cookie:
bloom_admin_session,HttpOnly,Secure,SameSite=Lax, host-only (thedomainattribute is never set for it -BLOOM_SESSION_COOKIE_DOMAIN(F17) must not apply to the admin cookie, since a parent-domain cookie is exactly the cross-subdomain bleed this design exists to prevent),Path=/. TTL: 12 hours (BLOOM_ADMIN_SESSION_TTL_SECONDS), vs 14 days for user sessions. get_current_admindependency chain: admin cookie -> session row withsurface='admin'and unexpired -> user row -> live grant lookup (per request, not cached in the session) ->AdminActor(user, role). Revoking a grant kills access on the next request even for live sessions. Permission enforcement is a second dependency:require_permission("users.delete")etc., resolved against the in-code map.- The user-surface
get_current_usernever accepts the admin cookie and vice versa - two cookie names, two surfaces, checked explicitly. - Test-login hazard (F16):
POST /api/auth/test-loginupserts arbitrary users in non-prod environments. It must never mintsurface='admin'sessions. For E2E, a separateBLOOM_ADMIN_TEST_GRANT=true(validator-forbidden in production, same pattern asconfig.py:517-531) lets the admin E2E suite grant a test user and exercise the surface.
The nav link (requirement 3): GET /api/auth/me (user surface) adds isAdmin: bool (a
grants lookup) and the auth config gains adminUrl (from BLOOM_ADMIN_DASHBOARD_URL). The
sidebar footer renders an external-anchor variant of FooterLink (F15) only when both are
present, following the existing capability-boolean pattern (F14). The link is a convenience
pointer only; possessing it grants nothing (the admin origin re-authenticates).
3.5 Framework: search before build
Candidates evaluated for Django-admin parity on a strict-TS React/Tailwind/shadcn stack against a FastAPI + raw-asyncpg backend:
| Option | License | Maintenance (as of 2026-08) | Stack fit | Django-parity mechanics | Verdict |
|---|---|---|---|---|---|
| shadcn-admin-kit (Marmelab) | MIT | Active; maintained by the react-admin team | Exact: shadcn/ui + Tailwind + Radix, vendored via shadcn registry into our own tree | Full ra-core engine: dataProvider/authProvider contracts, list+filter+sort+pagination, references (FK nav), optimistic CRUD with undo, bulk actions, canAccess RBAC hooks | Chosen |
| react-admin proper | MIT core (enterprise modules paid) | Very active, 10+ years, ~25k companies | Poor: hard dependency on MUI/emotion, a second design system beside Tailwind/shadcn | Best-in-class | Rejected on stack fit; we still get its engine via ra-core |
| Refine Core | MIT | v5 (Feb 2026) ships React 19 + TanStack Query v5 support, but the company pivoted to an AI app-builder in 2025 and the OSS framework moved to second billing | Good (headless; shadcn integration exists) | Comparable hooks-level mechanics, more boilerplate per resource | Viable runner-up; rejected on maintenance-risk + more per-resource glue |
| AdminJS | MIT | Active | Wrong shape: requires its own Node backend with ORM adapters (Prisma/Sequelize/TypeORM/Mongoose) | Auto-CRUD from ORM models | Rejected: there is no Node backend and no ORM here |
| sqladmin / starlette-admin / fastapi-admin | BSD/MIT | Active | Server-rendered, mount-on-FastAPI | Auto-CRUD from SQLAlchemy (or Tortoise) models | Structurally impossible: the backend is raw asyncpg with no ORM models to introspect (F7), and the interesting "models" are JSONB projections (F9). Adopting one would mean writing a parallel SQLAlchemy schema for 11 tables just to drive it, then still hand-building the JSONB surfaces - the worst of both worlds. Also fails the "more modern than Django admin" bar (they are Django admin's UX) |
| Hand-rolled (TanStack Table + Query + shadcn) | n/a | n/a | Exact | Everything hand-built: filter/sort/paginate state, reference resolution, optimistic mutations, bulk selection, per-resource forms | Rejected: rebuilding ra-core poorly; violates search-before-build |
Justification detail for the choice:
- Licensing:
shadcn-admin-kitandra-coreare MIT. The paid react-admin enterprise modules are neither needed nor pulled in. - Vendoring model fits the repo: shadcn registry components are copied into
apps/admin(the same model as the existing 8ui/primitives), so the UI layer is ours to restyle and the only npm runtime dependency isra-core(+ the radix pieces already in use). No lock-in to a hosted service or a foreign design language; a worst-casera-coreabandonment leaves a working vendored UI over a plain REST protocol. - The backend cost is small and explicit.
ra-coretalks to adataProvider; we implement one against a small admin REST convention (3.5.1) with hand-written SQL per resource - which is how this backend does everything already (F7). No ORM adoption, no codegen.
3.5.1 The admin resource protocol
One convention, every resource:
GET /api/admin/{resource}?filter={json}&sort=field&order=asc|desc&offset=0&limit=25
-> {"items": [...], "total": n}
GET /api/admin/{resource}/{id} -> item
POST /api/admin/{resource}/{id}/actions/{verb} -> action result (guarded mutations)
PUT /api/admin/{resource}/{id} -> item (only resources with editable fields)
DELETE /api/admin/{resource}/{id} -> 204 (only where whitelisted)
filteris a flat JSON object of whitelisted fields per resource; free-textqmaps to the resource's search columns (ILIKEover e.g.email,name, title-in-state). Every filterable column is backed by an index or an explicitly-accepted seq-scan (11 tables, small cardinality today;bloom_project_eventsandbloom_jobsalready carry the needed indexes).- Responses carry
totalin-body (notContent-Range); the dataProvider maps it. All list endpoints are paginated server-side with a hardlimit <= 100. - Mutations are actions (verbs), not generic PUTs, except for the few truly editable scalar
fields (e.g.
users.name). Every action handler runs inside the audit wrapper (section 4.3) and permission dependency. - Types for the admin SPA are hand-mirrored from the Pydantic response models, matching the repo's existing (deliberate) practice (no OpenAPI codegen exists; adopting one is out of scope for this milestone and noted as a possible later improvement for both apps).
3.6 RLS vs app-layer authorization
The brief asks where Supabase RLS vs app-layer authz should own the boundary. In this repo the question is settled by F8: the API connects directly to Postgres as a single privileged role and the code documents that RLS does not apply. Nothing talks to PostgREST/supabase-js; there is no per-user database identity to which a policy could bind. Therefore:
- App-layer authz owns the boundary:
get_current_admin+ the permission map + per-resource whitelists are the enforcement, and they get the test rigor that implies (every admin route tested for 401/403/404 behavior, including surface-confusion cases: user cookie on admin routes, admin cookie on user routes). - RLS is not adopted for the admin surface. Retrofitting RLS under a raw-asyncpg store would
require per-request
SET ROLE/JWT-claim plumbing across every store and buys nothing while the app connects as one role. - A cheaper database-side hardening is noted for the
only-mode deployable later: a distinct Postgres role for the admin instance with table-level grants (e.g. no DDL, noDELETEonbloom_audit_log). Recorded as a hardening option in M27-14, not a v1 requirement.
4. Security model and threat model
4.1 Authentication and authorization chain
Browser (admin.<domain> origin)
-> Pages Function proxy (first-party /api/*)
-> FastAPI admin router (mode != disabled)
-> get_current_admin:
bloom_admin_session cookie (host-only, HttpOnly, Secure, SameSite=Lax, 12h)
-> session row, surface='admin', unexpired
-> user row
-> live bloom_admin_grants lookup (every request)
-> require_permission("resource.verb") (in-code role->permission map)
-> audited handler (append bloom_audit_log)
Design invariants:
- Two surfaces, two cookies, zero bleed. The admin cookie is host-only on the admin origin;
the user cookie never authorizes
/api/admin/*; the admin cookie never authorizes user routes.BLOOM_SESSION_COOKIE_DOMAINis never applied to the admin cookie (F17 is the documented temptation; the admin surface explicitly opts out). - Sign-in does not imply access (F4 makes this mandatory: anyone with a Google account can sign in). Access = sign-in AND grant, re-verified per request.
- Deny looks like absence. Non-admin requests to
/api/admin/*receive 404 indisabledmode and 401/403 behind valid-cookie-but-no-grant paths; list endpoints never leak existence of resources across permissions. This extends the repo's resource-hiding convention (F6). - Least privilege by default: new grants default to
viewer;operatorandsuperadminare explicit escalations, audited.
4.2 Session, CSRF, and origin isolation across the subdomain split
- CSRF: the admin surface is same-origin end-to-end (SPA and API share the admin origin via
the proxy), so
SameSite=Laxon the admin cookie blocks cross-site sends of state-changing requests, and - unlike the user surface, which must tolerate a cross-origin dev topology (config.py:573-580downgrades toSameSite=Nonethere) - the admin cookie is alwaysLax; there is no cross-origin admin topology by design. Defense in depth on top: an origin-check middleware on all/api/admin/*mutations rejects requests whoseOrigin/Sec-Fetch-Siteindicate a cross-site initiator, and mutations are JSON-only (Content-Type: application/jsonenforced), which forces a CORS preflight for any cross-origin attempt - and the admin origin is deliberately not in the CORS allowlist (F11: the allowlist stays single-origin, GET/POST-only; the admin surface never needs a CORS entry because it is proxied same-origin). - XSS containment: the admin Pages project ships a strict CSP (
default-src 'self', no inline script, no third-party origins - the admin SPA has no analytics and no CDN assets), which the user dashboard currently does not have; the admin surface is where it matters most. React's default escaping + nodangerouslySetInnerHTMLpolicy (lint-enforced) covers the DOM side; JSON state blobs are rendered through a read-only tree viewer, never interpolated as HTML. - Session lifecycle: 12h TTL, logout deletes the server-side row (as today,
routes/auth.py:212-227); "revoke all admin sessions for user X" is a guarded action; grant revocation invalidates effectively immediately (per-request grant check). - Transport: HTTPS everywhere (Pages/exe.dev edge today, cert-manager in the k8s target
state);
Secureon the admin cookie unconditionally (no dev-http carve-out; local dev uses the Vite proxy on localhost, whereSecurecookies overhttp://localhostare accepted by browsers).
4.3 Audit logging of admin actions
New append-only table (boot-created like all others):
CREATE TABLE IF NOT EXISTS bloom_audit_log (
id bigserial PRIMARY KEY,
at timestamptz NOT NULL DEFAULT now(),
actor_id text NOT NULL, -- admin user id
actor_email text NOT NULL, -- snapshot; survives user deletion
actor_role text NOT NULL,
action text NOT NULL, -- "resource.verb", e.g. "sessions.revoke"
resource text NOT NULL,
resource_id text,
before jsonb, -- redacted snapshot where meaningful
after jsonb,
outcome text NOT NULL, -- ok | error | denied
request_id text, -- joins the structured log stream (X-Request-Id)
ip text,
user_agent text
);
CREATE INDEX IF NOT EXISTS bloom_audit_log_at_idx ON bloom_audit_log (at DESC);
CREATE INDEX IF NOT EXISTS bloom_audit_log_actor_idx ON bloom_audit_log (actor_id, at DESC);
- Every admin mutation goes through one
audited_admin_action(...)wrapper (modeled on the existingaudited_credential_opcontextmanager,credential_audit.py:50-61): success and failure both land a row; denied permission checks landoutcome='denied'. Admin reads are not row-logged (volume/noise), but are visible in the structured HTTP log stream withrequest_id, route template, and admin actor bound via the existing telemetry middleware. - Tamper resistance: the table has no update/delete code path anywhere in the app; the admin
API exposes it read-only (even to superadmin). DB-level
REVOKE UPDATE, DELETEfor the admin-mode role is the M27-14 hardening option. Retention: none in v1 (append forever; volumes are tiny); revisit with a retention setting when it matters. before/aftersnapshots pass the existing redaction discipline: credential names only, never values (the write-only Infisical contract is preserved - the admin surface has no endpoint that can read a secret value); session ids never logged (matchesroutes/auth.py:221-222).- Grant changes, session revocations, user deletions, job retries/cancels, lifecycle actions, and raw-state edits (with full before/after state diff) are all captured by construction.
4.4 Threat model (STRIDE over the admin surface)
Verification (M27-14): every row below is proven by a test or a documented manual check - the per-row pointers live in
docs/security/admin-threat-model.md, which mirrors this table and must be updated alongside it.
Assets: all user PII (emails/names), sessions, Telegram links, project state (including repo targets and conversation history), credential metadata, the job queue (adjacent to code execution on the worker VM, F18), outbound messaging (ability to speak to users' Telegram), the audit log itself.
| Threat | Vector | Mitigations |
|---|---|---|
| Spoofing | Attacker signs in with any Google account (open registration, F4) and reaches admin | Grant table required beyond sign-in; grant checked per request; bootstrap only via env; access-denied page mints nothing |
| Spoofing | Test-login mints an admin session in a deployed env | Test surface already 404s in prod (F16); admin surface additionally never accepts test-provider sessions unless BLOOM_ADMIN_TEST_GRANT (validator-forbidden in production) |
| Tampering | Admin edits bloom_runs.state JSONB and corrupts the orchestrator | No generic write path; guarded verbs only; raw editor is superadmin-only, schema-validated (Pydantic RunState + known-key checks), optimistic-concurrency-checked against updated_at, and fully diffed into the audit log |
| Tampering | CSRF from a malicious site drives admin mutations | Host-only SameSite=Lax cookie; origin-check middleware; JSON-only mutations; admin origin absent from CORS allowlist |
| Tampering | Audit-trail erasure to cover tracks | Append-only by construction; read-only API; DB-grant hardening option; request_id cross-links to the external log stream |
| Repudiation | "Who deleted that user?" unanswerable | bloom_audit_log rows for every mutation incl. actor snapshot, before/after, outcome |
| Information disclosure | Admin surface leaks credential values | No value-read endpoint exists anywhere (write-only store contract); admin sees names/timestamps only |
| Information disclosure | XSS on admin origin exfiltrates data or rides the session | Strict CSP; no third-party script origins; HttpOnly cookie (no token in JS at all, F3); JSON rendered via tree viewer |
| Information disclosure | Compromised user dashboard origin pivots to admin | Different registrable origin; host-only cookies; nothing in the user origin's storage or cookie jar authorizes admin |
| Denial of service | Bulk actions or unbounded list queries hammer the DB | Hard limit <= 100; bulk actions capped per request and audited; rate limit on admin auth endpoints (M27-14) |
| Elevation of privilege | viewer invokes an operator verb; operator grants themselves superadmin | Per-verb permission dependency (server-side, UI canAccess is cosmetic only); grant management is superadmin-only; last-superadmin lockout guard; all denials audited |
| Elevation of privilege | Job-queue mutation escalates to code execution with attacker payload (F18) | Job payloads are never editable; only retry (re-enqueue as-is) and cancel verbs exist, operator+; both audited |
Residual risks, stated plainly: (a) a compromised superadmin Google account is game over for Bloom-held data short of step-up auth - Google-side 2FA is the real control; an optional Cloudflare Access layer in front of the admin Pages project (free tier covers this team size) is recommended in M27-14 as a cheap second factor at the edge; (b) the shared Postgres role means a server-side RCE in either surface reaches all data regardless of this design (pre-existing, unchanged); (c) admin reads are not row-audited in v1.
5. Django-admin parity, mapped to a modern UX
5.1 Feature matrix
| Django admin capability | This plan | Where |
|---|---|---|
Model list views (ModelAdmin.list_display) | <List> + shadcn DataTable per resource; column config in code | kit + protocol GET /{resource} |
| Change/detail forms | <Show>/<Edit> with typed field components; most resources are read-mostly with action buttons | kit |
| Add/delete | Whitelisted per resource (5.2); delete flows show a cascade preview (Django's "are you sure?" collateral list, rebuilt from FK knowledge: user -> identities, sessions, links, workspaces, credentials meta, projects) | protocol actions |
Filtering (list_filter) | Per-resource whitelisted filter fields, JSON filter param; UI filter chips/selects | protocol + kit |
Search (search_fields) | q free-text per resource (email/name/title ILIKE) | protocol |
| Sorting | sort/order on whitelisted columns | protocol |
| Pagination | Server-side offset/limit, total in-body, hard cap 100 | protocol |
Relation navigation (FK links, raw_id_fields) | ra-core reference fields: session -> user, identity -> user, project -> owner (via bloom_workspaces chat-id join), event/job -> project; each rendered as a link chip | kit references |
Bulk actions (actions=) | Row-select + bulk verbs where safe (revoke sessions, cancel jobs); capped batch size; one audit row per affected item | kit bulk + protocol actions |
Inline related objects (InlineModelAdmin) | Detail pages embed related lists (user detail shows sessions, identities, links, grants, projects; project detail shows events, jobs, child flows) | kit <ReferenceManyField> equivalents |
History (LogEntry, history_view) | bloom_audit_log as a first-class resource + per-record "History" tab filtered to that resource id; project domain history additionally from bloom_project_events | 4.3, M27-13 |
| Permissions (add/change/delete/view per model) | Role -> permission map enforced server-side per verb; canAccess mirrors it in the UI (hide, do not rely) | 3.4 |
| Admin site index | Overview dashboard: user/project/job/session counters, recent failures, recent audit entries | M27-13 |
| Autocomplete fields | Async select over the q search for reference pickers | kit |
| Beyond Django admin (the "more modern UX") | Dark mode (theme system already shared), fully responsive incl. mobile (F19 gate), command palette (cmdk) for resource/record jumps, saved list views (URL-encoded filter state), optimistic mutations with undo (ra-core), read-only JSON tree inspector for run.state/event/job payloads, live-updating queue/overview via the existing SSE pattern (stretch), skeleton loading states matching bloom-web | kit + apps/admin |
5.2 Resource capability matrix (the whitelist, v1)
| Resource (table) | List/Show | Search/filter | Mutations (guarded verbs; minimum role) |
|---|---|---|---|
users | yes | q: email/name; filter: created range, has-grant | update_name (op), revoke_sessions (op), delete w/ cascade preview (superadmin), grant_admin/revoke_admin/change_role (superadmin) |
admin_grants | yes | filter: role | via users verbs above (no direct create/delete endpoints) |
sessions | yes | filter: user, surface, active/expired | revoke (op), bulk revoke (op) |
oauth_identities | yes | filter: provider; q: email | delete (superadmin) |
telegram_links | yes | filter: verified | unlink (op) |
workspaces | yes | q: user email | read-only |
global_credentials (metadata) | yes | q: name; filter: user | delete metadata (op; value deletion via existing store path, audited) |
projects (bloom_runs projection) | yes | q: title/thread id; filter: phase, lifecycle, owner | archive/unarchive lifecycle (op), trigger_tick (op), edit_state raw JSONB (superadmin, validated + diffed) |
project_events | yes | filter: thread, type, time range | read-only |
jobs | yes | filter: kind, status; sort: run_after/attempts | retry (op), cancel (op), bulk cancel (op); payloads immutable |
outbound_messages | yes (new route over existing list_recent, F10) | filter: channel, kind, status, thread | read-only |
runtime_correlations | yes | filter: runtime, status; q: external id | read-only |
audit_log | yes | filter: actor, action, resource, outcome, time | read-only, forever |
Everything not listed is not reachable from the admin surface. Growing the whitelist is a reviewed code change, never a config flip.
6. Milestone M27 - "Admin Dashboard": dependency-ordered issues
Numbering follows repo convention (M27-<n>: ...); "M27" to be confirmed as the next free
milestone number when created on GitHub. Sizes: S (<= half day), M (~1 day), L (1-2 days) for the
production engine. Per AGENTS.md the milestone integrates on branch m27, one PR per issue.
M27-1: Admin grants model + require_admin authz seam (api) - size M, depends: none
bloom_admin_grantstable (schema in 3.4) created idempotently in the account store; store methodsget_grant,list_grants,upsert_grant,revoke_grantwith last-superadmin lockout guard, on both Supabase and in-memory backends.AdminRoleenum +ADMIN_PERMISSIONSmap;get_current_adminandrequire_permission(perm)FastAPI dependencies (session surface check lands in M27-2; here the dependency validates cookie -> user -> grant against the user surface temporarily and is wired final in M27-2).BLOOM_ADMIN_EMAILSbootstrap seeding at startup, idempotent,granted_by='bootstrap'.GET /api/admin/mereturns{user, role, permissions}.- AC: no-grant user gets 403 with no resource detail; grants CRUD covered by unit tests incl. lockout guard; bootstrap seeds exactly once per user; mypy strict + ruff clean; zero change to any user-surface route behavior.
M27-2: Admin session surface + admin sign-in flow (api) - size L, depends: M27-1
bloom_sessions.surfacecolumn (idempotent ALTER);create_session(surface=...);bloom_admin_sessioncookie (host-only, Secure, SameSite=Lax,BLOOM_ADMIN_SESSION_TTL_SECONDSdefault 12h)./api/admin/auth/google/login|callback|logoutwrapping the existing OIDC helpers; callback requires a grant before minting; denial redirects to/access-deniedon the admin origin with nothing set.get_current_adminfinalized: admin cookie only,surface='admin'only, live grant re-check per request; user cookie explicitly rejected on admin routes and vice versa.BLOOM_ADMIN_API_MODE(disabled|mounted|only) with router-mounting factory;onlyskips scheduler/job-runner startup;disabledyields 404s.- Test-login can never mint admin sessions;
BLOOM_ADMIN_TEST_GRANT(non-prod validator, mirrorsconfig.py:517-531) enables E2E grants. - AC: surface-confusion matrix tested (4 combinations of cookie x surface); revoked grant denies
next request on a live session; mode switch covered by app-factory tests; production validator
rejects
BLOOM_ADMIN_TEST_GRANT=true.
M27-3: Append-only admin audit log (api) - size M, depends: M27-1 (parallel with M27-2)
bloom_audit_logtable + store (Supabase + memory);audited_admin_actionwrapper (contextmanager, modeled oncredential_audit.py:50-61) capturing actor snapshot, action, resource, before/after, outcome,request_idfrom the telemetry contextvars, ip/user-agent.- Read-only list/get with filters (actor, action, resource, outcome, time range), paginated.
- AC: success, error, and permission-denied paths each produce a row; no update/delete code path exists (grep-guard test); redaction verified (no credential values, no session ids in snapshots); wrapper adopted by M27-1's grant mutations retroactively.
M27-4: Root pnpm workspace + packages/{config,ui,api-client} extraction (web) - size L,
depends: none (parallel with M27-1..3)
- Root
package.json+pnpm-workspace.yaml(apps/*,packages/*), single root lockfile;bloom-webbecomes a workspace member with unchanged behavior. packages/config: shared tsconfig base (strict flags from F2's current config), eslint flat base, prettier, Tailwind preset (tokens fromindex.css/tailwind.config.js).packages/ui: the 8ui/primitives,bloom-mark,theme-provider(storage key as prop) +theme-toggle;bloom-webimports switched.packages/api-client:apiFetch/ApiError/apiUrlwith injectable base URL,use-authhooks + types, shared test utilities.- CI (
ci-test.yml,ci-build.yml,ci-security.ymlpnpm audit) and the web deploy workflows updated to install at the workspace root; Pages build output paths unchanged. - AC:
pnpm run checkgreen forbloom-web; built bundle byte-comparable modulo hashes; no duplicated ui/api-core source remains inapps/web; CI lockfile caching keys updated; e2e suite still passes against a preview.
M27-5: apps/admin scaffold + Pages deploy + admin sign-in UI (web/infra) - size L,
depends: M27-2, M27-4
- New workspace app
bloom-admin(Vite + strict TS frompackages/config;packages/ui+packages/api-client), with login page (Google button ->/api/admin/auth/google/login), access-denied page,RequireAdminroute guard onGET /api/admin/me, and an empty shell (sidebar + theme toggle + user menu). functions/api/[[path]].tsproxy (clone of the web one) +preview-api-origin.tsseam; strict CSP headers (_headers).- Cloudflare Pages project
bloom-admin;deploy-admin-dev.ymlmirroringdeploy-web-dev.yml; admin app wired intoci-test.yml/ci-build.yml. - AC: granted user completes Google sign-in on the admin origin and sees the shell; non-granted
user lands on access-denied with no session cookie set; CSP present on all responses; vitest
- lint + typecheck in CI; dev deploy green.
M27-6: Admin nav link in the user dashboard (api+web) - size S, depends: M27-1, M27-5
GET /api/auth/megainsisAdmin; auth config gainsadminUrlfromBLOOM_ADMIN_DASHBOARD_URL.- External-anchor
FooterLinkvariant in the sidebar footer, rendered only whenisAdmin && adminUrl, opening in a new tab; follows the capability-boolean pattern (F14). - AC: link hidden for non-admins and when URL unset; visible for granted users; unit tests for both; no layout shift for non-admins; mobile drawer parity (F19 note: covered by M27-8 pattern frames).
M27-7: Admin resource protocol + account-domain resources, read-only (api) - size L, depends: M27-1, M27-2, M27-3
- The 3.5.1 protocol helpers (filter/sort/pagination parsing, whitelist enforcement,
{items, total}envelope, hard limit cap). - Read-only resources:
users,admin_grants,sessions,oauth_identities,telegram_links,workspaces,global_credentialsmetadata - each with its 5.2 search/filter set, all behindrequire_permission("<resource>.view"). - AC: pagination/sort/filter/search behavior integration-tested per resource against the memory
store + one Supabase-live test (existing opt-in pattern); permission matrix tested for
viewer; every list capped at 100; responses carry no redacted fields (session ids exposed only as prefixes for identification).
M27-8: Penpot design studies - admin shell + list/detail patterns - size M, depends: none (start immediately; gates M27-9+ UI)
- Studies for: admin shell (sidebar/nav), resource list pattern (table, filters, bulk select), record detail pattern (fields + related inlines + history tab), overview dashboard, JSON inspector - as reusable patterns, not one frame per resource; desktop + ~390px mobile frames per AGENTS.md (F19), mobile favoring icon buttons per the M20 convention.
- AC: boards linked on the ticket; owner approval recorded before any M27-9+ UI PR merges; frames only include controls the backend whitelist actually supports (no fake affordances).
M27-9: shadcn-admin-kit foundation + read-only CRUD screens (admin app) - size L, depends: M27-5, M27-7, M27-8
- Vendor shadcn-admin-kit; implement
dataProvideragainst the 3.5.1 protocol andauthProvider/canAccessagainst/api/admin/mepermissions. - Register the M27-7 resources with list (search/filter/sort/pagination), show (with related inlines: user detail embeds sessions/identities/links/grants), and reference navigation.
- Command palette (cmdk) for resource jumps; saved views via URL filter state.
- AC: every M27-7 resource browsable end-to-end against dev; list state round-trips through the
URL;
viewersees no mutation affordances; matches approved M27-8 frames (desktop + mobile); vitest coverage on dataProvider param mapping.
M27-10: Guarded mutations wave 1 + bulk actions (api + admin app) - size L, depends: M27-7, M27-9
- Verbs per 5.2:
users.update_name,users.revoke_sessions,users.delete(with cascade preview endpoint + confirm flow), grant management verbs,sessions.revoke(+ bulk),oauth_identities.delete,telegram_links.unlink,global_credentials.delete. - All via
POST .../actions/{verb}, permission-gated, wrapped inaudited_admin_action, optimistic UI with undo where reversible, destructive confirms where not. - AC: every verb produces an audit row (incl. denied attempts); cascade preview matches actual FK cascades; bulk revoke capped and per-item audited; last-superadmin guard surfaced as a clear UI error; permission matrix integration-tested for all three roles.
M27-11: Projects resource - run read model + state inspector + lifecycle verbs (api + admin app) - size L, depends: M27-9
projectsprojection overbloom_runs(+ owner join viabloom_workspaces): list with phase/lifecycle/owner filters and title search; detail with overview fields, read-only JSON tree ofrun.state, embeddedproject_eventstimeline and child-flow list, links to the owner user record.- Verbs:
archive/unarchive,trigger_tick; superadminedit_state(Pydantic-validated,updated_atoptimistic-concurrency check, full before/after diff in audit). - AC: a concurrent
edit_stateagainst a staleupdated_atis rejected; invalid state shapes rejected with field-level errors; timeline matches the user-facing timeline route's data; lifecycle verbs reflected in the user dashboard after invalidation.
M27-12: Operational resources - jobs, outbound ledger, correlations (api + admin app) - size M, depends: M27-9
jobsresource (kind/status filters, attempts/run_after sort) withretry/cancel(+ bulk cancel) verbs, payloads rendered read-only;outbound_messages(first HTTP exposure of the existinglist_recentstore read, F10) andruntime_correlations, both read-only.- AC: retry re-enqueues the identical payload (byte-equal assertion in tests); cancel is idempotent; payload immutability enforced server-side (no verb accepts a payload); all three resources filter/paginate per protocol; audit rows for every job mutation.
M27-13: Audit-log UI + admin overview dashboard (admin app) - size M, depends: M27-9 (+ M27-3)
audit_logresource screens (filters per 5.2; per-record "History" tab on user/project detail filtered by resource id); overview home: counters (users, active projects, queue depth, failed jobs, active sessions) + recent audit entries + recent outbound failures. SSE live-refresh of the overview is a stretch goal, not an AC.- AC: history tab on a user shows that user's mutations end-to-end after performing them; counters match direct API queries; dashboard renders under 1s on dev data volumes.
M27-14: Hardening pass + threat-model verification (api + infra) - size M, depends: M27-2, M27-5, M27-10 (final gate before milestone PR)
- Origin-check middleware on admin mutations; rate limiting on
/api/admin/auth/*; CSP finalized +_headersaudit; admin E2E suite (grant -> sign-in -> browse -> mutate -> audit row) usingBLOOM_ADMIN_TEST_GRANT; verify every 4.4 mitigation with a test or a documented manual check; document the production posture (disabledon the user API +onlyadmin instance) indocs/deployment.md; decide/record the optional Cloudflare Access layer and the admin-role DB-grant hardening as follow-ups or in-scope per Al's call. - AC: the 4.4 table has a verification pointer per row; e2e green in CI against a deployed dev
admin; security stage (
bandit, audits) green;docs/index.mdlinks the milestone; CHANGELOG + version bump per AGENTS.md.
Dependency graph (issues on the same row can run in parallel):
M27-1 ─┬─ M27-2 ─┬─────────────── M27-5 ── M27-6
├─ M27-3 ─┤
│ └─ M27-7 ─┐
M27-4 ─┴─────────── (M27-5)│
M27-8 ─────────────────────┼─ M27-9 ─┬─ M27-10 ─┐
│ ├─ M27-11 ├─ M27-14
│ ├─ M27-12 │
│ └─ M27-13 ─┘
7. Where this plan diverges from the obvious approach, and why
- Pages-first, ingress-later (3.2). The obvious reading of "k8s + ingress subdomains" would scaffold helm/tofu first. The repo says SPAs ship on Cloudflare Pages and the k8s web path is not yet real (F12, F13); this plan ships on the real path and lands the cluster artifacts flag-gated, so the admin app neither blocks on nor forks the M24 cutover.
- No separate admin backend (3.3). "Separate deployable = separate service" is the reflexive security answer; here the DB credential is the whole blast radius and it is shared by construction, so the plan buys exposure control with a mode switch on one codebase instead of paying the permanent tax of a second service.
- Grants table, not
is_admin(3.4). The column is the obvious move; the table keeps privilege out of reach of the admin UI's own user-update verb, carries its own audit metadata, and dodges this codebase's overloaded "role" vocabulary. - A vendored kit on
ra-core, not a framework app and not hand-rolling (3.5). Notably, the popular FastAPI admin panels (sqladmin/starlette-admin) are eliminated by a structural fact (no ORM, F7) rather than taste - worth stating because it is the first thing a reviewer would suggest. - Guarded verbs, not generic CRUD writes (5.2). Django admin's "edit any row" translated
naively to
bloom_runs.statewould hand admins a tool whose main use case is corrupting the orchestrator. Parity is delivered on capability (you can fix bad state) while shaping the default path to safe, audited verbs. - App-layer authz, explicitly not RLS (3.6) - settled by how the backend actually connects to Postgres, and stated so the "should this be RLS?" review conversation is pre-empted with evidence.
8. Open questions for Al
- Production domain:
admin.<domain>needs<domain>to exist; any preference (affects only the custom-domain step, nothing structural)? - Cloudflare Access in front of the admin origin as an edge-level second factor: in scope for M27-14 or a follow-up?
- Who gets grants at launch besides you, and should
viewerexist at all in v1 (it is cheap to keep, but if the only operators are superadmins the map shrinks)? - Admin on staging/preview: should preview stacks deploy the admin SPA too (currently out of scope)?