diff --git a/README.md b/README.md index ab3b5a8..55ac9a7 100644 --- a/README.md +++ b/README.md @@ -264,12 +264,19 @@ roles: - slug: admin name: Admin permissions: [posts:read, posts:write] + - slug: document-editor + name: Document Editor + permissions: [documents:read] + resource_type_slug: document # optional; defaults to organization permissions: - slug: posts:read name: Read Posts - slug: posts:write name: Write Posts + - slug: documents:read + name: Read Documents + resource_type_slug: document # optional; defaults to organization ``` ### Pinning organization and user ids diff --git a/SUPPORTED.md b/SUPPORTED.md index 3e7e9cc..e776527 100644 --- a/SUPPORTED.md +++ b/SUPPORTED.md @@ -2,7 +2,7 @@ # Supported Features -The emulator implements **160 of 212** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.59.0`) (**75.5%**). +The emulator implements **166 of 250** endpoints in the WorkOS OpenAPI spec (`@workos/openapi-spec@0.80.0`) (**66.4%**). Endpoint coverage says whether a route exists, not whether a feature is usable; for example, Directory Sync implements every endpoint the spec defines for it and is @@ -19,16 +19,16 @@ answers "can I actually emulate this?". | Feature | Read | Write | Set up | Notes | | ------------------------ | -------- | -------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Organizations | ✅ 5/5 | ✅ 6/6 | ✅ seed `organizations` | | -| User Management | ✅ 8/8 | ⚠️ 7/9 | ✅ seed `users` | Email-change confirm/send endpoints are not implemented. | +| Organizations | ⚠️ 5/6 | ⚠️ 6/10 | ✅ seed `organizations` | IT contact endpoints are not implemented. | +| User Management | ⚠️ 8/11 | ⚠️ 7/13 | ✅ seed `users` | Email-change confirm/send and waitlist endpoints are not implemented. | | Authentication | ⚠️ 3/4 | ⚠️ 4/5 | ⚠️ API only | All grant types are hand-written rather than generated from the spec. Refresh tokens always rotate, which is stricter than production. | | Organization Memberships | ✅ 3/3 | ✅ 5/5 | ✅ seed `memberships` | Seeded via `memberships` nested under an organization. | | Groups | ✅ 3/3 | ✅ 5/5 | ✅ seed `groups` | Seeded via `groups` nested under an organization. Members reference a seeded membership by email. | | Invitations | ✅ 3/3 | ✅ 4/4 | ✅ seed `invitations` | | -| SSO | ✅ 5/5 | ✅ 3/3 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | +| SSO | ⚠️ 5/8 | ⚠️ 4/11 | ✅ seed `connections` | Seeded connections carry `profiles`, which drive the SSO login flow. | | Directory Sync | ✅ 6/6 | ✅ 1/1 | ❌ none | Read-only. Every spec endpoint is implemented and all `dsync.*` events are wired, but nothing can create a directory: there is no POST route and no seed key. Node callers can insert directly via `getWorkOSStore(emulator.store)`, which does emit the events. `dsync.group.user_added` / `user_removed` are never emitted — there is no group membership mutation surface. | | Multi-Factor Auth | ✅ 2/2 | ✅ 5/5 | ⚠️ API only | TOTP codes are accepted without verifying the shared secret. | -| FGA / Authorization | ⚠️ 15/19 | ⚠️ 13/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. | +| FGA / Authorization | ⚠️ 15/19 | ⚠️ 18/26 | ✅ seed `roles`, `permissions` | Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope. | | Audit Logs | ⚠️ 3/4 | ⚠️ 3/4 | ⚠️ API only | Events are stored and queryable. Export generation is not implemented. | | Vault | ✅ 5/5 | ⚠️ 3/6 | ⚠️ API only | Object CRUD is implemented; data-key encryption endpoints are not. | | Feature Flags | ✅ 4/4 | ✅ 4/4 | ✅ seed `featureFlags` | Every spec endpoint is implemented at its documented verb; the emulator additionally accepts `POST` on enable/disable and `PUT` on target creation as aliases, which production rejects. Flags resolve into the `feature_flags` access-token claim, the per-user and per-organization list endpoints, and `GET /sdk/feature-flags` — the Node SDK runtime client's polling endpoint, which the spec does not define. Production has no create-flag endpoint, so flags come from the `featureFlags` seed key. | @@ -42,7 +42,8 @@ answers "can I actually emulate this?". | Admin Portal | — | ✅ 1/1 | ⚠️ API only | Generates a portal link; the portal itself is not served. | | Widgets | — | ✅ 1/1 | ⚠️ API only | Mints widget tokens and serves the private `/_widgets/ApiKeys/*` routes the org-scope `` widget calls; that surface is outside the public spec, so it is not counted here. Other widgets and `scope="user"` API keys are not implemented. | | Radar | — | ⚠️ 1/4 | ⚠️ API only | Attempt listing only; no risk signals are computed. | -| Agents | ❌ 0/1 | ❌ 0/2 | ❌ none | Not implemented. | +| Agents | ❌ 0/7 | ❌ 0/9 | ❌ none | Not implemented. | +| Platform Teams | ❌ 0/1 | ❌ 0/1 | ❌ none | Not implemented. | ## How this file is generated diff --git a/bun.lock b/bun.lock index 7a5789a..07ace0c 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", - "@workos/openapi-spec": "^0.59.0", + "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", @@ -148,7 +148,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@workos/openapi-spec": ["@workos/openapi-spec@0.59.0", "", {}, "sha512-AGmrXTYdA1OluGb9vDtC21HQVqavVtVv5WpNjyBPo6OviBFfA1whzzkdwcgv0oG0DXnttqr0NsRQa7FxP6X0ug=="], + "@workos/openapi-spec": ["@workos/openapi-spec@0.80.0", "", {}, "sha512-jMAJVVvnWEdT3cq8G8H7U7gWJ3gIC19gRmkxB7DEA7YU2NXbhfGOt2Z4GDtspgljRt/GAp/pPT2+B5oaLIUyjw=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], diff --git a/package.json b/package.json index e7566c2..89a3909 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "@types/bun": "^1.3.14", "@types/node": "~22.19.7", "@types/semver": "^7.7.1", - "@workos/openapi-spec": "^0.59.0", + "@workos/openapi-spec": "^0.80.0", "husky": "^9.1.7", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", diff --git a/scripts/gen-supported-lib.spec.ts b/scripts/gen-supported-lib.spec.ts index 70e98c0..e28c099 100644 --- a/scripts/gen-supported-lib.spec.ts +++ b/scripts/gen-supported-lib.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from 'bun:test'; +import { readFileSync } from 'node:fs'; import { type SupportSpec, type FeatureDef, @@ -8,6 +9,7 @@ import { routeKey, parseSpecOperations, parseEmulatorRoutes, + parseRoleHelperRoutes, parseSeedConfigKeys, deriveSetup, buildMatrix, @@ -62,6 +64,19 @@ const fixtureRouteSource = ` app.delete('/directories/:id', (c) => {}); `; +// Mirrors how registerRoleRoutes registers under its pathPrefix parameter. +const fixtureRoleHelperSource = ` + app.post(pathPrefix, async (c) => {}); + app.get(pathPrefix, (c) => {}); + app.get(\`\${pathPrefix}/:slug\`, (c) => {}); + app.patch(\`\${pathPrefix}/:slug\`, async (c) => {}); + app.delete(\`\${pathPrefix}/:slug\`, (c) => {}); + app.get(\`\${pathPrefix}/:slug/permissions\`, (c) => {}); + app.put(\`\${pathPrefix}/:slug/permissions\`, async (c) => {}); + app.post(\`\${pathPrefix}/:slug/permissions\`, async (c) => {}); +`; +const fixtureRoleHelperRoutes = parseRoleHelperRoutes(fixtureRoleHelperSource); + const fixtureSeedKeys = ['organizations', 'users']; function buildFixtureMatrix() { @@ -163,14 +178,15 @@ describe('parseEmulatorRoutes', () => { ` roleType: 'EnvironmentRole',`, `});`, ].join('\n'); - const routes = parseEmulatorRoutes([source]); + const routes = parseEmulatorRoutes([source], fixtureRoleHelperRoutes); expect(routes.map((r) => `${r.method} ${r.path}`)).toEqual([ 'POST /authorization/roles', 'GET /authorization/roles', 'GET /authorization/roles/:slug', - 'PUT /authorization/roles/:slug', + 'PATCH /authorization/roles/:slug', 'DELETE /authorization/roles/:slug', 'GET /authorization/roles/:slug/permissions', + 'PUT /authorization/roles/:slug/permissions', 'POST /authorization/roles/:slug/permissions', ]); }); @@ -183,12 +199,40 @@ describe('parseEmulatorRoutes', () => { ` roleType: 'OrganizationRole',`, `});`, ].join('\n'); - const routes = parseEmulatorRoutes([source]); - expect(routes).toHaveLength(7); + const routes = parseEmulatorRoutes([source], fixtureRoleHelperRoutes); + expect(routes).toHaveLength(8); expect(routes[0]).toEqual({ method: 'POST', path: '/authorization/organizations/:orgId/roles' }); }); }); +describe('parseRoleHelperRoutes', () => { + it('reads every registration under pathPrefix, bare or templated', () => { + expect(fixtureRoleHelperRoutes).toHaveLength(8); + expect(fixtureRoleHelperRoutes[0]).toEqual({ method: 'POST', suffix: '' }); + expect(fixtureRoleHelperRoutes).toContainEqual({ method: 'PATCH', suffix: '/:slug' }); + expect(fixtureRoleHelperRoutes).toContainEqual({ method: 'PUT', suffix: '/:slug/permissions' }); + }); + + it('ignores registrations that are not under pathPrefix', () => { + const routes = parseRoleHelperRoutes(`app.get('/literal', h); app.get(other, h); app.get(\`\${other}/x\`, h);`); + expect(routes).toHaveLength(0); + }); + + it('expands nothing for registerRoleRoutes when no helper routes are supplied', () => { + expect(parseEmulatorRoutes([`registerRoleRoutes(ctx, { pathPrefix: '/authorization/roles' });`])).toHaveLength(0); + }); + + it('still matches the real helper, so the matrix cannot drift from it', () => { + const real = parseRoleHelperRoutes( + readFileSync(new URL('../src/workos/role-helpers.ts', import.meta.url), 'utf-8'), + ); + expect(real.length).toBeGreaterThan(0); + expect(real).toContainEqual({ method: 'PATCH', suffix: '/:slug' }); + expect(real).toContainEqual({ method: 'PUT', suffix: '/:slug/permissions' }); + expect(real).toContainEqual({ method: 'POST', suffix: '/:slug/permissions' }); + }); +}); + describe('parseSeedConfigKeys', () => { const source = ` export interface ErrorHookSeedConfig { diff --git a/scripts/gen-supported-lib.ts b/scripts/gen-supported-lib.ts index c7df38c..5d704c2 100644 --- a/scripts/gen-supported-lib.ts +++ b/scripts/gen-supported-lib.ts @@ -101,14 +101,15 @@ export interface FeatureDef { export const FEATURES: FeatureDef[] = [ { name: 'Organizations', - tags: ['organizations', 'organization-domains'], + tags: ['organizations', 'organization-domains', 'organizations.it-contacts'], seedKeys: ['organizations'], + notes: 'IT contact endpoints are not implemented.', }, { name: 'User Management', - tags: ['user-management.users', 'user-management.session-tokens'], + tags: ['user-management.users', 'user-management.session-tokens', 'user-management.waitlists'], seedKeys: ['users'], - notes: 'Email-change confirm/send endpoints are not implemented.', + notes: 'Email-change confirm/send and waitlist endpoints are not implemented.', }, { name: 'Authentication', @@ -155,7 +156,7 @@ export const FEATURES: FeatureDef[] = [ tags: ['authorization', 'permissions'], seedKeys: ['roles', 'permissions'], notes: - 'Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented.', + 'Checks and effective-permission listings honor resource-scoped role assignments and ancestor inheritance (`parent_resource_id`); group role assignments are not implemented. Resource types are not modeled: any `resource_type_slug` is accepted on roles and permissions, and permission scopes are not checked against the role scope.', }, { name: 'Audit Logs', @@ -248,7 +249,18 @@ export const FEATURES: FeatureDef[] = [ }, { name: 'Agents', - tags: ['agents'], + tags: [ + 'agents.blueprints', + 'agents.blueprints.tokens', + 'agents.instances', + 'agents.registrations', + 'agents.sessions', + ], + notes: 'Not implemented.', + }, + { + name: 'Platform Teams', + tags: ['platform.teams'], notes: 'Not implemented.', }, ]; @@ -299,33 +311,42 @@ export function parseSpecOperations(spec: SupportSpec): SpecOperation[] { return operations; } +/** A route `registerRoleRoutes` registers beneath each caller's `pathPrefix`. */ +export interface RoleHelperRoute { + method: string; + suffix: string; +} + /** - * Routes that `registerRoleRoutes` in `src/workos/role-helpers.ts` registers - * for each path prefix. Kept in sync with that helper — if it gains or loses - * a route, this list must be updated. + * Read the routes `registerRoleRoutes` (src/workos/role-helpers.ts) registers + * from its own source, so the matrix cannot drift from the helper. A + * registration is either `app.verb(pathPrefix, …)` (empty suffix) or + * `app.verb(\`${pathPrefix}/suffix\`, …)`. */ -const ROLE_HELPER_ROUTES: ReadonlyArray<{ method: string; suffix: string }> = [ - { method: 'POST', suffix: '' }, - { method: 'GET', suffix: '' }, - { method: 'GET', suffix: '/:slug' }, - { method: 'PUT', suffix: '/:slug' }, - { method: 'DELETE', suffix: '/:slug' }, - { method: 'GET', suffix: '/:slug/permissions' }, - { method: 'POST', suffix: '/:slug/permissions' }, -]; +export function parseRoleHelperRoutes(source: string): RoleHelperRoute[] { + const routes: RoleHelperRoute[] = []; + const pattern = /app\.(get|post|put|patch|delete)\((?:pathPrefix|`\$\{pathPrefix\}([^`]*)`)\s*,/g; + for (const match of source.matchAll(pattern)) { + routes.push({ method: match[1].toUpperCase(), suffix: match[2] ?? '' }); + } + return routes; +} /** * Extract route registrations from route source. Handles three patterns: * - `app.method('/literal')` — direct literal paths * - `app.method(`\`${prefix}/suffix\``) — template literals whose variable * is a `const` assigned a literal string earlier in the same file - * - `registerRoleRoutes(ctx, { pathPrefix: … })` — helper that registers a - * known set of routes under the given prefix + * - `registerRoleRoutes(ctx, { pathPrefix: … })` — helper whose routes are + * parsed from its own source (`roleHelperRoutes`) and expanded under the prefix * * Static parsing rather than booting the server keeps codegen free of side * effects (a real boot binds a port and seeds a store). */ -export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { +export function parseEmulatorRoutes( + sources: string[], + roleHelperRoutes: ReadonlyArray = [], +): EmulatorRoute[] { const routes: EmulatorRoute[] = []; const literalPattern = /app\.(get|post|put|patch|delete)\('([^']+)'/g; const templatePattern = /app\.(get|post|put|patch|delete)\(`([^`]+)`/g; @@ -358,7 +379,7 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { if (path) routes.push({ method: match[1].toUpperCase(), path }); } - // 4. registerRoleRoutes helper — expand the known routes from pathPrefix + // 4. registerRoleRoutes helper — expand the helper's own routes under each pathPrefix for (const match of source.matchAll(helperPattern)) { let prefix = match[1].trim(); if (prefix.startsWith("'") && prefix.endsWith("'")) { @@ -367,7 +388,7 @@ export function parseEmulatorRoutes(sources: string[]): EmulatorRoute[] { prefix = vars.get(prefix) ?? ''; } if (!prefix) continue; - for (const r of ROLE_HELPER_ROUTES) { + for (const r of roleHelperRoutes) { routes.push({ method: r.method, path: prefix + r.suffix }); } } diff --git a/scripts/gen-supported.ts b/scripts/gen-supported.ts index 875e700..78f6127 100644 --- a/scripts/gen-supported.ts +++ b/scripts/gen-supported.ts @@ -26,12 +26,14 @@ import { type SupportSpec, parseSpecOperations, parseEmulatorRoutes, + parseRoleHelperRoutes, parseSeedConfigKeys, buildMatrix, generateSupportedMarkdown, } from './gen-supported-lib.js'; const ROUTES_DIR = 'src/workos/routes'; +const ROLE_HELPER_FILE = 'src/workos/role-helpers.ts'; const SERVER_FILE = 'src/core/server.ts'; const INDEX_FILE = 'src/index.ts'; @@ -96,7 +98,15 @@ async function main(): Promise { ext === '.yaml' || ext === '.yml' ? (YAML.parse(raw) as SupportSpec) : (JSON.parse(raw) as SupportSpec); const operations = parseSpecOperations(spec); - const routes = parseEmulatorRoutes(readRouteSources()); + // The role helper registers its routes under a caller-supplied prefix, so its + // source is parsed on its own; an empty result means the parser drifted from + // how the helper registers routes, which must fail loudly rather than quietly + // dropping every role endpoint from the table. + const roleHelperRoutes = parseRoleHelperRoutes(readFileSync(resolve(ROLE_HELPER_FILE), 'utf-8')); + if (roleHelperRoutes.length === 0) { + throw new Error(`No route registrations found in ${ROLE_HELPER_FILE}; update parseRoleHelperRoutes.`); + } + const routes = parseEmulatorRoutes(readRouteSources(), roleHelperRoutes); const seedKeys = parseSeedConfigKeys(readFileSync(resolve(INDEX_FILE), 'utf-8')); // buildMatrix throws on an unmapped spec tag or a stale seed key — that is diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 9a08844..d663d86 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -3,6 +3,7 @@ */ import type { WorkOSSeedConfig } from './index.js'; import { validateJwtTemplateContent } from './jwt-template.js'; +import { isValidResourceTypeSlug } from './constants.js'; import { normalizeEmail, type NormalizedEmail } from './helpers.js'; /** @@ -563,6 +564,13 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: role.type, }); } + if (!isValidResourceTypeSlug(role.resource_type_slug)) { + errors.push({ + path: `roles[${index}].resource_type_slug`, + message: 'resource_type_slug must be a non-empty string if provided', + value: role.resource_type_slug, + }); + } }); } } @@ -591,6 +599,13 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: perm.name, }); } + if (!isValidResourceTypeSlug(perm.resource_type_slug)) { + errors.push({ + path: `permissions[${index}].resource_type_slug`, + message: 'resource_type_slug must be a non-empty string if provided', + value: perm.resource_type_slug, + }); + } }); } } diff --git a/src/workos/constants.ts b/src/workos/constants.ts index d2fda05..b9a87d4 100644 --- a/src/workos/constants.ts +++ b/src/workos/constants.ts @@ -21,6 +21,24 @@ export const STORE_KEY_PREFIXES = { radarIpList: 'radar_ip_list', } as const; +/** + * Resource type a permission or role is scoped to when the caller supplies + * none. Production scopes both to the built-in `organization` resource type by + * default; the emulator does the same so every response carries the + * spec-required `resource_type_slug`. + */ +export const DEFAULT_RESOURCE_TYPE_SLUG = 'organization'; + +/** + * `resource_type_slug` is optional wherever the emulator accepts it (create + * DTOs and seed entries), but a supplied value must be a non-empty string. + * Resource types are not modeled, so the slug is not checked against a + * registry the way production does. + */ +export function isValidResourceTypeSlug(value: unknown): value is string | undefined { + return value === undefined || (typeof value === 'string' && value.length > 0); +} + /** * WorkOS event catalog, generated from the OpenAPI spec. * Regenerate with: npm run gen:events -- path/to/open-api-spec.yaml diff --git a/src/workos/entities.ts b/src/workos/entities.ts index 1ae88da..9a42169 100644 --- a/src/workos/entities.ts +++ b/src/workos/entities.ts @@ -335,6 +335,7 @@ export interface WorkOSRole extends Entity { organization_id: string | null; is_default_role: boolean; priority: number; + resource_type_slug?: string; } export interface WorkOSPermission extends Entity { @@ -342,6 +343,7 @@ export interface WorkOSPermission extends Entity { slug: string; name: string; description: string | null; + resource_type_slug?: string; } export interface WorkOSRolePermission extends Entity { diff --git a/src/workos/generated/events.ts b/src/workos/generated/events.ts index f60bca4..9dfcdab 100644 --- a/src/workos/generated/events.ts +++ b/src/workos/generated/events.ts @@ -8,6 +8,13 @@ export const EVENTS = { actionAuthenticationDenied: 'action.authentication.denied', actionUserRegistrationDenied: 'action.user_registration.denied', + agentBlueprintCreated: 'agent.blueprint.created', + agentBlueprintDeleted: 'agent.blueprint.deleted', + agentBlueprintUpdated: 'agent.blueprint.updated', + agentInstanceCreated: 'agent.instance.created', + agentInstanceDeleted: 'agent.instance.deleted', + agentInstanceSessionCreated: 'agent.instance.session.created', + agentInstanceSessionRevoked: 'agent.instance.session.revoked', agentRegistrationClaimAttemptCreated: 'agent.registration.claim.attempt.created', agentRegistrationClaimCompleted: 'agent.registration.claim.completed', agentRegistrationCreated: 'agent.registration.created', @@ -94,6 +101,10 @@ export const EVENTS = { pipesConnectedAccountDisconnected: 'pipes.connected_account.disconnected', pipesConnectedAccountReauthorizationNeeded: 'pipes.connected_account.reauthorization_needed', radarChallengeCreated: 'radar.challenge_created', + resourceExportCompleted: 'resource_export.completed', + resourceExportCreated: 'resource_export.created', + resourceExportDownloaded: 'resource_export.downloaded', + resourceExportFailed: 'resource_export.failed', roleCreated: 'role.created', roleDeleted: 'role.deleted', roleUpdated: 'role.updated', @@ -123,6 +134,13 @@ export type WorkOSEventName = (typeof EVENTS)[keyof typeof EVENTS]; /** Event names subscribable via webhook endpoints (CreateWebhookEndpointDto). */ export const SUBSCRIBABLE_EVENTS: readonly WorkOSEventName[] = [ + 'agent.blueprint.created', + 'agent.blueprint.deleted', + 'agent.blueprint.updated', + 'agent.instance.created', + 'agent.instance.deleted', + 'agent.instance.session.created', + 'agent.instance.session.revoked', 'agent.registration.claim.attempt.created', 'agent.registration.claim.completed', 'agent.registration.created', @@ -227,6 +245,7 @@ export interface AuthenticationEventData { user_id: string | null; email: string | null; error?: { code: string; message: string }; + provider?: string; sso?: { organization_id: string | null; connection_id: string | null; session_id: string | null }; } @@ -260,6 +279,82 @@ export const EVENT_DATA_REQUIREMENTS: Record { - return formatEntity(role); +export function formatRole(role: WorkOSRole, ws: WorkOSStore): Record { + // Production inlines the role's permission slugs; the emulator keeps them in a + // join table, so resolve them here rather than at every call site. + const permissions = ws.rolePermissions + .findBy('role_id', role.id) + .map((rp) => ws.permissions.get(rp.permission_id)?.slug) + .filter((slug): slug is string => typeof slug === 'string'); + return { + ...formatEntity(role), + permissions, + // Rows persisted before roles carried a scope still format with the default. + resource_type_slug: role.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, + }; } export function formatPermission(p: WorkOSPermission): Record { - return formatEntity(p); + return { + ...formatEntity(p), + // The emulator has no WorkOS-managed system permissions; everything is user-defined. + system: false, + // Rows inserted without a scope (direct store inserts, pre-scope releases) + // still format with the default so the spec-required key is always present. + resource_type_slug: p.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, + }; } export function formatAuthorizationResource(r: WorkOSAuthorizationResource): Record { diff --git a/src/workos/index.ts b/src/workos/index.ts index 1ffa736..9dfe713 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -41,7 +41,7 @@ import { dataIntegrationRoutes } from './routes/data-integrations.js'; import { webhookEndpointRoutes } from './routes/webhook-endpoints.js'; import { eventRoutes } from './routes/events.js'; import { EventBus } from './event-bus.js'; -import { STORE_KEYS, EVENTS } from './constants.js'; +import { STORE_KEYS, EVENTS, DEFAULT_RESOURCE_TYPE_SLUG } from './constants.js'; import { validateSeedConfig, formatValidationErrors } from './config-validator.js'; import { validateJwtTemplateContent } from './jwt-template.js'; import { environmentIdFor, flagEventContext } from './flag-context.js'; @@ -229,12 +229,14 @@ export interface WorkOSSeedRole { is_default_role?: boolean; priority?: number; permissions?: string[]; + resource_type_slug?: string; } export interface WorkOSSeedPermission { slug: string; name: string; description?: string; + resource_type_slug?: string; } export interface WorkOSSeedWebhookEndpoint { @@ -617,6 +619,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee slug: permConfig.slug, name: permConfig.name, description: permConfig.description ?? null, + resource_type_slug: permConfig.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); } } @@ -632,6 +635,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee organization_id: roleConfig.organization_id ?? null, is_default_role: roleConfig.is_default_role ?? false, priority: roleConfig.priority ?? 0, + resource_type_slug: roleConfig.resource_type_slug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); if (roleConfig.permissions) { @@ -992,18 +996,25 @@ export const workosPlugin: ServicePlugin = { onInsert: (r) => eventBus.emit({ event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleCreated : EVENTS.roleCreated, - data: formatRole(r), + data: formatRole(r, ws), }), onUpdate: (r) => eventBus.emit({ event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleUpdated : EVENTS.roleUpdated, - data: formatRole(r), - }), - onDelete: (r) => - eventBus.emit({ - event: r.type === 'OrganizationRole' ? EVENTS.organizationRoleDeleted : EVENTS.roleDeleted, - data: formatRole(r), + data: formatRole(r, ws), }), + onDelete: (r) => { + // The role routes delete the role row before cascading its joins, so the + // permissions are still resolvable here. Production's organization_role.deleted + // carries them; its role.deleted never does. + const data = formatRole(r, ws); + if (r.type === 'OrganizationRole') { + eventBus.emit({ event: EVENTS.organizationRoleDeleted, data }); + } else { + delete data.permissions; + eventBus.emit({ event: EVENTS.roleDeleted, data }); + } + }, }); ws.permissions.setHooks({ onInsert: (p) => eventBus.emit({ event: EVENTS.permissionCreated, data: formatPermission(p) }), diff --git a/src/workos/response-shapes.spec.ts b/src/workos/response-shapes.spec.ts index 776d7aa..f8b1ce9 100644 --- a/src/workos/response-shapes.spec.ts +++ b/src/workos/response-shapes.spec.ts @@ -214,7 +214,7 @@ const CASES: ReadonlyArray<{ objectType: string; output: Record { objectType: 'directory', output: formatDirectory(directory) }, { objectType: 'directory_group', output: formatDirectoryGroup(directoryGroup) }, { objectType: 'directory_user', output: formatDirectoryUser(directoryUser) }, - { objectType: 'role', output: formatRole(role) }, + { objectType: 'role', output: formatRole(role, ws) }, { objectType: 'permission', output: formatPermission(permission) }, { objectType: 'api_key', output: formatApiKeyRecord(apiKey) }, { objectType: 'password_reset', output: formatPasswordReset(passwordReset) }, @@ -229,11 +229,6 @@ const KNOWN_MISSING_REQUIRED: Record = { // Spec models a connection `status` distinct from `state`; the emulator's // WorkOSConnection carries only `state`. connection: ['status'], - // The emulator's Role predates the spec's authorization Role: it has no - // `permissions` array or `resource_type_slug`. - role: ['permissions', 'resource_type_slug'], - // The emulator's Permission lacks the spec's `resource_type_slug` and `system`. - permission: ['resource_type_slug', 'system'], }; /** diff --git a/src/workos/role-helpers.ts b/src/workos/role-helpers.ts index 04ec60d..d9ff06f 100644 --- a/src/workos/role-helpers.ts +++ b/src/workos/role-helpers.ts @@ -1,8 +1,18 @@ import type { Context } from 'hono'; -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../core/index.js'; +import { + type RouteContext, + type Store, + WorkOSApiError, + notFound, + validationError, + parseJsonBody, + parseListParams, +} from '../core/index.js'; import type { WorkOSStore } from './store.js'; import type { WorkOSRole, WorkOSPermission } from './entities.js'; +import type { EventBus } from './event-bus.js'; import { getWorkOSStore } from './store.js'; +import { DEFAULT_RESOURCE_TYPE_SLUG, EVENTS, STORE_KEYS, isValidResourceTypeSlug } from './constants.js'; import { formatRole, formatPermission, formatListResponse } from './helpers.js'; export function findEnvRole(ws: WorkOSStore, slug: string): WorkOSRole | undefined { @@ -49,18 +59,42 @@ export function getRolePermissions(ws: WorkOSStore, roleId: string): WorkOSPermi return rps.map((rp) => ws.permissions.get(rp.permission_id)).filter(Boolean) as WorkOSPermission[]; } -export function replaceRolePermissions(ws: WorkOSStore, roleId: string, permissionSlugs: string[]): WorkOSPermission[] { - // Delete existing - ws.rolePermissions.deleteBy('role_id', roleId); - - // Insert new +/** + * Replace a role's permission set. Every slug is resolved before the join + * table is touched, so an unknown slug answers 404 and leaves the current set + * intact rather than half-applied. Returns whether the set actually changed, + * which is what decides whether a role.updated event is due, as in production. + */ +export function replaceRolePermissions(ws: WorkOSStore, roleId: string, permissionSlugs: string[]): boolean { + const next = new Map(); for (const permSlug of permissionSlugs) { const perm = ws.permissions.findOneBy('slug', permSlug); if (!perm) throw notFound('Permission'); + next.set(perm.id, perm); + } + + const current = new Set(ws.rolePermissions.findBy('role_id', roleId).map((rp) => rp.permission_id)); + const changed = current.size !== next.size || [...next.keys()].some((id) => !current.has(id)); + if (!changed) return false; + + ws.rolePermissions.deleteBy('role_id', roleId); + for (const perm of next.values()) { ws.rolePermissions.insert({ role_id: roleId, permission_id: perm.id }); } + return true; +} - return getRolePermissions(ws, roleId); +/** + * Production emits `role.updated` (or `organization_role.updated`) when a + * role's permission set changes through the permissions endpoints without + * touching the role row, so this goes to the bus directly rather than through + * the collection hooks. + */ +export function emitRolePermissionsUpdated(store: Store, ws: WorkOSStore, role: WorkOSRole): void { + store.getData(STORE_KEYS.eventBus)?.emit({ + event: role.type === 'OrganizationRole' ? EVENTS.organizationRoleUpdated : EVENTS.roleUpdated, + data: formatRole(role, ws), + }); } export interface RoleRouteConfig { @@ -71,6 +105,8 @@ export interface RoleRouteConfig { listFilter: (c: Context) => (r: WorkOSRole) => boolean; insertDefaults: (c: Context) => Partial; duplicateMessage: string; + /** Spec error code for a taken slug: `role_slug_conflict` or `organization_role_slug_conflict`. */ + duplicateCode: string; validateBeforeCreate?: (ws: WorkOSStore, c: Context) => void; } @@ -85,6 +121,7 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): const body = await parseJsonBody(c); const slug = body.slug as string; const name = body.name as string; + const resourceTypeSlug = body.resource_type_slug; if (!slug || typeof slug !== 'string') { throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); @@ -92,10 +129,19 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): if (!name || typeof name !== 'string') { throw validationError('name is required', [{ field: 'name', code: 'required' }]); } + // Resource types are not modeled by the emulator (no registry, no endpoint), + // so any non-empty slug is accepted, and a role's permissions are not checked + // against its scope. Production requires a defined type and matching scopes. + if (!isValidResourceTypeSlug(resourceTypeSlug)) { + throw validationError('resource_type_slug must be a non-empty string', [ + { field: 'resource_type_slug', code: 'invalid' }, + ]); + } const existing = config.findRole(ws, c, slug); if (existing) { - throw validationError(config.duplicateMessage, [{ field: 'slug', code: 'duplicate' }]); + // Production answers a taken slug with 409, not a 422 field error. + throw new WorkOSApiError(409, config.duplicateMessage, config.duplicateCode); } const defaults = config.insertDefaults(c); @@ -108,9 +154,10 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): organization_id: defaults.organization_id ?? null, is_default_role: Boolean(body.is_default_role), priority: typeof body.priority === 'number' ? body.priority : 0, + resource_type_slug: resourceTypeSlug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); - return c.json(formatRole(role), 201); + return c.json(formatRole(role, ws), 201); }); app.get(pathPrefix, (c) => { @@ -122,15 +169,16 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): filter: config.listFilter(c), }); - return c.json(formatListResponse(result, formatRole)); + return c.json(formatListResponse(result, (r) => formatRole(r, ws))); }); app.get(`${pathPrefix}/:slug`, (c) => { const role = config.requireRole(ws, c); - return c.json(formatRole(role)); + return c.json(formatRole(role, ws)); }); - app.put(`${pathPrefix}/:slug`, async (c) => { + // The spec (and every SDK) updates a role with PATCH; there is no PUT. + app.patch(`${pathPrefix}/:slug`, async (c) => { const role = config.requireRole(ws, c); const body = await parseJsonBody(c); @@ -141,20 +189,23 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): if ('priority' in body) updates.priority = body.priority; const updated = ws.roles.update(role.id, updates); - return c.json(formatRole(updated!)); + return c.json(formatRole(updated!, ws)); }); app.delete(`${pathPrefix}/:slug`, (c) => { const role = config.requireRole(ws, c); + // The role row goes first so the deleted event, emitted from the collection + // hook, can still resolve the role's permissions; the joins cascade after. + ws.roles.delete(role.id); ws.rolePermissions.deleteBy('role_id', role.id); ws.roleAssignments.deleteBy('role_id', role.id); - ws.roles.delete(role.id); return c.body(null, 204); }); - // Role permissions management + // Not in the spec — production inlines permission slugs on the role instead. + // Kept as the only way to read the full permission objects for a role. app.get(`${pathPrefix}/:slug/permissions`, (c) => { const role = config.requireRole(ws, c); const permissions = getRolePermissions(ws, role.id); @@ -166,21 +217,40 @@ export function registerRoleRoutes(ctx: RouteContext, config: RoleRouteConfig): }); }); - app.post(`${pathPrefix}/:slug/permissions`, async (c) => { + // Spec: PUT replaces the whole set, POST attaches one; both answer with the role. + app.put(`${pathPrefix}/:slug/permissions`, async (c) => { const role = config.requireRole(ws, c); const body = await parseJsonBody(c); - const permissionSlugs = body.permissions as string[]; - if (!Array.isArray(permissionSlugs)) { + const permissionSlugs = body.permissions; + if (!Array.isArray(permissionSlugs) || permissionSlugs.some((slug) => typeof slug !== 'string')) { throw validationError('permissions must be an array of slugs', [{ field: 'permissions', code: 'invalid' }]); } - const permissions = replaceRolePermissions(ws, role.id, permissionSlugs); + if (replaceRolePermissions(ws, role.id, permissionSlugs as string[])) { + emitRolePermissionsUpdated(store, ws, role); + } + return c.json(formatRole(role, ws)); + }); - return c.json({ - object: 'list', - data: permissions.map((p) => formatPermission(p)), - list_metadata: { before: null, after: null }, - }); + app.post(`${pathPrefix}/:slug/permissions`, async (c) => { + const role = config.requireRole(ws, c); + + const body = await parseJsonBody(c); + const slug = body.slug; + if (!slug || typeof slug !== 'string') { + throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); + } + const permission = ws.permissions.findOneBy('slug', slug); + if (!permission) throw notFound('Permission'); + + // Re-attaching is a no-op rather than a duplicate join row, and emits nothing. + const attached = ws.rolePermissions.findBy('role_id', role.id).some((rp) => rp.permission_id === permission.id); + if (!attached) { + ws.rolePermissions.insert({ role_id: role.id, permission_id: permission.id }); + emitRolePermissionsUpdated(store, ws, role); + } + + return c.json(formatRole(role, ws)); }); } diff --git a/src/workos/routes/authorization-checks.spec.ts b/src/workos/routes/authorization-checks.spec.ts index 6dc57af..8d6b5ae 100644 --- a/src/workos/routes/authorization-checks.spec.ts +++ b/src/workos/routes/authorization-checks.spec.ts @@ -65,7 +65,7 @@ describe('Authorization check + role assignment routes', () => { body: JSON.stringify({ slug: 'editor', name: 'Editor' }), }); await req('/authorization/roles/editor/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['posts:read', 'posts:write'] }), }); @@ -76,7 +76,7 @@ describe('Authorization check + role assignment routes', () => { }); const adminRole = await json(adminRes); await req('/authorization/roles/admin-role/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['admin:manage'] }), }); @@ -511,7 +511,7 @@ describe('Authorization check + role assignment routes', () => { body: JSON.stringify({ slug: 'editor', name: 'Org Editor' }), }); await req(`/authorization/organizations/${org.id}/roles/editor/permissions`, { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['posts:read'] }), }); diff --git a/src/workos/routes/authorization-org-roles.spec.ts b/src/workos/routes/authorization-org-roles.spec.ts index acb80f0..41bcd0a 100644 --- a/src/workos/routes/authorization-org-roles.spec.ts +++ b/src/workos/routes/authorization-org-roles.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; +import { workosPlugin, getWorkOSStore } from '../index.js'; const apiKeys: ApiKeyMap = { sk_test_orgrole: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_orgrole', 'Content-Type': 'application/json' }; @@ -11,9 +11,12 @@ function createTestApp() { describe('Authorization org role routes', () => { let app: ReturnType['app']; + let store: ReturnType['store']; beforeEach(() => { - app = createTestApp().app; + const server = createTestApp(); + app = server.app; + store = server.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -38,6 +41,17 @@ describe('Authorization org role routes', () => { expect(role.type).toBe('OrganizationRole'); expect(role.organization_id).toBe(org.id); expect(role.slug).toBe('org-admin'); + expect(role.resource_type_slug).toBe('organization'); + }); + + it('preserves an org role resource type', async () => { + const org = await createOrg('Scoped Org'); + const res = await req(`/authorization/organizations/${org.id}/roles`, { + method: 'POST', + body: JSON.stringify({ slug: 'doc-editor', name: 'Doc Editor', resource_type_slug: 'document' }), + }); + expect(res.status).toBe(201); + expect((await json(res)).resource_type_slug).toBe('document'); }); it('rejects duplicate slug within same org', async () => { @@ -50,7 +64,8 @@ describe('Authorization org role routes', () => { method: 'POST', body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), }); - expect(res.status).toBe(422); + expect(res.status).toBe(409); + expect((await json(res)).code).toBe('organization_role_slug_conflict'); }); it('allows same slug in different orgs', async () => { @@ -105,7 +120,7 @@ describe('Authorization org role routes', () => { body: JSON.stringify({ slug: 'upd', name: 'Original' }), }); const res = await req(`/authorization/organizations/${org.id}/roles/upd`, { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ name: 'Updated' }), }); expect(res.status).toBe(200); @@ -149,6 +164,29 @@ describe('Authorization org role routes', () => { expect(body.data[1].priority).toBe(1); }); + it('emits organization_role.deleted with the permissions the role held', async () => { + const org = await createOrg('Deleted Org'); + await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'org-gone', name: 'Gone' }), + }); + await req(`/authorization/organizations/${org.id}/roles`, { + method: 'POST', + body: JSON.stringify({ slug: 'org-doomed', name: 'Doomed' }), + }); + await req(`/authorization/organizations/${org.id}/roles/org-doomed/permissions`, { + method: 'PUT', + body: JSON.stringify({ permissions: ['org-gone'] }), + }); + + const res = await req(`/authorization/organizations/${org.id}/roles/org-doomed`, { method: 'DELETE' }); + expect(res.status).toBe(204); + + const deleted = getWorkOSStore(store).events.findBy('event', 'organization_role.deleted'); + expect(deleted).toHaveLength(1); + expect(deleted[0]!.data).toMatchObject({ slug: 'org-doomed', permissions: ['org-gone'] }); + }); + it('manages org role permissions', async () => { const org = await createOrg('Perm Org'); @@ -170,7 +208,7 @@ describe('Authorization org role routes', () => { // Set permissions await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`, { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['org-read', 'org-write'] }), }); @@ -183,7 +221,13 @@ describe('Authorization org role routes', () => { const delRes = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions/org-write`, { method: 'DELETE', }); - expect(delRes.status).toBe(204); + expect(delRes.status).toBe(200); + expect((await json(delRes)).permissions).toEqual(['org-read']); + + // Both the set and the removal changed the permission set, so each emitted + const updated = getWorkOSStore(store).events.findBy('event', 'organization_role.updated'); + expect(updated).toHaveLength(2); + expect(updated[1]!.data).toMatchObject({ slug: 'org-editor', permissions: ['org-read'] }); // Verify removal const afterRes = await req(`/authorization/organizations/${org.id}/roles/org-editor/permissions`); diff --git a/src/workos/routes/authorization-org-roles.ts b/src/workos/routes/authorization-org-roles.ts index f09c2d5..5ae988f 100644 --- a/src/workos/routes/authorization-org-roles.ts +++ b/src/workos/routes/authorization-org-roles.ts @@ -1,7 +1,7 @@ import { type RouteContext, notFound, validationError, parseJsonBody } from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatRole } from '../helpers.js'; -import { findOrgRole, requireOrgRole, registerRoleRoutes } from '../role-helpers.js'; +import { emitRolePermissionsUpdated, findOrgRole, requireOrgRole, registerRoleRoutes } from '../role-helpers.js'; export function authorizationOrgRoleRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -36,7 +36,7 @@ export function authorizationOrgRoleRoutes(ctx: RouteContext): void { return c.json({ object: 'list', - data: updated.map(formatRole), + data: updated.map((r) => formatRole(r, ws)), list_metadata: { before: null, after: null }, }); }); @@ -49,6 +49,7 @@ export function authorizationOrgRoleRoutes(ctx: RouteContext): void { listFilter: (c) => (r) => r.organization_id === c.req.param('orgId')! && r.type === 'OrganizationRole', insertDefaults: (c) => ({ organization_id: c.req.param('orgId')! }), duplicateMessage: 'Role with this slug already exists in this organization', + duplicateCode: 'organization_role_slug_conflict', validateBeforeCreate: (ws, c) => { const org = ws.organizations.get(c.req.param('orgId')!); if (!org) throw notFound('Organization'); @@ -66,6 +67,8 @@ export function authorizationOrgRoleRoutes(ctx: RouteContext): void { if (!rp) throw notFound('RolePermission'); ws.rolePermissions.delete(rp.id); - return c.body(null, 204); + emitRolePermissionsUpdated(store, ws, role); + // The spec answers with the updated role, not an empty 204. + return c.json(formatRole(role, ws)); }); } diff --git a/src/workos/routes/authorization-permissions.spec.ts b/src/workos/routes/authorization-permissions.spec.ts index f8eaff0..e9fcf9d 100644 --- a/src/workos/routes/authorization-permissions.spec.ts +++ b/src/workos/routes/authorization-permissions.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; +import { workosPlugin, seedFromConfig } from '../index.js'; +import { validateSeedConfig } from '../config-validator.js'; const apiKeys: ApiKeyMap = { sk_test_perm: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_perm', 'Content-Type': 'application/json' }; @@ -29,9 +30,50 @@ describe('Authorization permission routes', () => { expect(perm.object).toBe('permission'); expect(perm.slug).toBe('posts:read'); expect(perm.name).toBe('Read Posts'); + expect(perm).toMatchObject({ system: false, resource_type_slug: 'organization' }); expect(perm.id).toMatch(/^perm_/); }); + it('preserves a permission resource type', async () => { + const res = await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'documents:read', name: 'Read Documents', resource_type_slug: 'document' }), + }); + expect(res.status).toBe(201); + expect((await json(res)).resource_type_slug).toBe('document'); + }); + + it('preserves a seeded permission resource type', async () => { + const server = createTestApp(); + seedFromConfig(server.store, 'http://localhost:0', { + permissions: [{ slug: 'seeded:read', name: 'Seeded Read', resource_type_slug: 'document' }], + }); + const res = await server.app.request('/authorization/permissions/seeded:read', { headers }); + expect(res.status).toBe(200); + expect((await json(res)).resource_type_slug).toBe('document'); + }); + + it.each([[42 as unknown as string], ['']])('rejects an invalid seeded resource type %p', (resource_type_slug) => { + const result = validateSeedConfig({ + permissions: [{ slug: 'invalid:seed', name: 'Invalid Seed', resource_type_slug }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === 'permissions[0].resource_type_slug')).toBe(true); + }); + + it('keeps the resource type when a permission is updated', async () => { + await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'documents:write', name: 'Write Documents', resource_type_slug: 'document' }), + }); + const res = await req('/authorization/permissions/documents:write', { + method: 'PATCH', + body: JSON.stringify({ name: 'Edit Documents' }), + }); + expect(res.status).toBe(200); + expect(await json(res)).toMatchObject({ name: 'Edit Documents', resource_type_slug: 'document' }); + }); + it('rejects duplicate slug', async () => { await req('/authorization/permissions', { method: 'POST', @@ -41,7 +83,8 @@ describe('Authorization permission routes', () => { method: 'POST', body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), }); - expect(res.status).toBe(422); + expect(res.status).toBe(409); + expect((await json(res)).code).toBe('permission_slug_conflict'); }); it('rejects missing slug', async () => { @@ -52,6 +95,15 @@ describe('Authorization permission routes', () => { expect(res.status).toBe(422); }); + it.each([[42], ['']])('rejects an invalid resource type %p', async (resource_type_slug) => { + const res = await req('/authorization/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'invalid:scope', name: 'Invalid Scope', resource_type_slug }), + }); + expect(res.status).toBe(422); + expect((await json(res)).errors).toEqual([{ field: 'resource_type_slug', code: 'invalid' }]); + }); + it('lists permissions', async () => { await req('/authorization/permissions', { method: 'POST', @@ -90,7 +142,7 @@ describe('Authorization permission routes', () => { body: JSON.stringify({ slug: 'upd', name: 'Original' }), }); const res = await req('/authorization/permissions/upd', { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ name: 'Updated', description: 'desc' }), }); expect(res.status).toBe(200); @@ -122,7 +174,7 @@ describe('Authorization permission routes', () => { body: JSON.stringify({ slug: 'cascade-role', name: 'Cascade Role' }), }); await req('/authorization/roles/cascade-role/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['cascade-perm'] }), }); diff --git a/src/workos/routes/authorization-permissions.ts b/src/workos/routes/authorization-permissions.ts index 1bfcc97..d2fdced 100644 --- a/src/workos/routes/authorization-permissions.ts +++ b/src/workos/routes/authorization-permissions.ts @@ -1,6 +1,14 @@ -import { type RouteContext, notFound, validationError, parseJsonBody, parseListParams } from '../../core/index.js'; +import { + type RouteContext, + WorkOSApiError, + notFound, + validationError, + parseJsonBody, + parseListParams, +} from '../../core/index.js'; import { getWorkOSStore } from '../store.js'; import { formatPermission, formatListResponse } from '../helpers.js'; +import { DEFAULT_RESOURCE_TYPE_SLUG, isValidResourceTypeSlug } from '../constants.js'; export function authorizationPermissionRoutes(ctx: RouteContext): void { const { app, store } = ctx; @@ -10,6 +18,7 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { const body = await parseJsonBody(c); const slug = body.slug as string; const name = body.name as string; + const resourceTypeSlug = body.resource_type_slug; if (!slug || typeof slug !== 'string') { throw validationError('slug is required', [{ field: 'slug', code: 'required' }]); @@ -17,10 +26,18 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { if (!name || typeof name !== 'string') { throw validationError('name is required', [{ field: 'name', code: 'required' }]); } + // Resource types are not modeled by the emulator (no registry, no endpoint), + // so any non-empty slug is accepted. Production requires a defined type. + if (!isValidResourceTypeSlug(resourceTypeSlug)) { + throw validationError('resource_type_slug must be a non-empty string', [ + { field: 'resource_type_slug', code: 'invalid' }, + ]); + } const existing = ws.permissions.findOneBy('slug', slug); if (existing) { - throw validationError('Permission with this slug already exists', [{ field: 'slug', code: 'duplicate' }]); + // Production answers a taken slug with 409 permission_slug_conflict, not a 422 field error. + throw new WorkOSApiError(409, 'Permission with this slug already exists', 'permission_slug_conflict'); } const permission = ws.permissions.insert({ @@ -28,6 +45,7 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { slug, name, description: (body.description as string) ?? null, + resource_type_slug: resourceTypeSlug ?? DEFAULT_RESOURCE_TYPE_SLUG, }); return c.json(formatPermission(permission), 201); @@ -48,7 +66,8 @@ export function authorizationPermissionRoutes(ctx: RouteContext): void { return c.json(formatPermission(permission)); }); - app.put('/authorization/permissions/:slug', async (c) => { + // The spec (and every SDK) updates a permission with PATCH; there is no PUT. + app.patch('/authorization/permissions/:slug', async (c) => { const slug = c.req.param('slug'); const permission = ws.permissions.findOneBy('slug', slug); if (!permission) throw notFound('Permission'); diff --git a/src/workos/routes/authorization-roles.spec.ts b/src/workos/routes/authorization-roles.spec.ts index 0dc606b..01ea0e9 100644 --- a/src/workos/routes/authorization-roles.spec.ts +++ b/src/workos/routes/authorization-roles.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'bun:test'; import { createServer, type ApiKeyMap } from '../../core/index.js'; -import { workosPlugin } from '../index.js'; +import { workosPlugin, seedFromConfig, getWorkOSStore } from '../index.js'; +import { validateSeedConfig } from '../config-validator.js'; const apiKeys: ApiKeyMap = { sk_test_role: { environment: 'test' } }; const headers = { Authorization: 'Bearer sk_test_role', 'Content-Type': 'application/json' }; @@ -11,9 +12,12 @@ function createTestApp() { describe('Authorization environment role routes', () => { let app: ReturnType['app']; + let store: ReturnType['store']; beforeEach(() => { - app = createTestApp().app; + const server = createTestApp(); + app = server.app; + store = server.store; }); const req = (path: string, init?: RequestInit) => app.request(path, { headers, ...init }); @@ -30,9 +34,53 @@ describe('Authorization environment role routes', () => { expect(role.slug).toBe('admin'); expect(role.type).toBe('EnvironmentRole'); expect(role.organization_id).toBeNull(); + expect(role.resource_type_slug).toBe('organization'); expect(role.id).toMatch(/^role_/); }); + it('preserves a role resource type', async () => { + const res = await req('/authorization/roles', { + method: 'POST', + body: JSON.stringify({ slug: 'doc-editor', name: 'Doc Editor', resource_type_slug: 'document' }), + }); + expect(res.status).toBe(201); + expect((await json(res)).resource_type_slug).toBe('document'); + + // The update DTO has no scope field, so PATCH must leave it untouched + const patched = await req('/authorization/roles/doc-editor', { + method: 'PATCH', + body: JSON.stringify({ name: 'Document Editor', resource_type_slug: 'folder' }), + }); + expect(await json(patched)).toMatchObject({ name: 'Document Editor', resource_type_slug: 'document' }); + }); + + it.each([[42], ['']])('rejects an invalid resource type %p', async (resource_type_slug) => { + const res = await req('/authorization/roles', { + method: 'POST', + body: JSON.stringify({ slug: 'bad-scope', name: 'Bad Scope', resource_type_slug }), + }); + expect(res.status).toBe(422); + expect((await json(res)).errors).toEqual([{ field: 'resource_type_slug', code: 'invalid' }]); + }); + + it('preserves a seeded role resource type', async () => { + const server = createTestApp(); + seedFromConfig(server.store, 'http://localhost:0', { + roles: [{ slug: 'seeded-editor', name: 'Seeded Editor', resource_type_slug: 'document' }], + }); + const res = await server.app.request('/authorization/roles/seeded-editor', { headers }); + expect(res.status).toBe(200); + expect((await json(res)).resource_type_slug).toBe('document'); + }); + + it.each([[42 as unknown as string], ['']])('rejects an invalid seeded resource type %p', (resource_type_slug) => { + const result = validateSeedConfig({ + roles: [{ slug: 'bad-seed', name: 'Bad Seed', resource_type_slug }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((error) => error.path === 'roles[0].resource_type_slug')).toBe(true); + }); + it('rejects duplicate slug', async () => { await req('/authorization/roles', { method: 'POST', @@ -42,7 +90,8 @@ describe('Authorization environment role routes', () => { method: 'POST', body: JSON.stringify({ slug: 'dup', name: 'Dup 2' }), }); - expect(res.status).toBe(422); + expect(res.status).toBe(409); + expect((await json(res)).code).toBe('role_slug_conflict'); }); it('lists environment roles', async () => { @@ -77,7 +126,7 @@ describe('Authorization environment role routes', () => { body: JSON.stringify({ slug: 'upd', name: 'Original' }), }); const res = await req('/authorization/roles/upd', { - method: 'PUT', + method: 'PATCH', body: JSON.stringify({ name: 'Updated', description: 'new desc' }), }); expect(res.status).toBe(200); @@ -117,12 +166,17 @@ describe('Authorization environment role routes', () => { // Set permissions const setRes = await req('/authorization/roles/editor/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['read', 'write'] }), }); expect(setRes.status).toBe(200); const setBody = await json(setRes); - expect(setBody.data.length).toBe(2); + expect(setBody.object).toBe('role'); + expect([...setBody.permissions].sort()).toEqual(['read', 'write']); + + // The role itself carries the slugs, as in production + const roleRes = await req('/authorization/roles/editor'); + expect([...(await json(roleRes)).permissions].sort()).toEqual(['read', 'write']); // Get permissions const getRes = await req('/authorization/roles/editor/permissions'); @@ -148,13 +202,13 @@ describe('Authorization environment role routes', () => { // Set to p1 await req('/authorization/roles/rep/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['p1'] }), }); // Replace with p2 await req('/authorization/roles/rep/permissions', { - method: 'POST', + method: 'PUT', body: JSON.stringify({ permissions: ['p2'] }), }); @@ -164,6 +218,130 @@ describe('Authorization environment role routes', () => { expect(body.data[0].slug).toBe('p2'); }); + it('adds a single permission with POST', async () => { + for (const slug of ['add-a', 'add-b']) { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug, name: slug }) }); + } + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'adder', name: 'Adder' }) }); + await req('/authorization/roles/adder/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['add-a'] }), + }); + + const res = await req('/authorization/roles/adder/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'add-b' }), + }); + expect(res.status).toBe(200); + expect([...(await json(res)).permissions].sort()).toEqual(['add-a', 'add-b']); + + // Re-attaching is a no-op, not a duplicate + const again = await req('/authorization/roles/adder/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'add-b' }), + }); + expect([...(await json(again)).permissions].sort()).toEqual(['add-a', 'add-b']); + }); + + it('rejects malformed permission changes', async () => { + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'strict', name: 'Strict' }) }); + + const missing = await req('/authorization/roles/strict/permissions', { + method: 'POST', + body: JSON.stringify({ slug: 'nope' }), + }); + expect(missing.status).toBe(404); + + const noSlug = await req('/authorization/roles/strict/permissions', { method: 'POST', body: JSON.stringify({}) }); + expect(noSlug.status).toBe(422); + + const notArray = await req('/authorization/roles/strict/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: 'read' }), + }); + expect(notArray.status).toBe(422); + }); + + it('leaves permissions intact when a replacement names an unknown slug', async () => { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug: 'keep', name: 'Keep' }) }); + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'atomic', name: 'Atomic' }) }); + await req('/authorization/roles/atomic/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['keep'] }), + }); + + const res = await req('/authorization/roles/atomic/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['keep', 'missing'] }), + }); + expect(res.status).toBe(404); + + const role = await json(await req('/authorization/roles/atomic')); + expect(role.permissions).toEqual(['keep']); + }); + + it('emits role.updated only when the permission set changes', async () => { + for (const slug of ['ev-a', 'ev-b']) { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug, name: slug }) }); + } + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'evented', name: 'Evented' }) }); + const updates = () => getWorkOSStore(store).events.findBy('event', 'role.updated'); + + await req('/authorization/roles/evented/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['ev-a'] }), + }); + expect(updates()).toHaveLength(1); + expect(updates()[0]!.data).toMatchObject({ slug: 'evented', permissions: ['ev-a'] }); + + // The same set again changes nothing and emits nothing + await req('/authorization/roles/evented/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['ev-a'] }), + }); + expect(updates()).toHaveLength(1); + + await req('/authorization/roles/evented/permissions', { method: 'POST', body: JSON.stringify({ slug: 'ev-b' }) }); + expect(updates()).toHaveLength(2); + expect([...(updates()[1]!.data as any).permissions].sort()).toEqual(['ev-a', 'ev-b']); + + // Re-attaching is a no-op + await req('/authorization/roles/evented/permissions', { method: 'POST', body: JSON.stringify({ slug: 'ev-b' }) }); + expect(updates()).toHaveLength(2); + }); + + it('emits role.deleted without permissions, as production does', async () => { + await req('/authorization/permissions', { method: 'POST', body: JSON.stringify({ slug: 'gone', name: 'Gone' }) }); + await req('/authorization/roles', { method: 'POST', body: JSON.stringify({ slug: 'doomed', name: 'Doomed' }) }); + await req('/authorization/roles/doomed/permissions', { + method: 'PUT', + body: JSON.stringify({ permissions: ['gone'] }), + }); + + await req('/authorization/roles/doomed', { method: 'DELETE' }); + + const deleted = getWorkOSStore(store).events.findBy('event', 'role.deleted'); + expect(deleted).toHaveLength(1); + expect(deleted[0]!.data).toMatchObject({ slug: 'doomed', resource_type_slug: 'organization' }); + expect(deleted[0]!.data).not.toHaveProperty('permissions'); + }); + + it('formats a directly inserted role without a resource type using the default', async () => { + getWorkOSStore(store).roles.insert({ + object: 'role', + slug: 'legacy', + name: 'Legacy', + description: null, + type: 'EnvironmentRole', + organization_id: null, + is_default_role: false, + priority: 0, + }); + const res = await req('/authorization/roles/legacy'); + expect(res.status).toBe(200); + expect(await json(res)).toMatchObject({ resource_type_slug: 'organization', permissions: [] }); + }); + it('creates role with default flag', async () => { const res = await req('/authorization/roles', { method: 'POST', diff --git a/src/workos/routes/authorization-roles.ts b/src/workos/routes/authorization-roles.ts index 9feecd4..a3efc60 100644 --- a/src/workos/routes/authorization-roles.ts +++ b/src/workos/routes/authorization-roles.ts @@ -10,5 +10,6 @@ export function authorizationRoleRoutes(ctx: RouteContext): void { listFilter: () => (r) => r.type === 'EnvironmentRole', insertDefaults: () => ({ organization_id: null }), duplicateMessage: 'Role with this slug already exists', + duplicateCode: 'role_slug_conflict', }); }