changed: replaced roles and entitlements with group capabilities #1

Merged
jak merged 35 commits from groups-capabilities into main 2026-10-20 20:02:34 -04:00
Owner

Summary

  • Removes access roles and group entitlements. Groups now grant integrations, and each grant selects zero or more of the integration's capabilities.
  • Registers integrations only from the manifest an app publishes at /.well-known/consent-integration, through integrations preview and integrations apply or the admin UI review step. Preview lists added, changed, and removed capabilities and the groups and users a removal affects.
  • Access tokens carry the user's capabilities for the integration. User info returns live capabilities and fails once the user no longer has access.
  • Enforces tokens.MaxEncodedAccessTokenSize (3500 bytes) at registration by encoding the worst-case token for the manifest's full catalog. Oversized manifests are rejected with the overage in bytes.
  • Adds client.IntegrationManifest, client.HandleIntegrationManifest, and client.CheckCapabilityBudget so apps can publish their manifest and test their catalog size.
  • Updates the mock deployment to seed members, writers, and admin groups with notes.read and notes.write capabilities, and updates the README and integration skills to match.

Testing

  • Not run: make test has not been run against this branch for this description. Run it before merging.
  • Check that internal/service/manifests_test.go and pkg/tokens/budget_test.go pass, since they cover manifest validation and the token size budget.
  • Check that pkg/client/manifest_test.go passes, since it covers CheckCapabilityBudget for apps.
  • Not run: make mock-deployment end to end, to confirm the seeded groups grant the expected capabilities on mock 1, 2, and 3.
## Summary - Removes access roles and group entitlements. Groups now grant integrations, and each grant selects zero or more of the integration's capabilities. - Registers integrations only from the manifest an app publishes at `/.well-known/consent-integration`, through `integrations preview` and `integrations apply` or the admin UI review step. Preview lists added, changed, and removed capabilities and the groups and users a removal affects. - Access tokens carry the user's capabilities for the integration. User info returns live capabilities and fails once the user no longer has access. - Enforces `tokens.MaxEncodedAccessTokenSize` (3500 bytes) at registration by encoding the worst-case token for the manifest's full catalog. Oversized manifests are rejected with the overage in bytes. - Adds `client.IntegrationManifest`, `client.HandleIntegrationManifest`, and `client.CheckCapabilityBudget` so apps can publish their manifest and test their catalog size. - Updates the mock deployment to seed `members`, `writers`, and `admin` groups with `notes.read` and `notes.write` capabilities, and updates the README and integration skills to match. ## Testing - Not run: `make test` has not been run against this branch for this description. Run it before merging. - Check that `internal/service/manifests_test.go` and `pkg/tokens/budget_test.go` pass, since they cover manifest validation and the token size budget. - Check that `pkg/client/manifest_test.go` passes, since it covers `CheckCapabilityBudget` for apps. - Not run: `make mock-deployment` end to end, to confirm the seeded groups grant the expected capabilities on mock 1, 2, and 3.
We removed access roles. A user may now authorize, refresh, or read user info
for an integration only when one of their groups is linked to it. An
integration that no group grants is closed to everyone, where before an
integration without required roles was open to every user.

Consent administration now comes from membership in a protected 'admin'
group. 'consent init' seeds the group, it cannot be deleted, and its last
member cannot be removed or deleted. The Consent integration itself cannot be
granted to any group.

Operators who scripted '--role' or '--required-roles' now add users to
groups and link groups to integrations instead. The first administrator is
added with 'consent api groups members set -- admin <subject>'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Integrations now declare the capabilities their code understands in the
manifest they publish. Consent registers and updates an integration only from
that manifest, fetched from the app or pasted by an admin. We removed the
form and API for editing integration fields by hand.

Every change goes through a preview that lists changed fields and added,
changed, and removed capabilities. Applying sends the digest of the
registration that was previewed, and Consent rejects the apply if the
registration changed in the meantime. Removed capabilities are deleted. An
integration's name and audience cannot change; a new audience means a new
integration.

'client.DecodeIntegrationManifest' reads manifests strictly: unknown fields,
trailing data, oversized documents, and unknown versions are rejected. The
CLI's 'integrations create' and 'update' became 'preview' and 'apply', which
read a manifest file or stdin. The dev client gained '--capability' and
'--print-manifest' so the mock deployment registers its clients this way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
We removed group-defined entitlements. A group integration now selects
capabilities from the integration's own catalog, and every member of the
group receives them. A user's capabilities for an integration combine the
selections of all their granting groups. Memberships no longer carry
anything of their own.

Access tokens carry a sorted 'capabilities' claim for their integration,
with no scope required, and 'HasCapability' replaces 'HasEntitlement' in
'pkg/tokens', 'pkg/client', and 'pkg/testing'. The 'entitlements' scope
became 'groups'. With it, '/userinfo' lists the user's groups that grant the
integration, by name and display name. '/userinfo' always returns current
capabilities.

Capabilities ride in a browser cookie, so their size matters. The new
'tokens.CapabilityBudget' computes the exact size of the largest token an
integration can receive: its whole catalog, every scope, and both
audiences. Consent rejects a manifest whose catalog would exceed the access
token limit, so refresh cannot fail later. Apps can run the same check in
their own tests with 'client.CheckCapabilityBudget'.

The manifest preview now names the groups and users that lose a removed
capability. The admin UI chooses capabilities per group integration and
lists the groups that grant each integration. The CLI replaced 'groups
entitlements' and 'members set' with 'members add' and 'integrations set
--capability'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README, package docs, and the in-repo integration skills now describe
group access, capability catalogs, the manifest review flow, and the
'groups' scope. They no longer mention roles or entitlements.

We documented why capability catalogs have a size limit and how it plays
out. Access tokens travel in a browser cookie, so their size is capped, and
the largest possible token carries an integration's whole catalog. That
leaves room for roughly 65 to 120 capabilities, depending on name length.
The docs ask for short 'noun.verb' names, capabilities that name kinds of
action rather than individual records, and stable meanings. They show how
to test a catalog with 'client.CheckCapabilityBudget'.

The production integration skill also had stale setup snippets for
'tokens.InitClient', 'client.Init', and login links, which we corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jak added 16 commits 2026-10-20 14:20:50 -04:00
API handlers now read path values first, separate each phase with a blank
line, and name the response DTO before writing it. We removed the
'writeServiceError' helper so every handler writes service errors the same
way.

Orchestration tests that make many API calls now share one request layout.
Single-route tests keep the separate body and result style. Multi-field
literals and calls with several arguments are split one per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every transaction now defers its rollback right after it begins and commits
once on success. Each SQL step moved into a 'db.sql...Tx' helper, so the public
method shows the order of steps and picks the outcome. 'InsertGrants'
previously rolled back by hand on each error path.

We replaced 'ensureAdminRemainsTx' with 'sqlIsLastAdminTx'. It reports a bool
and lets 'DeleteUser' and 'RemoveGroupMembership' return 'ErrLastAdmin'
themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The manifest helpers now have verb-led names. 'normalizeIntegrationManifest'
and 'integrationManifestDigest' became the 'normalize' and 'computeDigest'
methods on 'IntegrationManifest'. We renamed the exported
'IntegrationCapabilityBudget' to 'BuildIntegrationCapabilityBudget' and
updated its caller in 'pkg/client'.

Each file now orders exported 'Service' methods first, then exported
functions, then unexported methods and functions. Methods on other types sit
with their type. Error checks are followed by a blank line, keyed literals
list one field per line, and long calls list one argument per line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service used to check durable state before writing and decoded storage
errors such as 'sql.ErrNoRows' and SQLite constraint messages itself. The
'Store' interface now documents each mutation's preconditions, atomic
effects, retry behavior, and errors. The database adapter enforces them in
its transactions and returns the service's semantic errors directly.

Group membership, group integration grants, manifest saves, and protected
deletes now reject unmet preconditions inside the adapter. Deletes return
not-found errors instead of a bool, and updates return the stored record.
Inserting grants or a refresh token for an unknown user now fails with
'ErrUserNotFound' instead of silently doing nothing.

The service passes store errors through unchanged. The API now logs errors
the service does not document and reports them only as an internal error, so
storage details no longer reach clients.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Finalizing an authorization used to insert grants and the authorization code
as two separate writes, and never rechecked group access. 'RecordAuthorization'
now checks that a group admits the user, records the missing grants at a
service-captured time, and stores the code in one transaction. It replaces
'InsertGrants'.

Refreshing used to delete the old refresh token before checking access and
storing the replacement, so a failed write lost the session. The service now
checks access first and calls 'RotateRefreshToken', which swaps the tokens
atomically. A user who has lost access has the refused token revoked.

The remaining list reads now pass store errors through unchanged. Tests seed
grants through a new 'RecordTestGrants' helper, so test setup also passes the
access check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service used to read a user's granting groups and their capabilities
with two separate store calls, so a change committed between them could mix
two moments. The store now offers 'GetIntegrationAccess', which reads both in
one statement. Tokens and '/userinfo' therefore report access exactly as of
that read.

