Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/organization-roles-in-token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': minor
---

A backend can authorize per organization from the access token alone (#416, ADR 0004). Inside an active organization, tokens gain `org_roles` and `org_permissions` beside `org_id`, and `GET /users/me` and `GET /users/me/permissions` report the same. Organization roles never appear in `roles`, so a guard on `admin` does not pass for an organization admin. Organization roles are now a catalogue, managed with `POST /admin/organization-roles` and `PATCH` and `DELETE /admin/organization-roles/:roleName`, listed by `GET /admin/roles`, and mapped to permissions with `PUT /admin/roles/organization/:roleName/permissions`. A new migration seeds it with the built-in `owner`, `admin` and `member` and every value memberships already hold, and rewrites no membership. **Behavior changes:** assigning a role outside the catalogue now answers 400. `PATCH /organizations/:id/members/:userId` now needs step-up. Only an owner or an instance `admin:write` can grant or remove `owner`, or remove an owner from an organization, and any other manager can only grant a role whose permissions they hold, so an organization admin can no longer make themselves owner. Adding, changing and removing members is audited as `organization_member_added`, `organization_member_updated` and `organization_member_removed`, and a refused change as `organization_member_role_grant_refused`. **Fixes:** removing a member now reaches the token. Previously every refresh copied `org_id` from the session until it ended. Now the next token drops the organization claims and the session stops naming the organization. Recovery tokens carry no organization claims.
3 changes: 3 additions & 0 deletions adapter-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@
{ "method": "GET", "path": "/admin/credential-count", "credential": "access" },
{ "method": "GET", "path": "/admin/enrollment", "credential": "access" },
{ "method": "POST", "path": "/admin/enrollment/invites", "credential": "access" },
{ "method": "POST", "path": "/admin/organization-roles", "credential": "access" },
{ "method": "DELETE", "path": "/admin/organization-roles/{roleName}", "credential": "access" },
{ "method": "PATCH", "path": "/admin/organization-roles/{roleName}", "credential": "access" },
{ "method": "GET", "path": "/admin/organizations", "credential": "access" },
{ "method": "POST", "path": "/admin/organizations", "credential": "access" },
{ "method": "DELETE", "path": "/admin/organizations/{organizationId}", "credential": "access" },
Expand Down
51 changes: 51 additions & 0 deletions docs/admin-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,53 @@ no one able to administer it.
Instances whose `available_roles` predates scoped roles and lists only `admin` get `admin`
instead, which is equivalent in power, so the grant never silently becomes a no-op.

## Permissions

Roles say who someone is. Permissions say what they can do, and a role grants a set of them. The
design is in [ADR 0004](./adr/0004-permissions-and-organization-roles.md).

- **Permissions** are a catalogue, managed at `/admin/permissions`. A name uses the role alphabet,
such as `invoices:read`. They match exactly: `invoices:write` does not satisfy `invoices:read`,
and there are no wildcards.
- **Instance roles** are `available_roles`. Removing one also removes its mappings, so adding the
name back later grants nothing.
- **Organization roles** are a catalogue at `/admin/organization-roles`, shared by every
organization on the instance. `owner`, `admin` and `member` are built in. Assigning a role
outside the catalogue answers `400`.
- **Mappings** are replaced as a whole with `PUT /admin/roles/:kind/:roleName/permissions`, where
`kind` is `instance` or `organization`. `GET /admin/roles` lists both kinds with what they grant.
A role grants through its literal name: holding `billing` does not pick up a mapping on
`billing:read`.

An instance `admin` and an organization `admin` are different roles and never share a grant.

Deleting a permission a role still grants answers `409` with those roles. So does deleting a
built-in organization role, or one a membership still holds.

### When a change applies

Access tokens carry permissions, so a change reaches a holder the next time a token is issued,
within `access_token_ttl`. `GET /users/me/permissions` reads the database on every call, for a check
that cannot wait. Removing someone from an organization works the same way: their next token drops
the organization claims.

### Switching to an organization

Organization claims apply to the active organization only. A session starts in the user's oldest
membership, and `POST /organizations/:organizationId/switch` changes it. Switching needs the
`organization:read` membership scope unless the member is an `owner` or `admin`, and a member added
without it gets `404`. So when adding someone who belongs to more than one organization, pass
`scopes: ["organization:read"]`, or they cannot reach that organization's permissions.

### Who can change organization roles

`PATCH /organizations/:organizationId/members/:userId` needs step-up. Only an owner or an instance
`admin:write` can grant or remove `owner`, or remove an owner from the organization. Any other
manager can only grant a role whose permissions they hold, and can take away any role except
`owner`. A refusal answers `403` naming the roles, and is audited as
`organization_member_role_grant_refused`. Changes that succeed are audited as
`organization_member_added`, `organization_member_updated` and `organization_member_removed`.

## Step-Up On Admin Writes

Every admin write that changes who can sign in, or what an account can do, needs a fresh
Expand All @@ -75,6 +122,10 @@ Guarded routes:
`/admin/organizations/:organizationId/members/:userId`
- `PUT` and `DELETE /admin/organizations/:organizationId/oauth-providers/:providerId/retirement`
- `POST /organizations/:organizationId/members` (the self-service route that requires `admin:write`)
- `PATCH /organizations/:organizationId/members/:userId`, for any organization manager
- `POST /admin/permissions`, `PATCH` and `DELETE /admin/permissions/:permissionName`
- `PUT /admin/roles/:kind/:roleName/permissions`
- `POST /admin/organization-roles`, `PATCH` and `DELETE /admin/organization-roles/:roleName`
- `DELETE /users/delete`, a user deleting their own account: it cannot be undone, and an access
session alone may be someone else's. An account with no passkey or TOTP cannot step up, so it
cannot delete itself; an administrator can (`DELETE /admin/users`).
Expand Down
3 changes: 2 additions & 1 deletion docs/adr/0004-permissions-and-organization-roles.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ This differs from `requireRole`, deliberately. Roles keep their scoped matching.

### P4. A set that does not fit is omitted whole

The permission arrays get 1 KiB, serialized, together. A server adapter keeps the whole access token inside a signed cookie, so it is base64 encoded twice under the browser's 4 KiB cookie limit, and a test builds that cookie at the budget. Over the budget both arrays are replaced with `"permissions_omitted": true`.
The organization roles and the two permission arrays get 1 KiB, serialized, together. A server adapter keeps the whole access token inside a signed cookie, so it is base64 encoded twice under the browser's 4 KiB cookie limit, and a test builds that cookie at the budget. Over the budget all three are replaced with `"permissions_omitted": true`.

Nothing is trimmed, because a partial list looks complete and denies silently. A guard that sees the marker reads `GET /users/me/permissions` with the caller's token and fails closed if it cannot. A plain 403 would turn an organization growing past a limit into an outage.

Expand All @@ -76,6 +76,7 @@ Sign-in, refresh, organization switch and session exchange all call `mintAccessT
## Consequences

- **Staleness.** Permissions are as fresh as the access token TTL, the same as `roles`. `GET /users/me/permissions` is live for a check that cannot wait.
- **Switching still needs `organization:read`.** A member without the scope, unless an `owner` or `admin`, cannot switch to the organization, so cannot reach its permissions unless it is their default. Reviewed on 2026-11-06 and kept as is.
- **Membership `scopes` stay out of the token.** They gate Seamless's own organization routes and are not app permissions.
- **Assigning an unlisted organization role is now a 400**, which is a behavior change for callers that invented role names at runtime.
- **Provider tokens are not covered.** `/oidc/sign` refuses unlisted claims, the provider's claims map replaces library defaults, `OidcAccountClaimsSchema` strips unknown keys, and its `org_id` is the default organization rather than the session's active one. seamless-auth-oidc-provider#47 takes that up and reuses these claim names.
6 changes: 5 additions & 1 deletion docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ All tokens are returned in the JSON body (the API never sets cookies). Present t
### Token shapes

- **Access token** — a signed JWT (RS256). Claims include `sub` (user id), `sid` (session id),
`iss`, `typ: "access"`, `roles`, and `org_id` when the session has an active organization.
`iss`, `typ: "access"`, `roles` and `permissions`. In an active organization it also carries
`org_id`, `org_roles` and `org_permissions`, which apply to that organization only. When the
permission claims would not fit, `permissions_omitted: true` replaces `permissions`, `org_roles`
and `org_permissions`, and absent then means "ask" rather than "none": read the live set from
`GET /users/me/permissions`. See [Permissions](./admin-operations.md#permissions).
Verify it against the JWKS at `GET /.well-known/jwks.json`.
- **Ephemeral token** — a signed JWT scoped to the pre-auth step; treat it as opaque. Its `jti`
is the attempt id every audit row written on the token carries, which is how one sign-in's
Expand Down
Loading
Loading