diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index ff141b26964..121b9b1557a 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -8849,12 +8849,12 @@ sim workspaces fork-resources [options] API request and response schemas: [listWorkspaceForkResources](/api-reference/workspace-sync/listWorkspaceForkResources). -### sim workspaces members +### sim workspaces members list [#sim-workspaces-members] List workspace members ```bash -sim workspaces members [options] +sim workspaces members list [options] ``` **Options** @@ -8869,6 +8869,68 @@ sim workspaces members [options] API request and response schemas: [listWorkspaceMembers](/api-reference/workspaces/listWorkspaceMembers). +### sim workspaces members remove + +Remove workspace member (OAuth login or personal API key required) + +This removes the member’s workspace access. If it was their last workspace in the organization, their organization membership is removed and their sessions end. + +```bash +sim workspaces members remove [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the workspace member, as returned by List workspace members. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +API request and response schemas: [removeWorkspaceMember](/api-reference/workspaces/removeWorkspaceMember). + +### sim workspaces members update + +Update workspace member (OAuth login or personal API key required) + +```bash +sim workspaces members update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the workspace member, as returned by List workspace members. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--role ` | Yes | New workspace role. Inherited organization Admin access, the workspace owner, and the workspace billing account keep Admin and cannot be changed here. Accepted values: `admin`, `write`, `read`. | + + + +API request and response schemas: [updateWorkspaceMember](/api-reference/workspaces/updateWorkspaceMember). + ### sim workspaces list List workspaces diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index 826b0d42133..cf195f61761 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -44,7 +44,7 @@ API request and response schemas: [getWorkspace](/api-reference/workspaces/getWo ### List workspace members ```bash -sim workspaces members [options] +sim workspaces members list [options] ``` **Options** @@ -59,6 +59,68 @@ sim workspaces members [options] API request and response schemas: [listWorkspaceMembers](/api-reference/workspaces/listWorkspaceMembers). +### Update workspace member + +OAuth login or personal API key required. + +```bash +sim workspaces members update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the workspace member, as returned by List workspace members. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--role ` | Yes | New workspace role. Inherited organization Admin access, the workspace owner, and the workspace billing account keep Admin and cannot be changed here. Accepted values: `admin`, `write`, `read`. | + + + +API request and response schemas: [updateWorkspaceMember](/api-reference/workspaces/updateWorkspaceMember). + +### Remove workspace member + +OAuth login or personal API key required. + +This removes the member’s workspace access. If it was their last workspace in the organization, their organization membership is removed and their sessions end. + +```bash +sim workspaces members remove [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `userId` | Yes | User identifier of the workspace member, as returned by List workspace members. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +API request and response schemas: [removeWorkspaceMember](/api-reference/workspaces/removeWorkspaceMember). + ### Create workspace invitations OAuth login or personal API key required. diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 89d0ab247db..ede1e4ba70b 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -421,6 +421,239 @@ } } }, + "/api/v2/workspaces/{workspaceId}/members/{userId}": { + "patch": { + "operationId": "updateWorkspaceMember", + "summary": "Update workspace member", + "description": "Change one existing member’s workspace role. Requires workspace Admin access. Only existing members can be changed; anyone else is Not found, and people are added with Create workspace invitations. You cannot lower your own role, and organization Admins, the workspace owner, and the workspace billing account always keep Admin. Members whose organization membership an identity provider manages cannot be changed here. Requesting the role a member already holds changes nothing and records no audit entry, except that an organization Admin is refused whatever role is requested. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.permissions.update", + "x-oauth-scope": "api:write", + "tags": ["Workspaces"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace the member belongs to.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the member belongs to." + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "User identifier of the workspace member, as returned by List workspace members.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "User identifier of the workspace member, as returned by List workspace members." + } + } + ], + "requestBody": { + "required": true, + "description": "The new workspace role for this member.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceMemberBody" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "role": "write" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The member with their updated workspace role.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceMemberResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "userId": "user-123", + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "role": "write", + "isExternal": false, + "joinedAt": "2026-01-15T10:30:00.000Z" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "removeWorkspaceMember", + "summary": "Remove workspace member", + "description": "Remove a member’s workspace access, or leave the workspace by naming yourself. Removing someone else requires workspace Admin access; any member may leave. The last Admin cannot leave, organization Admins hold access through their organization role and cannot be removed here, and the workspace billing account cannot be removed until billing is reassigned. Removing the workspace owner transfers ownership to the billing account. When this was the member’s last workspace in its organization, their organization membership and seat are removed too and all of their sessions end, since this endpoint is called with an API key or OAuth token rather than from a signed-in browser session. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.members.remove", + "x-oauth-scope": "api:write", + "tags": ["Workspaces"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace the member belongs to.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the member belongs to." + } + }, + { + "name": "userId", + "in": "path", + "required": true, + "description": "User identifier of the workspace member, as returned by List workspace members.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "User identifier of the workspace member, as returned by List workspace members." + } + } + ], + "responses": { + "200": { + "description": "Acknowledgement that workspace access was removed.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RemoveWorkspaceMemberResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "userId": "user-123", + "deleted": true, + "organizationMembershipRemoved": false + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/mcp-servers": { "get": { "operationId": "listMcpServers", @@ -14836,6 +15069,95 @@ } ] }, + "UpdateWorkspaceMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2WorkspaceMember" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workspace member response", + "description": "The member with their updated workspace role.", + "examples": [ + { + "data": { + "userId": "user-123", + "email": "jane@example.com", + "name": "Jane Smith", + "image": null, + "role": "write", + "isExternal": false, + "joinedAt": "2026-01-15T10:30:00.000Z" + } + } + ] + }, + "UpdateWorkspaceMemberBody": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": ["admin", "write", "read"], + "description": "New workspace role. Inherited organization Admin access, the workspace owner, and the workspace billing account keep Admin and cannot be changed here." + } + }, + "required": ["role"], + "additionalProperties": false, + "title": "Update workspace member body", + "description": "The new workspace role for this member.", + "examples": [ + { + "role": "write" + } + ] + }, + "V2WorkspaceMemberDeletion": { + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "User removed from the workspace." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether workspace access was removed." + }, + "organizationMembershipRemoved": { + "type": "boolean", + "description": "Whether this was the member’s last workspace in the organization, so their organization membership and seat were removed too." + } + }, + "required": ["userId", "deleted", "organizationMembershipRemoved"], + "additionalProperties": false, + "title": "Workspace member removal", + "description": "Acknowledges removal of a workspace member." + }, + "RemoveWorkspaceMemberResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2WorkspaceMemberDeletion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Remove workspace member response", + "description": "Acknowledgement that workspace access was removed.", + "examples": [ + { + "data": { + "userId": "user-123", + "deleted": true, + "organizationMembershipRemoved": false + } + } + ] + }, "V2McpServer": { "type": "object", "properties": { diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index b192484a92d..f6563814165 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -13178,9 +13178,23 @@ { "lang": "bash", "meta": null, - "sha256": "ea1cc85c4e5a1b39b5b26bca357d22583d993fbd321ba62dc529b811a553b050", - "bytes": 32, - "line": 8776 + "sha256": "210e8ab3d8af173ce648501ca50c828325ac1f4452006ad4485cb7a24d760ae4", + "bytes": 37, + "line": 8852 + }, + { + "lang": "bash", + "meta": null, + "sha256": "cdff2d4272d5bc3a18729e63e5ece9053d33ac0f8faf35cfaa3a289baa2dccc1", + "bytes": 48, + "line": 8874 + }, + { + "lang": "bash", + "meta": null, + "sha256": "c307203dd609208b90cbc0372e3401a75e943421bdf77e4e10779ed2c89ca4ff", + "bytes": 48, + "line": 8904 }, { "lang": "bash", @@ -17732,10 +17746,24 @@ { "lang": "bash", "meta": null, - "sha256": "ea1cc85c4e5a1b39b5b26bca357d22583d993fbd321ba62dc529b811a553b050", - "bytes": 32, + "sha256": "210e8ab3d8af173ce648501ca50c828325ac1f4452006ad4485cb7a24d760ae4", + "bytes": 37, "line": 42 }, + { + "lang": "bash", + "meta": null, + "sha256": "c307203dd609208b90cbc0372e3401a75e943421bdf77e4e10779ed2c89ca4ff", + "bytes": 48, + "line": 62 + }, + { + "lang": "bash", + "meta": null, + "sha256": "cdff2d4272d5bc3a18729e63e5ece9053d33ac0f8faf35cfaa3a289baa2dccc1", + "bytes": 48, + "line": 94 + }, { "lang": "bash", "meta": null, diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts new file mode 100644 index 00000000000..8fd8f472889 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/[userId]/route.ts @@ -0,0 +1,43 @@ +import { + v2RemoveWorkspaceMemberContract, + v2UpdateWorkspaceMemberContract, +} from '@/lib/api/contracts/v2/workspaces' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { presentWorkspaceMember } from '@/lib/api/server/workspace-presenters' +import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' +import { updateWorkspaceMember } from '@/lib/workspaces/application/manage-permissions' +import { + workspaceMemberOperations, + workspacePermissionOperations, +} from '@/lib/workspaces/application/operations' +import { removeWorkspaceMember } from '@/lib/workspaces/application/remove-member' + +/** PATCH /api/v2/workspaces/[workspaceId]/members/[userId] — Change one member's role. */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateWorkspaceMemberContract, + operation: workspacePermissionOperations.update, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkspaceErrorPolicies.manageWorkspaceMembers, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateWorkspaceMember, + present: ({ member }) => ({ data: presentWorkspaceMember(member) }), +}) + +/** DELETE /api/v2/workspaces/[workspaceId]/members/[userId] — Remove a member, or leave. */ +export const DELETE = defineV2JsonRoute({ + contract: v2RemoveWorkspaceMemberContract, + operation: workspaceMemberOperations.remove, + auth: v2ApiKeyAuth, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkspaceErrorPolicies.manageWorkspaceMembers, + mapInput: ({ params }) => params, + useCase: removeWorkspaceMember, + present: (result) => ({ + data: { + userId: result.removedUserId, + deleted: true as const, + organizationMembershipRemoved: result.organizationRemoval, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts index b12db8d4594..f047d313d36 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/members/route.ts @@ -4,6 +4,7 @@ import { } from '@/lib/api/contracts/v2/workspaces' import { cursorRoute, cursorScopeKey, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { presentWorkspaceMember } from '@/lib/api/server/workspace-presenters' import { OrchestrationError } from '@/lib/core/orchestration/types' import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' import { listPublicWorkspaceMembers } from '@/lib/workspaces/application/list-public-workspace-members' @@ -50,15 +51,7 @@ export const GET = defineV2JsonRoute({ }, useCase: listPublicWorkspaceMembers, present: ({ page }, { params }) => ({ - data: page.members.map((member) => ({ - userId: member.userId, - email: member.email, - name: member.name, - image: member.image, - role: member.role, - isExternal: member.isExternal, - joinedAt: member.joinedAt.toISOString(), - })), + data: page.members.map(presentWorkspaceMember), nextCursor: page.nextEmail ? encodeScopedCursor( memberCursorScope(params.workspaceId), diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index a8b28c7547f..d165badf853 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -22,8 +22,8 @@ import { captureServerEvent } from '@/lib/posthog/server' import { readWorkspacePermissions, updateWorkspacePermissions, - workspacePermissionOperations, } from '@/lib/workspaces/application/manage-permissions' +import { workspacePermissionOperations } from '@/lib/workspaces/application/operations' import { WorkspacePermissionError } from '@/lib/workspaces/permissions/management-store' function permissionErrorResponse(error: unknown): NextResponse { diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 0c9a109c23c..5c0b9cb6cff 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -11,16 +11,14 @@ import { NoWorkspaceAccessError, } from '@/lib/core/application/workspace-authorization' import { captureServerEvent } from '@/lib/posthog/server' -import { - removeWorkspaceMember, - removeWorkspaceMemberOperation, -} from '@/lib/workspaces/application/remove-member' +import { workspaceMemberOperations } from '@/lib/workspaces/application/operations' +import { removeWorkspaceMember } from '@/lib/workspaces/application/remove-member' import { WorkspaceBillingAccountRemovalError } from '@/lib/workspaces/utils' export const DELETE = defineInternalJsonRoute({ contract: removeWorkspaceMemberContract, auth: internalSessionAuth, - operation: removeWorkspaceMemberOperation, + operation: workspaceMemberOperations.remove, rateLimit: internalRateLimits.none({ reason: 'Preserve existing workspace member removal policy.', }), diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index b8416de9b3a..db013686a2c 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -91,6 +91,8 @@ import { v2GetWorkspaceContract, v2ListWorkspaceMembersContract, v2ListWorkspacesContract, + v2RemoveWorkspaceMemberContract, + v2UpdateWorkspaceMemberContract, } from '@/lib/api/contracts/v2/workspaces' import { defineOpenApiDocument, @@ -105,7 +107,11 @@ import { sandboxOperations } from '@/lib/sandboxes/application/operations' import { secretOperations } from '@/lib/secrets/application/operations' import { skillOperations } from '@/lib/skills/application/operations' import { toolExecutionOperations } from '@/lib/tool-execution/application/operations' -import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { + workspaceMemberOperations, + workspaceOperations, + workspacePermissionOperations, +} from '@/lib/workspaces/application/operations' const BLOCK_SUMMARY_EXAMPLE = { id: 'slack', @@ -752,6 +758,67 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2UpdateWorkspaceMemberContract, + resourceOperation('Workspaces', { + applicationOperation: workspacePermissionOperations.update, + operationId: 'updateWorkspaceMember', + summary: 'Update workspace member', + description: `Change one existing member’s workspace role. Requires workspace Admin access. Only existing members can be changed; anyone else is Not found, and people are added with Create workspace invitations. You cannot lower your own role, and organization Admins, the workspace owner, and the workspace billing account always keep Admin. Members whose organization membership an identity provider manages cannot be changed here. Requesting the role a member already holds changes nothing and records no audit entry, except that an organization Admin is refused whatever role is requested. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The member with their updated workspace role.' }, + }), + { + params: documentedSchema( + v2UpdateWorkspaceMemberContract.params, + 'UpdateWorkspaceMemberParams', + 'Update workspace member parameters', + 'Workspace and member identifiers.' + ), + query: v2UpdateWorkspaceMemberContract.query, + body: documentedSchema( + v2UpdateWorkspaceMemberContract.body, + 'UpdateWorkspaceMemberBody', + 'Update workspace member body', + 'The new workspace role for this member.', + [{ role: 'write' }] + ), + response: documentedSchema( + v2UpdateWorkspaceMemberContract.response.schema, + 'UpdateWorkspaceMemberResponse', + 'Update workspace member response', + 'The member with their updated workspace role.', + [{ data: { ...WORKSPACE_MEMBER_EXAMPLE, role: 'write' } }] + ), + } + ), + defineOpenApiRoute( + v2RemoveWorkspaceMemberContract, + resourceOperation('Workspaces', { + applicationOperation: workspaceMemberOperations.remove, + operationId: 'removeWorkspaceMember', + summary: 'Remove workspace member', + description: `Remove a member’s workspace access, or leave the workspace by naming yourself. Removing someone else requires workspace Admin access; any member may leave. The last Admin cannot leave, organization Admins hold access through their organization role and cannot be removed here, and the workspace billing account cannot be removed until billing is reassigned. Removing the workspace owner transfers ownership to the billing account. When this was the member’s last workspace in its organization, their organization membership and seat are removed too and all of their sessions end, since this endpoint is called with an API key or OAuth token rather than from a signed-in browser session. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Acknowledgement that workspace access was removed.' }, + }), + { + params: documentedSchema( + v2RemoveWorkspaceMemberContract.params, + 'RemoveWorkspaceMemberParams', + 'Remove workspace member parameters', + 'Workspace and member identifiers.' + ), + query: v2RemoveWorkspaceMemberContract.query, + response: documentedSchema( + v2RemoveWorkspaceMemberContract.response.schema, + 'RemoveWorkspaceMemberResponse', + 'Remove workspace member response', + 'Acknowledgement that workspace access was removed.', + [{ data: { userId: 'user-123', deleted: true, organizationMembershipRemoved: false } }] + ), + } + ), defineOpenApiRoute( v2ListMcpServersContract, resourceOperation('MCP Servers', { diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index e11e5599ff8..70e115098cb 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -1,5 +1,10 @@ import { z } from 'zod' -import { noInputSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { + MAX_ID_LENGTH, + noInputSchema, + requiredFieldSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, @@ -115,3 +120,59 @@ export const v2ListWorkspaceMembersContract = defineRouteContract({ query: v2ListWorkspaceMembersQuerySchema, response: { mode: 'json', schema: v2CursorListResponse(v2WorkspaceMemberSchema) }, }) + +const v2WorkspaceMemberParamsSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace the member belongs to.'), + userId: requiredFieldSchema('User ID is required') + .max(MAX_ID_LENGTH, 'User ID is too long') + .describe('User identifier of the workspace member, as returned by List workspace members.'), + }) + .strict() + +const v2UpdateWorkspaceMemberBodySchema = z + .object({ + role: z + .enum(['admin', 'write', 'read']) + .describe( + 'New workspace role. Inherited organization Admin access, the workspace owner, and the workspace billing account keep Admin and cannot be changed here.' + ), + }) + .strict() + +export const v2UpdateWorkspaceMemberContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workspaces/[workspaceId]/members/[userId]', + params: v2WorkspaceMemberParamsSchema, + query: noInputSchema, + body: v2UpdateWorkspaceMemberBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkspaceMemberSchema) }, +}) + +export const v2RemoveWorkspaceMemberContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/workspaces/[workspaceId]/members/[userId]', + params: v2WorkspaceMemberParamsSchema, + query: noInputSchema, + response: { + mode: 'json', + schema: v2DataResponse( + z + .object({ + userId: z.string().describe('User removed from the workspace.'), + deleted: z.literal(true).describe('Whether workspace access was removed.'), + organizationMembershipRemoved: z + .boolean() + .describe( + 'Whether this was the member’s last workspace in the organization, so their organization membership and seat were removed too.' + ), + }) + .strict() + .meta({ + id: 'V2WorkspaceMemberDeletion', + title: 'Workspace member removal', + description: 'Acknowledges removal of a workspace member.', + }) + ), + }, +}) diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index e21082c9268..20a0a4b34b8 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -357,6 +357,8 @@ import { v2GetWorkspaceContract, v2ListWorkspaceMembersContract, v2ListWorkspacesContract, + v2RemoveWorkspaceMemberContract, + v2UpdateWorkspaceMemberContract, } from '@/lib/api/contracts/v2/workspaces' import type { V2McpOperation } from '@/lib/api/mcp/types' @@ -2444,6 +2446,17 @@ export const V2_MCP_OPERATIONS = { '@/app/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/[userId]/route' ).then((route) => route.DELETE), }, + removeWorkspaceMember: { + contract: v2RemoveWorkspaceMemberContract, + summary: 'Remove workspace member', + description: + 'Remove a member’s workspace access, or leave the workspace by naming yourself. Removing someone else requires workspace Admin access; any member may leave. The last Admin cannot leave, organization Admins hold access through their organization role and cannot be removed here, and the workspace billing account cannot be removed until billing is reassigned. Removing the workspace owner transfers ownership to the billing account. When this was the member’s last workspace in its organization, their organization membership and seat are removed too and all of their sessions end, since this endpoint is called with an API key or OAuth token rather than from a signed-in browser session. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/members/[userId]/route').then( + (route) => route.DELETE + ), + }, renameFile: { contract: v2RenameFileContract, summary: 'Rename file', @@ -3008,6 +3021,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.PUT ), }, + updateWorkspaceMember: { + contract: v2UpdateWorkspaceMemberContract, + summary: 'Update workspace member', + description: + 'Change one existing member’s workspace role. Requires workspace Admin access. Only existing members can be changed; anyone else is Not found, and people are added with Create workspace invitations. You cannot lower your own role, and organization Admins, the workspace owner, and the workspace billing account always keep Admin. Members whose organization membership an identity provider manages cannot be changed here. Requesting the role a member already holds changes nothing and records no audit entry, except that an organization Admin is refused whatever role is requested. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/members/[userId]/route').then( + (route) => route.PATCH + ), + }, upsertCredentialMember: { contract: v2UpsertCredentialMemberContract, summary: 'Upsert credential member', diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index e4072fe5a65..8047e84737b 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -964,6 +964,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/workspaces/{workspaceId}/members', load: () => import('@/app/api/v2/workspaces/[workspaceId]/members/route'), }, + { + pattern: '/api/v2/workspaces/{workspaceId}/members/{userId}', + load: () => import('@/app/api/v2/workspaces/[workspaceId]/members/[userId]/route'), + }, { pattern: '/api/v2/workspaces/{workspaceId}/operations', load: () => import('@/app/api/v2/workspaces/[workspaceId]/operations/route'), diff --git a/apps/sim/lib/api/server/workspace-presenters.ts b/apps/sim/lib/api/server/workspace-presenters.ts new file mode 100644 index 00000000000..91fc0ee5a17 --- /dev/null +++ b/apps/sim/lib/api/server/workspace-presenters.ts @@ -0,0 +1,15 @@ +import type { V2WorkspaceMember } from '@/lib/api/contracts/v2/workspaces' +import type { WorkspaceMemberRow } from '@/lib/workspaces/public-queries' + +/** One effective member as every v2 member response renders them. */ +export function presentWorkspaceMember(member: WorkspaceMemberRow): V2WorkspaceMember { + return { + userId: member.userId, + email: member.email, + name: member.name, + image: member.image, + role: member.role, + isExternal: member.isExternal, + joinedAt: member.joinedAt.toISOString(), + } +} diff --git a/apps/sim/lib/settings/sections.ts b/apps/sim/lib/settings/sections.ts index 1e524c8a04c..f287e6c199c 100644 --- a/apps/sim/lib/settings/sections.ts +++ b/apps/sim/lib/settings/sections.ts @@ -112,7 +112,12 @@ export const settingsSections: Record ({ ...envFlagsMock, isScimEnabled: true })) + +const USER_NAMES = [ + 'orgOwner', + 'admin', + 'wsOwner', + 'billing', + 'writer', + 'reader', + 'managed', + 'outsider', +] as const +const KEY_HOLDERS = ['admin', 'writer', 'reader', 'outsider'] as const +type KeyHolder = (typeof KEY_HOLDERS)[number] + +/** A fresh organization, users, workspaces, and credentials for every test. */ +function newFixture() { + const workspaceId = generateId() + /** Personal workspace whose only explicit admin is `admin`; its owner holds no grant. */ + const lastAdminWorkspaceId = generateId() + /** Personal workspace whose billing account holds no grant, so its owner has no successor. */ + const unresolvedOwnerWorkspaceId = generateId() + return { + orgId: generateId(), + scimConnectionId: generateId(), + oauthClientId: generateId(), + workspaceId, + lastAdminWorkspaceId, + unresolvedOwnerWorkspaceId, + workspaceIds: [workspaceId, lastAdminWorkspaceId, unresolvedOwnerWorkspaceId], + users: Object.fromEntries(USER_NAMES.map((name) => [name, generateId()])) as Record< + (typeof USER_NAMES)[number], + string + >, + keys: Object.fromEntries( + KEY_HOLDERS.map((name) => [name, `sk-sim-fixture-${generateId()}`]) + ) as Record, + workspaceKey: `sk-sim-fixture-${generateId()}`, + oauthReadToken: `sim_oat_${generateShortId(32)}`, + oauthWriteToken: `sim_oat_${generateShortId(32)}`, + } +} + +let f = newFixture() + +type Credential = { apiKey: string } | { bearer: string } +const key = (name: KeyHolder): Credential => ({ apiKey: f.keys[name] }) + +async function call( + credential: Credential, + method: 'PATCH' | 'DELETE', + target: string, + options: { workspace?: string; body?: unknown; query?: string } = {} +) { + const scope = options.workspace ?? f.workspaceId + const headers = new Headers({ 'x-forwarded-for': '127.0.0.1' }) + if ('apiKey' in credential) headers.set('X-API-Key', credential.apiKey) + else headers.set('Authorization', `Bearer ${credential.bearer}`) + if (options.body !== undefined) headers.set('Content-Type', 'application/json') + const request = new NextRequest( + `http://localhost/api/v2/workspaces/${scope}/members/${target}${options.query ?? ''}`, + { + method, + headers, + ...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }), + } + ) + const context = { params: Promise.resolve({ workspaceId: scope, userId: target }) } + const response = + method === 'PATCH' ? await updateMember(request, context) : await removeMember(request, context) + expect(response.headers.get('cache-control')).toMatch(/no-store/) + return { + status: response.status, + retryAfter: response.headers.get('retry-after'), + body: await response.json(), + } +} + +async function roleOf(target: string, scope = f.workspaceId) { + const [row] = await db + .select({ role: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, scope), + eq(permissions.userId, target) + ) + ) + return row?.role ?? null +} + +function auditRows(action: string, scope = f.workspaceId) { + return db + .select() + .from(auditLog) + .where(and(eq(auditLog.workspaceId, scope), eq(auditLog.action, action))) + .orderBy(asc(auditLog.createdAt)) +} + +/** Audit is written after the response; wait for the expected row count. */ +async function awaitAudit(action: string, count: number, scope = f.workspaceId) { + return vi.waitFor( + async () => { + const rows = await auditRows(action, scope) + expect(rows).toHaveLength(count) + return rows + }, + { timeout: 5_000, interval: 100 } + ) +} + +async function orgMembership(target: string) { + return db + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, f.orgId), eq(member.userId, target))) +} + +async function seed() { + const { users } = f + const now = new Date() + const userIds = Object.values(users) + await db.insert(user).values( + Object.entries(users).map(([name, id]) => ({ + id, + name: `Fixture ${name}`, + email: `${id}@members.test`, + normalizedEmail: `${id}@members.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(userStats).values(userIds.map((id) => ({ id: generateId(), userId: id }))) + await db + .insert(organization) + .values({ id: f.orgId, name: 'Members fixture', slug: `members-${f.orgId}`, createdAt: now }) + await db.insert(member).values( + USER_NAMES.filter((name) => name !== 'outsider').map((name) => ({ + id: generateId(), + userId: users[name], + organizationId: f.orgId, + role: name === 'orgOwner' ? 'owner' : 'member', + })) + ) + await insertWorkspaceFixture(db, [ + { + id: f.workspaceId, + name: 'Members fixture', + ownerId: users.wsOwner, + billedAccountUserId: users.billing, + organizationId: f.orgId, + allowPersonalApiKeys: true, + }, + { + id: f.lastAdminWorkspaceId, + name: 'Last admin fixture', + ownerId: users.billing, + billedAccountUserId: users.billing, + allowPersonalApiKeys: true, + }, + { + id: f.unresolvedOwnerWorkspaceId, + name: 'Unresolved owner fixture', + ownerId: users.wsOwner, + billedAccountUserId: users.billing, + allowPersonalApiKeys: true, + }, + ]) + const grant = (target: string, scope: string, role: 'admin' | 'write' | 'read') => ({ + id: generateId(), + userId: target, + entityType: 'workspace', + entityId: scope, + permissionType: role, + }) + await db + .insert(permissions) + .values([ + grant(users.admin, f.workspaceId, 'admin'), + grant(users.wsOwner, f.workspaceId, 'admin'), + grant(users.billing, f.workspaceId, 'admin'), + grant(users.writer, f.workspaceId, 'write'), + grant(users.reader, f.workspaceId, 'read'), + grant(users.managed, f.workspaceId, 'write'), + grant(users.admin, f.lastAdminWorkspaceId, 'admin'), + grant(users.wsOwner, f.unresolvedOwnerWorkspaceId, 'admin'), + grant(users.admin, f.unresolvedOwnerWorkspaceId, 'admin'), + ]) + await db.insert(apiKey).values([ + ...KEY_HOLDERS.map((name) => ({ + id: generateId(), + userId: users[name], + name: 'Members fixture', + key: f.keys[name], + keyHash: hashApiKey(f.keys[name]), + type: 'personal', + })), + { + id: generateId(), + userId: users.admin, + workspaceId: f.workspaceId, + createdBy: users.admin, + name: 'Members workspace fixture', + key: f.workspaceKey, + keyHash: hashApiKey(f.workspaceKey), + type: 'workspace', + }, + ]) + await db.insert(oauthClient).values({ + id: f.oauthClientId, + clientId: f.oauthClientId, + name: 'Members fixture client', + public: true, + tokenEndpointAuthMethod: 'none', + grantTypes: ['authorization_code', 'refresh_token'], + redirectUris: ['http://127.0.0.1/callback'], + }) + await db.insert(oauthConsent).values({ + id: generateId(), + clientId: f.oauthClientId, + userId: users.admin, + scopes: ['api:read', 'api:write'], + createdAt: now, + updatedAt: now, + }) + await db.insert(oauthAccessToken).values( + ( + [ + [f.oauthReadToken, ['api:read']], + [f.oauthWriteToken, ['api:write']], + ] as const + ).map(([token, scopes]) => ({ + id: generateId(), + token: hashOAuthToken(token.slice('sim_oat_'.length)), + clientId: f.oauthClientId, + userId: users.admin, + scopes: [...scopes], + createdAt: now, + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), + })) + ) + await db.insert(scimConnection).values({ + id: f.scimConnectionId, + organizationId: f.orgId, + status: 'active', + settings: { lockManualMembership: true }, + }) + const managedEmail = `${users.managed}@members.test` + await db.insert(scimUser).values({ + id: generateId(), + connectionId: f.scimConnectionId, + userId: users.managed, + userName: managedEmail, + orderKey: users.managed, + attributes: { + userName: managedEmail, + active: true, + displayName: 'Managed user', + name: { formatted: 'Managed user' }, + emails: [{ value: managedEmail, primary: true, type: 'work' }], + }, + }) + await db.insert(session).values({ + id: generateId(), + token: generateShortId(32), + userId: users.reader, + expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + createdAt: now, + updatedAt: now, + }) +} + +async function cleanup() { + const userIds = Object.values(f.users) + await db.delete(auditLog).where(inArray(auditLog.workspaceId, f.workspaceIds)) + await db.delete(apiKey).where(inArray(apiKey.userId, userIds)) + await db.delete(oauthClient).where(eq(oauthClient.clientId, f.oauthClientId)) + await db + .delete(permissions) + .where( + and(eq(permissions.entityType, 'workspace'), inArray(permissions.entityId, f.workspaceIds)) + ) + await deleteWorkspaceFixture(db, inArray(workspace.id, f.workspaceIds)) + await db.delete(scimConnection).where(eq(scimConnection.id, f.scimConnectionId)) + await db.delete(member).where(eq(member.organizationId, f.orgId)) + await db.delete(organization).where(eq(organization.id, f.orgId)) + await db.delete(session).where(inArray(session.userId, userIds)) + await db.delete(userStats).where(inArray(userStats.userId, userIds)) + await db.delete(user).where(inArray(user.id, userIds)) +} + +/** Drives the real v2 member route handlers with hashed credentials against PostgreSQL. */ +describe('v2 workspace member management against PostgreSQL', () => { + beforeEach(async () => { + f = newFixture() + await seed() + }) + + afterEach(cleanup) + + afterAll(async () => { + await db.$client.end() + }) + + it('refuses a workspace API key on both methods with a coded 403', async () => { + for (const [method, body] of [ + ['PATCH', { role: 'write' }], + ['DELETE', undefined], + ] as const) { + const response = await call({ apiKey: f.workspaceKey }, method, f.users.reader, { body }) + expect(response).toMatchObject({ + status: 403, + body: { + error: { + code: 'FORBIDDEN', + details: { code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }, + }, + }, + }) + } + expect(await roleOf(f.users.reader)).toBe('read') + }) + + it('refuses an OAuth token without api:write', async () => { + for (const [method, body] of [ + ['PATCH', { role: 'write' }], + ['DELETE', undefined], + ] as const) { + const response = await call({ bearer: f.oauthReadToken }, method, f.users.reader, { body }) + expect(response).toMatchObject({ + status: 403, + body: { error: { details: { code: 'INSUFFICIENT_SCOPE' } } }, + }) + } + expect(await roleOf(f.users.reader)).toBe('read') + }) + + it('refuses a non-admin changing or removing someone else', async () => { + for (const [method, body] of [ + ['PATCH', { role: 'write' }], + ['DELETE', undefined], + ] as const) { + const response = await call(key('writer'), method, f.users.reader, { body }) + expect(response).toMatchObject({ + status: 403, + body: { error: { details: { code: 'INSUFFICIENT_WORKSPACE_ROLE' } } }, + }) + } + expect(await roleOf(f.users.reader)).toBe('read') + }) + + it('answers an unreachable and an unknown workspace with the same 404', async () => { + const unreachable = await call(key('outsider'), 'PATCH', f.users.reader, { + body: { role: 'write' }, + }) + const unknown = await call(key('admin'), 'PATCH', f.users.reader, { + workspace: generateId(), + body: { role: 'write' }, + }) + expect(unreachable).toEqual(unknown) + expect(unreachable).toEqual({ + status: 404, + retryAfter: null, + body: { error: { code: 'NOT_FOUND', message: 'Workspace not found' } }, + }) + expect((await call(key('outsider'), 'DELETE', f.users.reader)).status).toBe(404) + }) + + it('answers a non-member target with 404 on both methods and writes nothing', async () => { + const patch = await call(key('admin'), 'PATCH', f.users.outsider, { body: { role: 'write' } }) + expect(patch).toMatchObject({ + status: 404, + body: { error: { code: 'NOT_FOUND', message: 'Member not found' } }, + }) + expect((await call(key('admin'), 'DELETE', f.users.outsider)).status).toBe(404) + expect(await roleOf(f.users.outsider)).toBeNull() + expect(await auditRows('member.role_changed')).toHaveLength(0) + }) + + it('rejects invalid bodies, query strings, and ids before any write', async () => { + const admin = key('admin') + const invalid = [ + call(admin, 'PATCH', f.users.reader, { body: { role: 'owner' } }), + call(admin, 'PATCH', f.users.reader, { body: { role: 'write', extra: true } }), + call(admin, 'PATCH', f.users.reader, { body: {} }), + call(admin, 'PATCH', f.users.reader, { body: { role: 'write' }, query: '?force=1' }), + call(admin, 'DELETE', f.users.reader, { query: '?force=1' }), + call(admin, 'PATCH', 'x'.repeat(200), { body: { role: 'write' } }), + ] + for (const response of await Promise.all(invalid)) { + expect(response).toMatchObject({ status: 400, body: { error: { code: 'BAD_REQUEST' } } }) + } + expect(await roleOf(f.users.reader)).toBe('read') + }) + + it('keeps the SCIM_MANAGED_MEMBERSHIP code for a directory-managed member', async () => { + const response = await call(key('admin'), 'PATCH', f.users.managed, { body: { role: 'read' } }) + expect(response).toMatchObject({ + status: 403, + body: { error: { code: 'FORBIDDEN', details: { code: 'SCIM_MANAGED_MEMBERSHIP' } } }, + }) + expect(await roleOf(f.users.managed)).toBe('write') + }) + + it('answers a role change held up by a competing writer with a retryable 503', async () => { + let lockTaken!: () => void + let release!: () => void + const taken = new Promise((resolve) => { + lockTaken = resolve + }) + const released = new Promise((resolve) => { + release = resolve + }) + // Held from its own async context, so the request is not nested inside this transaction. + const competingWriter = db.transaction(async (tx) => { + await tx + .select({ id: member.id }) + .from(member) + .where(and(eq(member.organizationId, f.orgId), eq(member.userId, f.users.reader))) + .for('update') + lockTaken() + await released + }) + await taken + const response = await call(key('admin'), 'PATCH', f.users.reader, { + body: { role: 'write' }, + }).finally(release) + await competingWriter + expect(response).toMatchObject({ + status: 503, + body: { error: { code: 'SERVICE_UNAVAILABLE' } }, + }) + expect(Number(response.retryAfter)).toBeGreaterThan(0) + expect(await roleOf(f.users.reader)).toBe('read') + }, 30_000) + + it('refuses an admin lowering their own role', async () => { + const response = await call(key('admin'), 'PATCH', f.users.admin, { body: { role: 'write' } }) + expect(response.status).toBe(400) + expect(response.body.error.message).toMatch(/own admin/) + expect(await roleOf(f.users.admin)).toBe('admin') + }) + + it('keeps Admin for the owner, the billing account, and organization admins', async () => { + const admin = key('admin') + const refusals = await Promise.all([ + call(admin, 'PATCH', f.users.wsOwner, { body: { role: 'read' } }), + call(admin, 'PATCH', f.users.billing, { body: { role: 'read' } }), + call(admin, 'DELETE', f.users.orgOwner), + call(admin, 'DELETE', f.users.billing), + ]) + expect(refusals.map((response) => response.status)).toEqual([400, 400, 400, 400]) + expect(await roleOf(f.users.wsOwner)).toBe('admin') + expect(await roleOf(f.users.billing)).toBe('admin') + }) + + it('refuses an inherited organization admin, who holds no grant row, whatever role is requested', async () => { + expect(await roleOf(f.users.orgOwner)).toBeNull() + for (const role of ['admin', 'write', 'read'] as const) { + const response = await call(key('admin'), 'PATCH', f.users.orgOwner, { body: { role } }) + expect(response).toEqual({ + status: 400, + retryAfter: null, + body: { + error: { + code: 'BAD_REQUEST', + message: 'Organization admins are workspace admins and their role cannot be changed', + }, + }, + }) + } + expect(await roleOf(f.users.orgOwner)).toBeNull() + expect(await auditRows('member.role_changed')).toHaveLength(0) + }) + + it('returns the roster member and audits the key owner as actor', async () => { + const response = await call(key('admin'), 'PATCH', f.users.reader, { body: { role: 'write' } }) + expect(response.status).toBe(200) + expect(Object.keys(response.body)).toEqual(['data']) + const roster = await listMembers( + new NextRequest(`http://localhost/api/v2/workspaces/${f.workspaceId}/members`, { + headers: { 'X-API-Key': f.keys.admin, 'x-forwarded-for': '127.0.0.1' }, + }), + { params: Promise.resolve({ workspaceId: f.workspaceId }) } + ) + const listed = (await roster.json()).data.find( + (entry: { userId: string }) => entry.userId === f.users.reader + ) + expect(response.body.data).toEqual(listed) + expect(listed.role).toBe('write') + expect(await roleOf(f.users.reader)).toBe('write') + + const [entry] = await awaitAudit('member.role_changed', 1) + expect(entry.actorId).toBe(f.users.admin) + expect(entry.metadata).toMatchObject({ + targetUserId: f.users.reader, + previousRole: 'read', + actor: { kind: 'personal_api_key' }, + }) + }) + + it('lets an OAuth token with api:write change a role', async () => { + const response = await call({ bearer: f.oauthWriteToken }, 'PATCH', f.users.reader, { + body: { role: 'write' }, + }) + expect(response).toMatchObject({ status: 200, body: { data: { role: 'write' } } }) + expect(await roleOf(f.users.reader)).toBe('write') + const [entry] = await awaitAudit('member.role_changed', 1) + expect(entry.actorId).toBe(f.users.admin) + expect(entry.metadata).toMatchObject({ actor: { kind: 'oauth_access_token' } }) + }) + + it('writes and audits nothing when the requested role is already held', async () => { + const grantRow = () => + db + .select({ updatedAt: permissions.updatedAt }) + .from(permissions) + .where(and(eq(permissions.entityId, f.workspaceId), eq(permissions.userId, f.users.reader))) + const [before] = await grantRow() + const response = await call(key('admin'), 'PATCH', f.users.reader, { body: { role: 'read' } }) + expect(response).toMatchObject({ status: 200, body: { data: { role: 'read' } } }) + await new Promise((settle) => setTimeout(settle, 500)) + expect(await auditRows('member.role_changed')).toHaveLength(0) + expect(await grantRow()).toEqual([before]) + }) + + it('refuses the last explicit admin leaving', async () => { + const response = await call(key('admin'), 'DELETE', f.users.admin, { + workspace: f.lastAdminWorkspaceId, + }) + expect(response.status).toBe(400) + expect(response.body.error.message).toMatch(/last admin/) + expect(await roleOf(f.users.admin, f.lastAdminWorkspaceId)).toBe('admin') + }) + + it('answers an owner removal with no successor as 409, not 500', async () => { + const response = await call(key('admin'), 'DELETE', f.users.wsOwner, { + workspace: f.unresolvedOwnerWorkspaceId, + }) + expect(response.status).toBe(409) + expect(response.body.error.code).toBe('CONFLICT') + expect(response.body.error.message).toMatch(/billing account/) + expect(await roleOf(f.users.wsOwner, f.unresolvedOwnerWorkspaceId)).toBe('admin') + const [row] = await db + .select({ ownerId: workspace.ownerId }) + .from(workspace) + .where(eq(workspace.id, f.unresolvedOwnerWorkspaceId)) + expect(row.ownerId).toBe(f.users.wsOwner) + }) + + it('removes another member and audits the removal', async () => { + const response = await call(key('admin'), 'DELETE', f.users.writer) + expect(response).toMatchObject({ + status: 200, + body: { + data: { userId: f.users.writer, deleted: true, organizationMembershipRemoved: true }, + }, + }) + expect(await roleOf(f.users.writer)).toBeNull() + expect(await orgMembership(f.users.writer)).toHaveLength(0) + const [entry] = await awaitAudit('member.removed', 1) + expect(entry.actorId).toBe(f.users.admin) + expect(entry.metadata).toMatchObject({ removedUserId: f.users.writer }) + }) + + it('lets a member leave with a personal key, ending org membership and sessions', async () => { + const response = await call(key('reader'), 'DELETE', f.users.reader) + expect(response).toMatchObject({ + status: 200, + body: { + data: { userId: f.users.reader, deleted: true, organizationMembershipRemoved: true }, + }, + }) + expect(await roleOf(f.users.reader)).toBeNull() + expect(await orgMembership(f.users.reader)).toHaveLength(0) + expect(await db.select().from(session).where(eq(session.userId, f.users.reader))).toHaveLength( + 0 + ) + const [entry] = await awaitAudit('member.removed', 1) + expect(entry.actorId).toBe(f.users.reader) + expect(entry.metadata).toMatchObject({ selfRemoval: true }) + expect((await call(key('reader'), 'DELETE', f.users.reader)).status).toBe(404) + }) +}) diff --git a/apps/sim/lib/workspaces/api/route-policies.ts b/apps/sim/lib/workspaces/api/route-policies.ts index e1c4f6115af..d333d04dc37 100644 --- a/apps/sim/lib/workspaces/api/route-policies.ts +++ b/apps/sim/lib/workspaces/api/route-policies.ts @@ -1,8 +1,13 @@ +import { findCause } from '@sim/utils/errors' import { createInternalResourceConcealmentPolicy, createV2ResourceConcealmentPolicy, internalOrchestrationErrorPolicy, } from '@/lib/api/server/routes' +import { isRetryableTransactionError } from '@/lib/db/transaction' +import { WorkspaceBusyError } from '@/lib/workspaces/permissions/management-store' +import { WorkspaceBillingAccountRemovalError } from '@/lib/workspaces/utils' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' /** * A caller naming a workspace it cannot reach must not be able to tell that @@ -11,10 +16,30 @@ import { * `get-public-workspace` and `list-public-workspace-members` already uses, so * the two responses are byte-identical. */ +function isWorkspaceBusyError(error: unknown): error is WorkspaceBusyError { + return error instanceof WorkspaceBusyError +} + export const v2WorkspaceErrorPolicies = { concealWorkspaceAuthorization: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Workspace not found', }), + /** + * Member administration adds two actionable failures the shared projection + * would answer as 500: an owner removal with no billing account to inherit + * (409) and contention that outlived the transaction (503, whether the + * removal's driver error or the role store's busy refusal surfaces it). + */ + manageWorkspaceMembers: createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', + render(error) { + if (error instanceof WorkspaceBillingAccountRemovalError) + return v2Error('CONFLICT', error.message) + if (isRetryableTransactionError(error) || findCause(error, isWorkspaceBusyError)) + return v2Error('SERVICE_UNAVAILABLE', 'The workspace is busy; retry in a moment') + return v2CaughtOrchestrationError(error) + }, + }), } as const export const internalWorkspaceErrorPolicies = { diff --git a/apps/sim/lib/workspaces/application/manage-permissions.test.ts b/apps/sim/lib/workspaces/application/manage-permissions.test.ts index 8af2a28ecbd..681d1b2bd87 100644 --- a/apps/sim/lib/workspaces/application/manage-permissions.test.ts +++ b/apps/sim/lib/workspaces/application/manage-permissions.test.ts @@ -1,8 +1,8 @@ import type { Principal } from '@sim/auth/principal' import { createDelegatedPrincipal, - createPersonalApiKeyPrincipal, createSessionPrincipal, + createWorkspaceApiKeyPrincipal, } from '@sim/testing/factories/principal.factory' import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock' @@ -27,6 +27,7 @@ vi.mock('@/lib/workspaces/permissions/management-store', async (importOriginal) reconcileWorkspacePermissionCredentials: hoisted.reconcile, })) +import { forbiddenErrorDetails } from '@/lib/core/application/forbidden' import { COPILOT_APPLICATION_SYSTEM_ERROR_MESSAGE, messageForCopilotApplicationError, @@ -118,14 +119,16 @@ describe('workspace permission operations', () => { expect(mocks.update).not.toHaveBeenCalled() }) - it('rejects unsupported principals before canonical loading', async () => { - await expect( - updateWorkspacePermissions.execute({ - principal: createPersonalApiKeyPrincipal({ userId: 'actor', keyId: 'key' }), + it('rejects a workspace key, even for its own workspace, before canonical loading', async () => { + const result = await updateWorkspacePermissions + .execute({ + principal: createWorkspaceApiKeyPrincipal({ workspaceId: 'workspace', keyId: 'key' }), input, }) - ).rejects.toThrow() + .catch((error: unknown) => error) + expect(forbiddenErrorDetails(result)).toEqual({ code: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED' }) expect(mocks.context).not.toHaveBeenCalled() + expect(mocks.update).not.toHaveBeenCalled() }) it('rejects duplicate direct application updates before the mutation', async () => { diff --git a/apps/sim/lib/workspaces/application/manage-permissions.ts b/apps/sim/lib/workspaces/application/manage-permissions.ts index 57507c2710e..2b5f75fb71d 100644 --- a/apps/sim/lib/workspaces/application/manage-permissions.ts +++ b/apps/sim/lib/workspaces/application/manage-permissions.ts @@ -1,10 +1,12 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { type Principal, resolvePrincipalSubject } from '@sim/auth/principal' -import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' -import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { PermissionType } from '@sim/platform-authz/workspace' +import { findCause } from '@sim/utils/errors' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { HttpError } from '@/lib/core/utils/http-error' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' +import { workspacePermissionOperations } from '@/lib/workspaces/application/operations' import { type WorkspacePermissionUpdate, workspacePermissionUpdatesSchema, @@ -14,29 +16,7 @@ import { updateWorkspacePermissionRecords, } from '@/lib/workspaces/permissions/management-store' import { getWorkspacePermissionsForViewer } from '@/lib/workspaces/permissions/utils' - -export const workspacePermissionOperations = { - /** permission-group-exempt: the roster describes the workspace the caller belongs to. */ - read: defineWorkspaceOperation({ - id: 'workspaces.permissions.read', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['session', 'delegated'], - delegatedServices: ['copilot'], - capability: 'none', - contentDelivery: 'none', - }), - /** permission-group-exempt: workspace administrators manage existing collaborator roles. */ - update: defineWorkspaceOperation({ - id: 'workspaces.permissions.update', - minimumRole: 'admin', - workspaceApiKey: 'deny', - principalKinds: ['session', 'delegated'], - delegatedServices: ['copilot'], - capability: 'none', - contentDelivery: 'none', - }), -} as const +import { getPublicWorkspaceMember } from '@/lib/workspaces/public-queries' export interface ReadWorkspacePermissionsInput { workspaceId: string @@ -75,56 +55,109 @@ export const readWorkspacePermissions = defineAuthorizedWorkspaceUseCase({ }, }) +const STORE_FAILURE_CODES: Partial> = { + 400: 'validation', + 403: 'forbidden', + 404: 'not_found', + 409: 'conflict', +} + +/** + * The caller-safe failure a store error stands for, or the error itself when it + * names nothing a caller may see: the store's typed errors promise caller-safe + * wording, driver failures stay private. A coded refusal the HTTP error wraps + * keeps its code (`SCIM_MANAGED_MEMBERSHIP`), and the HTTP error stays the + * `cause` the internal route renders. + */ +function callerSafeStoreFailure(error: unknown): unknown { + const httpError = findCause( + error, + (cause): cause is HttpError => + cause instanceof HttpError && STORE_FAILURE_CODES[cause.statusCode] !== undefined + ) + const code = httpError && STORE_FAILURE_CODES[httpError.statusCode] + if (!httpError || !code) return error + const refusal = findCause( + httpError.cause, + (cause): cause is ForbiddenOperationError => cause instanceof ForbiddenOperationError + ) + const failure = refusal + ? new ForbiddenOperationError(refusal.detailCode, httpError.message) + : new OrchestrationError(code, httpError.message) + failure.cause = httpError + return failure +} + +async function applyPermissionUpdates( + workspaceId: string, + principal: Principal, + updates: WorkspacePermissionUpdate[] +) { + const parsed = workspacePermissionUpdatesSchema.safeParse({ updates }) + if (!parsed.success) throw new OrchestrationError('validation', parsed.error.issues[0].message) + return updateWorkspacePermissionRecords( + workspaceId, + actorUserId(principal), + parsed.data.updates + ).catch((error: unknown) => { + throw callerSafeStoreFailure(error) + }) +} + +type PermissionChange = Awaited>[number] + +function projectPermissionChangeAudit(workspaceId: string, changes: PermissionChange[]) { + return changes.map((change) => ({ + action: AuditAction.MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: change.targetEmail ?? change.targetUserId, + description: `Changed permissions for ${change.targetEmail ?? change.targetUserId} from ${change.previousRole ?? 'unknown'} to ${change.newRole}`, + metadata: change, + })) +} + export const updateWorkspacePermissions = defineAuthorizedWorkspaceUseCase({ operation: workspacePermissionOperations.update, resolveContext: ({ input }: { input: UpdateWorkspacePermissionsInput }) => resolveContext({ input }), authorizationOptions, async execute({ principal, input, context }) { - const parsed = workspacePermissionUpdatesSchema.safeParse(input) - if (!parsed.success) throw new OrchestrationError('validation', parsed.error.issues[0].message) - const changes = await updateWorkspacePermissionRecords( - context.workspaceId, - actorUserId(principal), - parsed.data.updates - ).catch((error: unknown) => { - // This store's typed errors promise caller-safe wording; driver failures remain private. - let cause: unknown = error - const seen = new Set() - while (cause instanceof Error && !seen.has(cause)) { - seen.add(cause) - if (cause instanceof HttpError) { - const code = - cause.statusCode === 400 - ? 'validation' - : cause.statusCode === 403 - ? 'forbidden' - : cause.statusCode === 404 - ? 'not_found' - : cause.statusCode === 409 - ? 'conflict' - : undefined - if (code) { - const failure = new OrchestrationError(code, cause.message) - failure.cause = cause - throw failure - } - } - cause = cause.cause - } - throw error - }) + const changes = await applyPermissionUpdates(context.workspaceId, principal, input.updates) return { message: 'Permissions updated successfully', changes } }, projectAudit: ({ context, result }) => - result.changes.map((change) => ({ - action: AuditAction.MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: context.workspaceId, - resourceName: change.targetEmail ?? change.targetUserId, - description: `Changed permissions for ${change.targetEmail ?? change.targetUserId} from ${change.previousRole ?? 'unknown'} to ${change.newRole}`, - metadata: change, - })), + projectPermissionChangeAudit(context.workspaceId, result.changes), + afterSuccess: ({ principal, context }) => + reconcileWorkspacePermissionCredentials(context.workspaceId, actorUserId(principal)), +}) + +export interface UpdateWorkspaceMemberInput extends ReadWorkspacePermissionsInput { + userId: string + role: PermissionType +} + +/** + * One member's role change through the same operation and store as + * {@link updateWorkspacePermissions}, answered with the member as the roster + * shows them; requesting the current role changes and audits nothing. The + * member is read before the write so a failed read cannot leave a committed, + * unaudited change. + */ +export const updateWorkspaceMember = defineAuthorizedWorkspaceUseCase({ + operation: workspacePermissionOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceMemberInput }) => resolveContext({ input }), + authorizationOptions, + async execute({ principal, input, context }) { + const member = await getPublicWorkspaceMember(context.workspaceId, input.userId) + if (!member) throw new OrchestrationError('not_found', 'Member not found') + const changes = await applyPermissionUpdates(context.workspaceId, principal, [ + { userId: input.userId, permissions: input.role }, + ]) + return { changes, member: { ...member, role: input.role } } + }, + projectAudit: ({ context, result }) => + projectPermissionChangeAudit(context.workspaceId, result.changes), afterSuccess: ({ principal, context }) => reconcileWorkspacePermissionCredentials(context.workspaceId, actorUserId(principal)), }) diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts index f7d23bb3493..118f382157e 100644 --- a/apps/sim/lib/workspaces/application/operations.ts +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -39,3 +39,41 @@ export const workspaceOperations = { delegatedServices: ['copilot'], }), } as const + +export const workspacePermissionOperations = { + /** permission-group-exempt: the roster describes the workspace the caller belongs to. */ + read: defineWorkspaceOperation({ + id: 'workspaces.permissions.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session', 'delegated'], + delegatedServices: ['copilot'], + capability: 'none', + contentDelivery: 'none', + }), + /** permission-group-exempt: workspace administrators manage existing collaborator roles. */ + update: defineWorkspaceOperation({ + id: 'workspaces.permissions.update', + minimumRole: 'admin', + workspaceApiKey: 'deny', + oauthScope: 'api:write', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], + delegatedServices: ['copilot'], + capability: 'none', + contentDelivery: 'none', + }), +} as const + +export const workspaceMemberOperations = { + /** permission-group-exempt: members may leave; workspace administrators may remove existing collaborators. */ + remove: defineWorkspaceOperation({ + id: 'workspaces.members.remove', + minimumRole: 'read', + workspaceApiKey: 'deny', + capability: 'none', + contentDelivery: 'none', + oauthScope: 'api:write', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], + delegatedServices: ['copilot'], + }), +} as const diff --git a/apps/sim/lib/workspaces/application/remove-member.ts b/apps/sim/lib/workspaces/application/remove-member.ts index 9fc09180883..b176619cff8 100644 --- a/apps/sim/lib/workspaces/application/remove-member.ts +++ b/apps/sim/lib/workspaces/application/remove-member.ts @@ -1,29 +1,18 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase, requireCurrentHumanRole } from '@/lib/core/application' -import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' +import { workspaceMemberOperations } from '@/lib/workspaces/application/operations' import { removeWorkspaceMemberRecord } from '@/lib/workspaces/permissions/member-removal-store' -/** permission-group-exempt: members may leave; workspace administrators may remove existing collaborators. */ -export const removeWorkspaceMemberOperation = defineWorkspaceOperation({ - id: 'workspaces.members.remove', - minimumRole: 'read', - workspaceApiKey: 'deny', - capability: 'none', - contentDelivery: 'none', - principalKinds: ['session', 'delegated'], - delegatedServices: ['copilot'], -}) - export interface RemoveWorkspaceMemberInput { workspaceId: string userId: string } export const removeWorkspaceMember = defineAuthorizedWorkspaceUseCase({ - operation: removeWorkspaceMemberOperation, + operation: workspaceMemberOperations.remove, resolveContext: ({ input }: { input: RemoveWorkspaceMemberInput }) => resolveActiveWorkspaceApplicationContext(input.workspaceId), authorizationOptions: { delegation: { audience: 'sim:settings', isWithinScope: () => true } }, diff --git a/apps/sim/lib/workspaces/permissions/management-store.ts b/apps/sim/lib/workspaces/permissions/management-store.ts index 3f996df28eb..ee2b035d86b 100644 --- a/apps/sim/lib/workspaces/permissions/management-store.ts +++ b/apps/sim/lib/workspaces/permissions/management-store.ts @@ -78,7 +78,7 @@ class WorkspaceContextChangedError extends HttpError { * reach the client as a generic server error, which is what bounding the lock * wait would otherwise have produced. */ -class WorkspaceBusyError extends HttpError { +export class WorkspaceBusyError extends HttpError { readonly statusCode = 409 constructor() { @@ -87,12 +87,16 @@ class WorkspaceBusyError extends HttpError { } } -/** A target whose membership the organization's directory owns; the guard's own wording names the remedy. */ +/** + * A target whose membership the organization's directory owns; the guard's own + * wording names the remedy. The coded refusal stays its `cause` so surfaces that + * publish `details.code` can still name it. + */ class DirectoryManagedMemberError extends HttpError { readonly statusCode = 403 constructor(cause: ForbiddenOperationError) { - super(cause.message) + super(cause.message, { cause }) this.name = 'DirectoryManagedMemberError' } } diff --git a/apps/sim/lib/workspaces/public-queries.ts b/apps/sim/lib/workspaces/public-queries.ts index 14c81417b6e..68dd082d10b 100644 --- a/apps/sim/lib/workspaces/public-queries.ts +++ b/apps/sim/lib/workspaces/public-queries.ts @@ -13,7 +13,7 @@ export interface PublicWorkspaceDetail { updatedAt: Date } -interface WorkspaceMemberRow { +export interface WorkspaceMemberRow { userId: string email: string name: string @@ -26,6 +26,8 @@ interface WorkspaceMemberRow { interface QueryWorkspaceMembersOptions { limit: number afterEmail?: string + /** Restricts both member sources to one user, for a single-member read. */ + userId?: string } export interface WorkspaceMemberPage { @@ -153,6 +155,7 @@ export async function queryPublicWorkspaceMembers( const emailOrder = sql`${user.email} COLLATE "C"` const emailCursor = options.afterEmail ? gt(emailOrder, options.afterEmail) : undefined + const userFilter = options.userId ? eq(user.id, options.userId) : undefined const sourceLimit = options.limit + 1 const hasRelevantOrganizationMembership = workspaceRow.organizationId ? sql`EXISTS ( @@ -183,7 +186,8 @@ export async function queryPublicWorkspaceMembers( and( eq(permissions.entityType, 'workspace'), eq(permissions.entityId, workspaceId), - emailCursor + emailCursor, + userFilter ) ) .orderBy(asc(emailOrder)) @@ -204,7 +208,8 @@ export async function queryPublicWorkspaceMembers( and( eq(member.organizationId, workspaceRow.organizationId), inArray(member.role, [...ORG_ADMIN_ROLES]), - emailCursor + emailCursor, + userFilter ) ) .orderBy(asc(emailOrder)) @@ -259,3 +264,16 @@ export async function queryPublicWorkspaceMembers( nextEmail: hasMore ? (members.at(-1)?.email ?? null) : null, } } + +/** + * One effective member, projected exactly as the roster projects them, or null + * when the user holds neither an explicit grant nor inherited organization + * admin access (or the workspace is archived). + */ +export async function getPublicWorkspaceMember( + workspaceId: string, + userId: string +): Promise { + const page = await queryPublicWorkspaceMembers(workspaceId, { limit: 1, userId }) + return page?.members[0] ?? null +} diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 8118dd99ca6..831ebd1c710 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1518,17 +1518,34 @@ export const CLI_CONTRACT: CliContract = { ], }, listWorkspaceMembers: { - command: 'workspaces members', + command: 'workspaces members list', + // `workspaces members` shipped as this list before the group gained update + // and remove, so naming the group still lists. + defaultSubcommand: true, describe: 'List workspace members', profileWorkspacePath: true, + // `members update` and `members remove` take the user id, and the listing + // is where a caller finds it. Appended last so the shipped text columns keep + // their positions for scripts that read them by field. columns: [ { header: 'email' }, { header: 'name' }, { header: 'role' }, { header: 'external', path: 'isExternal', format: 'bool' }, { header: 'joined', path: 'joinedAt', format: 'timestamp' }, + { header: 'user', path: 'userId' }, ], }, + updateWorkspaceMember: { + command: 'workspaces members update', + profileWorkspacePath: true, + }, + removeWorkspaceMember: { + command: 'workspaces members remove', + profileWorkspacePath: true, + confirm: + 'This removes the member’s workspace access. If it was their last workspace in the organization, their organization membership is removed and their sessions end.', + }, listAuditLogs: { allWorkspaces: true, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 992fa1a7246..17621517fa6 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -12677,6 +12677,24 @@ export type RemovePermissionGroupMemberResponse = { data: RemovePermissionGroupMemberResponseRef0 } +/** `DELETE /api/v2/workspaces/[workspaceId]/members/[userId]` */ +export type RemoveWorkspaceMemberParams = { + workspaceId: string + userId: string +} + +export type RemoveWorkspaceMemberQuery = Record + +type RemoveWorkspaceMemberResponseRef0 = { + userId: string + deleted: true + organizationMembershipRemoved: boolean +} + +export type RemoveWorkspaceMemberResponse = { + data: RemoveWorkspaceMemberResponseRef0 +} + /** `PATCH /api/v2/files/[fileId]` */ export type RenameFileParams = { fileId: string @@ -15854,6 +15872,32 @@ export type UpdateWorkspaceForkMappingsResponse = { data: UpdateWorkspaceForkMappingsResponseRef0 } +/** `PATCH /api/v2/workspaces/[workspaceId]/members/[userId]` */ +export type UpdateWorkspaceMemberParams = { + workspaceId: string + userId: string +} + +export type UpdateWorkspaceMemberQuery = Record + +export type UpdateWorkspaceMemberBody = { + role: 'admin' | 'write' | 'read' +} + +type UpdateWorkspaceMemberResponseRef0 = { + userId: string + email: string + name: string + image: string | null + role: 'admin' | 'write' | 'read' + isExternal: boolean + joinedAt: string +} + +export type UpdateWorkspaceMemberResponse = { + data: UpdateWorkspaceMemberResponseRef0 +} + /** `POST /api/v2/knowledge/[knowledgeBaseId]/documents` */ export type UploadKnowledgeDocumentParams = { knowledgeBaseId: string @@ -23102,6 +23146,18 @@ export const V2_OPERATIONS = { summary: 'Remove permission group member', workspaceKeyUnsupported: true, }, + removeWorkspaceMember: { + method: 'DELETE', + path: '/api/v2/workspaces/[workspaceId]/members/[userId]', + pathParams: ['workspaceId', 'userId'] as const, + pathParamDocs: { + workspaceId: 'Workspace the member belongs to.', + userId: 'User identifier of the workspace member, as returned by List workspace members.', + }, + responseMode: 'json', + summary: 'Remove workspace member', + workspaceKeyUnsupported: true, + }, renameFile: { method: 'PATCH', path: '/api/v2/files/[fileId]', @@ -25053,6 +25109,27 @@ export const V2_OPERATIONS = { }, }, }, + updateWorkspaceMember: { + method: 'PATCH', + path: '/api/v2/workspaces/[workspaceId]/members/[userId]', + pathParams: ['workspaceId', 'userId'] as const, + pathParamDocs: { + workspaceId: 'Workspace the member belongs to.', + userId: 'User identifier of the workspace member, as returned by List workspace members.', + }, + responseMode: 'json', + summary: 'Update workspace member', + workspaceKeyUnsupported: true, + body: { + role: { + kind: 'enum', + required: true, + values: ['admin', 'write', 'read'] as const, + describe: + 'New workspace role. Inherited organization Admin access, the workspace owner, and the workspace billing account keep Admin and cannot be changed here.', + }, + }, + }, uploadKnowledgeDocument: { method: 'POST', path: '/api/v2/knowledge/[knowledgeBaseId]/documents', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 97fafa33d86..3d61ffca49f 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -447,6 +447,7 @@ describe('destructive operations are gated', () => { 'createOrganizationInvitation', 'resendOrganizationInvitation', 'updateOrganizationMember', + 'updateWorkspaceMember', 'createPermissionGroup', 'updatePermissionGroup', 'addPermissionGroupMember', diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 8c765eedd11..47efd80c360 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -459,7 +459,7 @@ export async function executeOperation( const omitsWorkspace = commandSpec.allWorkspaces && requestFlags.allWorkspaces === true /** * A workspace carried in the path is resolved exactly like one carried in a - * field. `workspaces get` and `workspaces members` take theirs as a path + * field. `workspaces get` and `workspaces members list` take theirs as a path * parameter, so they skipped `requireWorkspace` and fell into `buildRequest`'s * own fallback: a second wording for the same precondition, and — because * `requireWorkspace` checks the key first — advice to set a workspace on an diff --git a/packages/sim-cli/src/runtime/group-default.test.ts b/packages/sim-cli/src/runtime/group-default.test.ts index 9fa9c58eee4..cd0d0064a73 100644 --- a/packages/sim-cli/src/runtime/group-default.test.ts +++ b/packages/sim-cli/src/runtime/group-default.test.ts @@ -43,6 +43,26 @@ describe('a nested group that defaults to its list', () => { expect(await searchOf(['credentials', 'providers', '--search', 'github'])).toEqual(['github']) }) + it('lists workspace members under the shipped `workspaces members` spelling, options included', async () => { + const pathsAndLimits = async (argv: string[]) => { + const seen: string[] = [] + await runEmbeddedCli(argv, { + ...identity, + transport: async (url) => { + const parsed = new URL(String(url)) + seen.push(`${parsed.pathname}?limit=${parsed.searchParams.get('limit')}`) + return Response.json({ data: [], nextCursor: null }) + }, + }) + return seen + } + const expected = ['/api/v2/workspaces/workspace/members?limit=5'] + expect(await pathsAndLimits(['workspaces', 'members', '--limit', '5'])).toEqual(expected) + expect(await pathsAndLimits(['workspaces', 'members', 'list', '--limit', '5'])).toEqual( + expected + ) + }) + it('leaves a group without a default printing its usage, sending nothing', async () => { const group = await run(['credentials']) expect(group.paths).toEqual([]) diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index dff09edea85..999b30ba486 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -3808,7 +3808,6 @@ "apps/sim/lib/api/contracts/v2/workspaces.ts#V2ListWorkspaceMembersQuery", "apps/sim/lib/api/contracts/v2/workspaces.ts#V2ListWorkspacesQuery", "apps/sim/lib/api/contracts/v2/workspaces.ts#V2Workspace", - "apps/sim/lib/api/contracts/v2/workspaces.ts#V2WorkspaceMember", "apps/sim/lib/api/contracts/v2/workspaces.ts#V2WorkspaceMemberCursor", "apps/sim/lib/api/contracts/v2/workspaces.ts#V2WorkspaceParams", "apps/sim/lib/api/contracts/v2/workspaces.ts#V2WorkspaceSortBy", diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 762bf4ee069..9d442e36519 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -466,6 +466,15 @@ const LEGACY_COMMAND_ANCHORS: Record = { 'wait-for-copy-and-deployment-readiness-exit-3-for-configuration-1-for-failure-or-4-for-timeout', } +/** + * Reference-page anchors a renamed command keeps, so links to the spelling it + * shipped under still land on it. `workspaces members` became a group whose + * default subcommand is `list`. + */ +const LEGACY_REFERENCE_ANCHORS: Record = { + 'workspaces members list': 'sim-workspaces-members', +} + const VERB_ORDER = [ 'list', 'ls', @@ -913,7 +922,9 @@ function renderCommand(entry: DocumentedCommand, options: RenderOptions = {}): s const description = entry.command.description() const operation = operationOf(entry.command) const spec = operation ? CLI_CONTRACT[operation] : undefined - const anchor = commandHeadings ? undefined : LEGACY_COMMAND_ANCHORS[entry.path.join(' ')] + const anchor = (commandHeadings ? LEGACY_REFERENCE_ANCHORS : LEGACY_COMMAND_ANCHORS)[ + entry.path.join(' ') + ] const lines = [`${'#'.repeat(level)} ${escapeProse(heading)}${anchor ? ` [#${anchor}]` : ''}`, ''] if (commandHeadings && description) lines.push(escapeProse(sentenceCase(description)), '') if (!commandHeadings) {