Skip to content

feat(userauth,dashboard): accept ADR-016 and implement slice 1 (schema + grant path) - #92

Merged
flo2517 merged 1 commit into
mainfrom
feat/issue-76-rbac-slice-1-schema-and-grant-path
Aug 7, 2026
Merged

feat(userauth,dashboard): accept ADR-016 and implement slice 1 (schema + grant path)#92
flo2517 merged 1 commit into
mainfrom
feat/issue-76-rbac-slice-1-schema-and-grant-path

Conversation

@flo2517

@flo2517 flo2517 commented Aug 7, 2026

Copy link
Copy Markdown
Owner

What

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, PR #89), 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.

Testing

  • userauth: role default, SetRole grant/revoke round trip (verified against a real re-Authenticate, not just SetRole'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.

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

🤖 Generated with Claude Code

…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>
@flo2517
flo2517 merged commit 4d34ee6 into main Aug 7, 2026
4 checks passed
@flo2517
flo2517 deleted the feat/issue-76-rbac-slice-1-schema-and-grant-path branch August 7, 2026 14:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants