feat(userauth,dashboard): accept ADR-016 and implement slice 1 (schema + grant path) - #92
Merged
Conversation
…chema + grant path) Accepts docs/control-plane/dashboard-rbac-and-tenant-isolation-proposal.md as ADR-016 (moved to docs/adr/016-dashboard-rbac-and-tenant-isolation.md, Status: Accepted) and implements its first sequencing slice: the role schema, the grant path, and the authorization middleware -- with zero routes wrapped in it yet, exactly as the ADR's own sequencing specifies. Slice 2 (tenant workload views) still needs ADR-016 §7 questions 1 and 2 answered before it can ship. Accepting ADR-016 claimed ADR-016 from ADR-012 §6's gate-reservation table (previously "replicated off-chain data plane," #33). Rather than cascade-renumber the whole table again (the same mistake corrected once already this session), only that one gate moved -- to ADR-024, one past the table's current ceiling. ADR-012 documents the corrected policy this establishes: an unplanned ADR always takes the next integer; a collision moves only the one colliding gate to the ceiling, not the whole table. ## Schema (migrations/000012_user_roles.sql) `users.role text NOT NULL DEFAULT 'tenant' CHECK (role IN ('tenant', 'operator'))`. DEFAULT 'tenant' is a fail-closed default: every existing user (and every future wallet-auto-provisioned one, unchanged) becomes the least-privileged role; nobody is silently upgraded to operator by this migration. ## userauth - `User.Role`, `RoleTenant`/`RoleOperator` constants, `ValidRole`. - `RoleSatisfies(actual, required)`: a ranked comparison (operator satisfies a tenant-tier requirement too) that explicitly fails closed for an unrecognized `actual` role rather than relying on a map's zero-value behavior. - `Repository.SetRole`, implemented on `PostgresRepository` (`ErrUserNotFound` for an unknown user_id; the CHECK constraint, not duplicated Go-side validation, is the authoritative guard against an invalid role value). - `CreateUser`/`Authenticate` now read/return `Role`. ## cmd/controlplane-admin `grant-role <user-id> <tenant|operator>` -- the only way a user becomes (or stops being) an operator, mirroring create-user/issue-key's existing break-glass, no-self-service pattern. ## internal/dashboard `requireRole(minRole, next)`: 401 for no credential, 403 for a valid credential with an insufficient role (kept distinct so a caller can tell "log in" from "you're logged in but not allowed"). Refactored `authenticatedUserID` into a thin wrapper over a new `authenticatedUser` (returns the full `userauth.User`, not just the ID) rather than duplicating the bearer-token-parsing logic. ## Tested - `userauth`: role default, SetRole grant/revoke round trip (verified against a real re-Authenticate, not just the SetRole call's own return value), ErrUserNotFound, CHECK-constraint rejection of an invalid role, RoleSatisfies ordering including the fail-closed unrecognized-role case. - `dashboard`: requireRole against every combination (unauthenticated, tenant-at-tenant-gate, tenant-at-operator-gate, operator-at-either-gate, revoked key) -- all against the real PostgresRepository, not a fake. - `grant-role` smoke-tested end to end against the running local dev stack (create-user, grant operator, verify via a direct SQL read, reject an invalid role, reject an unknown user_id), then cleaned up. - gofmt, go vet, go build, and the full control-plane test suite (including every OPENINFRA_TEST_*-gated live Postgres/Redis/chain test) all ran clean. Leaves #76 open: slices 2-6 (tenant workload views, the /api/v1/overview breaking change, operator queue/worker views, the audit log, and E2E tests) are all still outstanding. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
What
Accepts
docs/control-plane/dashboard-rbac-and-tenant-isolation-proposal.mdas ADR-016 (moved todocs/adr/016-dashboard-rbac-and-tenant-isolation.md, Status: Accepted) and implements its first sequencing slice: the role schema, the grant path, and the authorization middleware — with zero routes wrapped in it yet, exactly as the ADR's own sequencing specifies. Slice 2 (tenant workload views) still needs ADR-016 §7 questions 1 and 2 answered before it can ship.Accepting ADR-016 claimed
ADR-016from ADR-012 §6's gate-reservation table (previously "replicated off-chain data plane," #33). Rather than cascade-renumber the whole table again (the same mistake corrected once already this session, PR #89), only that one gate moved — toADR-024, one past the table's current ceiling. ADR-012 documents the corrected policy this establishes: an unplanned ADR always takes the next integer; a collision moves only the one colliding gate to the ceiling, not the whole table.Schema (
migrations/000012_user_roles.sql)users.role text NOT NULL DEFAULT 'tenant' CHECK (role IN ('tenant', 'operator')).DEFAULT 'tenant'is a fail-closed default: every existing user (and every future wallet-auto-provisioned one, unchanged) becomes the least-privileged role; nobody is silently upgraded to operator by this migration.userauthUser.Role,RoleTenant/RoleOperatorconstants,ValidRole.RoleSatisfies(actual, required): a ranked comparison (operator satisfies a tenant-tier requirement too) that explicitly fails closed for an unrecognizedactualrole rather than relying on a map's zero-value behavior.Repository.SetRole, implemented onPostgresRepository(ErrUserNotFoundfor an unknownuser_id; the CHECK constraint, not duplicated Go-side validation, is the authoritative guard against an invalid role value).CreateUser/Authenticatenow read/returnRole.cmd/controlplane-admingrant-role <user-id> <tenant|operator>— the only way a user becomes (or stops being) an operator, mirroringcreate-user/issue-key's existing break-glass, no-self-service pattern.internal/dashboardrequireRole(minRole, next): 401 for no credential, 403 for a valid credential with an insufficient role (kept distinct so a caller can tell "log in" from "you're logged in but not allowed"). RefactoredauthenticatedUserIDinto a thin wrapper over a newauthenticatedUser(returns the fulluserauth.User, not just the ID) rather than duplicating the bearer-token-parsing logic.Testing
userauth: role default,SetRolegrant/revoke round trip (verified against a real re-Authenticate, not justSetRole's own return value),ErrUserNotFound, CHECK-constraint rejection of an invalid role,RoleSatisfiesordering including the fail-closed unrecognized-role case.dashboard:requireRoleagainst every combination (unauthenticated, tenant-at-tenant-gate, tenant-at-operator-gate, operator-at-either-gate, revoked key) — all against the realPostgresRepository, not a fake.grant-rolesmoke-tested end to end against the running local dev stack (create-user, grant operator, verify via a direct SQL read, reject an invalid role, reject an unknownuser_id), then cleaned up.gofmt,go vet,go build, and the full control-plane test suite (including everyOPENINFRA_TEST_*-gated live Postgres/Redis/chain test) all ran clean.This leaves #76 open: slices 2-6 (tenant workload views, the
/api/v1/overviewbreaking change, operator queue/worker views, the audit log, and E2E tests) are all still outstanding.🤖 Generated with Claude Code