From 9088637feee472e046feb9cd811291c76fdd9140 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 23:12:49 -0500 Subject: [PATCH 1/2] feat(permissions): map roles to permissions and carry them in the access token Adds a permission catalogue and a role mapping, an audited admin API with step-up on writes, and a permissions claim on every access token, minted in one place. A set that would not fit is omitted whole, and a guard reads the live set from GET /users/me/permissions. Permissions match exactly. Records the design in ADR 0004. Closes #415. --- .changeset/permissions-mapped-to-roles.md | 5 + adapter-manifest.json | 11 + ...0004-permissions-and-organization-roles.md | 81 ++ openapi.json | 1089 +++++++++++++++- src/controllers/authentication.ts | 11 +- src/controllers/organizations.ts | 4 +- src/controllers/permissions.ts | 148 +++ src/controllers/systemConfig.ts | 14 + src/controllers/user.ts | 8 +- src/generated/api.ts | 1104 ++++++++++++++++- src/lib/token.ts | 14 +- .../20261105120000-add-permissions.cjs | 55 + src/models/permissions.ts | 54 + src/models/rolePermissions.ts | 53 + src/routes/admin.routes.ts | 138 +++ src/routes/users.routes.ts | 18 + src/schemas/authEvent.types.ts | 4 + src/schemas/permission.requests.ts | 16 + src/schemas/permission.responses.ts | 21 + src/services/accessTokenService.ts | 77 ++ src/services/permissionService.ts | 219 ++++ src/services/sessionExchangeService.ts | 5 +- src/services/sessionIssuance.ts | 8 +- tests/integration/admin/adminStepUp.spec.ts | 4 + tests/integration/admin/permissions.spec.ts | 276 +++++ .../authentication/authentication.spec.ts | 2 +- .../organizations/organizations.spec.ts | 1 + .../systemConfig/systemConfig.spec.ts | 37 + tests/postgres/permissions.spec.ts | 130 ++ tests/setup/mocks.ts | 19 + tests/unit/controllers/authentication.spec.ts | 1 + tests/unit/models/models.spec.ts | 2 + .../unit/services/accessTokenService.spec.ts | 175 +++ .../unit/services/sessionIssueService.spec.ts | 3 + 34 files changed, 3791 insertions(+), 16 deletions(-) create mode 100644 .changeset/permissions-mapped-to-roles.md create mode 100644 docs/adr/0004-permissions-and-organization-roles.md create mode 100644 src/controllers/permissions.ts create mode 100644 src/migrations/20261105120000-add-permissions.cjs create mode 100644 src/models/permissions.ts create mode 100644 src/models/rolePermissions.ts create mode 100644 src/schemas/permission.requests.ts create mode 100644 src/schemas/permission.responses.ts create mode 100644 src/services/accessTokenService.ts create mode 100644 src/services/permissionService.ts create mode 100644 tests/integration/admin/permissions.spec.ts create mode 100644 tests/postgres/permissions.spec.ts create mode 100644 tests/unit/services/accessTokenService.spec.ts diff --git a/.changeset/permissions-mapped-to-roles.md b/.changeset/permissions-mapped-to-roles.md new file mode 100644 index 00000000..277596d4 --- /dev/null +++ b/.changeset/permissions-mapped-to-roles.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': minor +--- + +Roles can now grant permissions, and access tokens carry them (#415, ADR 0004). An admin manages the catalogue with `GET` and `POST /admin/permissions` and `PATCH` and `DELETE /admin/permissions/:permissionName`, reads roles with `GET /admin/roles`, and replaces what a role grants with `PUT /admin/roles/:kind/:roleName/permissions`. Each write needs step-up and is audited. Deleting a permission a role still grants answers 409 with those roles. Instance roles stay in `available_roles`, and removing one also removes its mappings, so adding the name back later does not restore old grants. Access tokens gain a `permissions` claim, matched exactly with no wildcards or implied grants, which is empty on a recovery session. When the permission claims would pass 1 KiB, which keeps an adapter's session cookie under the browser limit, the token instead carries `permissions_omitted: true` and a guard reads `GET /users/me/permissions`, which answers from the database on every call. `GET /users/me` gains `user.permissions`. Changes reach a token when it is next issued, within the access token lifetime. New migration adds the `permissions` and `role_permissions` tables. diff --git a/adapter-manifest.json b/adapter-manifest.json index 372a6ad8..07b08e31 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -70,6 +70,10 @@ "path": "/admin/organizations/{organizationId}/oauth-providers/{providerId}/retirement", "credential": "access" }, + { "method": "GET", "path": "/admin/permissions", "credential": "access" }, + { "method": "POST", "path": "/admin/permissions", "credential": "access" }, + { "method": "DELETE", "path": "/admin/permissions/{permissionName}", "credential": "access" }, + { "method": "PATCH", "path": "/admin/permissions/{permissionName}", "credential": "access" }, { "method": "GET", "path": "/admin/reports/authentication-coverage", "credential": "access" }, { "method": "GET", "path": "/admin/resources", "credential": "access" }, { "method": "POST", "path": "/admin/resources", "credential": "access" }, @@ -77,6 +81,12 @@ { "method": "GET", "path": "/admin/resources/{id}", "credential": "access" }, { "method": "PATCH", "path": "/admin/resources/{id}", "credential": "access" }, { "method": "GET", "path": "/admin/review-accounts", "credential": "access" }, + { "method": "GET", "path": "/admin/roles", "credential": "access" }, + { + "method": "PUT", + "path": "/admin/roles/{kind}/{roleName}/permissions", + "credential": "access" + }, { "method": "GET", "path": "/admin/sessions", "credential": "access" }, { "method": "GET", "path": "/admin/sessions/{userId}", "credential": "access" }, { "method": "DELETE", "path": "/admin/sessions/{userId}/revoke-all", "credential": "access" }, @@ -278,6 +288,7 @@ "clears": ["access", "registration", "refresh"] }, { "method": "GET", "path": "/users/me", "credential": "access", "clears": ["preAuth"] }, + { "method": "GET", "path": "/users/me/permissions", "credential": "access" }, { "method": "POST", "path": "/webauthn/login/finish", diff --git a/docs/adr/0004-permissions-and-organization-roles.md b/docs/adr/0004-permissions-and-organization-roles.md new file mode 100644 index 00000000..e57b95e6 --- /dev/null +++ b/docs/adr/0004-permissions-and-organization-roles.md @@ -0,0 +1,81 @@ +# ADR 0004: Permissions and organization roles in the access token + +- **Status:** Accepted +- **Date:** 2026-11-05 +- **Issues:** #415, #416, seamless-auth-types#98. Guards follow in seamless-auth-server#218, seamless-auth-go#29, seamless-auth-python#32 and seamless-auth-rust#33. + +## Context + +Two role systems exist and neither carries permissions. + +- **Instance roles** are `users.roles`, validated against `available_roles`. They reach the token as `roles` and match through `roleGrantsAccess`, which is colon-scoped and implies some grants. +- **Organization roles** are `organization_memberships.roles`, free-form strings with `owner`, `admin` and `member` by convention. Only `org_id` reaches the token, so a backend cannot authorize per organization without asking the API. Membership `scopes` gate the API's own organization routes. + +Every adopter ends up writing a role-to-capability table of its own. + +## Decisions + +### P1. One catalogue, one mapping + +`permissions` holds the catalogue. `role_permissions` maps a role, named by kind (`instance` or `organization`) and name, to a permission. + +- `available_roles` stays the instance role registry. Moving it would change the system config contract, the CLI and the console, for no gain here. Removing a role from it deletes its mappings, so adding the name back later cannot silently restore old grants. +- A role's permissions come from the literal name held. Holding `billing` does not pick up a mapping on `billing:read`, so the console can show exactly why someone has a permission. +- Deleting a permission a role still grants is a 409. Taking access away is a decision about the role. + +### P2. Claims are flat, and organization claims stay apart + +```json +{ + "sub": "user uuid", + "sid": "session uuid", + "typ": "access", + "roles": ["user"], + "permissions": ["reports:read"], + "org_id": "org uuid", + "org_roles": ["admin"], + "org_permissions": ["projects:write"] +} +``` + +- `roles` and `org_id` do not change. Verify-only parsers in every adapter refuse a token whose `roles` is not a string array, so that shape is frozen. +- Organization roles never merge into `roles`. A guard on `requireRole('admin')` must not pass for an organization admin. +- With no active organization, all three `org_*` claims are absent. +- Flat rather than a nested `org` object, so `org_id` stays where adapters already read it and Go and Rust need no nested type. + +### P3. Permissions match exactly + +`invoices:read` matches only `invoices:read`, with no wildcards and no implied grants. Any JWT library can check the token with a plain membership test, nothing is granted that the console does not list, and there is less to keep identical in four languages. Names use the role alphabet, which has no `*`. + +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`. + +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. + +### P5. Organization roles are a catalogue + +`organization_roles` is instance-wide, seeded with the built-in `owner`, `admin` and `member` and every distinct value already in memberships. The migration rewrites no membership, including legacy values a new role name could not use, so nobody loses a role. Assigning a role outside the catalogue is a 400. Built-ins cannot be deleted, because the API's own organization routes depend on them, and a role still held cannot be deleted. + +Per-organization custom roles belong to v1.1 and can be added with a nullable column. + +### P6. What a guard checks + +`requirePermission` passes on `permissions` or `org_permissions`, so an instance-wide grant applies everywhere. An option restricts it to the active organization and can bind a route's organization id to `org_id`. Without that binding, a token for organization A passes on `/orgs/B/...`, so the guide leads with the bound form. Adapters ship this in their own issues; the semantics are settled here. + +### P7. Who can grant organization roles + +Role changes need step-up and are audited. Only an owner or an instance `admin:write` can grant or remove `owner`. A manager can only grant a role whose permissions they hold. Without this an organization admin could make themselves owner, or hand out any permission-bearing role, with no record. + +### P8. One place mints a token + +Sign-in, refresh, organization switch and session exchange all call `mintAccessToken`, so they cannot disagree about what a token grants. It reads the membership each time. With no membership, the organization claims are dropped and the session's organization is cleared, so removal from an organization reaches the token at the next refresh. Recovery tokens carry no permissions and no organization claims. + +## 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. +- **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. diff --git a/openapi.json b/openapi.json index b848e9f3..91631ccd 100644 --- a/openapi.json +++ b/openapi.json @@ -3974,6 +3974,1007 @@ } } }, + "/admin/permissions": { + "get": { + "summary": "List permissions", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "permissions": [null], "total": 0 }, + "schema": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "description": { "type": "string", "nullable": true }, + "createdAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "updatedAt": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["id", "name", "description", "createdAt", "updatedAt"] + } + }, + "total": { "type": "integer", "minimum": 0 } + }, + "required": ["permissions", "total"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + }, + "post": { + "summary": "Create a permission", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "description": { "type": "string", "minLength": 1, "maxLength": 500 } + }, + "required": ["name"] + } + } + } + }, + "responses": { + "201": { + "description": "HTTP 201", + "content": { + "application/json": { + "example": { + "permission": { + "id": "string", + "name": "string", + "description": "string", + "createdAt": null, + "updatedAt": null + } + }, + "schema": { + "type": "object", + "properties": { + "permission": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "description": { "type": "string", "nullable": true }, + "createdAt": { "type": "string", "nullable": true, "format": "date-time" }, + "updatedAt": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["id", "name", "description", "createdAt", "updatedAt"] + } + }, + "required": ["permission"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/admin/permissions/{permissionName}": { + "patch": { + "summary": "Update a permission's description", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "required": true, + "name": "permissionName", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "description": { + "type": "string", + "nullable": true, + "minLength": 1, + "maxLength": 500 + } + }, + "required": ["description"] + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "permission": { + "id": "string", + "name": "string", + "description": "string", + "createdAt": null, + "updatedAt": null + } + }, + "schema": { + "type": "object", + "properties": { + "permission": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "name": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "description": { "type": "string", "nullable": true }, + "createdAt": { "type": "string", "nullable": true, "format": "date-time" }, + "updatedAt": { "type": "string", "nullable": true, "format": "date-time" } + }, + "required": ["id", "name", "description", "createdAt", "updatedAt"] + } + }, + "required": ["permission"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + }, + "delete": { + "summary": "Delete a permission", + "description": "Answers 409 with the roles that still grant it.", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "required": true, + "name": "permissionName", + "in": "path" + } + ], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "message": "string", "token": "string", "delivery": null }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "delivery": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["otp_sms"] }, + "to": { "type": "string" }, + "token": { "anyOf": [{ "type": "string" }, { "type": "number" }] }, + "purpose": { "type": "string", "enum": ["account_recovery"] } + }, + "required": ["kind", "to", "token"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["magic_link_email"] }, + "to": { "type": "string" }, + "token": { "type": "string" }, + "magicLinkUrl": { "type": "string" } + }, + "required": ["kind", "to", "magicLinkUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["enrollment_invite_email"] }, + "to": { "type": "string" }, + "signInUrl": { "type": "string" } + }, + "required": ["kind", "to", "signInUrl"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_email"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed", + "application_notice" + ] + }, + "to": { "type": "string" }, + "subject": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" }, + "application": { + "type": "object", + "properties": { + "clientId": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["clientId", "name"] + } + }, + "required": ["kind", "notice", "to", "subject", "text"] + }, + { + "type": "object", + "properties": { + "kind": { "type": "string", "enum": ["account_notice_sms"] }, + "notice": { + "type": "string", + "enum": [ + "account_recovery_opened", + "account_recovery_completed", + "recovery_code_used", + "identity_changed", + "application_notice" + ] + }, + "to": { "type": "string" }, + "text": { "type": "string" }, + "cancelUrl": { "type": "string" }, + "application": { + "type": "object", + "properties": { + "clientId": { "type": "string" }, + "name": { "type": "string" } + }, + "required": ["clientId", "name"] + } + }, + "required": ["kind", "notice", "to", "text"] + } + ] + } + }, + "required": ["message"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": "string", + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] + } + }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "409": { + "description": "HTTP 409", + "content": { + "application/json": { + "example": { "error": "string", "details": null }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "details": { "type": "object", "additionalProperties": { "nullable": true } } + }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/admin/roles": { + "get": { + "summary": "List roles and the permissions they grant", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { "roles": [null] }, + "schema": { + "type": "object", + "properties": { + "roles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 80 }, + "kind": { "type": "string", "enum": ["instance", "organization"] }, + "description": { "type": "string", "nullable": true }, + "builtIn": { "type": "boolean" }, + "permissions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + } + } + }, + "required": ["name", "kind", "description", "builtIn", "permissions"] + } + } + }, + "required": ["roles"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/admin/roles/{kind}/{roleName}/permissions": { + "put": { + "summary": "Replace the permissions a role grants", + "description": "Takes effect on the next access token each holder receives, and at once on `GET /users/me/permissions`. Every permission has to exist.", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "enum": ["instance", "organization"] }, + "required": true, + "name": "kind", + "in": "path" + }, + { + "schema": { "type": "string", "minLength": 1, "maxLength": 80 }, + "required": true, + "name": "roleName", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "permissions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "maxItems": 200 + } + }, + "required": ["permissions"] + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "role": { + "name": "string", + "kind": null, + "description": "string", + "builtIn": true, + "permissions": [null] + } + }, + "schema": { + "type": "object", + "properties": { + "role": { + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 80 }, + "kind": { "type": "string", "enum": ["instance", "organization"] }, + "description": { "type": "string", "nullable": true }, + "builtIn": { "type": "boolean" }, + "permissions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + } + } + }, + "required": ["name", "kind", "description", "builtIn", "permissions"] + } + }, + "required": ["role"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { "error": "string", "details": null }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string" }, + "details": { "type": "object", "additionalProperties": { "nullable": true } } + }, + "required": ["error"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "404": { + "description": "HTTP 404", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/admin/enrollment": { "get": { "summary": "Passkey enrollment progress", @@ -5576,6 +6577,10 @@ "admin_oidc_resource_created", "admin_oidc_resource_deleted", "admin_oidc_resource_updated", + "admin_permission_created", + "admin_permission_deleted", + "admin_permission_updated", + "admin_role_permissions_updated", "admin_session_revoked", "admin_user_import_completed", "admin_user_imported", @@ -5704,6 +6709,10 @@ "admin_oidc_resource_created", "admin_oidc_resource_deleted", "admin_oidc_resource_updated", + "admin_permission_created", + "admin_permission_deleted", + "admin_permission_updated", + "admin_role_permissions_updated", "admin_session_revoked", "admin_user_import_completed", "admin_user_imported", @@ -29560,7 +30569,10 @@ "phone": "string", "roles": [null], "lastLogin": null, - "activeOrganizationId": "string" + "activeOrganizationId": "string", + "permissions": [null], + "organizationRoles": [null], + "organizationPermissions": [null] }, "credentials": [null], "organizations": [null], @@ -29609,7 +30621,13 @@ } }, "lastLogin": { "type": "string", "nullable": true, "format": "date-time" }, - "activeOrganizationId": { "type": "string", "nullable": true } + "activeOrganizationId": { "type": "string", "nullable": true }, + "permissions": { "type": "array", "items": { "type": "string" } }, + "organizationRoles": { "type": "array", "items": { "type": "string" } }, + "organizationPermissions": { + "type": "array", + "items": { "type": "string" } + } }, "required": ["id", "email", "phone", "roles"] }, @@ -29847,6 +30865,73 @@ } } }, + "/users/me/permissions": { + "get": { + "summary": "Get the caller's live roles and permissions", + "description": "Read from the database on every call, so a change applies here before the access token catches up. A guard reads this when a token carries `permissions_omitted`.", + "tags": ["Users"], + "security": [{ "bearerAuth": [] }], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "example": { + "roles": [null], + "permissions": [null], + "organizationId": "string", + "organizationRoles": [null], + "organizationPermissions": [null] + }, + "schema": { + "type": "object", + "properties": { + "roles": { "type": "array", "items": { "type": "string" } }, + "permissions": { "type": "array", "items": { "type": "string" } }, + "organizationId": { "type": "string", "nullable": true }, + "organizationRoles": { "type": "array", "items": { "type": "string" } }, + "organizationPermissions": { "type": "array", "items": { "type": "string" } } + }, + "required": [ + "roles", + "permissions", + "organizationId", + "organizationRoles", + "organizationPermissions" + ] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, "/users/credentials": { "post": { "summary": "Update credential metadata", diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 2b014af6..e3254458 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -13,12 +13,12 @@ import { EPHEMERAL_TOKEN_TTL_SECONDS } from '../lib/ephemeralTokenTtl.js'; import { createRefreshTokenLookup, generateRefreshToken, - signAccessToken, signEphemeralToken, } from '../lib/token.js'; import { Credential } from '../models/credentials.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; +import { mintAccessToken } from '../services/accessTokenService.js'; import { AuthEventService } from '../services/authEventService.js'; import { hasBreakGlassCodeFactor } from '../services/breakGlassService.js'; import { @@ -751,8 +751,13 @@ export const refreshSession = async (req: Request, res: Response) => { }); } - const token = await signAccessToken(newSession.id, user.id, user.roles, session.organizationId, { - recovery: session.recovery === true, + const token = await mintAccessToken({ + session: { + id: newSession.id, + organizationId: session.organizationId, + recovery: session.recovery, + }, + user, }); if (token && newRefreshToken) { diff --git a/src/controllers/organizations.ts b/src/controllers/organizations.ts index 7dc29457..5cf62795 100644 --- a/src/controllers/organizations.ts +++ b/src/controllers/organizations.ts @@ -9,12 +9,12 @@ import { UniqueConstraintError } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { hasScopedRole } from '../lib/scopedRoles.js'; -import { signAccessToken } from '../lib/token.js'; import { OrganizationMembership } from '../models/organizationMemberships.js'; import { Organization } from '../models/organizations.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import { AdminOrganizationListQuerySchema } from '../schemas/organization.requests.js'; +import { mintAccessToken } from '../services/accessTokenService.js'; import { AuthEventService } from '../services/authEventService.js'; import { countOwners, @@ -169,7 +169,7 @@ export async function switchOrganization(req: RouteRequest, res: Response) { } await session.update({ organizationId: organization.id }); - const token = await signAccessToken(session.id, user.id, user.roles, organization.id); + const token = await mintAccessToken({ session, user }); const { access_token_ttl } = await getSystemConfig(); return res.json({ diff --git a/src/controllers/permissions.ts b/src/controllers/permissions.ts new file mode 100644 index 00000000..90b5b44c --- /dev/null +++ b/src/controllers/permissions.ts @@ -0,0 +1,148 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { RoleKind } from '@seamless-auth/types'; +import { Request, Response } from 'express'; +import { ForeignKeyConstraintError } from 'sequelize'; + +import { resolveAuthorization } from '../services/accessTokenService.js'; +import { AuthEventService } from '../services/authEventService.js'; +import { + createPermission as createPermissionRecord, + deletePermission as deletePermissionRecord, + listPermissions as listPermissionRecords, + listRoles as listRoleRecords, + PermissionError, + serializePermission, + setRolePermissions as setRolePermissionRecords, + updatePermission as updatePermissionRecord, +} from '../services/permissionService.js'; +import { AuthenticatedRequest, RouteRequest } from '../types/types.js'; + +function actorUserId(req: Request) { + return (req as AuthenticatedRequest).user?.id ?? null; +} + +function sendError(res: Response, error: unknown) { + if (error instanceof PermissionError) { + return res + .status(error.status) + .json({ error: error.message, ...(error.details ? { details: error.details } : {}) }); + } + + throw error; +} + +export async function listPermissions(_req: Request, res: Response) { + const permissions = await listPermissionRecords(); + + return res.json({ permissions: permissions.map(serializePermission), total: permissions.length }); +} + +export async function createPermission(req: Request, res: Response) { + try { + const permission = await createPermissionRecord(req.body.name, req.body.description); + + await AuthEventService.log({ + actorUserId: actorUserId(req), + type: 'admin_permission_created', + req, + metadata: { permission: permission.name }, + }); + + return res.status(201).json({ permission: serializePermission(permission) }); + } catch (error) { + return sendError(res, error); + } +} + +export async function updatePermission(req: RouteRequest, res: Response) { + try { + const permission = await updatePermissionRecord( + req.params.permissionName, + req.body.description, + ); + + await AuthEventService.log({ + actorUserId: actorUserId(req), + type: 'admin_permission_updated', + req, + metadata: { permission: permission.name }, + }); + + return res.json({ permission: serializePermission(permission) }); + } catch (error) { + return sendError(res, error); + } +} + +export async function deletePermission(req: RouteRequest, res: Response) { + const { permissionName } = req.params; + + try { + await deletePermissionRecord(permissionName); + + await AuthEventService.log({ + actorUserId: actorUserId(req), + type: 'admin_permission_deleted', + req, + metadata: { permission: permissionName }, + }); + + return res.json({ message: 'Success' }); + } catch (error) { + if (error instanceof ForeignKeyConstraintError) { + return res.status(409).json({ error: 'Permission is still granted by a role' }); + } + + return sendError(res, error); + } +} + +export async function listRoles(_req: Request, res: Response) { + return res.json({ roles: await listRoleRecords() }); +} + +export async function setRolePermissions(req: RouteRequest, res: Response) { + const kind = req.params.kind as RoleKind; + const name = req.params.roleName; + + try { + const { role, added, removed } = await setRolePermissionRecords({ + kind, + name, + permissions: req.body.permissions, + actorUserId: actorUserId(req), + }); + + await AuthEventService.log({ + actorUserId: actorUserId(req), + type: 'admin_role_permissions_updated', + req, + metadata: { kind, role: name, added, removed }, + }); + + return res.json({ role }); + } catch (error) { + return sendError(res, error); + } +} + +export async function getEffectivePermissions(req: Request, res: Response) { + const authReq = req as AuthenticatedRequest; + const { roles, permissions } = await resolveAuthorization({ + roles: authReq.user.roles, + recovery: authReq.recoverySession === true, + }); + + return res.json({ + roles, + permissions, + organizationId: null, + organizationRoles: [], + organizationPermissions: [], + }); +} diff --git a/src/controllers/systemConfig.ts b/src/controllers/systemConfig.ts index 351dadb1..aaf4171c 100644 --- a/src/controllers/systemConfig.ts +++ b/src/controllers/systemConfig.ts @@ -25,6 +25,7 @@ import { isAccountRecoveryAvailable } from '../services/accountRecoveryService.j import { AuthEventService } from '../services/authEventService.js'; import { getLoginPolicy } from '../services/loginPolicyService.js'; import { findSessionExchangeClient } from '../services/oidcClientRegistry.js'; +import { deleteRoleMappings } from '../services/permissionService.js'; import { isPolicyKey, lockPolicyForConfigPatch, @@ -127,6 +128,8 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { const touchesPolicy = Object.keys(updates).some(isPolicyKey); + let retiredRoles: string[] = []; + const policyVersion = await SystemConfig.sequelize!.transaction(async (tx) => { if (touchesPolicy) { await lockPolicyForConfigPatch(tx); @@ -150,6 +153,16 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { ); } + if (updates.available_roles) { + const remaining = new Set(updates.available_roles); + + retiredRoles = await deleteRoleMappings( + 'instance', + (existing.available_roles ?? []).filter((role) => !remaining.has(role)), + tx, + ); + } + // Drafts are optional, so a policy change made here is versioned like a publish and // can be rolled back the same way. return touchesPolicy ? recordConfigPatchPolicyVersion(tx, updatedBy) : null; @@ -164,6 +177,7 @@ export async function updateSystemConfig(req: ServiceRequest, res: Response) { before: existingMap, after: updates, ...(policyVersion ? { policyVersion: policyVersion.version } : {}), + ...(retiredRoles.length ? { retiredRoleMappings: retiredRoles } : {}), }, }); diff --git a/src/controllers/user.ts b/src/controllers/user.ts index d615e2a2..28a90f72 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -8,6 +8,7 @@ import { Request, Response } from 'express'; import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; +import { resolveAuthorization } from '../services/accessTokenService.js'; import { refuseLastAdminChange, wouldLeaveNoAdmin } from '../services/adminContinuity.js'; import { serializeCredential } from '../services/apiResponseSerializers.js'; import { AuthEventService } from '../services/authEventService.js'; @@ -24,7 +25,7 @@ export const getUser = async (req: Request, res: Response) => { try { if (authUser) { - const [credentials, organizations] = await Promise.all([ + const [credentials, organizations, authorization] = await Promise.all([ Credential.findAll({ where: { userId: authUser.id }, attributes: [ @@ -43,6 +44,10 @@ export const getUser = async (req: Request, res: Response) => { ], }), listOrganizationsForUser(authUser.id), + resolveAuthorization({ + roles: authUser.roles, + recovery: authReq.recoverySession === true, + }), ]); const activeOrganizationId = authReq.organizationId ?? null; @@ -54,6 +59,7 @@ export const getUser = async (req: Request, res: Response) => { roles: authUser.roles, lastLogin: authUser.lastLogin, activeOrganizationId, + permissions: authorization.permissions, }, credentials: credentials.map(serializeCredential), organizations, diff --git a/src/generated/api.ts b/src/generated/api.ts index fe7cfa42..06f73808 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -3795,6 +3795,990 @@ export interface paths { patch?: never; trace?: never; }; + '/admin/permissions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List permissions */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "permissions": [ + * null + * ], + * "total": 0 + * } + */ + 'application/json': { + permissions: { + id: string; + name: string; + description: string | null; + /** Format: date-time */ + createdAt: string | null; + /** Format: date-time */ + updatedAt: string | null; + }[]; + total: number; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + /** Create a permission */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + name: string; + description?: string; + }; + }; + }; + responses: { + /** @description HTTP 201 */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "permission": { + * "id": "string", + * "name": "string", + * "description": "string", + * "createdAt": null, + * "updatedAt": null + * } + * } + */ + 'application/json': { + permission: { + id: string; + name: string; + description: string | null; + /** Format: date-time */ + createdAt: string | null; + /** Format: date-time */ + updatedAt: string | null; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/admin/permissions/{permissionName}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete a permission + * @description Answers 409 with the roles that still grant it. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + permissionName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "delivery": null + * } + */ + 'application/json': { + message: string; + token?: string; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed' + | 'application_notice'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + application?: { + clientId: string; + name: string; + }; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed' + | 'application_notice'; + to: string; + text: string; + cancelUrl?: string; + application?: { + clientId: string; + name: string; + }; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 409 */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "details": null + * } + */ + 'application/json': { + error: string; + details?: { + [key: string]: unknown; + }; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + /** Update a permission's description */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + permissionName: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + description: string | null; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "permission": { + * "id": "string", + * "name": "string", + * "description": "string", + * "createdAt": null, + * "updatedAt": null + * } + * } + */ + 'application/json': { + permission: { + id: string; + name: string; + description: string | null; + /** Format: date-time */ + createdAt: string | null; + /** Format: date-time */ + updatedAt: string | null; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "message": "string", + * "details": { + * "issues": [ + * null + * ] + * } + * } + */ + 'application/json': { + error: string; + message?: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + trace?: never; + }; + '/admin/roles': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List roles and the permissions they grant */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "roles": [ + * null + * ] + * } + */ + 'application/json': { + roles: { + name: string; + /** @enum {string} */ + kind: 'instance' | 'organization'; + description: string | null; + builtIn: boolean; + permissions: string[]; + }[]; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/admin/roles/{kind}/{roleName}/permissions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Replace the permissions a role grants + * @description Takes effect on the next access token each holder receives, and at once on `GET /users/me/permissions`. Every permission has to exist. + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + kind: 'instance' | 'organization'; + roleName: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + permissions: string[]; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "role": { + * "name": "string", + * "kind": null, + * "description": "string", + * "builtIn": true, + * "permissions": [ + * null + * ] + * } + * } + */ + 'application/json': { + role: { + name: string; + /** @enum {string} */ + kind: 'instance' | 'organization'; + description: string | null; + builtIn: boolean; + permissions: string[]; + }; + }; + }; + }; + /** @description HTTP 400 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "error": "string", + * "details": null + * } + */ + 'application/json': { + error: string; + details?: { + [key: string]: unknown; + }; + }; + }; + }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 404 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/admin/enrollment': { parameters: { query?: never; @@ -5224,6 +6208,10 @@ export interface paths { | 'admin_oidc_resource_created' | 'admin_oidc_resource_deleted' | 'admin_oidc_resource_updated' + | 'admin_permission_created' + | 'admin_permission_deleted' + | 'admin_permission_updated' + | 'admin_role_permissions_updated' | 'admin_session_revoked' | 'admin_user_import_completed' | 'admin_user_imported' @@ -5346,6 +6334,10 @@ export interface paths { | 'admin_oidc_resource_created' | 'admin_oidc_resource_deleted' | 'admin_oidc_resource_updated' + | 'admin_permission_created' + | 'admin_permission_deleted' + | 'admin_permission_updated' + | 'admin_role_permissions_updated' | 'admin_session_revoked' | 'admin_user_import_completed' | 'admin_user_imported' @@ -28942,7 +29934,16 @@ export interface paths { * null * ], * "lastLogin": null, - * "activeOrganizationId": "string" + * "activeOrganizationId": "string", + * "permissions": [ + * null + * ], + * "organizationRoles": [ + * null + * ], + * "organizationPermissions": [ + * null + * ] * }, * "credentials": [ * null @@ -28997,6 +29998,9 @@ export interface paths { /** Format: date-time */ lastLogin?: string | null; activeOrganizationId?: string | null; + permissions?: string[]; + organizationRoles?: string[]; + organizationPermissions?: string[]; }; credentials: { id: string; @@ -29135,6 +30139,104 @@ export interface paths { patch?: never; trace?: never; }; + '/users/me/permissions': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the caller's live roles and permissions + * @description Read from the database on every call, so a change applies here before the access token catches up. A guard reads this when a token carries `permissions_omitted`. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "roles": [ + * null + * ], + * "permissions": [ + * null + * ], + * "organizationId": "string", + * "organizationRoles": [ + * null + * ], + * "organizationPermissions": [ + * null + * ] + * } + */ + 'application/json': { + roles: string[]; + permissions: string[]; + organizationId: string | null; + organizationRoles: string[]; + organizationPermissions: string[]; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/users/credentials': { parameters: { query?: never; diff --git a/src/lib/token.ts b/src/lib/token.ts index 6320ec7c..7a5542d2 100644 --- a/src/lib/token.ts +++ b/src/lib/token.ts @@ -24,12 +24,23 @@ function getRefreshTokenLookupSecret() { return authSecret('REFRESH_TOKEN_LOOKUP_SECRET'); } +/** What a token grants beyond its roles. `accessTokenService` decides what goes in here. */ +export type AuthorizationClaims = { + permissions?: string[]; + org_roles?: string[]; + org_permissions?: string[]; + permissions_omitted?: true; +}; + export async function signAccessToken( sessionId: string, userId: string, roles?: string[], organizationId?: string | null, - { recovery = false }: { recovery?: boolean } = {}, + { + recovery = false, + authorization = {}, + }: { recovery?: boolean; authorization?: AuthorizationClaims } = {}, ) { const { kid, privateKeyPem } = await getSigningKey(); @@ -42,6 +53,7 @@ export async function signAccessToken( iss: process.env.ISSUER, typ: 'access', roles, + ...authorization, ...(organizationId ? { org_id: organizationId } : {}), // Lets a server adapter refuse a half-recovered account without asking the API. ...(recovery ? { recovery: true } : {}), diff --git a/src/migrations/20261105120000-add-permissions.cjs b/src/migrations/20261105120000-add-permissions.cjs new file mode 100644 index 00000000..2144106e --- /dev/null +++ b/src/migrations/20261105120000-add-permissions.cjs @@ -0,0 +1,55 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +'use strict'; + +/** + * Permissions and the roles that grant them (ADR 0004). A mapping names its role rather than + * referencing a row, because instance roles live in `available_roles`. It references the + * permission by name so that minting a token reads one table, and it restricts deletion so + * that removing a permission some role still grants is a refusal rather than a silent loss + * of access. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS public.permissions ( + id uuid NOT NULL, + name character varying(80) NOT NULL, + description text, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + CONSTRAINT permissions_pkey PRIMARY KEY (id), + CONSTRAINT permissions_name_key UNIQUE (name) + ); + + CREATE TABLE IF NOT EXISTS public.role_permissions ( + "roleKind" character varying(20) NOT NULL, + "roleName" character varying(80) NOT NULL, + "permissionName" character varying(80) NOT NULL, + "createdBy" uuid, + "createdAt" timestamp with time zone NOT NULL, + CONSTRAINT role_permissions_pkey PRIMARY KEY ("roleKind", "roleName", "permissionName"), + CONSTRAINT role_permissions_permission_fkey + FOREIGN KEY ("permissionName") REFERENCES public.permissions(name) + ON UPDATE RESTRICT ON DELETE RESTRICT, + CONSTRAINT role_permissions_role_kind CHECK ("roleKind" IN ('instance', 'organization')) + ); + + CREATE INDEX IF NOT EXISTS role_permissions_permission_idx + ON public.role_permissions ("permissionName"); + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP TABLE IF EXISTS public.role_permissions; + DROP TABLE IF EXISTS public.permissions; + `); + }, +}; diff --git a/src/models/permissions.ts b/src/models/permissions.ts new file mode 100644 index 00000000..a4ad8225 --- /dev/null +++ b/src/models/permissions.ts @@ -0,0 +1,54 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +export interface PermissionAttributes { + id?: string; + name: string; + description?: string | null; + createdAt?: Date; + updatedAt?: Date; +} + +type PermissionCreationAttributes = Optional< + PermissionAttributes, + 'id' | 'description' | 'createdAt' | 'updatedAt' +>; + +export class Permission + extends Model + implements PermissionAttributes +{ + declare id: string; + declare name: string; + declare description: string | null; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; +} + +const initializePermissionModel = (sequelize: Sequelize) => { + Permission.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + allowNull: false, + }, + name: { type: DataTypes.STRING(80), allowNull: false, unique: true }, + description: { type: DataTypes.TEXT, allowNull: true }, + }, + { + sequelize, + tableName: 'permissions', + modelName: 'Permission', + }, + ); + return Permission; +}; + +export default initializePermissionModel; diff --git a/src/models/rolePermissions.ts b/src/models/rolePermissions.ts new file mode 100644 index 00000000..8cfa0cc2 --- /dev/null +++ b/src/models/rolePermissions.ts @@ -0,0 +1,53 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { RoleKind } from '@seamless-auth/types'; +import { DataTypes, Model, Optional, Sequelize } from 'sequelize'; + +export interface RolePermissionAttributes { + roleKind: RoleKind; + roleName: string; + permissionName: string; + /** The admin who made the mapping. */ + createdBy?: string | null; + createdAt?: Date; +} + +type RolePermissionCreationAttributes = Optional< + RolePermissionAttributes, + 'createdBy' | 'createdAt' +>; + +export class RolePermission + extends Model + implements RolePermissionAttributes +{ + declare roleKind: RoleKind; + declare roleName: string; + declare permissionName: string; + declare createdBy: string | null; + declare readonly createdAt: Date; +} + +const initializeRolePermissionModel = (sequelize: Sequelize) => { + RolePermission.init( + { + roleKind: { type: DataTypes.STRING(20), primaryKey: true }, + roleName: { type: DataTypes.STRING(80), primaryKey: true }, + permissionName: { type: DataTypes.STRING(80), primaryKey: true }, + createdBy: { type: DataTypes.UUID, allowNull: true }, + }, + { + sequelize, + tableName: 'role_permissions', + modelName: 'RolePermission', + updatedAt: false, + }, + ); + return RolePermission; +}; + +export default initializeRolePermissionModel; diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index c20b0548..bb971cc5 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -37,6 +37,14 @@ import { updateMember, updateOrganization, } from '../controllers/organizations.js'; +import { + createPermission, + deletePermission, + listPermissions, + listRoles, + setRolePermissions, + updatePermission, +} from '../controllers/permissions.js'; import { getReviewAccountStatus } from '../controllers/reviewAccounts.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; @@ -94,6 +102,20 @@ import { OrganizationMembershipEnvelopeResponseSchema, OrganizationMembersResponseSchema, } from '../schemas/organization.responses.js'; +import { + CreatePermissionRequestSchema, + PermissionNameParamSchema, + RolePermissionsParamSchema, + SetRolePermissionsRequestSchema, + UpdatePermissionRequestSchema, +} from '../schemas/permission.requests.js'; +import { + PermissionConflictErrorSchema, + PermissionEnvelopeResponseSchema, + PermissionListResponseSchema, + RoleEnvelopeResponseSchema, + RoleListResponseSchema, +} from '../schemas/permission.responses.js'; import { ReviewAccountsQuerySchema, ReviewAccountsResponseSchema, @@ -322,6 +344,122 @@ adminRouter.delete( restoreOAuthProvider, ); +adminRouter.get( + '/permissions', + { + auth: 'access', + middleware: [requireAdmin('read')], + tags: ['Admin'], + summary: 'List permissions', + schemas: { + response: { + 200: PermissionListResponseSchema, + }, + }, + }, + listPermissions, +); + +adminRouter.post( + '/permissions', + { + auth: 'access', + middleware: [requireAdmin('write'), requireStepUp()], + tags: ['Admin'], + summary: 'Create a permission', + schemas: { + body: CreatePermissionRequestSchema, + response: { + 201: PermissionEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, + 409: InternalErrorSchema, + }, + }, + }, + createPermission, +); + +adminRouter.patch( + '/permissions/:permissionName', + { + auth: 'access', + middleware: [requireAdmin('write'), requireStepUp()], + tags: ['Admin'], + summary: "Update a permission's description", + schemas: { + params: PermissionNameParamSchema, + body: UpdatePermissionRequestSchema, + response: { + 200: PermissionEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, + 404: InternalErrorSchema, + }, + }, + }, + updatePermission, +); + +adminRouter.delete( + '/permissions/:permissionName', + { + auth: 'access', + middleware: [requireAdmin('write'), requireStepUp()], + tags: ['Admin'], + summary: 'Delete a permission', + description: 'Answers 409 with the roles that still grant it.', + schemas: { + params: PermissionNameParamSchema, + response: { + 200: MessageSchema, + 403: StepUpRequiredErrorSchema, + 404: InternalErrorSchema, + 409: PermissionConflictErrorSchema, + }, + }, + }, + deletePermission, +); + +adminRouter.get( + '/roles', + { + auth: 'access', + middleware: [requireAdmin('read')], + tags: ['Admin'], + summary: 'List roles and the permissions they grant', + schemas: { + response: { + 200: RoleListResponseSchema, + }, + }, + }, + listRoles, +); + +adminRouter.put( + '/roles/:kind/:roleName/permissions', + { + auth: 'access', + middleware: [requireAdmin('write'), requireStepUp()], + tags: ['Admin'], + summary: 'Replace the permissions a role grants', + description: + 'Takes effect on the next access token each holder receives, and at once on ' + + '`GET /users/me/permissions`. Every permission has to exist.', + schemas: { + params: RolePermissionsParamSchema, + body: SetRolePermissionsRequestSchema, + response: { + 200: RoleEnvelopeResponseSchema, + 400: PermissionConflictErrorSchema, + 403: StepUpRequiredErrorSchema, + 404: InternalErrorSchema, + }, + }, + }, + setRolePermissions, +); + adminRouter.get( '/enrollment', { diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index dfdd6edb..486bfa5f 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -6,6 +6,7 @@ import { DeleteCredentialRequestSchema, UpdateCredentialRequestSchema } from '@seamless-auth/types'; +import { getEffectivePermissions } from '../controllers/permissions.js'; import { deleteCredential, deleteUser, getUser, updateCredential } from '../controllers/user.js'; import { createRouter } from '../lib/createRouter.js'; import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; @@ -13,6 +14,7 @@ import { requireStepUp } from '../middleware/requireStepUp.js'; import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { MeResponseSchema } from '../schemas/me.response.js'; +import { EffectivePermissionsResponseSchema } from '../schemas/permission.responses.js'; import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; const usersRouter = createRouter('/users'); @@ -32,6 +34,22 @@ usersRouter.get( getUser, ); +usersRouter.get( + '/me/permissions', + { + auth: 'access', + tags: ['Users'], + summary: "Get the caller's live roles and permissions", + description: + 'Read from the database on every call, so a change applies here before the access ' + + 'token catches up. A guard reads this when a token carries `permissions_omitted`.', + schemas: { + response: EffectivePermissionsResponseSchema, + }, + }, + getEffectivePermissions, +); + usersRouter.post( '/credentials', { diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 99ad5227..7c17f545 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -33,6 +33,10 @@ export const AUTH_EVENT_TYPES = [ 'admin_oidc_resource_created', 'admin_oidc_resource_deleted', 'admin_oidc_resource_updated', + 'admin_permission_created', + 'admin_permission_deleted', + 'admin_permission_updated', + 'admin_role_permissions_updated', 'admin_session_revoked', 'admin_user_import_completed', 'admin_user_imported', diff --git a/src/schemas/permission.requests.ts b/src/schemas/permission.requests.ts new file mode 100644 index 00000000..ee5444b9 --- /dev/null +++ b/src/schemas/permission.requests.ts @@ -0,0 +1,16 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +export { + CreateOrganizationRoleRequestSchema, + CreatePermissionRequestSchema, + OrganizationRoleParamSchema, + PermissionNameParamSchema, + RolePermissionsParamSchema, + SetRolePermissionsRequestSchema, + UpdateOrganizationRoleRequestSchema, + UpdatePermissionRequestSchema, +} from '@seamless-auth/types'; diff --git a/src/schemas/permission.responses.ts b/src/schemas/permission.responses.ts new file mode 100644 index 00000000..f9e8aec5 --- /dev/null +++ b/src/schemas/permission.responses.ts @@ -0,0 +1,21 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { z } from 'zod'; + +export { + EffectivePermissionsResponseSchema, + PermissionEnvelopeResponseSchema, + PermissionListResponseSchema, + RoleEnvelopeResponseSchema, + RoleListResponseSchema, +} from '@seamless-auth/types'; + +/** Local because only this server's refusals name what stood in the way. */ +export const PermissionConflictErrorSchema = z.object({ + error: z.string(), + details: z.record(z.string(), z.unknown()).optional(), +}); diff --git a/src/services/accessTokenService.ts b/src/services/accessTokenService.ts new file mode 100644 index 00000000..9fbd14a9 --- /dev/null +++ b/src/services/accessTokenService.ts @@ -0,0 +1,77 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { Transaction } from 'sequelize'; + +import { type AuthorizationClaims, signAccessToken } from '../lib/token.js'; +import { permissionsForRoles } from './permissionService.js'; + +/** + * How much of an access token the permission arrays may take, serialized. A server adapter + * keeps the whole token inside a signed cookie, so it is base64 encoded twice under the + * browser's 4 KiB limit. `tests/unit/services/accessTokenService.spec.ts` builds that cookie + * at this budget and holds it under the limit. + */ +export const PERMISSION_CLAIMS_BUDGET_BYTES = 1024; + +type AuthorizationInput = { + roles: string[] | null | undefined; + recovery: boolean; + transaction?: Transaction; +}; + +export type ResolvedAuthorization = { + roles: string[]; + permissions: string[]; +}; + +/** A recovery session can only enroll a new factor, so it holds no permissions. */ +export async function resolveAuthorization({ + roles, + recovery, + transaction, +}: AuthorizationInput): Promise { + return { + roles: roles ?? [], + permissions: recovery ? [] : await permissionsForRoles('instance', roles, transaction), + }; +} + +/** + * All or nothing. A partial list looks complete and denies silently, so a set that does not + * fit is replaced with a marker that sends a guard to `GET /users/me/permissions`. + */ +export function authorizationClaims({ permissions }: ResolvedAuthorization): AuthorizationClaims { + const claims = { permissions }; + + if (Buffer.byteLength(JSON.stringify(claims)) > PERMISSION_CLAIMS_BUDGET_BYTES) { + return { permissions_omitted: true }; + } + + return claims; +} + +/** + * Every first-party access token is minted here, so sign-in, refresh, organization switch and + * session exchange cannot disagree about what a token grants. + */ +export async function mintAccessToken({ + session, + user, + transaction, +}: { + session: { id: string; organizationId?: string | null; recovery?: boolean | null }; + user: { id: string; roles?: string[] | null }; + transaction?: Transaction; +}) { + const recovery = session.recovery === true; + const authorization = await resolveAuthorization({ roles: user.roles, recovery, transaction }); + + return signAccessToken(session.id, user.id, user.roles ?? [], session.organizationId, { + recovery, + authorization: authorizationClaims(authorization), + }); +} diff --git a/src/services/permissionService.ts b/src/services/permissionService.ts new file mode 100644 index 00000000..64ed0e3c --- /dev/null +++ b/src/services/permissionService.ts @@ -0,0 +1,219 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { RoleDefinition, RoleKind } from '@seamless-auth/types'; +import { Op, Transaction, UniqueConstraintError } from 'sequelize'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { Permission } from '../models/permissions.js'; +import { RolePermission } from '../models/rolePermissions.js'; + +export class PermissionError extends Error { + constructor( + readonly status: number, + message: string, + readonly details?: Record, + ) { + super(message); + this.name = 'PermissionError'; + } +} + +export function serializePermission(permission: Permission) { + return { + id: permission.id, + name: permission.name, + description: permission.description ?? null, + createdAt: permission.createdAt, + updatedAt: permission.updatedAt, + }; +} + +export async function listPermissions() { + return Permission.findAll({ order: [['name', 'ASC']] }); +} + +export async function createPermission(name: string, description?: string) { + try { + return await Permission.create({ name, description: description ?? null }); + } catch (error) { + if (error instanceof UniqueConstraintError) { + throw new PermissionError(409, 'Permission already exists'); + } + + throw error; + } +} + +export async function updatePermission(name: string, description: string | null) { + const permission = await Permission.findOne({ where: { name } }); + + if (!permission) { + throw new PermissionError(404, 'Permission not found'); + } + + await permission.update({ description }); + + return permission; +} + +/** + * Refused while a role still grants it. Taking access away is a decision about the role, so + * it has to be made there, where it can be seen. The foreign key holds the same line against + * a mapping written between the two statements here. + */ +export async function deletePermission(name: string) { + const permission = await Permission.findOne({ where: { name } }); + + if (!permission) { + throw new PermissionError(404, 'Permission not found'); + } + + const grantedBy = await RolePermission.findAll({ where: { permissionName: name } }); + + if (grantedBy.length > 0) { + throw new PermissionError(409, 'Permission is still granted by a role', { + roles: grantedBy.map(({ roleKind, roleName }) => ({ kind: roleKind, name: roleName })), + }); + } + + await permission.destroy(); +} + +async function mappingsByRole(roleKind: RoleKind) { + const mappings = await RolePermission.findAll({ + where: { roleKind }, + order: [['permissionName', 'ASC']], + }); + const byRole = new Map(); + + for (const { roleName, permissionName } of mappings) { + byRole.set(roleName, [...(byRole.get(roleName) ?? []), permissionName]); + } + + return byRole; +} + +function instanceRole(name: string, permissions: string[]): RoleDefinition { + return { name, kind: 'instance', description: null, builtIn: false, permissions }; +} + +export async function listRoles(): Promise { + const [{ available_roles }, instanceMappings] = await Promise.all([ + getSystemConfig(), + mappingsByRole('instance'), + ]); + + return (available_roles ?? []).map((name) => + instanceRole(name, instanceMappings.get(name) ?? []), + ); +} + +async function findRole(kind: RoleKind, name: string) { + const roles = await listRoles(); + + return roles.find((role) => role.kind === kind && role.name === name) ?? null; +} + +/** + * Replaces the role's mapping as a whole. Returns the role and what changed, for the audit + * event. The role has to exist and every permission has to be in the catalogue: a grant + * naming a permission nothing defines would read as access control while controlling nothing. + */ +export async function setRolePermissions({ + kind, + name, + permissions, + actorUserId, +}: { + kind: RoleKind; + name: string; + permissions: string[]; + actorUserId: string | null; +}) { + const role = await findRole(kind, name); + + if (!role) { + throw new PermissionError(404, 'Role not found'); + } + + const next = Array.from(new Set(permissions)).sort(); + const known = await Permission.findAll({ where: { name: { [Op.in]: next } } }); + const knownNames = new Set(known.map((permission) => permission.name)); + const unknown = next.filter((permission) => !knownNames.has(permission)); + + if (unknown.length > 0) { + throw new PermissionError(400, 'Unknown permissions', { permissions: unknown }); + } + + const previous = await RolePermission.sequelize!.transaction(async (transaction) => { + const current = await RolePermission.findAll({ + where: { roleKind: kind, roleName: name }, + transaction, + lock: transaction.LOCK.UPDATE, + }); + + await RolePermission.destroy({ where: { roleKind: kind, roleName: name }, transaction }); + await RolePermission.bulkCreate( + next.map((permissionName) => ({ + roleKind: kind, + roleName: name, + permissionName, + createdBy: actorUserId, + })), + { transaction }, + ); + + return current.map((mapping) => mapping.permissionName); + }); + + return { + role: { ...role, permissions: next }, + added: next.filter((permission) => !previous.includes(permission)), + removed: previous.filter((permission) => !next.includes(permission)), + }; +} + +/** + * For a role leaving the instance. Without this, adding the name back later would silently + * restore whatever it used to grant. Returns the roles that held any mapping, for the audit. + */ +export async function deleteRoleMappings( + kind: RoleKind, + names: string[], + transaction?: Transaction, +) { + if (names.length === 0) return []; + + const where = { roleKind: kind, roleName: { [Op.in]: names } }; + const mappings = await RolePermission.findAll({ where, transaction }); + + if (mappings.length === 0) return []; + + await RolePermission.destroy({ where, transaction }); + + return Array.from(new Set(mappings.map((mapping) => mapping.roleName))); +} + +/** + * What the roles grant. Keyed on the literal name held: holding `billing` does not pick up a + * mapping on `billing:read`, so the console can show exactly why someone has a permission. + */ +export async function permissionsForRoles( + kind: RoleKind, + roles: string[] | null | undefined, + transaction?: Transaction, +) { + if (!roles?.length) return []; + + const mappings = await RolePermission.findAll({ + where: { roleKind: kind, roleName: { [Op.in]: roles } }, + attributes: ['permissionName'], + transaction, + }); + + return Array.from(new Set(mappings.map((mapping) => mapping.permissionName))).sort(); +} diff --git a/src/services/sessionExchangeService.ts b/src/services/sessionExchangeService.ts index 54f58c72..998c04d9 100644 --- a/src/services/sessionExchangeService.ts +++ b/src/services/sessionExchangeService.ts @@ -15,12 +15,13 @@ import { createLocalJWKSet, type JSONWebKeySet, type JWTPayload, jwtVerify } fro import { Op, WhereOptions } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { createRefreshTokenLookup, generateRefreshToken, signAccessToken } from '../lib/token.js'; +import { createRefreshTokenLookup, generateRefreshToken } from '../lib/token.js'; import { OidcPayload, OidcPayloadAttributes } from '../models/oidcPayloads.js'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; import getLogger from '../utils/logger.js'; import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; +import { mintAccessToken } from './accessTokenService.js'; import { AuthEventService } from './authEventService.js'; import { resolveCurrentSession } from './oidcAuthContext.js'; import { findActiveOidcClient, isSessionExchangeClient } from './oidcClientRegistry.js'; @@ -207,7 +208,7 @@ async function consumeAndIssue( ); // Inside, so a signing failure hands the token back rather than spending it. - const token = await signAccessToken(child.id, user.id, user.roles, parent.organizationId); + const token = await mintAccessToken({ session: child, user, transaction }); return { child, token }; }); diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index 32193fdf..0fb75cfd 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -8,9 +8,10 @@ import { Request, Response } from 'express'; import { isBreakGlassAdmin } from '../config/breakGlass.js'; import { getSystemConfig } from '../config/getSystemConfig.js'; -import { createRefreshTokenLookup, generateRefreshToken, signAccessToken } from '../lib/token.js'; +import { createRefreshTokenLookup, generateRefreshToken } from '../lib/token.js'; import { Session, SessionAuthMethod } from '../models/sessions.js'; import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; +import { mintAccessToken } from './accessTokenService.js'; import { enforceConcurrentSessionLimit } from './concurrentSessionPolicy.js'; import { isPasskeyRequiredForUser } from './loginPolicyService.js'; import { getDefaultOrganizationIdForUser } from './organizationService.js'; @@ -164,8 +165,9 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis authTime: now, }); - const token = await signAccessToken(session.id, user.id, user.roles, organizationId, { - recovery, + const token = await mintAccessToken({ + session: { id: session.id, organizationId, recovery }, + user, }); if (!token || !refreshToken) { diff --git a/tests/integration/admin/adminStepUp.spec.ts b/tests/integration/admin/adminStepUp.spec.ts index 987e3da5..617eb914 100644 --- a/tests/integration/admin/adminStepUp.spec.ts +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -36,6 +36,10 @@ const GUARDED = [ 'POST /admin/resources', 'PATCH /admin/resources/{id}', 'DELETE /admin/resources/{id}', + 'POST /admin/permissions', + 'PATCH /admin/permissions/{permissionName}', + 'DELETE /admin/permissions/{permissionName}', + 'PUT /admin/roles/{kind}/{roleName}/permissions', 'POST /admin/users', 'POST /admin/users/import', 'DELETE /admin/users', diff --git a/tests/integration/admin/permissions.spec.ts b/tests/integration/admin/permissions.spec.ts new file mode 100644 index 00000000..9efe270a --- /dev/null +++ b/tests/integration/admin/permissions.spec.ts @@ -0,0 +1,276 @@ +import { Application } from 'express'; +import { ForeignKeyConstraintError, UniqueConstraintError } from 'sequelize'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createApp } from '../../../src/app'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { Permission } from '../../../src/models/permissions.js'; +import { RolePermission } from '../../../src/models/rolePermissions.js'; +import { Session } from '../../../src/models/sessions.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { buildFreshStepUpSession, buildSession } from '../../factories/sessionFactory'; + +let app: Application; + +const now = new Date('2026-11-05T12:00:00.000Z'); + +function buildPermission(overrides: Partial = {}) { + return { + id: '4e1f2a3b-5c6d-4e7f-8a9b-0c1d2e3f4a5b', + name: 'invoices:read', + description: null, + createdAt: now, + updatedAt: now, + update: vi.fn().mockImplementation(function update(this: any, values: any) { + Object.assign(this, values); + return Promise.resolve(this); + }), + destroy: vi.fn(), + ...overrides, + }; +} + +beforeAll(async () => { + app = await createApp(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + available_roles: ['user', 'admin:write', 'finance'], + }); + (Permission.findAll as any).mockResolvedValue([]); + (RolePermission.findAll as any).mockResolvedValue([]); +}); + +describe('permission catalogue', () => { + it('lists permissions', async () => { + (Permission.findAll as any).mockResolvedValue([buildPermission()]); + + const res = await request(app).get('/admin/permissions'); + + expect(res.status).toBe(200); + expect(res.body.total).toBe(1); + expect(res.body.permissions[0].name).toBe('invoices:read'); + }); + + it('creates a permission and audits it', async () => { + (Permission.create as any).mockResolvedValue(buildPermission()); + + const res = await request(app).post('/admin/permissions').send({ name: 'invoices:read' }); + + expect(res.status).toBe(201); + expect(res.body.permission.name).toBe('invoices:read'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'admin_permission_created', + metadata: { permission: 'invoices:read' }, + }), + ); + }); + + it('needs a fresh step-up to create', async () => { + (Session.findOne as any).mockResolvedValue(buildSession()); + + const res = await request(app).post('/admin/permissions').send({ name: 'invoices:read' }); + + expect(res.status).toBe(403); + expect(Permission.create).not.toHaveBeenCalled(); + }); + + // A wildcard would read as a grant that exact matching never honors. + it('refuses a wildcard name', async () => { + const res = await request(app).post('/admin/permissions').send({ name: 'invoices:*' }); + + expect(res.status).toBe(400); + expect(Permission.create).not.toHaveBeenCalled(); + }); + + it('answers 409 for a name already taken', async () => { + (Permission.create as any).mockRejectedValue(new UniqueConstraintError({})); + + const res = await request(app).post('/admin/permissions').send({ name: 'invoices:read' }); + + expect(res.status).toBe(409); + }); + + it('updates a description', async () => { + (Permission.findOne as any).mockResolvedValue(buildPermission()); + + const res = await request(app) + .patch('/admin/permissions/invoices:read') + .send({ description: 'Read invoices' }); + + expect(res.status).toBe(200); + expect(res.body.permission.description).toBe('Read invoices'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'admin_permission_updated' }), + ); + }); + + it('answers 404 updating a permission that does not exist', async () => { + (Permission.findOne as any).mockResolvedValue(null); + + const res = await request(app) + .patch('/admin/permissions/invoices:read') + .send({ description: null }); + + expect(res.status).toBe(404); + }); + + it('deletes a permission no role grants', async () => { + const permission = buildPermission(); + (Permission.findOne as any).mockResolvedValue(permission); + + const res = await request(app).delete('/admin/permissions/invoices:read'); + + expect(res.status).toBe(200); + expect(permission.destroy).toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'admin_permission_deleted' }), + ); + }); + + it('refuses to delete a permission a role grants, and names the role', async () => { + const permission = buildPermission(); + (Permission.findOne as any).mockResolvedValue(permission); + (RolePermission.findAll as any).mockResolvedValue([ + { roleKind: 'instance', roleName: 'finance' }, + ]); + + const res = await request(app).delete('/admin/permissions/invoices:read'); + + expect(res.status).toBe(409); + expect(res.body.details.roles).toEqual([{ kind: 'instance', name: 'finance' }]); + expect(permission.destroy).not.toHaveBeenCalled(); + }); + + it('answers 409 when a mapping lands between the check and the delete', async () => { + (Permission.findOne as any).mockResolvedValue( + buildPermission({ + destroy: vi.fn().mockRejectedValue(new ForeignKeyConstraintError({} as any)), + }), + ); + + const res = await request(app).delete('/admin/permissions/invoices:read'); + + expect(res.status).toBe(409); + }); + + it('answers 404 deleting a permission that does not exist', async () => { + (Permission.findOne as any).mockResolvedValue(null); + + const res = await request(app).delete('/admin/permissions/invoices:read'); + + expect(res.status).toBe(404); + }); +}); + +describe('role mappings', () => { + it('lists instance roles with what they grant', async () => { + (RolePermission.findAll as any).mockResolvedValue([ + { roleName: 'finance', permissionName: 'invoices:read' }, + { roleName: 'finance', permissionName: 'invoices:write' }, + ]); + + const res = await request(app).get('/admin/roles'); + + expect(res.status).toBe(200); + expect(res.body.roles).toContainEqual({ + name: 'finance', + kind: 'instance', + description: null, + builtIn: false, + permissions: ['invoices:read', 'invoices:write'], + }); + expect(res.body.roles).toContainEqual( + expect.objectContaining({ name: 'user', permissions: [] }), + ); + }); + + it('replaces a mapping and audits what changed', async () => { + (Permission.findAll as any).mockResolvedValue([ + { name: 'invoices:read' }, + { name: 'reports:read' }, + ]); + (RolePermission.findAll as any) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([{ permissionName: 'invoices:read' }, { permissionName: 'old:read' }]); + + const res = await request(app) + .put('/admin/roles/instance/finance/permissions') + .send({ permissions: ['reports:read', 'invoices:read', 'reports:read'] }); + + expect(res.status).toBe(200); + expect(res.body.role.permissions).toEqual(['invoices:read', 'reports:read']); + expect(RolePermission.destroy).toHaveBeenCalled(); + expect(RolePermission.bulkCreate).toHaveBeenCalledWith( + [ + expect.objectContaining({ roleName: 'finance', permissionName: 'invoices:read' }), + expect.objectContaining({ roleName: 'finance', permissionName: 'reports:read' }), + ], + expect.anything(), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'admin_role_permissions_updated', + metadata: { + kind: 'instance', + role: 'finance', + added: ['reports:read'], + removed: ['old:read'], + }, + }), + ); + }); + + it('needs a fresh step-up to change a mapping', async () => { + (Session.findOne as any).mockResolvedValue(buildSession()); + + const res = await request(app) + .put('/admin/roles/instance/finance/permissions') + .send({ permissions: [] }); + + expect(res.status).toBe(403); + expect(RolePermission.bulkCreate).not.toHaveBeenCalled(); + }); + + it('answers 404 for a role the instance does not offer', async () => { + const res = await request(app) + .put('/admin/roles/instance/ghost/permissions') + .send({ permissions: [] }); + + expect(res.status).toBe(404); + expect(RolePermission.bulkCreate).not.toHaveBeenCalled(); + }); + + it('refuses a permission the catalogue does not hold, and names it', async () => { + const res = await request(app) + .put('/admin/roles/instance/finance/permissions') + .send({ permissions: ['ghost:read'] }); + + expect(res.status).toBe(400); + expect(res.body.details.permissions).toEqual(['ghost:read']); + expect(RolePermission.bulkCreate).not.toHaveBeenCalled(); + }); +}); + +describe('GET /users/me/permissions', () => { + it('answers with what the caller holds now', async () => { + (RolePermission.findAll as any).mockResolvedValue([{ permissionName: 'invoices:read' }]); + + const res = await request(app).get('/users/me/permissions'); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ + roles: ['user'], + permissions: ['invoices:read'], + organizationId: null, + organizationRoles: [], + organizationPermissions: [], + }); + }); +}); diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 91dd5593..a7157441 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -684,7 +684,7 @@ describe('POST /refresh', () => { expect.any(String), expect.any(Array), undefined, - { recovery: false }, + { recovery: false, authorization: { permissions: [] } }, ); expect(Session.create).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/tests/integration/organizations/organizations.spec.ts b/tests/integration/organizations/organizations.spec.ts index 713a8564..ab21fe98 100644 --- a/tests/integration/organizations/organizations.spec.ts +++ b/tests/integration/organizations/organizations.spec.ts @@ -159,6 +159,7 @@ describe('organizations', () => { 'user-1', ['user'], testOrganizationId, + { recovery: false, authorization: { permissions: [] } }, ); }); }); diff --git a/tests/integration/systemConfig/systemConfig.spec.ts b/tests/integration/systemConfig/systemConfig.spec.ts index dcfac5e3..a2d04f48 100644 --- a/tests/integration/systemConfig/systemConfig.spec.ts +++ b/tests/integration/systemConfig/systemConfig.spec.ts @@ -3,7 +3,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Application } from 'express'; import { createApp } from '../../../src/app'; +import { RolePermission } from '../../../src/models/rolePermissions.js'; import { SystemConfig } from '../../../src/models/systemConfig.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; import { User } from '../../../src/models/users.js'; import { getSystemConfig, @@ -231,6 +233,41 @@ describe('PATCH /system-config/admin', () => { expect(res.status).toBe(400); }); + it('removes the mappings of a role leaving the instance, so adding it back grants nothing', async () => { + (User.findAll as any).mockResolvedValue([]); + (SystemConfig.findAll as any).mockResolvedValue([]); + (RolePermission.findAll as any).mockResolvedValue([{ roleName: 'admin' }]); + + const res = await request(app) + .patch('/system-config/admin') + .send({ available_roles: ['user'] }); + + expect(res.status).toBe(200); + expect(RolePermission.destroy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ roleKind: 'instance' }), + }), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'system_config_updated', + metadata: expect.objectContaining({ retiredRoleMappings: ['admin'] }), + }), + ); + }); + + it('leaves mappings alone when no role leaves', async () => { + (User.findAll as any).mockResolvedValue([]); + (SystemConfig.findAll as any).mockResolvedValue([]); + + const res = await request(app) + .patch('/system-config/admin') + .send({ available_roles: ['user', 'admin', 'finance'] }); + + expect(res.status).toBe(200); + expect(RolePermission.destroy).not.toHaveBeenCalled(); + }); + it('rejects removing role in use', async () => { (User.findAll as any).mockResolvedValue([{ roles: ['admin'] }]); diff --git a/tests/postgres/permissions.spec.ts b/tests/postgres/permissions.spec.ts new file mode 100644 index 00000000..b17422b5 --- /dev/null +++ b/tests/postgres/permissions.spec.ts @@ -0,0 +1,130 @@ +import { randomUUID } from 'crypto'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +import { invalidateSystemConfigCache } from '../../src/config/getSystemConfig.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { + createPermission, + deletePermission, + deleteRoleMappings, + listRoles, + permissionsForRoles, + setRolePermissions, +} from '../../src/services/permissionService.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; + +async function setAvailableRoles(roles: string[]) { + for (const [key, value] of Object.entries(buildSystemConfig({ available_roles: roles }))) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, CAST(:value AS jsonb), now(), now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + { replacements: { key, value: JSON.stringify(value) } }, + ); + } + invalidateSystemConfigCache(); +} + +function unique(prefix: string) { + return `${prefix}-${randomUUID().slice(0, 8)}`; +} + +const finance = 'finance'; + +beforeAll(async () => { + await initializeModels(); +}); + +beforeEach(async () => { + await getSequelize().query('DELETE FROM role_permissions; DELETE FROM permissions;'); + await setAvailableRoles(['user', finance]); +}); + +afterAll(async () => { + await getSequelize().close(); +}); + +describe('permissions on the deployed schema', () => { + it('replaces a mapping and reads it back the way a token does', async () => { + const read = unique('invoices:read'); + const write = unique('invoices:write'); + await createPermission(read); + await createPermission(write); + + await setRolePermissions({ + kind: 'instance', + name: finance, + permissions: [read, write], + actorUserId: null, + }); + const change = await setRolePermissions({ + kind: 'instance', + name: finance, + permissions: [read], + actorUserId: null, + }); + + expect(change.removed).toEqual([write]); + expect(change.added).toEqual([]); + await expect(permissionsForRoles('instance', [finance])).resolves.toEqual([read]); + expect((await listRoles()).find((role) => role.name === finance)?.permissions).toEqual([read]); + }); + + it('keeps the kinds of role apart', async () => { + const read = unique('invoices:read'); + await createPermission(read); + await setRolePermissions({ + kind: 'instance', + name: finance, + permissions: [read], + actorUserId: null, + }); + + await expect(permissionsForRoles('organization', [finance])).resolves.toEqual([]); + }); + + it('refuses to delete a permission a role grants, at the service and at the key', async () => { + const read = unique('invoices:read'); + await createPermission(read); + await setRolePermissions({ + kind: 'instance', + name: finance, + permissions: [read], + actorUserId: null, + }); + + await expect(deletePermission(read)).rejects.toMatchObject({ status: 409 }); + await expect( + getSequelize().query('DELETE FROM permissions WHERE name = :name', { + replacements: { name: read }, + }), + ).rejects.toThrow(/role_permissions_permission_fkey/); + }); + + it('refuses a role kind the schema does not know', async () => { + const read = unique('invoices:read'); + await createPermission(read); + + await expect( + getSequelize().query( + `INSERT INTO role_permissions ("roleKind", "roleName", "permissionName", "createdAt") + VALUES ('global', 'x', :name, now())`, + { replacements: { name: read } }, + ), + ).rejects.toThrow(/role_permissions_role_kind/); + }); + + it('forgets a retired role, so adding the name back grants nothing', async () => { + const read = unique('invoices:read'); + await createPermission(read); + await setRolePermissions({ + kind: 'instance', + name: finance, + permissions: [read], + actorUserId: null, + }); + + await expect(deleteRoleMappings('instance', [finance])).resolves.toEqual([finance]); + await expect(permissionsForRoles('instance', [finance])).resolves.toEqual([]); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 013c5bdb..c4b21fad 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -172,6 +172,25 @@ vi.mock('../../src/models/organizationMemberships.js', () => ({ }, })); +vi.mock('../../src/models/permissions.js', () => ({ + Permission: { + create: vi.fn(), + findAll: vi.fn(async () => []), + findOne: vi.fn(), + }, +})); + +vi.mock('../../src/models/rolePermissions.js', () => ({ + RolePermission: { + sequelize: { + transaction: vi.fn((fn: any) => fn({ LOCK: { UPDATE: 'UPDATE' } })), + }, + bulkCreate: vi.fn(), + destroy: vi.fn(), + findAll: vi.fn(async () => []), + }, +})); + vi.mock('../../src/models/sessions.js', () => ({ Session: { create: vi.fn(), diff --git a/tests/unit/controllers/authentication.spec.ts b/tests/unit/controllers/authentication.spec.ts index 96376ce8..518c667d 100644 --- a/tests/unit/controllers/authentication.spec.ts +++ b/tests/unit/controllers/authentication.spec.ts @@ -150,6 +150,7 @@ describe('refreshSession', () => { ); expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles, undefined, { recovery: false, + authorization: { permissions: [] }, }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index 4ed7e1e5..3c399ebc 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -13,6 +13,8 @@ vi.unmock('../../../src/models/magicLinks.js'); vi.unmock('../../../src/models/oauthIdentities.js'); vi.unmock('../../../src/models/organizations.js'); vi.unmock('../../../src/models/organizationMemberships.js'); +vi.unmock('../../../src/models/permissions.js'); +vi.unmock('../../../src/models/rolePermissions.js'); vi.unmock('../../../src/models/webauthnChallenges.js'); vi.unmock('../../../src/models/userExternalIds.js'); vi.unmock('../../../src/models/policyVersions.js'); diff --git a/tests/unit/services/accessTokenService.spec.ts b/tests/unit/services/accessTokenService.spec.ts new file mode 100644 index 00000000..8ab9f443 --- /dev/null +++ b/tests/unit/services/accessTokenService.spec.ts @@ -0,0 +1,175 @@ +import { generateKeyPairSync } from 'crypto'; +import { SignJWT, decodeJwt } from 'jose'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.unmock('../../../src/lib/token'); + +vi.hoisted(() => { + process.env.ISSUER = 'https://auth.customer-domain.example.com'; +}); + +vi.mock('../../../src/utils/signingKeyStore.js', () => ({ + getSigningKey: vi.fn(), +})); + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { signAccessToken } from '../../../src/lib/token.js'; +import { RolePermission } from '../../../src/models/rolePermissions.js'; +import { + authorizationClaims, + mintAccessToken, + PERMISSION_CLAIMS_BUDGET_BYTES, + resolveAuthorization, +} from '../../../src/services/accessTokenService.js'; +import { getSigningKey } from '../../../src/utils/signingKeyStore.js'; + +const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); + +function mappings(...names: string[]) { + (RolePermission.findAll as any).mockResolvedValue( + names.map((permissionName) => ({ permissionName })), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + (getSigningKey as any).mockResolvedValue({ kid: 'k1', privateKeyPem }); + (getSystemConfig as any).mockResolvedValue({ access_token_ttl: '15m' }); + mappings(); +}); + +const user = { id: '7b6f7a0e-52a4-4f1b-9d32-4c1a0f1e2d3c', roles: ['user'] }; +const session = { id: '0f4a9a3e-1d7c-4b9e-8a11-2f6c7d8e9a0b' }; + +function fittingPermissions() { + const permissions: string[] = []; + + for (let index = 0; ; index += 1) { + const next = [...permissions, `resource-${index}:action`]; + + if (Buffer.byteLength(JSON.stringify({ permissions: next })) > PERMISSION_CLAIMS_BUDGET_BYTES) { + return permissions; + } + + permissions.push(next[next.length - 1]); + } +} + +describe('resolveAuthorization', () => { + it('reads what the roles grant, deduplicated and sorted', async () => { + mappings('b:read', 'a:read', 'b:read'); + + await expect(resolveAuthorization({ roles: ['x', 'y'], recovery: false })).resolves.toEqual({ + roles: ['x', 'y'], + permissions: ['a:read', 'b:read'], + }); + expect(RolePermission.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ roleKind: 'instance' }), + }), + ); + }); + + it('grants a recovery session nothing, without reading the mappings', async () => { + mappings('a:read'); + + await expect(resolveAuthorization({ roles: ['x'], recovery: true })).resolves.toEqual({ + roles: ['x'], + permissions: [], + }); + expect(RolePermission.findAll).not.toHaveBeenCalled(); + }); + + it('does not query for a user with no roles', async () => { + await expect(resolveAuthorization({ roles: [], recovery: false })).resolves.toEqual({ + roles: [], + permissions: [], + }); + expect(RolePermission.findAll).not.toHaveBeenCalled(); + }); +}); + +describe('authorizationClaims', () => { + it('carries a set that fits', () => { + expect(authorizationClaims({ roles: [], permissions: ['a:read'] })).toEqual({ + permissions: ['a:read'], + }); + }); + + it('carries a set exactly at the budget', () => { + const permissions = fittingPermissions(); + + expect(authorizationClaims({ roles: [], permissions })).toEqual({ permissions }); + }); + + // A partial list looks complete and denies silently, so nothing is trimmed. + it('replaces a set over the budget with the marker', () => { + const permissions = [...fittingPermissions(), 'one-more:action']; + + expect(authorizationClaims({ roles: [], permissions })).toEqual({ + permissions_omitted: true, + }); + }); +}); + +describe('mintAccessToken', () => { + it('signs the claims into the token', async () => { + mappings('reports:read'); + + const claims = decodeJwt(await mintAccessToken({ session, user })); + + expect(claims).toMatchObject({ + sub: user.id, + sid: session.id, + roles: ['user'], + permissions: ['reports:read'], + }); + expect(claims.permissions_omitted).toBeUndefined(); + }); + + it('marks a recovery token and gives it no permissions', async () => { + mappings('reports:read'); + + const claims = decodeJwt( + await mintAccessToken({ session: { ...session, recovery: true }, user }), + ); + + expect(claims.recovery).toBe(true); + expect(claims.permissions).toEqual([]); + }); + + /** + * A server adapter signs the whole access token into a cookie alongside the user's details + * (`issueSessionCookies` in seamless-auth-server), and browsers drop a cookie over 4 KiB, + * which would sign the user out. This builds that cookie at the worst case the budget allows. + */ + it('keeps the adapter session cookie under the browser limit at the budget', async () => { + mappings(...fittingPermissions()); + + const longRoles = Array.from({ length: 5 }, (_, index) => `billing:invoices:role-${index}`); + const token = await signAccessToken(session.id, user.id, longRoles, session.id, { + authorization: authorizationClaims( + await resolveAuthorization({ roles: longRoles, recovery: false }), + ), + }); + + const cookie = await new SignJWT({ + sub: user.id, + sessionId: session.id, + token, + roles: longRoles, + email: `${'a'.repeat(64)}@${'b'.repeat(40)}.example.com`, + phone: '+15555550100', + organizationId: session.id, + }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('15m') + .sign(new TextEncoder().encode('cookie-secret-cookie-secret-cookie')); + + const setCookie = `seamless-access=${cookie}; Max-Age=900; Path=/; HttpOnly; Secure; SameSite=Lax`; + + expect(Buffer.byteLength(setCookie)).toBeLessThanOrEqual(4096); + }); +}); diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index 19f93e76..d788ddee 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -111,6 +111,7 @@ describe('issueSessionAndRespond', () => { ); expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, null, { recovery: false, + authorization: { permissions: [] }, }); expect(res.status).toHaveBeenCalledWith(200); expect(res.json).toHaveBeenCalledWith({ @@ -285,6 +286,7 @@ describe('issueSessionAndRespond', () => { expect(Session.create).toHaveBeenCalledWith(expect.objectContaining({ recovery: true })); expect(signAccessToken).toHaveBeenCalledWith('session-1', mockUser.id, mockUser.roles, null, { recovery: true, + authorization: { permissions: [] }, }); expect(res.json).toHaveBeenCalledWith( expect.objectContaining({ recovery: true, nextStep: 'enroll_passkey' }), @@ -426,6 +428,7 @@ describe('issueSessionAndRespond', () => { 'org-1', { recovery: false, + authorization: { permissions: [] }, }, ); }); From c0a0dce9bdf7f981ffd54d426bf091be108ae57f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 23:56:28 -0500 Subject: [PATCH 2/2] fix(deps): use @seamless-auth/types 0.38.0 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4dfddd31..7004eecb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.37.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -2927,9 +2927,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.37.0.tgz", - "integrity": "sha512-4rd7iEJPCKWPOIP5YKx+pyC9MgmsxOnnP/KR9DMYfCk5qLmAwMXBSRD9AazVYy0L6uvkBbRs3/o7+G/LlxXMxA==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.38.0.tgz", + "integrity": "sha512-qALF5GFQEv+9yucQdn179cpTdEMMH/PjtZWAHatWk1qUEOzb1ElOqoBZgC4/THAk6u4aLgDyuDt/dCD/BzloqA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index 4e745301..855c3341 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.37.0", + "@seamless-auth/types": "^0.38.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2",