ADR-0004 — A group grant carries view, and a group belongs to one client
Date: 2026-09-10 Status: accepted
Refines ADR-0003, which it does not supersede: the grant is still declared, still group-scoped, and still expresses nothing realm-wide. Two properties of it were wrong, and both were found by running it.
Context
ADR-0003 shipped svc-onboarding a grant of
manage-members, manage-membership, view-members over /participants, measured to permit
creating a participant and administering an existing one. ../onboarding then ran under
it, and measurement on the running 26.6.0 found two things the earlier measurements could
not see, because each was taken one grant per throwaway realm.
The member scopes cannot name the group they are about. Holding exactly those three,
every route to the group's own id answers 403:
| call | three member scopes | with view added |
|---|---|---|
GET /group-by-path/participants |
403 | 200 |
GET /groups/{id} on the administered group |
403 | 200 |
GET /groups and GET /groups?search=participants |
403 | 403 |
GET /groups/{id}/members |
200 | 200 |
POST /users into the group |
201 | 201 |
Every member call is addressed by that id. A grant without view can therefore create a
participant and can never find one again — and because the consumer resolves its group
lazily, provisioning a new login still works and only adoption fails: a re-approval, a
retried enablement step, a 403 a long way from the declaration that caused it.
Two clients granted the same group deny each other. A permission for svc-a and
another for svc-b on /participants leaves both with 403 on everything — creating,
reading members, resolving the group. The admin-permissions resource server's
decisionStrategy is UNANIMOUS and so is each permission's, so a permission whose client
policy does not name you votes against you. Keycloak creates both with a 201 and
reports nothing. Nothing declares two clients on one group today, and sync would have
produced it the moment somebody did — silently revoking the grant from the client that was
already working.
Decision
view is required in practice and warned about, not refused. clients.yaml gains it
for svc-onboarding, and compute_sync_plan warns when a declaration carries any
member-addressed scope without view, exactly as it already warns on the
manage-members / manage-membership half-grant. It is a warning rather than a refusal
for the same reason: a permission whose scopes are all member-addressed is a coherent
narrower grant to want, and the warning names what will break.
Granting view does not widen the reach beyond the group. GET /groups — listing the
realm's groups — stays 403, because it is a realm-wide act.
A group is declared by at most one client, and a second declaration is refused when the
file is loaded. malformed_admin_permissions() reports it alongside the unknown-scope
and nested-path checks, before anything is authenticated.
Two alternatives were weighed and rejected:
- One permission per group whose client policy names every declared client. A permission
carries one scope list, so every co-declaring client would receive the union — a
client that declared
view-memberssilently gainingmanage-members. The mechanism exists to make a grant exact; a union defeats it. decisionStrategy: AFFIRMATIVEon the admin-permissions resource server. It keeps each client's scopes exact, but it changes how every permission under that resource server combines, including ones an operator made by hand in the console. This tool's discipline is that it touches only what carries its sentinel, and a realm-wide evaluation change is the opposite of that.
Consequences
The platform cannot express two services administering one group. Nothing needs it. If something does, the choice above is the one to reopen, and the two candidates are recorded here rather than re-derived. A refusal is recoverable by editing a file; the silent revocation it prevents is not.
A grant declared before this change keeps working and starts warning. The warning does not block a sync and the permission is still applied, so an existing realm converges on the next run rather than failing.
GET /groups/{id}/members ignores search entirely. Measured on a group of 130:
?search=, ?exact=, any value — the endpoint answers 200 with every member, and its only
parameters are first, max and briefRepresentation. ADR-0003 closes by naming that
call as the containment-preserving way for ../onboarding to look somebody up. The call is
permitted; the filter is not real, so the lookup is a paged scan matched by the caller. It
does not change this decision and it does change what the consumer must implement.