Control plane: web console, tenant-scoped admin, and fleet config versioning#8
Merged
Conversation
Security: tenant-scoped key create rejects takeover of another tenant's AK; demo seed accounts now require explicit CP_DEV_SEED=true; login throttling plus a timing-safe verify for unknown emails; constant-time CSRF compare; password reset evicts live sessions (session issue epoch vs password change epoch); admin mutations get actor-attributed audit logs; requireAdmin is an allowlist. Config: rollback re-validates the retained document before republishing (a stale doc would brick peer reloads and fresh boots); publish supports expected_version compare-and-insert with a 409 on a moved head, and the UI confirms and sends its loaded version. /admin/usage/series is one bucketed store query (rollup + ledger tail, both dialects) instead of up to 400 usage_by_user calls. Client/web: gateway 404/409 map to typed sentinels so the control plane answers real status codes; expired sessions force re-login via a global 401 handler; keys tenant filter debounced; e2e now covers publish, rollback, and failed login; CI builds the control-plane image. Also carries the control-plane quality pass: shared admin helpers, useAction/FormModal dedup, slices.SortFunc/cmp.Or modernization, ctx threading, errgroup overview fan-out.
/code: fix declaration interleaving (parseTargets, scopeQuery, statusWriter, userSeed, window, keyBelongsTo, interface checks), move user store impls under internal/user to mirror the kv seam, propagate ctx in mock PublishConfig, use t.Context() in tests, correct WithFunc names, trim restating comments. /code-rs: fold repeated qualified paths into use imports. /code-ts (new standard): type-only imports, expand UsersPage one-liner JSX, dedup applySession, drive routes from the filtered nav table, reuse jsonBody in ConfigPage. Simplify: scopedKey guard helper, configBody carries expected_version, drop serveWeb's redundant Content-Type.
Tenant-admin key mutations now authenticate with per-tenant gateway tokens (CP_GATEWAY_TENANT_TOKENS, fail-closed) so the gateway's AdminScope draws the boundary; the client-side guards and their fleet-wide key scans are gone. The gateway redacts vendor_cost_micros under a tenant scope and answers /admin/keys?ak= as an exact page. Control-plane adds an X-Request-ID middleware threaded through logs and proxied calls, key-list pagination passthrough, and an openapi drift test. Docs catch up (root README, control-plane README, api.md, deployment.md); release-control-plane.yml ships linux/darwin amd64/arm64 binary tarballs beside the existing multi-arch images.
Browser -> control plane -> gateway binary on live Postgres/Redis: scoped create/list, cross-tenant patch and create-takeover answered 404 by the gateway, fail-closed mutation without a tenant token, ak= filter anti-probing, and vendor-cost redaction verified on billed traffic. Mock create-takeover aligned to the gateway's 404 semantics. Integration runs are uncached (-count=1); CI wires CP_TEST_GW_BIN.
/admin/usage/users and /admin/usage/series already zeroed vendor_cost_micros for tenant tokens; /admin/usage leaked the margin basis. Centralize the policy in AdminScope::sees_vendor_cost and apply it in all three usage reads.
The MAX(id)-guarded insert was not a real CAS: under READ COMMITTED two concurrent guarded publishes both see the old head and both insert, so expected_version silently loses an update. All publishes now take pg_advisory_xact_lock and guarded ones check the head under it; the PG-gated test races eight contenders and admits exactly one.
The post-commit prune was a second round trip whose failure surfaced an error for a publish that had already landed and notified the fleet; one locked transaction makes retention all-or-nothing with the insert. Also flatten the subscribe loop to while-let.
ALTER TABLE .. IF NOT EXISTS takes AccessExclusive even as a no-op, so one transaction holding several tables' locks deadlocks against live data transactions (40P01 in CI: finalize vs schema bootstrap). Per-DDL transactions keep bootstrap serialization while never holding more than one relation lock at a time.
On a shared database the config store outlives earlier suites' publishes and wins over the file seed (the CI job runs the Rust suites first), so the E2E gateway booted with a foreign config and never became healthy. The test now provisions a uniquely named database and force-drops it on cleanup, making it order-independent.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Delivers a browser-facing control plane for the gateway fleet plus the gateway-side admin surface it needs.
Gateway (Rust)
GET /admin/config,POST /admin/config/validate,GET /admin/config/versions,POST /admin/config/versions/{id}/rollback,GET /admin/usage/series, and?ak=exact key lookup.?expected_version=is checked under the lock and answers 409 on a moved head. Retention pruning rides in the same transaction.vendor_cost_microsis redacted for tenant tokens on all three usage reads (policy centralized inAdminScope::sees_vendor_cost);?ak=lookups answer an empty page for foreign keys instead of a 404 oracle.bytes::Bytesresponse bodies, SSE chunks borrowid/model, account selection without per-call name clones.Control plane (Go + React)
CP_GATEWAY_TENANT_TOKENS) and fail closed when a tenant has no token.Delivery
Verification
cargo fmt --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(383 passed)go vet,go test ./...; web: vitest +tsc -bbuild; Playwright e2e in CImake test-integration: real Postgres/Redis via compose, tenant-token full-chain E2E against a real gateway binary (cross-tenant probes answer 404, mutations without a tenant token fail closed with 502, tenant tokens read vendor cost as 0 on real billed traffic), guarded-publish contention test admits exactly one of eight concurrent publishers