Skip to content

feat(auth): groups and group-based route grants (#18 Phase 2) - #24

Merged
swimmesberger merged 7 commits into
mainfrom
wt/whats-next-377ea7
Aug 10, 2026
Merged

feat(auth): groups and group-based route grants (#18 Phase 2)#24
swimmesberger merged 7 commits into
mainfrom
wt/whats-next-377ea7

Conversation

@swimmesberger

Copy link
Copy Markdown
Owner

Delivers groups + group-based grants from #18 (Phase 2 item 2): route access grants can now name a user or a group, membership feeds policy evaluation and identity forwarding, and admins get a Groups management surface.

What changed

Schema — new Group (unique normalized name) and GroupMember entities; RouteAccessGrant extended in place per the design doc's v2 note: UserId nullable + GroupId, a CHECK constraint enforcing exactly one subject, and two partial unique indexes replacing the old (RouteId, UserId) one. Migration AddGroups is data-preserving for existing grants; its Down() carries the repo's first hand-edited migration line (deleting group grants before the rebuild — the v1 schema cannot represent them, and the scaffolded rollback would otherwise corrupt them into user-0 grants).

PolicyIsAuthorizedAsync / AccessibleRouteIdsAsync keep their signatures; a shared GrantAdmits expression folds group membership into the grant query itself, so all four call sites (verify, callback, login dance, tenant discovery) gained group semantics in one round trip and the two entry points structurally cannot drift.

Forwarding — deliberately no bespoke X-Watchtower-Groups header, despite the issue's shorthand: the codebase's stated contract (IdentityForwarding remarks, central-auth README) forwards via the two adopted ecosystem vocabularies. Remote mode emits Remote-Groups, AuthRequest emits X-Auth-Request-Groups — comma-joined, sorted ordinal, omitted when empty, both names strip-and-copy per the superset invariant. The signed JWT always carries a groups claim (empty array = "in no group", the fail-closed reading), and UserInfo now answers groups. One membership read per verified request feeds both channels. Group names are validated to comma-free printable ASCII at creation so they are header-safe by construction, with a symmetric defense-in-depth filter at format time.

Groups modulegroups.list/create/rename/delete/getMembers/setMembers, all admin-only, with audit events (group.* kinds) written post-commit. proxy.getAccess/setAccess gain grantedGroupIds as a non-breaking trailing optional; validation is fail-fast before any write and reconciliation is per subject kind.

Frontend — new Groups admin page (gated { module: 'Groups', role: 'Admin' }) with a members roster dialog, and a Groups roster in the route Access dialog for Restricted mode.

Docsdocs/central-auth/design.md and README updated to describe groups as shipped (data model, JWT claims, per-mode header tables), since the new code cites them as authority.

Review notes

  • Implemented via an orchestrated pipeline: an independent review pass (two rounds, all findings resolved — notably the RenameGroup post-commit cancellation window, the migration rollback corruption, and schema-constraint test coverage) followed by a final architecture gate.
  • Not in scope (remaining Central authorization Phase 2: OIDC upstream, groups, template inheritance, TOTP #18 items): template policy inheritance, OIDC upstream, TOTP, grant caching.
  • Verification: build 0 warnings/0 errors; 475/475 tests (up from 415 — schema-constraint, policy, module, verify-endpoint and header coverage added); rpc-schema.json re-export produces no diff; frontend codegen + typecheck + build clean.

Closes nothing on its own — #18 stays open for the remaining Phase 2 items.

Groups become a subject a route can be granted to, and a fact protected
upstreams are told about.

Schema: `Group` (unique normalized name, mirroring the User precedent) and
`GroupMember`; `RouteAccessGrant.UserId` becomes nullable alongside a new
`GroupId`, with a CHECK constraint making "exactly one subject" a schema fact
rather than a handler convention, and the single unique index replaced by one
partial index per subject kind. The SQLite table rebuild in `AddGroups`
preserves existing grants (they keep their user id and get a null group).

Policy: `IsAuthorizedAsync` and `AccessibleRouteIdsAsync` now share one
`GrantAdmits` predicate — a grant matches when it names the account or a group
the account is in — so all four call sites gain group semantics at once and the
two entry points cannot drift. Membership folds into the grant query, keeping
verify at one round trip.

Forwarding: `Remote-Groups` and `X-Auth-Request-Groups` move from strip-only to
strip-and-copy for their mode (names sorted ordinal, comma-joined, omitted when
empty); the JWT gains an always-present `groups` claim and UserInfo answers
`groups` as of now. Both channels are fed from one membership read per request,
so an upstream cannot see two different answers in one request.
`groups.list/create/rename/delete/getMembers/setMembers`, every handler
`[RequireRole("Admin")]` — adding an account to a group grants it every route
that group is named on, so it is exactly as privileged as writing the grant.

Names are validated where they enter the system rather than escaped where they
leave it: trimmed, non-empty, at most 64 characters, printable ASCII and no
comma. The comma rule is load-bearing — group names travel comma-joined in the
forwarded group header, so a group called `viewers,admins` would hand its
members a role nobody granted.

`setMembers` is a whole-set replace with the `setAccess` reconciliation shape:
every id validated before any write, unchanged members untouched, audit row
after the commit. Four new audit kinds record the acts that can change access
without a route being touched.

`proxy.setAccess` takes an optional trailing `grantedGroupIds` (a client that
predates groups keeps today's behaviour rather than being rejected) and both
handlers report the two grant sets separately; leaving `Restricted` still
clears both.
…Info

GroupsModuleTests covers the module through its real generated pipelines: the
name charset (a comma, a control character and a non-ASCII name are all
refused, because each would make the forwarded name mean something other than
what was typed), case-insensitive uniqueness, whole-set membership
reconciliation, the fail-whole validation, both cascade directions, and the
Admin denial for all six handlers.

RouteAccessPolicyGrantsTests extends the anti-drift estate to every grant shape
— direct, via a group, via a group the account is not in, and both kinds on one
route — and adds revocation by leaving the group, by deleting it, and the
fail-closed reading of an unknown mode with only a group grant behind it.

ProxyAccessModuleTests round-trips group grants, reconciles the two axes
independently, and asserts an unknown group id is refused before any write.

AccessVerifyTests covers it at the wire: a member of a granted group passes with
headers, a non-member gets 403, revoking membership closes the route on the next
request, group names arrive comma-joined and sorted under the mode's own name
only, a JWT-only route still carries them in the assertion, and a smuggled
`Remote-Groups: admins` is replaced by the real membership.
…ialog

New `groups` module gated `{ module: 'Groups', role: 'Admin' }` and slotted
right after Users in the sidebar — the two are read together, since a group is
only a set of those accounts. The page follows the Users conventions: DataList
with a mobile card, create/rename dialogs sharing one name form, ConfirmDialog
for delete, and a Members dialog using the same checkbox roster the Access
dialog uses, saving the whole set in one call.

The copy carries the two things an operator cannot see from the screen: the
group name is forwarded to protected apps (so a rename is visible to them, and
commas are refused), and membership is evaluated per request (so adding or
removing a member takes effect immediately, and deleting a group revokes what
it granted).

The route Access dialog gains a Groups roster beside the users one for
`Restricted`, submitted as `grantedGroupIds`; both are cleared client-side for
the modes they do not belong to, matching what the server does.

rpc-schema.json regenerated: six `groups.*` methods, and `grantedGroupIds`
optional on setAccess input, present on both access responses.
…er, tests

RenameGroup read its member count after the commit with the request token, so
a caller hanging up could drop the `group.renamed` audit row — the exact hole
the CancellationToken.None discipline exists to close. The count moved above
the commit; a rename cannot change membership, so it reads the same either way.

The AddGroups Down() gets its first hand-edit: a `DELETE FROM
route_access_grants WHERE group_id IS NOT NULL` ahead of the scaffolded
rebuild. The v1 schema cannot represent a group grant, so a downgrade has to
drop them — and without this the rebuild's `IFNULL(user_id, 0)` would silently
rewrite every group grant into a grant for user 0, while the non-partial unique
index it recreates would fail outright for a route holding two of them. Up() is
untouched; the generated down SQL now starts with the DELETE.

IdentityForwarding's group filter dropped comma-bearing names but not
control/non-ASCII ones, contradicting its own comment: such a name would fail
HeaderSafe on the *joined* value and cost the account every other group it is
in. It now applies exactly the creation-time charset rule, so one bad row costs
one group rather than all of them. Both kinds still ride the JWT intact.

New RouteAccessGrantSchemaTests pins what three handler comments lean on: the
CHECK rejects a grant naming both subjects or neither, the partial unique index
rejects a duplicate (route, group), and several group grants coexist on one
route despite their shared null user_id. Group-header formatting gains unit
coverage for the sort, the absent-when-empty rule, and per-name dropping.

Three near-identical group seeders collapse into a new
Application.Tests/AccessTestEstate (the counterpart of the Api.Tests one),
which also sets a real CreatedAt — as does the Api.Tests seeder now.
design.md and README.md still said groups were Phase 2 while the new code cites
them as authority. Surgical updates only:

- design.md §3 gains the real `Group`/`GroupMember` sketches and the actual
  `RouteAccessGrant` shape (nullable UserId + GroupId under a CHECK), plus a
  paragraph on why uniqueness is two partial indexes and why membership is
  resolved per request rather than materialised into grant rows.
- §2.3 documents the always-present `groups` JWT claim (empty array when the
  account is in none — "no memberships" has to be distinguishable from "not
  answered") and the per-mode ecosystem group header: sorted, comma-joined,
  omitted when empty.
- The status line, §2.8 staging, §7 modules and §8 frontend now say what is
  actually built; OIDC, TOTP and template inheritance stay listed as remaining.
- README's per-mode header table gains the group headers with the encoding and
  the omitted-when-empty rule, UserInfo now lists `groups`, and the limitations
  section swaps "no groups yet" for the real remaining gap — group membership is
  Watchtower's own directory, and tenant routes do not inherit policy.
- design.md §10's milestone list still had "groups + group grants" as Phase 2
  future work; it now gets the same strikethrough/(done) treatment as §2.8 and
  the status line. Swept the rest of both docs — no other stale pairing left.
- Dropped the dead `Password` const from GroupsModuleTests (its only consumer
  was the SeedUserAsync that moved to AccessTestEstate) and the now-unused
  Identity using from RouteAccessPolicyGrantsTests. GroupsModuleTests keeps its
  Identity using — the delete-cascade test still resolves a UserManager.
- Removed a stray double blank line inside the §3 data-model fence, where every
  other entity is separated by one.
@swimmesberger
swimmesberger merged commit 3ffe86d into main Aug 10, 2026
2 checks passed
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.

1 participant