It replaces 'ListIntegrationAccessGroups' and
'ListIntegrationAccessCapabilities'. The adapter tests that called them now
use the new read.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service used to read a group or integration first only to turn a missing
parent into a not-found error before listing its children. The four
parent-scoped store lists now return 'ErrGroupNotFound' or
'ErrIntegrationNotFound' themselves, from the same statement that reads the
children. An existing parent with no children still returns an empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home page's grant list used to list every integration, check the user's
access to each one, and then read granted scopes per integration, so its
queries grew with the number of integrations. 'ListUserGrants' is now a
single store read that returns the integrations a user's groups grant, each
with the user's granted scopes, as of one moment.

We removed 'IntegrationsAccessibleTo', whose only caller was
'ListUserGrants'. Its tests now check the same cases through
'ListUserGrants'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Previewing a manifest used to read the integration, then its catalog, then
for removed capabilities every granting group, each group's members, and
each member's user record, so its queries grew with the number of members.
The store now offers 'GetIntegrationRegistration', which returns the
integration, its catalog, and the groups and user handles holding each
capability in one read transaction.

The preview compares the manifest against that registration, and describing
removed capabilities no longer touches the store.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Login reported every failure to read the integration as 'integration not
found', so storage errors returned 400 and were never logged. Adding a group's
integration access treated any read failure as a new grant and reset the
group's capabilities. Both now return errors other than not-found unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
'ListUserGrants' returned an empty list for a subject that does not exist. It
now returns 'ErrUserNotFound' from the same statement, matching the other
lists scoped to a parent record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The API integration list and the admin group page read each integration's
capabilities with a separate query. They now use 'ListIntegrationManifests',
which returns every integration with its catalog in one statement. The home
page reads all groups once instead of one group per membership. Setting a
group's access checks all requested capabilities in one query.

The admin group page now shows the Consent integration's display name rather
than its internal name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Manifests accepted any non-empty integration name, including names with
slashes or spaces. The CLI and API could not address those integrations. Names
must now match the pattern groups and capabilities use.

We removed the admin test for escaping such names in links, because no valid
name needs escaping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The service took its API audience as a separate option, while the capability
budget assumed it equals the issuer domain. Had the two differed, manifests
could pass the budget and then fail to issue tokens at refresh. The service now
builds its API token validator from the token server options, so one value
serves both roles.

'service.Options' no longer has 'ResourceTokenClientOpts', and 'service.New'
requires a signing key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Deleting an integration left its users' scope grants and refresh tokens in
place. An integration registered again under the same name skipped the consent
screen. With the same audience, old sessions could resume.

Grants now cascade on integration deletion. Refresh tokens record the
integration they were issued for and cascade the same way. Rotation keeps the
consumed token's integration. 'InsertRefreshToken' now takes the integration
name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dev client defaulted to 'example@localhost', which the integration name
validator now rejects. We changed the default to 'example'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed update from the review page fell back to the import page, as if
the admin had been adding a new integration. We now treat a non-empty base
digest as an update and show the error on that integration's page. If the
integration was deleted in the meantime, the error appears on the import page
instead of as a 404.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Capability normalization returned nil for a grant without capabilities, so
the API encoded those grants as 'capabilities: null'. It now always returns
a slice, and the API shows an empty list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
User reads now join each membership's group, so 'service.User.Groups' carries
name and display instead of bare names. The home page uses that directly and no
longer lists every group to look up display names. The admin users list shows
display names as badges, with the group name as the badge title.

The admin users API changed shape. Each entry in a user's "groups" is now an
object with "name" and "display", matching the userinfo groups. API clients
that read group names as strings need to read the "name" field instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The admin integration page and the API's get-integration endpoint each made
several separate reads. The admin page could show a capability catalog and a
group list from different moments.

We added every group with access to the integration, including groups that
grant no capability, to the registration that 'GetIntegrationRegistration'
reads in one transaction. Both views now use that single read through a new
service method, and the service's 'ListIntegrationGroups' was removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The integration registration read now returns every group with access, so
nothing outside the database tests called this method. We pointed those tests
at 'GetIntegrationRegistration' so they keep checking the same behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scope names are shared between Consent and the apps that integrate with it,
but only 'internal/service' defined them. 'client.CheckCapabilityBudget'
imported the service package to build its budget.

'pkg/tokens' now exports 'ScopeIdentity', 'ScopeProfile', 'ScopeGroups', and
'ScopeNames'. 'NewCapabilityBudget' builds a budget that holds every scope.
The service keeps only the scope labels and descriptions, keyed by these
constants. We removed 'service.ScopeNames' and
'service.BuildIntegrationCapabilityBudget'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After the previous commit, scopes were listed twice: in 'tokens.ScopeNames'
and as the keys of the service's scope registry. The registry decided which
scopes a request could include, while 'ScopeNames' fed the capability budget.
A scope added only to the registry would have been grantable but missing from
the budget, so the budget could undercount the largest token.

