From f7176bb73155f778756d14258107288a80c8014a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 23:19:34 -0500 Subject: [PATCH 1/4] feat(organizations): carry organization roles and permissions in the access token Adds org_roles and org_permissions beside org_id for the active organization, and a catalogue of organization roles seeded from what memberships already hold. Assigning a role outside the catalogue answers 400. Role changes need step-up and are audited. Only an owner or an instance admin:write can grant or remove owner, and other managers can only grant a role whose permissions they hold, so an organization admin can no longer make themselves owner. Fixes removal from an organization not reaching the token: refresh copied org_id from the session until it ended. The mint now reads the membership. Closes #416. --- .changeset/organization-roles-in-token.md | 5 + adapter-manifest.json | 3 + ...0004-permissions-and-organization-roles.md | 2 +- openapi.json | 713 ++++++++++++++++-- src/controllers/authentication.ts | 4 +- src/controllers/organizations.ts | 105 ++- src/controllers/permissions.ts | 82 +- src/controllers/user.ts | 4 + src/generated/api.ts | 658 +++++++++++++++- src/lib/authEventCategories.ts | 1 + .../20261105130000-add-organization-roles.cjs | 53 ++ src/models/organizationRoles.ts | 50 ++ src/routes/admin.routes.ts | 68 ++ src/routes/organization.routes.ts | 12 +- src/schemas/authEvent.types.ts | 7 + src/services/accessTokenService.ts | 103 ++- src/services/organizationService.ts | 45 +- src/services/permissionService.ts | 145 +++- src/services/sessionExchangeService.ts | 10 +- src/services/sessionIssuance.ts | 2 +- tests/integration/admin/adminStepUp.spec.ts | 3 + tests/integration/admin/permissions.spec.ts | 182 +++++ .../authentication/authentication.spec.ts | 40 +- .../organizationRoleSecurity.spec.ts | 250 ++++++ .../organizations/organizations.spec.ts | 5 +- tests/postgres/permissions.spec.ts | 115 ++- tests/postgres/sessionExchange.spec.ts | 6 +- tests/setup/mocks.ts | 13 + tests/unit/controllers/authentication.spec.ts | 4 +- tests/unit/models/models.spec.ts | 1 + .../unit/services/accessTokenService.spec.ts | 181 ++++- .../unit/services/sessionIssueService.spec.ts | 3 +- 32 files changed, 2675 insertions(+), 200 deletions(-) create mode 100644 .changeset/organization-roles-in-token.md create mode 100644 src/migrations/20261105130000-add-organization-roles.cjs create mode 100644 src/models/organizationRoles.ts create mode 100644 tests/integration/organizations/organizationRoleSecurity.spec.ts diff --git a/.changeset/organization-roles-in-token.md b/.changeset/organization-roles-in-token.md new file mode 100644 index 00000000..9912fbb7 --- /dev/null +++ b/.changeset/organization-roles-in-token.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': minor +--- + +A backend can authorize per organization from the access token alone (#416, ADR 0004). Inside an active organization, tokens gain `org_roles` and `org_permissions` beside `org_id`, and `GET /users/me` and `GET /users/me/permissions` report the same. Organization roles never appear in `roles`, so a guard on `admin` does not pass for an organization admin. Organization roles are now a catalogue, managed with `POST /admin/organization-roles` and `PATCH` and `DELETE /admin/organization-roles/:roleName`, listed by `GET /admin/roles`, and mapped to permissions with `PUT /admin/roles/organization/:roleName/permissions`. A new migration seeds it with the built-in `owner`, `admin` and `member` and every value memberships already hold, and rewrites no membership. **Behavior changes:** assigning a role outside the catalogue now answers 400. `PATCH /organizations/:id/members/:userId` now needs step-up. Only an owner or an instance `admin:write` can grant or remove `owner`, or remove an owner from an organization, and any other manager can only grant a role whose permissions they hold, so an organization admin can no longer make themselves owner. Adding, changing and removing members is audited as `organization_member_added`, `organization_member_updated` and `organization_member_removed`, and a refused change as `organization_member_role_grant_refused`. **Fixes:** removing a member now reaches the token. Previously every refresh copied `org_id` from the session until it ended. Now the next token drops the organization claims and the session stops naming the organization. Recovery tokens carry no organization claims. diff --git a/adapter-manifest.json b/adapter-manifest.json index 07b08e31..68a07d00 100644 --- a/adapter-manifest.json +++ b/adapter-manifest.json @@ -35,6 +35,9 @@ { "method": "GET", "path": "/admin/credential-count", "credential": "access" }, { "method": "GET", "path": "/admin/enrollment", "credential": "access" }, { "method": "POST", "path": "/admin/enrollment/invites", "credential": "access" }, + { "method": "POST", "path": "/admin/organization-roles", "credential": "access" }, + { "method": "DELETE", "path": "/admin/organization-roles/{roleName}", "credential": "access" }, + { "method": "PATCH", "path": "/admin/organization-roles/{roleName}", "credential": "access" }, { "method": "GET", "path": "/admin/organizations", "credential": "access" }, { "method": "POST", "path": "/admin/organizations", "credential": "access" }, { "method": "DELETE", "path": "/admin/organizations/{organizationId}", "credential": "access" }, diff --git a/docs/adr/0004-permissions-and-organization-roles.md b/docs/adr/0004-permissions-and-organization-roles.md index e57b95e6..effdbae5 100644 --- a/docs/adr/0004-permissions-and-organization-roles.md +++ b/docs/adr/0004-permissions-and-organization-roles.md @@ -51,7 +51,7 @@ This differs from `requireRole`, deliberately. Roles keep their scoped matching. ### P4. A set that does not fit is omitted whole -The permission arrays get 1 KiB, serialized, together. A server adapter keeps the whole access token inside a signed cookie, so it is base64 encoded twice under the browser's 4 KiB cookie limit, and a test builds that cookie at the budget. Over the budget both arrays are replaced with `"permissions_omitted": true`. +The organization roles and the two permission arrays get 1 KiB, serialized, together. A server adapter keeps the whole access token inside a signed cookie, so it is base64 encoded twice under the browser's 4 KiB cookie limit, and a test builds that cookie at the budget. Over the budget all three are replaced with `"permissions_omitted": true`. Nothing is trimmed, because a partial list looks complete and denies silently. A guard that sees the marker reads `GET /users/me/permissions` with the caller's token and fails closed if it cannot. A plain 403 would turn an organization growing past a limit into an outage. diff --git a/openapi.json b/openapi.json index 91631ccd..49f436fa 100644 --- a/openapi.json +++ b/openapi.json @@ -4975,6 +4975,619 @@ } } }, + "/admin/organization-roles": { + "post": { + "summary": "Create an organization role", + "description": "Roles are instance-wide: any organization can assign one.", + "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 }, + "permissions": { + "type": "array", + "items": { + "type": "string", + "pattern": "^(?!.*[_/\\\\\\s])(?=.{1,80}$)[A-Za-z0-9-]+(?::[A-Za-z0-9-]+)*$" + }, + "maxItems": 200 + } + }, + "required": ["name"] + } + } + } + }, + "responses": { + "201": { + "description": "HTTP 201", + "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"] + } + } + } + }, + "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/organization-roles/{roleName}": { + "patch": { + "summary": "Update an organization role's description", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "minLength": 1, "maxLength": 80 }, + "required": true, + "name": "roleName", + "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": { + "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", + "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 an organization role", + "description": "Answers 409 for a built-in role and for a role any membership still holds.", + "tags": ["Admin"], + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "schema": { "type": "string", "minLength": 1, "maxLength": 80 }, + "required": true, + "name": "roleName", + "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/enrollment": { "get": { "summary": "Passkey enrollment progress", @@ -6577,6 +7190,9 @@ "admin_oidc_resource_created", "admin_oidc_resource_deleted", "admin_oidc_resource_updated", + "admin_organization_role_created", + "admin_organization_role_deleted", + "admin_organization_role_updated", "admin_permission_created", "admin_permission_deleted", "admin_permission_updated", @@ -6626,6 +7242,10 @@ "oidc_token_issued", "oidc_token_refreshed", "oidc_token_revoked", + "organization_member_added", + "organization_member_removed", + "organization_member_role_grant_refused", + "organization_member_updated", "otp_failed", "otp_success", "otp_suspicious", @@ -6709,6 +7329,9 @@ "admin_oidc_resource_created", "admin_oidc_resource_deleted", "admin_oidc_resource_updated", + "admin_organization_role_created", + "admin_organization_role_deleted", + "admin_organization_role_updated", "admin_permission_created", "admin_permission_deleted", "admin_permission_updated", @@ -6758,6 +7381,10 @@ "oidc_token_issued", "oidc_token_refreshed", "oidc_token_revoked", + "organization_member_added", + "organization_member_removed", + "organization_member_role_grant_refused", + "organization_member_updated", "otp_failed", "otp_success", "otp_suspicious", @@ -21069,37 +21696,12 @@ "description": "HTTP 400", "content": { "application/json": { - "example": { - "error": "string", - "message": "string", - "details": { "issues": [null] } - }, + "example": { "error": "string", "details": 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"] - } + "details": { "type": "object", "additionalProperties": { "nullable": true } } }, "required": ["error"] } @@ -21197,6 +21799,7 @@ "/organizations/{organizationId}/members/{userId}": { "patch": { "summary": "Update organization member", + "description": "Only an owner or an instance `admin:write` can grant or remove `owner`, and any other manager can only grant a role whose permissions they hold. Added roles have to be in the catalogue.", "tags": ["Organizations"], "security": [{ "bearerAuth": [] }], "parameters": [ @@ -21298,37 +21901,12 @@ "description": "HTTP 400", "content": { "application/json": { - "example": { - "error": "string", - "message": "string", - "details": { "issues": [null] } - }, + "example": { "error": "string", "details": 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"] - } + "details": { "type": "object", "additionalProperties": { "nullable": true } } }, "required": ["error"] } @@ -21346,7 +21924,8 @@ "method": null, "verifiedAt": "string", "expiresAt": "string", - "maxAgeSeconds": 0 + "maxAgeSeconds": 0, + "details": null }, "schema": { "type": "object", @@ -21361,7 +21940,8 @@ }, "verifiedAt": { "type": "string", "nullable": true }, "expiresAt": { "type": "string", "nullable": true }, - "maxAgeSeconds": { "type": "number" } + "maxAgeSeconds": { "type": "number" }, + "details": { "type": "object", "additionalProperties": { "nullable": true } } }, "required": ["error"] } @@ -21589,29 +22169,12 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { - "message": "string", - "error": "string", - "fresh": true, - "method": null, - "verifiedAt": "string", - "expiresAt": "string", - "maxAgeSeconds": 0 - }, + "example": { "error": "string", "details": null }, "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" } + "details": { "type": "object", "additionalProperties": { "nullable": true } } }, "required": ["error"] } diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index e3254458..ecc31e0c 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -751,7 +751,7 @@ export const refreshSession = async (req: Request, res: Response) => { }); } - const token = await mintAccessToken({ + const { token, organizationId } = await mintAccessToken({ session: { id: newSession.id, organizationId: session.organizationId, @@ -772,7 +772,7 @@ export const refreshSession = async (req: Request, res: Response) => { refreshToken: newRefreshToken, sub: user.id, sessionId: newSession.id, - organizationId: session.organizationId, + organizationId, roles: user.roles, email: user.email, phone: user.phone, diff --git a/src/controllers/organizations.ts b/src/controllers/organizations.ts index 5cf62795..883be58b 100644 --- a/src/controllers/organizations.ts +++ b/src/controllers/organizations.ts @@ -28,12 +28,14 @@ import { normalizeMembershipValues, normalizeOrganizationRoles, normalizeOrganizationSlug, + refusedRoleChanges, requireOrganizationAccess, requireOrganizationManager, serializeMembership, serializeOrganization, setOAuthProviderRetired, } from '../services/organizationService.js'; +import { assertOrganizationRolesExist, PermissionError } from '../services/permissionService.js'; import { AuthenticatedRequest, RouteRequest } from '../types/types.js'; import { parseDurationToSeconds } from '../utils/utils.js'; @@ -49,6 +51,41 @@ function currentSessionId(req: Request) { return (req as AuthenticatedRequest).sessionId; } +function sendPermissionError(res: Response, error: unknown) { + if (error instanceof PermissionError) { + return res.status(error.status).json({ error: error.message, details: error.details }); + } + + throw error; +} + +/** ADR 0004, P7. Answers and audits the refusal, and returns whether it did. */ +async function refuseRoleChange( + req: RouteRequest, + res: Response, + managerMembership: OrganizationMembership | null, + targetUserId: string, + current: string[], + next: string[], +) { + const user = authUser(req); + const refused = await refusedRoleChanges(user, managerMembership, current, next); + + if (refused.length === 0) return false; + + await AuthEventService.log({ + actorUserId: user.id, + userId: targetUserId, + type: 'organization_member_role_grant_refused', + req, + metadata: { organizationId: req.params.organizationId, roles: refused }, + }); + + res.status(403).json({ error: 'Not allowed to change these roles', details: { roles: refused } }); + + return true; +} + export async function listOrganizations(req: Request, res: Response) { const user = authUser(req); const organizations = await listOrganizationsForUser(user.id); @@ -169,7 +206,11 @@ export async function switchOrganization(req: RouteRequest, res: Response) { } await session.update({ organizationId: organization.id }); - const token = await mintAccessToken({ session, user }); + const { token } = await mintAccessToken({ + session: { id: session.id, organizationId: organization.id, recovery: session.recovery }, + user, + membership, + }); const { access_token_ttl } = await getSystemConfig(); return res.json({ @@ -221,14 +262,26 @@ export async function addMember(req: RouteRequest, res: Response) { return res.status(409).json({ error: 'User is already an organization member' }); } + const roles = normalizeOrganizationRoles(req.body.roles); + try { + await assertOrganizationRolesExist(roles); + const membership = await OrganizationMembership.create({ organizationId, userId: memberUser.id, - roles: normalizeOrganizationRoles(req.body.roles), + roles, scopes: normalizeMembershipValues(req.body.scopes), }); + await AuthEventService.log({ + actorUserId: user.id, + userId: memberUser.id, + type: 'organization_member_added', + req, + metadata: { organizationId, roles }, + }); + return res.status(201).json({ membership: serializeMembership(membership, memberUser, { includePrivate: true }), }); @@ -241,14 +294,17 @@ export async function addMember(req: RouteRequest, res: Response) { return res.status(409).json({ error: 'User is already an organization member' }); } - throw error; + return sendPermissionError(res, error); } } export async function updateMember(req: RouteRequest, res: Response) { const user = authUser(req); const { organizationId, userId } = req.params; - const { organization } = await requireOrganizationManager(user, organizationId); + const { organization, membership: managerMembership } = await requireOrganizationManager( + user, + organizationId, + ); if (!organization) { return res.status(404).json({ error: 'Organization not found' }); @@ -262,6 +318,16 @@ export async function updateMember(req: RouteRequest, res: Response) { const nextRoles = req.body.roles === undefined ? membership.roles : normalizeOrganizationRoles(req.body.roles); + const currentRoles = membership.roles ?? []; + + if (await refuseRoleChange(req, res, managerMembership, userId, currentRoles, nextRoles)) return; + + // Only what is being added: a member keeps a legacy role the catalogue has since lost. + try { + await assertOrganizationRolesExist(nextRoles.filter((role) => !currentRoles.includes(role))); + } catch (error) { + return sendPermissionError(res, error); + } if (membership.roles?.includes('owner') && !nextRoles.includes('owner')) { const ownerCount = await countOwners(organizationId); @@ -278,6 +344,19 @@ export async function updateMember(req: RouteRequest, res: Response) { : normalizeMembershipValues(req.body.scopes), }); + await AuthEventService.log({ + actorUserId: user.id, + userId, + type: 'organization_member_updated', + req, + metadata: { + organizationId, + rolesAdded: nextRoles.filter((role) => !currentRoles.includes(role)), + rolesRemoved: currentRoles.filter((role) => !nextRoles.includes(role)), + ...(req.body.scopes === undefined ? {} : { scopes: membership.scopes }), + }, + }); + const memberUser = await User.findByPk(userId); return res.json({ @@ -290,7 +369,10 @@ export async function updateMember(req: RouteRequest, res: Response) { export async function removeMember(req: RouteRequest, res: Response) { const user = authUser(req); const { organizationId, userId } = req.params; - const { organization } = await requireOrganizationManager(user, organizationId); + const { organization, membership: managerMembership } = await requireOrganizationManager( + user, + organizationId, + ); if (!organization) { return res.status(404).json({ error: 'Organization not found' }); @@ -302,6 +384,11 @@ export async function removeMember(req: RouteRequest, res: Response) { return res.status(404).json({ error: 'Membership not found' }); } + // Removing someone is the most complete way to take their roles, so the same rule holds. + if (await refuseRoleChange(req, res, managerMembership, userId, membership.roles ?? [], [])) { + return; + } + if (membership.roles?.includes('owner')) { const ownerCount = await countOwners(organizationId); if (ownerCount <= 1) { @@ -311,6 +398,14 @@ export async function removeMember(req: RouteRequest, res: Response) { await membership.destroy(); + await AuthEventService.log({ + actorUserId: user.id, + userId, + type: 'organization_member_removed', + req, + metadata: { organizationId, roles: membership.roles ?? [] }, + }); + return res.json({ message: 'Success' }); } diff --git a/src/controllers/permissions.ts b/src/controllers/permissions.ts index 90b5b44c..0366928b 100644 --- a/src/controllers/permissions.ts +++ b/src/controllers/permissions.ts @@ -11,13 +11,16 @@ import { ForeignKeyConstraintError } from 'sequelize'; import { resolveAuthorization } from '../services/accessTokenService.js'; import { AuthEventService } from '../services/authEventService.js'; import { + createOrganizationRole as createOrganizationRoleRecord, createPermission as createPermissionRecord, + deleteOrganizationRole as deleteOrganizationRoleRecord, deletePermission as deletePermissionRecord, listPermissions as listPermissionRecords, listRoles as listRoleRecords, PermissionError, serializePermission, setRolePermissions as setRolePermissionRecords, + updateOrganizationRole as updateOrganizationRoleRecord, updatePermission as updatePermissionRecord, } from '../services/permissionService.js'; import { AuthenticatedRequest, RouteRequest } from '../types/types.js'; @@ -133,16 +136,71 @@ export async function setRolePermissions(req: RouteRequest, res: Response) { 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: [], - }); + + return res.json( + await resolveAuthorization({ + userId: authReq.user.id, + roles: authReq.user.roles, + organizationId: authReq.organizationId, + recovery: authReq.recoverySession === true, + }), + ); +} + +export async function createOrganizationRole(req: Request, res: Response) { + try { + const role = await createOrganizationRoleRecord({ + name: req.body.name, + description: req.body.description, + permissions: req.body.permissions, + actorUserId: actorUserId(req), + }); + + await AuthEventService.log({ + actorUserId: actorUserId(req), + type: 'admin_organization_role_created', + req, + metadata: { role: role.name, permissions: role.permissions }, + }); + + return res.status(201).json({ role }); + } catch (error) { + return sendError(res, error); + } +} + +export async function updateOrganizationRole(req: RouteRequest, res: Response) { + try { + const role = await updateOrganizationRoleRecord(req.params.roleName, req.body.description); + + await AuthEventService.log({ + actorUserId: actorUserId(req), + type: 'admin_organization_role_updated', + req, + metadata: { role: role.name }, + }); + + return res.json({ role }); + } catch (error) { + return sendError(res, error); + } +} + +export async function deleteOrganizationRole(req: RouteRequest, res: Response) { + const { roleName } = req.params; + + try { + await deleteOrganizationRoleRecord(roleName); + + await AuthEventService.log({ + actorUserId: actorUserId(req), + type: 'admin_organization_role_deleted', + req, + metadata: { role: roleName }, + }); + + return res.json({ message: 'Success' }); + } catch (error) { + return sendError(res, error); + } } diff --git a/src/controllers/user.ts b/src/controllers/user.ts index 28a90f72..e5d17a89 100644 --- a/src/controllers/user.ts +++ b/src/controllers/user.ts @@ -45,7 +45,9 @@ export const getUser = async (req: Request, res: Response) => { }), listOrganizationsForUser(authUser.id), resolveAuthorization({ + userId: authUser.id, roles: authUser.roles, + organizationId: authReq.organizationId, recovery: authReq.recoverySession === true, }), ]); @@ -60,6 +62,8 @@ export const getUser = async (req: Request, res: Response) => { lastLogin: authUser.lastLogin, activeOrganizationId, permissions: authorization.permissions, + organizationRoles: authorization.organizationRoles, + organizationPermissions: authorization.organizationPermissions, }, credentials: credentials.map(serializeCredential), organizations, diff --git a/src/generated/api.ts b/src/generated/api.ts index 06f73808..f17bc323 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -4779,6 +4779,594 @@ export interface paths { patch?: never; trace?: never; }; + '/admin/organization-roles': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Create an organization role + * @description Roles are instance-wide: any organization can assign one. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + name: string; + description?: string; + permissions?: string[]; + }; + }; + }; + responses: { + /** @description HTTP 201 */ + 201: { + 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 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; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/admin/organization-roles/{roleName}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete an organization role + * @description Answers 409 for a built-in role and for a role any membership still holds. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + roleName: 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 an organization role's description */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + roleName: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + description: string | null; + }; + }; + }; + 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", + * "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/enrollment': { parameters: { query?: never; @@ -6208,6 +6796,9 @@ export interface paths { | 'admin_oidc_resource_created' | 'admin_oidc_resource_deleted' | 'admin_oidc_resource_updated' + | 'admin_organization_role_created' + | 'admin_organization_role_deleted' + | 'admin_organization_role_updated' | 'admin_permission_created' | 'admin_permission_deleted' | 'admin_permission_updated' @@ -6257,6 +6848,10 @@ export interface paths { | 'oidc_token_issued' | 'oidc_token_refreshed' | 'oidc_token_revoked' + | 'organization_member_added' + | 'organization_member_removed' + | 'organization_member_role_grant_refused' + | 'organization_member_updated' | 'otp_failed' | 'otp_success' | 'otp_suspicious' @@ -6334,6 +6929,9 @@ export interface paths { | 'admin_oidc_resource_created' | 'admin_oidc_resource_deleted' | 'admin_oidc_resource_updated' + | 'admin_organization_role_created' + | 'admin_organization_role_deleted' + | 'admin_organization_role_updated' | 'admin_permission_created' | 'admin_permission_deleted' | 'admin_permission_updated' @@ -6383,6 +6981,10 @@ export interface paths { | 'oidc_token_issued' | 'oidc_token_refreshed' | 'oidc_token_revoked' + | 'organization_member_added' + | 'organization_member_removed' + | 'organization_member_role_grant_refused' + | 'organization_member_updated' | 'otp_failed' | 'otp_success' | 'otp_suspicious' @@ -20297,23 +20899,13 @@ export interface paths { /** * @example { * "error": "string", - * "message": "string", - * "details": { - * "issues": [ - * null - * ] - * } + * "details": null * } */ 'application/json': { error: string; - message?: string; details?: { - issues: { - path: (string | number)[]; - code: string; - message: string; - }[]; + [key: string]: unknown; }; }; }; @@ -20574,24 +21166,15 @@ export interface paths { content: { /** * @example { - * "message": "string", * "error": "string", - * "fresh": true, - * "method": null, - * "verifiedAt": "string", - * "expiresAt": "string", - * "maxAgeSeconds": 0 + * "details": null * } */ 'application/json': { - message?: string; error: string; - fresh?: boolean; - /** @enum {string|null} */ - method?: 'webauthn' | 'totp' | null; - verifiedAt?: string | null; - expiresAt?: string | null; - maxAgeSeconds?: number; + details?: { + [key: string]: unknown; + }; }; }; }; @@ -20653,7 +21236,10 @@ export interface paths { }; options?: never; head?: never; - /** Update organization member */ + /** + * Update organization member + * @description Only an owner or an instance `admin:write` can grant or remove `owner`, and any other manager can only grant a role whose permissions they hold. Added roles have to be in the catalogue. + */ patch: { parameters: { query?: never; @@ -20735,23 +21321,13 @@ export interface paths { /** * @example { * "error": "string", - * "message": "string", - * "details": { - * "issues": [ - * null - * ] - * } + * "details": null * } */ 'application/json': { error: string; - message?: string; details?: { - issues: { - path: (string | number)[]; - code: string; - message: string; - }[]; + [key: string]: unknown; }; }; }; @@ -20770,7 +21346,8 @@ export interface paths { * "method": null, * "verifiedAt": "string", * "expiresAt": "string", - * "maxAgeSeconds": 0 + * "maxAgeSeconds": 0, + * "details": null * } */ 'application/json': { @@ -20782,6 +21359,9 @@ export interface paths { verifiedAt?: string | null; expiresAt?: string | null; maxAgeSeconds?: number; + details?: { + [key: string]: unknown; + }; }; }; }; diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index 8e26f656..b8f39a25 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -50,6 +50,7 @@ const PREFIX_CATEGORIES: ReadonlyArray<[string, AuthEventCategory]> = [ ['system_config_', 'system'], ['policy_', 'system'], ['admin_', 'system'], + ['organization_member_', 'system'], ['user_data_', 'system'], ]; diff --git a/src/migrations/20261105130000-add-organization-roles.cjs b/src/migrations/20261105130000-add-organization-roles.cjs new file mode 100644 index 00000000..f623eee0 --- /dev/null +++ b/src/migrations/20261105130000-add-organization-roles.cjs @@ -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 + */ + +'use strict'; + +/** + * The catalogue of organization roles (ADR 0004, P5). Seeded with the built-ins and every + * value memberships already hold, spelled as they are, so nobody loses a role and no membership + * is rewritten. A value too long for the column stays held, and is simply not in the catalogue. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + CREATE TABLE IF NOT EXISTS public.organization_roles ( + name character varying(80) NOT NULL, + description text, + "builtIn" boolean DEFAULT false NOT NULL, + "createdAt" timestamp with time zone NOT NULL, + "updatedAt" timestamp with time zone NOT NULL, + CONSTRAINT organization_roles_pkey PRIMARY KEY (name) + ); + + INSERT INTO public.organization_roles (name, description, "builtIn", "createdAt", "updatedAt") + VALUES + ('owner', 'Full control of the organization, including its owners.', true, now(), now()), + ('admin', 'Manages the organization and its members.', true, now(), now()), + ('member', 'Belongs to the organization.', true, now(), now()) + ON CONFLICT (name) DO NOTHING; + + INSERT INTO public.organization_roles (name, "createdAt", "updatedAt") + SELECT DISTINCT held.role, now(), now() + FROM public.organization_memberships membership + CROSS JOIN LATERAL jsonb_array_elements_text( + CASE WHEN jsonb_typeof(membership.roles) = 'array' + THEN membership.roles ELSE '[]'::jsonb END + ) AS held(role) + WHERE length(held.role) BETWEEN 1 AND 80 + ON CONFLICT (name) DO NOTHING; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DELETE FROM public.role_permissions WHERE "roleKind" = 'organization'; + DROP TABLE IF EXISTS public.organization_roles; + `); + }, +}; diff --git a/src/models/organizationRoles.ts b/src/models/organizationRoles.ts new file mode 100644 index 00000000..853c1f8a --- /dev/null +++ b/src/models/organizationRoles.ts @@ -0,0 +1,50 @@ +/* + * 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 OrganizationRoleAttributes { + name: string; + description?: string | null; + /** `owner`, `admin` and `member`, which the organization routes depend on. */ + builtIn?: boolean; + createdAt?: Date; + updatedAt?: Date; +} + +type OrganizationRoleCreationAttributes = Optional< + OrganizationRoleAttributes, + 'description' | 'builtIn' | 'createdAt' | 'updatedAt' +>; + +export class OrganizationRole + extends Model + implements OrganizationRoleAttributes +{ + declare name: string; + declare description: string | null; + declare builtIn: boolean; + declare readonly createdAt: Date; + declare readonly updatedAt: Date; +} + +const initializeOrganizationRoleModel = (sequelize: Sequelize) => { + OrganizationRole.init( + { + name: { type: DataTypes.STRING(80), primaryKey: true }, + description: { type: DataTypes.TEXT, allowNull: true }, + builtIn: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + }, + { + sequelize, + tableName: 'organization_roles', + modelName: 'OrganizationRole', + }, + ); + return OrganizationRole; +}; + +export default initializeOrganizationRoleModel; diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts index bb971cc5..d955271e 100644 --- a/src/routes/admin.routes.ts +++ b/src/routes/admin.routes.ts @@ -38,11 +38,14 @@ import { updateOrganization, } from '../controllers/organizations.js'; import { + createOrganizationRole, createPermission, + deleteOrganizationRole, deletePermission, listPermissions, listRoles, setRolePermissions, + updateOrganizationRole, updatePermission, } from '../controllers/permissions.js'; import { getReviewAccountStatus } from '../controllers/reviewAccounts.js'; @@ -103,10 +106,13 @@ import { OrganizationMembersResponseSchema, } from '../schemas/organization.responses.js'; import { + CreateOrganizationRoleRequestSchema, CreatePermissionRequestSchema, + OrganizationRoleParamSchema, PermissionNameParamSchema, RolePermissionsParamSchema, SetRolePermissionsRequestSchema, + UpdateOrganizationRoleRequestSchema, UpdatePermissionRequestSchema, } from '../schemas/permission.requests.js'; import { @@ -460,6 +466,68 @@ adminRouter.put( setRolePermissions, ); +adminRouter.post( + '/organization-roles', + { + auth: 'access', + middleware: [requireAdmin('write'), requireStepUp()], + tags: ['Admin'], + summary: 'Create an organization role', + description: 'Roles are instance-wide: any organization can assign one.', + schemas: { + body: CreateOrganizationRoleRequestSchema, + response: { + 201: RoleEnvelopeResponseSchema, + 400: PermissionConflictErrorSchema, + 403: StepUpRequiredErrorSchema, + 409: PermissionConflictErrorSchema, + }, + }, + }, + createOrganizationRole, +); + +adminRouter.patch( + '/organization-roles/:roleName', + { + auth: 'access', + middleware: [requireAdmin('write'), requireStepUp()], + tags: ['Admin'], + summary: "Update an organization role's description", + schemas: { + params: OrganizationRoleParamSchema, + body: UpdateOrganizationRoleRequestSchema, + response: { + 200: RoleEnvelopeResponseSchema, + 403: StepUpRequiredErrorSchema, + 404: InternalErrorSchema, + }, + }, + }, + updateOrganizationRole, +); + +adminRouter.delete( + '/organization-roles/:roleName', + { + auth: 'access', + middleware: [requireAdmin('write'), requireStepUp()], + tags: ['Admin'], + summary: 'Delete an organization role', + description: 'Answers 409 for a built-in role and for a role any membership still holds.', + schemas: { + params: OrganizationRoleParamSchema, + response: { + 200: MessageSchema, + 403: StepUpRequiredErrorSchema, + 404: InternalErrorSchema, + 409: PermissionConflictErrorSchema, + }, + }, + }, + deleteOrganizationRole, +); + adminRouter.get( '/enrollment', { diff --git a/src/routes/organization.routes.ts b/src/routes/organization.routes.ts index c2a3d00e..e14733b2 100644 --- a/src/routes/organization.routes.ts +++ b/src/routes/organization.routes.ts @@ -35,6 +35,7 @@ import { OrganizationMembersResponseSchema, OrganizationSwitchResponseSchema, } from '../schemas/organization.responses.js'; +import { PermissionConflictErrorSchema } from '../schemas/permission.responses.js'; import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; const organizationRouter = createRouter('/organizations'); @@ -162,6 +163,7 @@ organizationRouter.post( body: AddOrganizationMemberRequestSchema, response: { 201: OrganizationMembershipEnvelopeResponseSchema, + 400: PermissionConflictErrorSchema, 403: StepUpRequiredErrorSchema, 404: InternalErrorSchema, 409: InternalErrorSchema, @@ -174,16 +176,21 @@ organizationRouter.post( organizationRouter.patch( '/:organizationId/members/:userId', { - middleware: [requireDeviceSessionStepUp], + middleware: [requireDeviceSessionStepUp, requireStepUp()], auth: 'access', tags: ['Organizations'], summary: 'Update organization member', + description: + 'Only an owner or an instance `admin:write` can grant or remove `owner`, and any other ' + + 'manager can only grant a role whose permissions they hold. Added roles have to be in ' + + 'the catalogue.', schemas: { params: OrganizationMemberParamSchema, body: UpdateOrganizationMemberRequestSchema, response: { 200: OrganizationMembershipEnvelopeResponseSchema, - 400: InternalErrorSchema, + 400: PermissionConflictErrorSchema, + 403: StepUpRequiredErrorSchema.extend(PermissionConflictErrorSchema.shape), 404: InternalErrorSchema, }, }, @@ -203,6 +210,7 @@ organizationRouter.delete( response: { 200: MessageSchema, 400: InternalErrorSchema, + 403: PermissionConflictErrorSchema, 404: InternalErrorSchema, }, }, diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 7c17f545..0b565e29 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -33,6 +33,9 @@ export const AUTH_EVENT_TYPES = [ 'admin_oidc_resource_created', 'admin_oidc_resource_deleted', 'admin_oidc_resource_updated', + 'admin_organization_role_created', + 'admin_organization_role_deleted', + 'admin_organization_role_updated', 'admin_permission_created', 'admin_permission_deleted', 'admin_permission_updated', @@ -82,6 +85,10 @@ export const AUTH_EVENT_TYPES = [ 'oidc_token_issued', 'oidc_token_refreshed', 'oidc_token_revoked', + 'organization_member_added', + 'organization_member_removed', + 'organization_member_role_grant_refused', + 'organization_member_updated', 'otp_failed', 'otp_success', 'otp_suspicious', diff --git a/src/services/accessTokenService.ts b/src/services/accessTokenService.ts index 9fbd14a9..ccfa037f 100644 --- a/src/services/accessTokenService.ts +++ b/src/services/accessTokenService.ts @@ -7,36 +7,79 @@ import type { Transaction } from 'sequelize'; import { type AuthorizationClaims, signAccessToken } from '../lib/token.js'; +import { OrganizationMembership } from '../models/organizationMemberships.js'; +import { Session } from '../models/sessions.js'; +import { findMembership } from './organizationService.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. + * How much of an access token the organization roles and 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 = { + userId: string; roles: string[] | null | undefined; + organizationId?: string | null; recovery: boolean; + /** The membership, when the caller has just read it. Otherwise it is read here. */ + membership?: OrganizationMembership | null; transaction?: Transaction; }; export type ResolvedAuthorization = { roles: string[]; permissions: string[]; + /** Null when there is no active organization, or the user no longer belongs to it. */ + organizationId: string | null; + organizationRoles: string[]; + organizationPermissions: string[]; }; -/** A recovery session can only enroll a new factor, so it holds no permissions. */ +/** + * What the user holds right now. The membership is read rather than trusted from the session, + * so removal from an organization takes effect here, whatever the session row still says. A + * recovery session can only enroll a new factor, so it holds nothing. + */ export async function resolveAuthorization({ + userId, roles, + organizationId, recovery, + membership, transaction, }: AuthorizationInput): Promise { + const none = { organizationId: null, organizationRoles: [], organizationPermissions: [] }; + + if (recovery) { + return { roles: roles ?? [], permissions: [], ...none }; + } + + const permissions = await permissionsForRoles('instance', roles, transaction); + const held = + membership === undefined && organizationId + ? await findMembership(userId, organizationId, transaction) + : membership; + + if (!organizationId || !held) { + return { roles: roles ?? [], permissions, ...none }; + } + + const organizationRoles = Array.isArray(held.roles) ? held.roles : []; + return { roles: roles ?? [], - permissions: recovery ? [] : await permissionsForRoles('instance', roles, transaction), + permissions, + organizationId, + organizationRoles, + organizationPermissions: await permissionsForRoles( + 'organization', + organizationRoles, + transaction, + ), }; } @@ -44,8 +87,18 @@ export async function resolveAuthorization({ * 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 }; +export function authorizationClaims({ + permissions, + organizationId, + organizationRoles, + organizationPermissions, +}: ResolvedAuthorization): AuthorizationClaims { + const claims = { + permissions, + ...(organizationId + ? { org_roles: organizationRoles, org_permissions: organizationPermissions } + : {}), + }; if (Buffer.byteLength(JSON.stringify(claims)) > PERMISSION_CLAIMS_BUDGET_BYTES) { return { permissions_omitted: true }; @@ -56,22 +109,46 @@ export function authorizationClaims({ permissions }: ResolvedAuthorization): Aut /** * Every first-party access token is minted here, so sign-in, refresh, organization switch and - * session exchange cannot disagree about what a token grants. + * session exchange cannot disagree about what a token grants. Returns the organization the + * token actually carries, for the response: a member who has been removed has none, and the + * session stops naming it. A recovery token drops the claims but keeps the session's + * organization, which applies again once a factor is enrolled. */ export async function mintAccessToken({ session, user, + membership, transaction, }: { session: { id: string; organizationId?: string | null; recovery?: boolean | null }; user: { id: string; roles?: string[] | null }; + membership?: OrganizationMembership | 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, { + const authorization = await resolveAuthorization({ + userId: user.id, + roles: user.roles, + organizationId: session.organizationId, recovery, - authorization: authorizationClaims(authorization), + membership, + transaction, }); + + if (session.organizationId && !authorization.organizationId && !recovery) { + await Session.update({ organizationId: null }, { where: { id: session.id }, transaction }); + } + + const token = await signAccessToken( + session.id, + user.id, + user.roles ?? [], + authorization.organizationId, + { recovery, authorization: authorizationClaims(authorization) }, + ); + + return { + token, + organizationId: recovery ? (session.organizationId ?? null) : authorization.organizationId, + }; } diff --git a/src/services/organizationService.ts b/src/services/organizationService.ts index 0deca51f..c72a3f9a 100644 --- a/src/services/organizationService.ts +++ b/src/services/organizationService.ts @@ -4,12 +4,13 @@ * See LICENSE file in the project root for full license information */ -import { Op, UniqueConstraintError, WhereOptions } from 'sequelize'; +import { Op, Transaction, UniqueConstraintError, WhereOptions } from 'sequelize'; import { hasScopedRole } from '../lib/scopedRoles.js'; import { OrganizationMembership } from '../models/organizationMemberships.js'; import { Organization } from '../models/organizations.js'; import { User } from '../models/users.js'; +import { permissionsForRoles } from './permissionService.js'; import { revokeAllSessionsForUsers } from './sessionRevocation.js'; export type OrganizationRole = 'owner' | 'admin' | 'member'; @@ -213,12 +214,17 @@ export async function createOrganizationForUser({ } } -export async function findMembership(userId: string, organizationId: string) { +export async function findMembership( + userId: string, + organizationId: string, + transaction?: Transaction, +) { return OrganizationMembership.findOne({ where: { organizationId, userId, }, + ...(transaction ? { transaction } : {}), }); } @@ -227,6 +233,41 @@ export function isOrganizationManager(user: User, membership?: OrganizationMembe return Boolean(membership?.roles?.some((role) => role === 'owner' || role === 'admin')); } +/** + * ADR 0004, P7. Whether this manager may move a member from `current` to `next` roles. Owner + * is the only role that can touch owners, and otherwise a manager can only hand out what they + * hold themselves, so managing members is not a way to gain a permission. An instance + * `admin:write` and an owner are exempt: an instance is theirs to run, and an owner's + * permissions depend on whatever the role happens to be mapped to. Returns what stood in the way. + */ +export async function refusedRoleChanges( + user: User, + membership: OrganizationMembership | null | undefined, + current: string[], + next: string[], +) { + if (hasScopedRole(user.roles, 'admin:write')) return []; + + const held = membership?.roles ?? []; + + if (held.includes('owner')) return []; + + const added = next.filter((role) => !current.includes(role)); + const removed = current.filter((role) => !next.includes(role)); + const refused: string[] = [...added, ...removed].filter((role) => role === 'owner'); + const heldPermissions = new Set(await permissionsForRoles('organization', held)); + + for (const role of added.filter((name) => name !== 'owner')) { + const granted = await permissionsForRoles('organization', [role]); + + if (granted.some((permission) => !heldPermissions.has(permission))) { + refused.push(role); + } + } + + return refused; +} + export function hasOrganizationScope( user: User, membership: OrganizationMembership | null | undefined, diff --git a/src/services/permissionService.ts b/src/services/permissionService.ts index 64ed0e3c..34dc02b9 100644 --- a/src/services/permissionService.ts +++ b/src/services/permissionService.ts @@ -8,6 +8,8 @@ import type { RoleDefinition, RoleKind } from '@seamless-auth/types'; import { Op, Transaction, UniqueConstraintError } from 'sequelize'; import { getSystemConfig } from '../config/getSystemConfig.js'; +import { OrganizationMembership } from '../models/organizationMemberships.js'; +import { OrganizationRole } from '../models/organizationRoles.js'; import { Permission } from '../models/permissions.js'; import { RolePermission } from '../models/rolePermissions.js'; @@ -101,15 +103,44 @@ function instanceRole(name: string, permissions: string[]): RoleDefinition { return { name, kind: 'instance', description: null, builtIn: false, permissions }; } +function organizationRole(role: OrganizationRole, permissions: string[]): RoleDefinition { + return { + name: role.name, + kind: 'organization', + description: role.description ?? null, + builtIn: role.builtIn === true, + permissions, + }; +} + export async function listRoles(): Promise { - const [{ available_roles }, instanceMappings] = await Promise.all([ - getSystemConfig(), - mappingsByRole('instance'), - ]); + const [{ available_roles }, instanceMappings, organizationRoles, organizationMappings] = + await Promise.all([ + getSystemConfig(), + mappingsByRole('instance'), + OrganizationRole.findAll({ order: [['name', 'ASC']] }), + mappingsByRole('organization'), + ]); + + return [ + ...(available_roles ?? []).map((name) => instanceRole(name, instanceMappings.get(name) ?? [])), + ...organizationRoles.map((role) => + organizationRole(role, organizationMappings.get(role.name) ?? []), + ), + ]; +} + +async function assertPermissionsExist(permissions: string[]) { + const names = Array.from(new Set(permissions)).sort(); + const known = await Permission.findAll({ where: { name: { [Op.in]: names } } }); + const knownNames = new Set(known.map((permission) => permission.name)); + const unknown = names.filter((permission) => !knownNames.has(permission)); - return (available_roles ?? []).map((name) => - instanceRole(name, instanceMappings.get(name) ?? []), - ); + if (unknown.length > 0) { + throw new PermissionError(400, 'Unknown permissions', { permissions: unknown }); + } + + return names; } async function findRole(kind: RoleKind, name: string) { @@ -140,14 +171,7 @@ export async function setRolePermissions({ 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 next = await assertPermissionsExist(permissions); const previous = await RolePermission.sequelize!.transaction(async (transaction) => { const current = await RolePermission.findAll({ @@ -217,3 +241,94 @@ export async function permissionsForRoles( return Array.from(new Set(mappings.map((mapping) => mapping.permissionName))).sort(); } + +/** Refuses roles outside the catalogue, so a typo is not stored as a grant of nothing. */ +export async function assertOrganizationRolesExist(roles: string[]) { + const known = await OrganizationRole.findAll({ where: { name: { [Op.in]: roles } } }); + const knownNames = new Set(known.map((role) => role.name)); + const unknown = roles.filter((role) => !knownNames.has(role)); + + if (unknown.length > 0) { + throw new PermissionError(400, 'Unknown organization roles', { roles: unknown }); + } +} + +export async function createOrganizationRole({ + name, + description, + permissions = [], + actorUserId, +}: { + name: string; + description?: string; + permissions?: string[]; + actorUserId: string | null; +}) { + const names = await assertPermissionsExist(permissions); + + try { + const role = await OrganizationRole.sequelize!.transaction(async (transaction) => { + const created = await OrganizationRole.create( + { name, description: description ?? null }, + { transaction }, + ); + + await RolePermission.bulkCreate( + names.map((permissionName) => ({ + roleKind: 'organization' as const, + roleName: name, + permissionName, + createdBy: actorUserId, + })), + { transaction }, + ); + + return created; + }); + + return organizationRole(role, names); + } catch (error) { + if (error instanceof UniqueConstraintError) { + throw new PermissionError(409, 'Organization role already exists'); + } + + throw error; + } +} + +export async function updateOrganizationRole(name: string, description: string | null) { + const role = await OrganizationRole.findByPk(name); + + if (!role) { + throw new PermissionError(404, 'Role not found'); + } + + await role.update({ description }); + + return organizationRole(role, await permissionsForRoles('organization', [name])); +} + +/** Refused for a built-in, which the organization routes depend on, and for a role still held. */ +export async function deleteOrganizationRole(name: string) { + const role = await OrganizationRole.findByPk(name); + + if (!role) { + throw new PermissionError(404, 'Role not found'); + } + + if (role.builtIn) { + throw new PermissionError(409, 'A built-in role cannot be deleted'); + } + + const memberships = await OrganizationMembership.findAll({ attributes: ['roles'] }); + const holders = memberships.filter((membership) => membership.roles?.includes(name)).length; + + if (holders > 0) { + throw new PermissionError(409, 'Organization role is still held', { holders }); + } + + await OrganizationRole.sequelize!.transaction(async (transaction) => { + await deleteRoleMappings('organization', [name], transaction); + await role.destroy({ transaction }); + }); +} diff --git a/src/services/sessionExchangeService.ts b/src/services/sessionExchangeService.ts index 998c04d9..a258fedc 100644 --- a/src/services/sessionExchangeService.ts +++ b/src/services/sessionExchangeService.ts @@ -208,9 +208,13 @@ async function consumeAndIssue( ); // Inside, so a signing failure hands the token back rather than spending it. - const token = await mintAccessToken({ session: child, user, transaction }); + const minted = await mintAccessToken({ + session: { id: child.id, organizationId: parent.organizationId, recovery: false }, + user, + transaction, + }); - return { child, token }; + return { child, ...minted }; }); if (!issued) return null; @@ -223,7 +227,7 @@ async function consumeAndIssue( refreshToken, sub: user.id, sessionId: issued.child.id, - organizationId: parent.organizationId, + organizationId: issued.organizationId, roles: user.roles, email: user.email, phone: user.phone, diff --git a/src/services/sessionIssuance.ts b/src/services/sessionIssuance.ts index 0fb75cfd..69d7759b 100644 --- a/src/services/sessionIssuance.ts +++ b/src/services/sessionIssuance.ts @@ -165,7 +165,7 @@ export async function issueSessionAndRespond(params: IssueSessionParams): Promis authTime: now, }); - const token = await mintAccessToken({ + const { token } = await mintAccessToken({ session: { id: session.id, organizationId, recovery }, user, }); diff --git a/tests/integration/admin/adminStepUp.spec.ts b/tests/integration/admin/adminStepUp.spec.ts index 617eb914..7f36f542 100644 --- a/tests/integration/admin/adminStepUp.spec.ts +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -36,6 +36,9 @@ const GUARDED = [ 'POST /admin/resources', 'PATCH /admin/resources/{id}', 'DELETE /admin/resources/{id}', + 'POST /admin/organization-roles', + 'PATCH /admin/organization-roles/{roleName}', + 'DELETE /admin/organization-roles/{roleName}', 'POST /admin/permissions', 'PATCH /admin/permissions/{permissionName}', 'DELETE /admin/permissions/{permissionName}', diff --git a/tests/integration/admin/permissions.spec.ts b/tests/integration/admin/permissions.spec.ts index 9efe270a..e459c019 100644 --- a/tests/integration/admin/permissions.spec.ts +++ b/tests/integration/admin/permissions.spec.ts @@ -5,6 +5,8 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../../../src/app'; import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { OrganizationMembership } from '../../../src/models/organizationMemberships.js'; +import { OrganizationRole } from '../../../src/models/organizationRoles.js'; import { Permission } from '../../../src/models/permissions.js'; import { RolePermission } from '../../../src/models/rolePermissions.js'; import { Session } from '../../../src/models/sessions.js'; @@ -44,6 +46,7 @@ beforeEach(() => { }); (Permission.findAll as any).mockResolvedValue([]); (RolePermission.findAll as any).mockResolvedValue([]); + (OrganizationRole.findAll as any).mockResolvedValue([]); }); describe('permission catalogue', () => { @@ -197,6 +200,7 @@ describe('role mappings', () => { { name: 'reports:read' }, ]); (RolePermission.findAll as any) + .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([{ permissionName: 'invoices:read' }, { permissionName: 'old:read' }]); @@ -258,6 +262,184 @@ describe('role mappings', () => { }); }); +describe('organization role catalogue', () => { + const role = (overrides: Partial = {}) => ({ + name: 'finance', + description: null, + builtIn: false, + update: vi.fn().mockImplementation(function update(this: any, values: any) { + Object.assign(this, values); + return Promise.resolve(this); + }), + destroy: vi.fn(), + ...overrides, + }); + + it('lists organization roles beside instance roles, which never share a grant', async () => { + (OrganizationRole.findAll as any).mockResolvedValue([ + role({ name: 'admin', builtIn: true }), + role(), + ]); + (RolePermission.findAll as any).mockImplementation(async ({ where }: any) => + where.roleKind === 'organization' + ? [{ roleName: 'admin', permissionName: 'projects:write' }] + : [], + ); + + const res = await request(app).get('/admin/roles'); + + expect(res.body.roles).toContainEqual({ + name: 'admin', + kind: 'organization', + description: null, + builtIn: true, + permissions: ['projects:write'], + }); + expect(res.body.roles).toContainEqual( + expect.objectContaining({ name: 'admin:write', kind: 'instance', permissions: [] }), + ); + }); + + it('creates a role with its permissions and audits it', async () => { + (Permission.findAll as any).mockResolvedValue([{ name: 'invoices:read' }]); + (OrganizationRole.create as any).mockResolvedValue(role()); + + const res = await request(app) + .post('/admin/organization-roles') + .send({ name: 'finance', permissions: ['invoices:read'] }); + + expect(res.status).toBe(201); + expect(res.body.role).toMatchObject({ + name: 'finance', + kind: 'organization', + permissions: ['invoices:read'], + }); + expect(RolePermission.bulkCreate).toHaveBeenCalledWith( + [expect.objectContaining({ roleKind: 'organization', roleName: 'finance' })], + expect.anything(), + ); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'admin_organization_role_created' }), + ); + }); + + it('holds a new role to the role name pattern', async () => { + const res = await request(app).post('/admin/organization-roles').send({ name: 'Team Lead' }); + + expect(res.status).toBe(400); + expect(OrganizationRole.create).not.toHaveBeenCalled(); + }); + + it('refuses an unknown permission on a new role', async () => { + const res = await request(app) + .post('/admin/organization-roles') + .send({ name: 'finance', permissions: ['ghost:read'] }); + + expect(res.status).toBe(400); + expect(res.body.details.permissions).toEqual(['ghost:read']); + expect(OrganizationRole.create).not.toHaveBeenCalled(); + }); + + it('answers 409 for a name already taken', async () => { + (OrganizationRole.create as any).mockRejectedValue(new UniqueConstraintError({})); + + const res = await request(app).post('/admin/organization-roles').send({ name: 'finance' }); + + expect(res.status).toBe(409); + }); + + it('updates a description', async () => { + (OrganizationRole.findByPk as any).mockResolvedValue(role()); + + const res = await request(app) + .patch('/admin/organization-roles/finance') + .send({ description: 'Finance team' }); + + expect(res.status).toBe(200); + expect(res.body.role.description).toBe('Finance team'); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'admin_organization_role_updated' }), + ); + }); + + it('answers 404 for a role that does not exist', async () => { + (OrganizationRole.findByPk as any).mockResolvedValue(null); + + const patched = await request(app) + .patch('/admin/organization-roles/ghost') + .send({ description: null }); + const deleted = await request(app).delete('/admin/organization-roles/ghost'); + + expect(patched.status).toBe(404); + expect(deleted.status).toBe(404); + }); + + it('addresses a legacy role whose name a new one could not use', async () => { + const legacy = role({ name: 'Team Lead' }); + (OrganizationRole.findByPk as any).mockResolvedValue(legacy); + (OrganizationMembership.findAll as any).mockResolvedValue([]); + + const res = await request(app).delete('/admin/organization-roles/Team%20Lead'); + + expect(res.status).toBe(200); + expect(legacy.destroy).toHaveBeenCalled(); + }); + + it('deletes an unheld role along with its mappings, and audits it', async () => { + const unheld = role(); + (OrganizationRole.findByPk as any).mockResolvedValue(unheld); + (OrganizationMembership.findAll as any).mockResolvedValue([{ roles: ['member'] }]); + (RolePermission.findAll as any).mockResolvedValue([{ roleName: 'finance' }]); + + const res = await request(app).delete('/admin/organization-roles/finance'); + + expect(res.status).toBe(200); + expect(RolePermission.destroy).toHaveBeenCalled(); + expect(unheld.destroy).toHaveBeenCalled(); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ type: 'admin_organization_role_deleted' }), + ); + }); + + it('refuses to delete a built-in role', async () => { + const builtIn = role({ name: 'owner', builtIn: true }); + (OrganizationRole.findByPk as any).mockResolvedValue(builtIn); + + const res = await request(app).delete('/admin/organization-roles/owner'); + + expect(res.status).toBe(409); + expect(builtIn.destroy).not.toHaveBeenCalled(); + }); + + it('refuses to delete a role still held', async () => { + const held = role(); + (OrganizationRole.findByPk as any).mockResolvedValue(held); + (OrganizationMembership.findAll as any).mockResolvedValue([{ roles: ['finance'] }]); + + const res = await request(app).delete('/admin/organization-roles/finance'); + + expect(res.status).toBe(409); + expect(res.body.details.holders).toBe(1); + expect(held.destroy).not.toHaveBeenCalled(); + }); + + it('maps permissions onto an organization role', async () => { + (OrganizationRole.findAll as any).mockResolvedValue([role()]); + (Permission.findAll as any).mockResolvedValue([{ name: 'invoices:read' }]); + + const res = await request(app) + .put('/admin/roles/organization/finance/permissions') + .send({ permissions: ['invoices:read'] }); + + expect(res.status).toBe(200); + expect(res.body.role.kind).toBe('organization'); + expect(RolePermission.bulkCreate).toHaveBeenCalledWith( + [expect.objectContaining({ roleKind: 'organization', permissionName: 'invoices:read' })], + expect.anything(), + ); + }); +}); + describe('GET /users/me/permissions', () => { it('answers with what the caller holds now', async () => { (RolePermission.findAll as any).mockResolvedValue([{ permissionName: 'invoices:read' }]); diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index a7157441..781eabfc 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -5,6 +5,7 @@ import { Application } from 'express'; import { Credential } from '../../../src/models/credentials'; import { createApp } from '../../../src/app'; import { EPHEMERAL_TOKEN_TTL_SECONDS } from '../../../src/lib/ephemeralTokenTtl'; +import { OrganizationMembership } from '../../../src/models/organizationMemberships'; import { Session } from '../../../src/models/sessions'; import { User } from '../../../src/models/users'; import { buildUser } from '../../factories/userFactory'; @@ -683,7 +684,7 @@ describe('POST /refresh', () => { 'new-session', expect.any(String), expect.any(Array), - undefined, + null, { recovery: false, authorization: { permissions: [] } }, ); expect(Session.create).toHaveBeenCalledWith( @@ -696,6 +697,43 @@ describe('POST /refresh', () => { expect(res.body.refreshToken).toBe('refresh'); }); + it('stops issuing organization claims once the member has been removed', async () => { + const session = { + id: 'session-1', + replacedBySessionId: null, + revokedAt: null, + userId: 'user-1', + infraId: 'app', + mode: 'server', + userAgent: 'agent', + organizationId: '2c0d53c2-a541-452b-b71b-54c7f15e5877', + save: vi.fn(), + }; + + (findRefreshSessionByToken as any).mockResolvedValue(session); + (User.findOne as any).mockResolvedValue(buildUser()); + (Session.create as any).mockResolvedValue({ id: 'new-session' }); + (OrganizationMembership.findOne as any).mockResolvedValue(null); + (signAccessToken as any).mockResolvedValue('access'); + (generateRefreshToken as any).mockReturnValue('refresh'); + (createRefreshTokenLookup as any).mockReturnValue('refresh-lookup'); + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + refresh_token_ttl: '1h', + session_idle_ttl: '8h', + }); + + const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token'); + + expect(res.status).toBe(200); + expect(res.body.organizationId).toBeNull(); + expect((signAccessToken as any).mock.calls.at(-1)[3]).toBeNull(); + expect(Session.update).toHaveBeenCalledWith( + { organizationId: null }, + expect.objectContaining({ where: { id: 'new-session' } }), + ); + }); + describe('of an exchanged session', () => { const child = () => ({ id: 'child-1', diff --git a/tests/integration/organizations/organizationRoleSecurity.spec.ts b/tests/integration/organizations/organizationRoleSecurity.spec.ts new file mode 100644 index 00000000..c0ec26d8 --- /dev/null +++ b/tests/integration/organizations/organizationRoleSecurity.spec.ts @@ -0,0 +1,250 @@ +import { Application } from 'express'; +import { Op } 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 { OrganizationMembership } from '../../../src/models/organizationMemberships.js'; +import { OrganizationRole } from '../../../src/models/organizationRoles.js'; +import { Organization } from '../../../src/models/organizations.js'; +import { RolePermission } from '../../../src/models/rolePermissions.js'; +import { Session } from '../../../src/models/sessions.js'; +import { User } from '../../../src/models/users.js'; +import { AuthEventService } from '../../../src/services/authEventService.js'; +import { + buildOrganization, + buildOrganizationMembership, + testOrganizationId, +} from '../../factories/organizationFactory'; +import { buildFreshStepUpSession, buildSession } from '../../factories/sessionFactory'; +import { buildUser } from '../../factories/userFactory'; + +const memberId = 'a1863941-552c-428a-aecd-599814979e8d'; + +let app: Application; + +beforeAll(async () => { + app = await createApp(); +}); + +function mappings(byRole: Record) { + (RolePermission.findAll as any).mockImplementation(async ({ where }: any) => + where.roleName[Op.in].flatMap((role: string) => + (byRole[role] ?? []).map((permissionName) => ({ permissionName })), + ), + ); +} + +function mockMemberships(caller: string[], target: string[] = ['member']) { + (Organization.findByPk as any).mockResolvedValue(buildOrganization()); + (OrganizationMembership.findOne as any).mockImplementation(async ({ where }: any) => + where.userId === 'user-1' + ? buildOrganizationMembership({ roles: caller }) + : buildOrganizationMembership({ userId: memberId, roles: target }), + ); + (OrganizationMembership.findAll as any).mockResolvedValue([ + buildOrganizationMembership({ roles: ['owner'] }), + buildOrganizationMembership({ userId: memberId, roles: target }), + ]); + (User.findByPk as any).mockResolvedValue(buildUser({ id: memberId })); +} + +beforeEach(() => { + vi.clearAllMocks(); + (Session.findOne as any).mockResolvedValue(buildFreshStepUpSession()); + (getSystemConfig as any).mockResolvedValue({ access_token_ttl: '15m' }); + (RolePermission.findAll as any).mockResolvedValue([]); + (OrganizationRole.findAll as any).mockImplementation(async ({ where }: any) => + where.name[Op.in].map((name: string) => ({ name })), + ); +}); + +describe('who can grant organization roles', () => { + it('refuses an organization admin granting owner to someone else', async () => { + mockMemberships(['admin']); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['owner'] }); + + expect(res.status).toBe(403); + expect(res.body.details.roles).toEqual(['owner']); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'organization_member_role_grant_refused', + userId: memberId, + }), + ); + }); + + it('refuses an organization admin taking owner away', async () => { + mockMemberships(['admin'], ['owner', 'admin']); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['admin'] }); + + expect(res.status).toBe(403); + }); + + it('refuses an organization admin removing an owner', async () => { + const target = buildOrganizationMembership({ userId: memberId, roles: ['owner'] }); + mockMemberships(['admin'], ['owner']); + (OrganizationMembership.findOne as any).mockImplementation(async ({ where }: any) => + where.userId === 'user-1' ? buildOrganizationMembership({ roles: ['admin'] }) : target, + ); + + const res = await request(app).delete( + `/organizations/${testOrganizationId}/members/${memberId}`, + ); + + expect(res.status).toBe(403); + expect(target.destroy).not.toHaveBeenCalled(); + }); + + it('lets an owner grant owner', async () => { + mockMemberships(['owner']); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['owner'] }); + + expect(res.status).toBe(200); + }); + + it('refuses a role carrying a permission the manager does not hold', async () => { + mockMemberships(['admin']); + mappings({ admin: ['projects:write'], finance: ['payments:approve'] }); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['finance'] }); + + expect(res.status).toBe(403); + expect(res.body.details.roles).toEqual(['finance']); + }); + + it('allows a role whose permissions the manager holds', async () => { + mockMemberships(['admin']); + mappings({ admin: ['projects:write', 'projects:read'], viewer: ['projects:read'] }); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['viewer'] }); + + expect(res.status).toBe(200); + }); + + it('lets a manager take away a role they could not grant', async () => { + mockMemberships(['admin'], ['finance', 'member']); + mappings({ admin: [], finance: ['payments:approve'] }); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['member'] }); + + expect(res.status).toBe(200); + }); + + it('exempts an owner from holding the permissions, which depend on the mapping', async () => { + mockMemberships(['owner']); + mappings({ finance: ['payments:approve'] }); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['finance'] }); + + expect(res.status).toBe(200); + }); + + it('needs a fresh step-up', async () => { + mockMemberships(['owner']); + (Session.findOne as any).mockResolvedValue(buildSession()); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['member'] }); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('step_up_required'); + }); + + it('refuses a role outside the catalogue, and names it', async () => { + mockMemberships(['owner']); + (OrganizationRole.findAll as any).mockResolvedValue([]); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['ghost'] }); + + expect(res.status).toBe(400); + expect(res.body.details.roles).toEqual(['ghost']); + }); + + it('lets a member keep a legacy role the catalogue no longer lists', async () => { + mockMemberships(['owner'], ['Team Lead']); + (OrganizationRole.findAll as any).mockResolvedValue([{ name: 'member' }]); + + const res = await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['Team Lead', 'member'] }); + + expect(res.status).toBe(200); + }); +}); + +describe('organization membership audit', () => { + it('records what a role change added and removed', async () => { + mockMemberships(['owner'], ['member']); + + await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ roles: ['admin'] }); + + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'organization_member_updated', + actorUserId: 'user-1', + userId: memberId, + metadata: { + organizationId: testOrganizationId, + rolesAdded: ['admin'], + rolesRemoved: ['member'], + }, + }), + ); + }); + + it('records a scope change', async () => { + mockMemberships(['owner'], ['member']); + + await request(app) + .patch(`/organizations/${testOrganizationId}/members/${memberId}`) + .send({ scopes: ['members:read'] }); + + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'organization_member_updated', + metadata: expect.objectContaining({ scopes: ['members:read'] }), + }), + ); + }); + + it('records a removal', async () => { + mockMemberships(['owner'], ['member']); + + const res = await request(app).delete( + `/organizations/${testOrganizationId}/members/${memberId}`, + ); + + expect(res.status).toBe(200); + expect(AuthEventService.log).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'organization_member_removed', + userId: memberId, + metadata: { organizationId: testOrganizationId, roles: ['member'] }, + }), + ); + }); +}); diff --git a/tests/integration/organizations/organizations.spec.ts b/tests/integration/organizations/organizations.spec.ts index ab21fe98..b0154d9c 100644 --- a/tests/integration/organizations/organizations.spec.ts +++ b/tests/integration/organizations/organizations.spec.ts @@ -159,7 +159,10 @@ describe('organizations', () => { 'user-1', ['user'], testOrganizationId, - { recovery: false, authorization: { permissions: [] } }, + { + recovery: false, + authorization: { permissions: [], org_roles: ['owner', 'admin'], org_permissions: [] }, + }, ); }); }); diff --git a/tests/postgres/permissions.spec.ts b/tests/postgres/permissions.spec.ts index b17422b5..4c9a164a 100644 --- a/tests/postgres/permissions.spec.ts +++ b/tests/postgres/permissions.spec.ts @@ -1,8 +1,12 @@ import { randomUUID } from 'crypto'; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { decodeJwt } from 'jose'; +import { createRequire } from 'module'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { invalidateSystemConfigCache } from '../../src/config/getSystemConfig.js'; import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { OrganizationRole } from '../../src/models/organizationRoles.js'; +import { mintAccessToken } from '../../src/services/accessTokenService.js'; import { createPermission, deletePermission, @@ -13,6 +17,14 @@ import { } from '../../src/services/permissionService.js'; import { buildSystemConfig } from '../factories/systemConfigFactory.js'; +vi.hoisted(() => { + process.env.ISSUER = 'http://localhost:5312'; +}); + +const migration = createRequire(import.meta.url)( + '../../src/migrations/20261105130000-add-organization-roles.cjs', +); + async function setAvailableRoles(roles: string[]) { for (const [key, value] of Object.entries(buildSystemConfig({ available_roles: roles }))) { await getSequelize().query( @@ -44,6 +56,107 @@ afterAll(async () => { await getSequelize().close(); }); +describe('the organization role migration', () => { + it('seeds the built-ins and every held value, spelled as it is, and rewrites nothing', async () => { + const { Organization, OrganizationMembership, User } = await initializeModels(); + const userId = randomUUID(); + const legacy = `Team Lead ${randomUUID().slice(0, 8)}`; + const tooLong = 'x'.repeat(90); + + await User.create({ id: userId, email: `${userId}@example.com`, phone: null }); + const organization = await Organization.create({ name: unique('Acme'), slug: unique('acme') }); + const membership = await OrganizationMembership.create({ + organizationId: organization.id, + userId, + roles: [legacy, tooLong], + }); + + await migration.up(getSequelize().getQueryInterface()); + + const roles = await OrganizationRole.findAll(); + const names = roles.map((role) => role.name); + + expect(roles.filter((role) => role.builtIn).map((role) => role.name)).toEqual( + expect.arrayContaining(['owner', 'admin', 'member']), + ); + expect(names).toContain(legacy); + expect(names).not.toContain(tooLong); + expect(roles.find((role) => role.name === legacy)?.builtIn).toBe(false); + + await membership.reload(); + expect(membership.roles).toEqual([legacy, tooLong]); + }); +}); + +describe('a token minted on the deployed schema', () => { + it('carries the active organization, then loses it when the member is removed', async () => { + const { Organization, OrganizationMembership, Session, User } = await initializeModels(); + const read = unique('reports:read'); + const write = unique('projects:write'); + await createPermission(read); + await createPermission(write); + await setRolePermissions({ + kind: 'instance', + name: finance, + permissions: [read], + actorUserId: null, + }); + await setRolePermissions({ + kind: 'organization', + name: 'admin', + permissions: [write], + actorUserId: null, + }); + + const userId = randomUUID(); + const user = await User.create({ + id: userId, + email: `${userId}@example.com`, + phone: null, + roles: [finance], + }); + const organization = await Organization.create({ name: unique('Acme'), slug: unique('acme') }); + const membership = await OrganizationMembership.create({ + organizationId: organization.id, + userId, + roles: ['admin'], + }); + const now = Date.now(); + const session = await Session.create({ + userId, + organizationId: organization.id, + mode: 'server', + refreshTokenLookup: randomUUID(), + expiresAt: new Date(now + 3_600_000), + idleExpiresAt: new Date(now + 3_600_000), + }); + + const before = decodeJwt((await mintAccessToken({ session, user })).token); + + expect(before).toMatchObject({ + roles: [finance], + permissions: [read], + org_id: organization.id, + org_roles: ['admin'], + org_permissions: [write], + }); + + await membership.destroy(); + + const after = await mintAccessToken({ session, user }); + const claims = decodeJwt(after.token); + + expect(after.organizationId).toBeNull(); + expect(claims.permissions).toEqual([read]); + expect(claims).not.toHaveProperty('org_id'); + expect(claims).not.toHaveProperty('org_roles'); + expect(claims).not.toHaveProperty('org_permissions'); + + await session.reload(); + expect(session.organizationId).toBeNull(); + }); +}); + 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'); diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts index f8fefb83..91f27d31 100644 --- a/tests/postgres/sessionExchange.spec.ts +++ b/tests/postgres/sessionExchange.spec.ts @@ -2062,12 +2062,14 @@ describe('organization routes on a device child', () => { }); it('does not change a loopback child', async () => { - const { token, organization, member } = await persona(AUTHORIZATION_CODE); + const { user, token, organization, member } = await persona(AUTHORIZATION_CODE); expect( (await request(app).get(`/organizations/${organization.id}/members`).set(bearer(token))) .status, ).toBe(200); + // Changing a member's roles needs step-up on every session (ADR 0004, P7). + await stepUpWithRealTotp(user, token); expect( ( await request(app) @@ -2094,6 +2096,8 @@ describe('organization routes on a device child', () => { (await request(app).get(`/organizations/${organization.id}/members`).set(bearer(token))) .status, ).toBe(200); + // Changing a member's roles needs step-up on every session (ADR 0004, P7). + await stepUpWithRealTotp(user, token); expect( ( await request(app) diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index c4b21fad..f19ca2b8 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -172,6 +172,19 @@ vi.mock('../../src/models/organizationMemberships.js', () => ({ }, })); +vi.mock('../../src/models/organizationRoles.js', () => ({ + OrganizationRole: { + sequelize: { + transaction: vi.fn((fn: any) => fn({ LOCK: { UPDATE: 'UPDATE' } })), + }, + create: vi.fn(), + findAll: vi.fn(async ({ where }: any = {}) => + (where?.name?.[Symbol.for('in')] ?? []).map((name: string) => ({ name })), + ), + findByPk: vi.fn(), + }, +})); + vi.mock('../../src/models/permissions.js', () => ({ Permission: { create: vi.fn(), diff --git a/tests/unit/controllers/authentication.spec.ts b/tests/unit/controllers/authentication.spec.ts index 518c667d..fde633f7 100644 --- a/tests/unit/controllers/authentication.spec.ts +++ b/tests/unit/controllers/authentication.spec.ts @@ -148,7 +148,7 @@ describe('refreshSession', () => { refreshTokenLookup: 'new-refresh-lookup', }), ); - expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles, undefined, { + expect(signAccessToken).toHaveBeenCalledWith('session-2', user.id, user.roles, null, { recovery: false, authorization: { permissions: [] }, }); @@ -159,7 +159,7 @@ describe('refreshSession', () => { refreshToken: 'new-raw-refresh-token', sub: user.id, roles: user.roles, - organizationId: undefined, + organizationId: null, sessionId: 'session-2', email: user.email, phone: user.phone, diff --git a/tests/unit/models/models.spec.ts b/tests/unit/models/models.spec.ts index 3c399ebc..409e8fc8 100644 --- a/tests/unit/models/models.spec.ts +++ b/tests/unit/models/models.spec.ts @@ -13,6 +13,7 @@ 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/organizationRoles.js'); vi.unmock('../../../src/models/permissions.js'); vi.unmock('../../../src/models/rolePermissions.js'); vi.unmock('../../../src/models/webauthnChallenges.js'); diff --git a/tests/unit/services/accessTokenService.spec.ts b/tests/unit/services/accessTokenService.spec.ts index 8ab9f443..df976021 100644 --- a/tests/unit/services/accessTokenService.spec.ts +++ b/tests/unit/services/accessTokenService.spec.ts @@ -14,7 +14,9 @@ vi.mock('../../../src/utils/signingKeyStore.js', () => ({ import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { signAccessToken } from '../../../src/lib/token.js'; +import { OrganizationMembership } from '../../../src/models/organizationMemberships.js'; import { RolePermission } from '../../../src/models/rolePermissions.js'; +import { Session } from '../../../src/models/sessions.js'; import { authorizationClaims, mintAccessToken, @@ -37,6 +39,7 @@ beforeEach(() => { (getSigningKey as any).mockResolvedValue({ kid: 'k1', privateKeyPem }); (getSystemConfig as any).mockResolvedValue({ access_token_ttl: '15m' }); mappings(); + (OrganizationMembership.findOne as any).mockResolvedValue(undefined); }); const user = { id: '7b6f7a0e-52a4-4f1b-9d32-4c1a0f1e2d3c', roles: ['user'] }; @@ -56,13 +59,31 @@ function fittingPermissions() { } } +const organizationId = '2c0d53c2-a541-452b-b71b-54c7f15e5877'; +const noOrganization = { organizationId: null, organizationRoles: [], organizationPermissions: [] }; + +function mappingsByKind(instance: string[], organization: string[]) { + (RolePermission.findAll as any).mockImplementation(async ({ where }: any) => + (where.roleKind === 'organization' ? organization : instance).map((permissionName) => ({ + permissionName, + })), + ); +} + +function memberOf(roles: unknown) { + (OrganizationMembership.findOne as any).mockResolvedValue({ roles }); +} + describe('resolveAuthorization', () => { + const base = { userId: user.id, recovery: false }; + 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({ + await expect(resolveAuthorization({ ...base, roles: ['x', 'y'] })).resolves.toEqual({ roles: ['x', 'y'], permissions: ['a:read', 'b:read'], + ...noOrganization, }); expect(RolePermission.findAll).toHaveBeenCalledWith( expect.objectContaining({ @@ -71,72 +92,182 @@ describe('resolveAuthorization', () => { ); }); + it('keeps instance and organization grants apart', async () => { + mappingsByKind(['reports:read'], ['projects:write']); + memberOf(['admin']); + + await expect( + resolveAuthorization({ ...base, roles: ['user'], organizationId }), + ).resolves.toEqual({ + roles: ['user'], + permissions: ['reports:read'], + organizationId, + organizationRoles: ['admin'], + organizationPermissions: ['projects:write'], + }); + }); + + it('holds nothing in an organization the user no longer belongs to', async () => { + mappingsByKind(['reports:read'], ['projects:write']); + (OrganizationMembership.findOne as any).mockResolvedValue(null); + + await expect( + resolveAuthorization({ ...base, roles: ['user'], organizationId }), + ).resolves.toEqual({ roles: ['user'], permissions: ['reports:read'], ...noOrganization }); + }); + + it('uses a membership the caller already read', async () => { + await resolveAuthorization({ + ...base, + roles: [], + organizationId, + membership: { roles: ['admin'] } as any, + }); + + expect(OrganizationMembership.findOne).not.toHaveBeenCalled(); + }); + + it('reads a malformed membership as holding no roles', async () => { + memberOf('admin'); + + const resolved = await resolveAuthorization({ ...base, roles: [], organizationId }); + + expect(resolved.organizationRoles).toEqual([]); + }); + it('grants a recovery session nothing, without reading the mappings', async () => { mappings('a:read'); + memberOf(['admin']); - await expect(resolveAuthorization({ roles: ['x'], recovery: true })).resolves.toEqual({ - roles: ['x'], - permissions: [], - }); + await expect( + resolveAuthorization({ ...base, roles: ['x'], organizationId, recovery: true }), + ).resolves.toEqual({ roles: ['x'], permissions: [], ...noOrganization }); expect(RolePermission.findAll).not.toHaveBeenCalled(); }); it('does not query for a user with no roles', async () => { - await expect(resolveAuthorization({ roles: [], recovery: false })).resolves.toEqual({ + await expect(resolveAuthorization({ ...base, roles: [] })).resolves.toEqual({ roles: [], permissions: [], + ...noOrganization, }); expect(RolePermission.findAll).not.toHaveBeenCalled(); }); }); describe('authorizationClaims', () => { - it('carries a set that fits', () => { - expect(authorizationClaims({ roles: [], permissions: ['a:read'] })).toEqual({ + const resolved = { roles: [], permissions: [], ...noOrganization }; + + it('carries a set that fits, with no organization claims outside an organization', () => { + expect(authorizationClaims({ ...resolved, permissions: ['a:read'] })).toEqual({ permissions: ['a:read'], }); }); + it('carries the organization claims inside one, even when empty', () => { + expect( + authorizationClaims({ ...resolved, organizationId, organizationRoles: ['member'] }), + ).toEqual({ permissions: [], org_roles: ['member'], org_permissions: [] }); + }); + it('carries a set exactly at the budget', () => { const permissions = fittingPermissions(); - expect(authorizationClaims({ roles: [], permissions })).toEqual({ permissions }); + expect(authorizationClaims({ ...resolved, 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({ + expect(authorizationClaims({ ...resolved, permissions })).toEqual({ permissions_omitted: true, }); }); + + it('counts the organization claims against the budget', () => { + expect( + authorizationClaims({ + ...resolved, + organizationId, + organizationRoles: ['member'], + organizationPermissions: fittingPermissions(), + }), + ).toEqual({ permissions_omitted: true }); + }); }); describe('mintAccessToken', () => { it('signs the claims into the token', async () => { - mappings('reports:read'); + mappingsByKind(['reports:read'], ['projects:write']); + memberOf(['admin']); - const claims = decodeJwt(await mintAccessToken({ session, user })); + const minted = await mintAccessToken({ + session: { ...session, organizationId }, + user, + }); + const claims = decodeJwt(minted.token); + expect(minted.organizationId).toBe(organizationId); expect(claims).toMatchObject({ sub: user.id, sid: session.id, roles: ['user'], permissions: ['reports:read'], + org_id: organizationId, + org_roles: ['admin'], + org_permissions: ['projects:write'], }); expect(claims.permissions_omitted).toBeUndefined(); }); - it('marks a recovery token and gives it no permissions', async () => { - mappings('reports:read'); + // The reason the claims are separate: a guard on `admin` must not pass for an org admin. + it('never puts an organization role in roles', async () => { + memberOf(['admin', 'owner']); - const claims = decodeJwt( - await mintAccessToken({ session: { ...session, recovery: true }, user }), + const { token } = await mintAccessToken({ session: { ...session, organizationId }, user }); + + expect(decodeJwt(token).roles).toEqual(['user']); + }); + + it('carries no organization claims without an active organization', async () => { + const claims = decodeJwt((await mintAccessToken({ session, user })).token); + + expect(claims).not.toHaveProperty('org_id'); + expect(claims).not.toHaveProperty('org_roles'); + expect(claims).not.toHaveProperty('org_permissions'); + expect(Session.update).not.toHaveBeenCalled(); + }); + + it('drops the claims and clears the session for a removed member', async () => { + (OrganizationMembership.findOne as any).mockResolvedValue(null); + + const minted = await mintAccessToken({ session: { ...session, organizationId }, user }); + + expect(minted.organizationId).toBeNull(); + expect(decodeJwt(minted.token)).not.toHaveProperty('org_id'); + expect(Session.update).toHaveBeenCalledWith( + { organizationId: null }, + expect.objectContaining({ where: { id: session.id } }), ); + }); + + it('marks a recovery token and gives it no permissions or organization claims', async () => { + mappings('reports:read'); + memberOf(['admin']); + + const minted = await mintAccessToken({ + session: { ...session, organizationId, recovery: true }, + user, + }); + const claims = decodeJwt(minted.token); expect(claims.recovery).toBe(true); expect(claims.permissions).toEqual([]); + expect(claims).not.toHaveProperty('org_id'); + // The organization applies again once a factor is enrolled, so the session keeps it. + expect(minted.organizationId).toBe(organizationId); + expect(Session.update).not.toHaveBeenCalled(); }); /** @@ -145,13 +276,17 @@ describe('mintAccessToken', () => { * 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 authorization = authorizationClaims({ + roles: longRoles, + permissions: fittingPermissions(), + ...noOrganization, + }); + + expect(authorization.permissions_omitted).toBeUndefined(); + + const token = await signAccessToken(session.id, user.id, longRoles, organizationId, { + authorization, }); const cookie = await new SignJWT({ @@ -161,7 +296,7 @@ describe('mintAccessToken', () => { roles: longRoles, email: `${'a'.repeat(64)}@${'b'.repeat(40)}.example.com`, phone: '+15555550100', - organizationId: session.id, + organizationId, }) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() diff --git a/tests/unit/services/sessionIssueService.spec.ts b/tests/unit/services/sessionIssueService.spec.ts index d788ddee..fbaf45e6 100644 --- a/tests/unit/services/sessionIssueService.spec.ts +++ b/tests/unit/services/sessionIssueService.spec.ts @@ -23,6 +23,7 @@ vi.mock('../../../src/config/getSystemConfig.js', () => ({ })); vi.mock('../../../src/services/organizationService.js', () => ({ + findMembership: vi.fn(async () => ({ roles: [] })), getDefaultOrganizationIdForUser: vi.fn(), })); @@ -428,7 +429,7 @@ describe('issueSessionAndRespond', () => { 'org-1', { recovery: false, - authorization: { permissions: [] }, + authorization: { permissions: [], org_roles: [], org_permissions: [] }, }, ); }); From 76437d81f1981e35c91b558eb2a9cc9582e143fa Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Thu, 5 Nov 2026 23:21:02 -0500 Subject: [PATCH 2/4] docs(permissions): document permissions, organization roles and when changes apply --- docs/admin-operations.md | 43 ++++++++++++++++++++++++++++++++++++++++ docs/api-contract.md | 6 +++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 6642081d..9295f275 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -56,6 +56,45 @@ no one able to administer it. Instances whose `available_roles` predates scoped roles and lists only `admin` get `admin` instead, which is equivalent in power, so the grant never silently becomes a no-op. +## Permissions + +Roles say who someone is. Permissions say what they can do, and a role grants a set of them. The +design is in [ADR 0004](./adr/0004-permissions-and-organization-roles.md). + +- **Permissions** are a catalogue, managed at `/admin/permissions`. A name uses the role alphabet, + such as `invoices:read`. They match exactly: `invoices:write` does not satisfy `invoices:read`, + and there are no wildcards. +- **Instance roles** are `available_roles`. Removing one also removes its mappings, so adding the + name back later grants nothing. +- **Organization roles** are a catalogue at `/admin/organization-roles`, shared by every + organization on the instance. `owner`, `admin` and `member` are built in. Assigning a role + outside the catalogue answers `400`. +- **Mappings** are replaced as a whole with `PUT /admin/roles/:kind/:roleName/permissions`, where + `kind` is `instance` or `organization`. `GET /admin/roles` lists both kinds with what they grant. + A role grants through its literal name: holding `billing` does not pick up a mapping on + `billing:read`. + +An instance `admin` and an organization `admin` are different roles and never share a grant. + +Deleting a permission a role still grants answers `409` with those roles. So does deleting a +built-in organization role, or one a membership still holds. + +### When a change applies + +Access tokens carry permissions, so a change reaches a holder the next time a token is issued, +within `access_token_ttl`. `GET /users/me/permissions` reads the database on every call, for a check +that cannot wait. Removing someone from an organization works the same way: their next token drops +the organization claims. + +### Who can change organization roles + +`PATCH /organizations/:organizationId/members/:userId` needs step-up. Only an owner or an instance +`admin:write` can grant or remove `owner`, or remove an owner from the organization. Any other +manager can only grant a role whose permissions they hold, and can take away any role except +`owner`. A refusal answers `403` naming the roles, and is audited as +`organization_member_role_grant_refused`. Changes that succeed are audited as +`organization_member_added`, `organization_member_updated` and `organization_member_removed`. + ## Step-Up On Admin Writes Every admin write that changes who can sign in, or what an account can do, needs a fresh @@ -75,6 +114,10 @@ Guarded routes: `/admin/organizations/:organizationId/members/:userId` - `PUT` and `DELETE /admin/organizations/:organizationId/oauth-providers/:providerId/retirement` - `POST /organizations/:organizationId/members` (the self-service route that requires `admin:write`) +- `PATCH /organizations/:organizationId/members/:userId`, for any organization manager +- `POST /admin/permissions`, `PATCH` and `DELETE /admin/permissions/:permissionName` +- `PUT /admin/roles/:kind/:roleName/permissions` +- `POST /admin/organization-roles`, `PATCH` and `DELETE /admin/organization-roles/:roleName` - `DELETE /users/delete`, a user deleting their own account: it cannot be undone, and an access session alone may be someone else's. An account with no passkey or TOTP cannot step up, so it cannot delete itself; an administrator can (`DELETE /admin/users`). diff --git a/docs/api-contract.md b/docs/api-contract.md index ecbc2718..2f68f4f2 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -30,7 +30,11 @@ All tokens are returned in the JSON body (the API never sets cookies). Present t ### Token shapes - **Access token** — a signed JWT (RS256). Claims include `sub` (user id), `sid` (session id), - `iss`, `typ: "access"`, `roles`, and `org_id` when the session has an active organization. + `iss`, `typ: "access"`, `roles` and `permissions`. In an active organization it also carries + `org_id`, `org_roles` and `org_permissions`, which apply to that organization only. When the + permission claims would not fit, `permissions_omitted: true` replaces `permissions`, `org_roles` + and `org_permissions`, and absent then means "ask" rather than "none": read the live set from + `GET /users/me/permissions`. See [Permissions](./admin-operations.md#permissions). Verify it against the JWKS at `GET /.well-known/jwks.json`. - **Ephemeral token** — a signed JWT scoped to the pre-auth step; treat it as opaque. Its `jti` is the attempt id every audit row written on the token carries, which is how one sign-in's From d29c788b6d129cec8b7fa15a732be288cd7a36c6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 6 Nov 2026 00:28:25 -0500 Subject: [PATCH 3/4] docs(permissions): state that switching organization needs organization:read --- docs/admin-operations.md | 8 ++++++++ docs/adr/0004-permissions-and-organization-roles.md | 1 + 2 files changed, 9 insertions(+) diff --git a/docs/admin-operations.md b/docs/admin-operations.md index 9295f275..c4834781 100644 --- a/docs/admin-operations.md +++ b/docs/admin-operations.md @@ -86,6 +86,14 @@ within `access_token_ttl`. `GET /users/me/permissions` reads the database on eve that cannot wait. Removing someone from an organization works the same way: their next token drops the organization claims. +### Switching to an organization + +Organization claims apply to the active organization only. A session starts in the user's oldest +membership, and `POST /organizations/:organizationId/switch` changes it. Switching needs the +`organization:read` membership scope unless the member is an `owner` or `admin`, and a member added +without it gets `404`. So when adding someone who belongs to more than one organization, pass +`scopes: ["organization:read"]`, or they cannot reach that organization's permissions. + ### Who can change organization roles `PATCH /organizations/:organizationId/members/:userId` needs step-up. Only an owner or an instance diff --git a/docs/adr/0004-permissions-and-organization-roles.md b/docs/adr/0004-permissions-and-organization-roles.md index effdbae5..935c337e 100644 --- a/docs/adr/0004-permissions-and-organization-roles.md +++ b/docs/adr/0004-permissions-and-organization-roles.md @@ -76,6 +76,7 @@ Sign-in, refresh, organization switch and session exchange all call `mintAccessT ## Consequences - **Staleness.** Permissions are as fresh as the access token TTL, the same as `roles`. `GET /users/me/permissions` is live for a check that cannot wait. +- **Switching still needs `organization:read`.** A member without the scope, unless an `owner` or `admin`, cannot switch to the organization, so cannot reach its permissions unless it is their default. Kept as is: what a member may do is decided by the scopes an admin grants, and membership alone does not widen it. - **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. From 2978157ccace9a1eb3bc6b4f2679c749e5d3b199 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 6 Nov 2026 00:28:55 -0500 Subject: [PATCH 4/4] docs(adr): record the organization:read decision without a reason it was not given --- docs/adr/0004-permissions-and-organization-roles.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0004-permissions-and-organization-roles.md b/docs/adr/0004-permissions-and-organization-roles.md index 935c337e..15c7ddca 100644 --- a/docs/adr/0004-permissions-and-organization-roles.md +++ b/docs/adr/0004-permissions-and-organization-roles.md @@ -76,7 +76,7 @@ Sign-in, refresh, organization switch and session exchange all call `mintAccessT ## Consequences - **Staleness.** Permissions are as fresh as the access token TTL, the same as `roles`. `GET /users/me/permissions` is live for a check that cannot wait. -- **Switching still needs `organization:read`.** A member without the scope, unless an `owner` or `admin`, cannot switch to the organization, so cannot reach its permissions unless it is their default. Kept as is: what a member may do is decided by the scopes an admin grants, and membership alone does not widen it. +- **Switching still needs `organization:read`.** A member without the scope, unless an `owner` or `admin`, cannot switch to the organization, so cannot reach its permissions unless it is their default. Reviewed on 2026-11-06 and kept as is. - **Membership `scopes` stay out of the token.** They gate Seamless's own organization routes and are not app permissions. - **Assigning an unlisted organization role is now a 400**, which is a behavior change for callers that invented role names at runtime. - **Provider tokens are not covered.** `/oidc/sign` refuses unlisted claims, the provider's claims map replaces library defaults, `OidcAccountClaimsSchema` strips unknown keys, and its `org_id` is the default organization rather than the session's active one. seamless-auth-oidc-provider#47 takes that up and reuses these claim names.