'pkg/tokens' now holds the one table of scopes and their dependencies.
'ScopeNames' reads from it, and the new 'ScopeRequires' reports each scope's
dependencies. The service validates requested scopes through 'ScopeRequires'
and keeps only the labels and descriptions shown at authorization. We removed
the unused 'Requires' field from 'service.ScopeDefinition'.

We also noted in the README that the public package structure needs a rethink.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a refresh was denied for lost access, we revoked its token. If the token
had already been removed, for example by a concurrent refresh, the caller got
ErrTokenNotFound instead of ErrAccessDenied. A missing token now counts as
revoked, and the refresh reports the denial.

Four refresh tests used tokens for unregistered audiences, so they reached the
denial branch and relied on the old error. We registered the integration and
granted the user access, so they still check that a missing or revoked token
is rejected with ErrTokenNotFound.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Removing a membership, removing a group's access to an integration, or deleting
a group could leave users with no way to reach an integration. Their scope
grants and refresh tokens stayed in place. Sessions only ended at the next
refresh, and regained access skipped the consent screen.

Each of these operations now deletes those grants and refresh tokens in the
same transaction, but only for users that no other group still admits. The
Consent integration's sessions are never touched, since users reach it without
groups.

One refresh test asserted that grants survived lost access. It now checks that
they are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Consent stamped its issuer domain into every app token's audience so its
API could accept them. Nothing kept the issuer domain apart from the
public host or from app audiences, which allowed two collisions. When the
issuer domain equalled the public host, Consent's web UI accepted every
app's tokens as logins, including on admin pages. When an app registered
the issuer domain as its audience, it accepted every other app's tokens,
and refresh treated its own tokens as Consent-only and skipped the group
check.

The issuer domain now appears only in iss. FinalizeAuthorization mints
the integration's audience alone, and client validators require an exact
audience match. Refresh and userinfo recognise Consent's tokens by issuer
and signature, then resolve the integration from the single audience. An
unknown audience is denied. The internal consent integration needs no
group access and gets no capabilities. Every other integration needs
current group access. The rules that skipped the issuer domain and fell
back to Consent-only are gone.

The capability budget now counts the issuer once, so catalogs get a
little more room.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tokens already carried one audience, but pkg/tokens still modelled it as
a list. Options and accessors now use a single string, and the aud claim
is one JSON string. Issuing refuses a blank audience or one containing
whitespace. Decoding rejects any token whose aud is not a single
non-blank string, whichever validator is used.

We reshaped the Validator interface around that model. ValidateIssuer
replaces ValidateDomain. ValidateAudience replaces ValidateAudiences and
ShouldValidateAudience: a Client accepts only its exact audience, and a
Server accepts any audience because Consent recognises its own tokens by
issuer and signature.

Callers of pkg/tokens and pkg/testing need to pass an audience string
instead of a slice, and custom Validator implementations need the new
method set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README, the pkg/client docs, and the integration skills now explain
that the issuer domain only identifies the signer, and that every token
has exactly one audience, its integration's. The README's capability
estimates reflect the budget counting the issuer once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Refresh checked access against the integration named by the token's
audience. Rotation then stored the replacement under the integration and
user recorded on the consumed row. Every path that writes a row takes both
from the same integration record, so the two never differed in practice.
Only the callers kept them in step, not the store.

RotateRefreshToken now consumes the row only when it belongs to the user
with the replacement's subject and the integration with the replacement's
audience. The match happens in the same delete. A mismatch reports
ErrTokenNotFound and leaves the row unchanged.

We kept the access check ahead of the store read. Losing access deletes a
user's refresh tokens, and refresh must still report the denial for a token
that is already gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Token refresh and userinfo used a nil access to mean the internal consent
integration. Callers branched on nil to skip the group check and drop
capabilities. A nil returned by mistake for any other integration would
also have skipped the group check.

IntegrationAccess now carries an explicit AdmitsEveryone flag, and
Admitted is true when that flag is set or a group grants access. The zero
value admits no one. GetIntegrationAccess is the one place that gives the
consent integration access that admits everyone, with no groups or
capabilities. Every integration, including consent, now goes through the
same group-access check, and callers always get a non-nil access.

Userinfo still refuses the groups scope for consent tokens, now by
checking AdmitsEveryone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jak merged commit 59a6779366 into main 2026-10-20 20:02:34 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
pollinator/consent!1
No description provided.