diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md index e7f0039e84b..6caa5ff7c57 100644 --- a/.agents/skills/migrate-application-operation/SKILL.md +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -78,6 +78,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -270,6 +307,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.claude/commands/migrate-application-operation.md b/.claude/commands/migrate-application-operation.md index bc61333c358..d8048553e78 100644 --- a/.claude/commands/migrate-application-operation.md +++ b/.claude/commands/migrate-application-operation.md @@ -77,6 +77,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -269,6 +306,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/.cursor/commands/migrate-application-operation.md b/.cursor/commands/migrate-application-operation.md index 9fac674ca6f..0742f452523 100644 --- a/.cursor/commands/migrate-application-operation.md +++ b/.cursor/commands/migrate-application-operation.md @@ -73,6 +73,43 @@ Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent oper Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. +## Freeze observable behavior before editing + +Treat the legacy route or tool as an ordered program, not merely a bag of business logic. Before moving code, write a compact baseline for every in-scope entry point and add focused characterization tests for behavior not already pinned down. + +Capture all of these when they apply: + +- Accepted inputs, including trimming, blank omission, duplicate query keys, aliases, defaults, and bounds. +- Authentication and authorization order, minimum roles, resource membership, concealment, and exact error/status mapping. +- Exact success bodies, optional fields, status codes, redirects, cookies, headers, and binary or stream behavior. +- Mutation ordering, transaction boundaries, idempotency, no-ops, and observable state after each possible partial failure. +- Audit, notification, analytics, and billing timing plus exact semantic dimensions and attribution. +- Browser or protocol state ownership, concurrency isolation, expiry, callback ordering, and cleanup behavior. +- Every value newly crossing into HTML, JavaScript, SQL, URLs, logs, provider payloads, or another encoding context. + +Compare the old statement order with the proposed application lifecycle explicitly: + +```text +legacy parse/normalize + -> legacy authorization checks + -> branch-specific canonical lookup + -> mutation(s) + -> per-step side effects + -> response or redirect catch +``` + +Moving those steps under a wrapper may change behavior even when each individual call is reused. In particular: + +- `projectAudit` and `afterSuccess` run only after `execute` returns. They cannot describe earlier committed mutations when a later step throws. Make the compound mutation atomic or define explicit partial-result/failure projection semantics before migrating it. +- Operation metadata is executable policy. Adding a resource role to a workspace-only legacy read is an authorization change, not an architectural cleanup. +- A shared error policy does not automatically preserve route-local concealment, subclass ordering, browser redirects, or branch-specific messages. +- A shared contract does not automatically preserve manual `URLSearchParams` normalization or exact legacy response unions. +- A shared use case may own domain behavior while separate surface presenters still preserve different wire shapes. +- Per-flow identity is insufficient when another part of the flow remains in browser-global state such as one cookie. +- Passing a newly supported parameter through old rendering code creates a new security boundary even when the renderer itself is unchanged. + +Fail fast if the baseline cannot be established from code, tests, or an explicit product decision. Do not infer that behavior is unimportant because it was previously implicit. + ## Keep the layers distinct Use these responsibilities: @@ -265,6 +302,10 @@ Add focused tests for every migrated surface and principal kind allowed by the o - Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. - Copilot or tools: trusted context, exact registered operation membership, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. - Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. +- Compatibility characterization: legacy normalization, exact response/redirect/cookie behavior, concealment, error subclass precedence, and branch-specific output. +- Failure sequencing: inject a failure after each independently committing step and assert persisted state plus audit, analytics, and notification effects. +- Concurrency: overlap stateful browser or provider flows and prove each callback consumes only its own state and return destination. +- Rendering boundaries: exercise hostile values for every newly connected input that reaches HTML, inline JavaScript, URLs, logs, or provider requests. Run at minimum: diff --git a/apps/docs/content/docs/en/integrations/logrocket.mdx b/apps/docs/content/docs/en/integrations/logrocket.mdx index b986ace5ccb..de718d20190 100644 --- a/apps/docs/content/docs/en/integrations/logrocket.mdx +++ b/apps/docs/content/docs/en/integrations/logrocket.mdx @@ -188,4 +188,3 @@ Register a release version in LogRocket so uploaded source maps can decode stack | --------- | ---- | ----------- | | `version` | string | Release version that was registered | - diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 77de6a7d2ca..122cc4f3e99 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -479,7 +479,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 045fd369674..f2e459d88fa 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2268,7 +2268,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 6f3da73eccb..a1b2d4d6eaf 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -2213,7 +2213,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 46f3e4fec47..cc4cfd86202 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -601,7 +601,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 30d0b7a5ff5..a9b3861e23c 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -39,7 +39,7 @@ }, { "name": "Credentials", - "description": "List OAuth and service-account connections without secret material." + "description": "Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material." }, { "name": "Secrets", @@ -1574,7 +1574,7 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", "tags": ["Credentials"], "parameters": [ { @@ -1716,102 +1716,131 @@ "$ref": "#/components/responses/ServiceUnavailable" } } + }, + "post": { + "operationId": "createServiceAccountCredential", + "summary": "Create Service-Account Credential", + "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], + "requestBody": { + "required": true, + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateServiceAccountCredentialRequest" + } + } + } + }, + "responses": { + "200": { + "description": "An existing credential matched the verified source.", + "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/CreateServiceAccountCredentialResponse" + } + } + } + }, + "201": { + "description": "The service-account credential was created.", + "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/CreateServiceAccountCredentialResponse" + } + } + } + }, + "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" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } } }, - "/api/v2/secrets": { + "/api/v2/credentials/providers": { "get": { - "operationId": "listSecrets", - "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "operationId": "listCredentialProviders", + "summary": "List Credential Providers", + "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", + "tags": ["Credentials"], "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace whose secret metadata should be listed.", + "description": "Workspace used to evaluate credential-provider availability and integration policy.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace whose secret metadata should be listed." - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Restrict results to one ownership scope.", - "schema": { - "description": "Restrict results to one ownership scope.", - "type": "string", - "enum": ["workspace", "personal"] + "description": "Workspace used to evaluate credential-provider availability and integration policy." } }, { "name": "search", "in": "query", "required": false, - "description": "Case-insensitive substring match against the secret name.", + "description": "Case-insensitive substring match against the credential provider name.", "schema": { - "description": "Case-insensitive substring match against the secret name.", + "description": "Case-insensitive substring match against the credential provider name.", "type": "string", "minLength": 1, "maxLength": 200 } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } } ], "responses": { "200": { - "description": "Secret metadata visible to the caller.", + "description": "Credential provider catalog with caller-specific availability.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1826,7 +1855,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListSecretsResponse" + "$ref": "#/components/schemas/ListCredentialProvidersResponse" } } } @@ -1855,62 +1884,26 @@ } } }, - "/api/v2/secrets/{name}": { - "put": { - "operationId": "setSecret", - "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], - "parameters": [ - { - "name": "name", - "in": "path", - "required": true, - "description": "Secret to create, replace, or delete.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." - } - } - ], + "/api/v2/credentials/connections": { + "post": { + "operationId": "createCredentialConnection", + "summary": "Create Credential Connection", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], "requestBody": { "required": true, - "description": "Ownership scope and write-only value for the secret.", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretRequest" + "$ref": "#/components/schemas/CreateCredentialConnectionBody" } } } }, "responses": { "200": { - "description": "The existing secret value was replaced.", - "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/SetSecretResponse" - } - } - } - }, - "201": { - "description": "The secret was created.", + "description": "A short-lived browser authorization URL.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1925,7 +1918,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/SetSecretResponse" + "$ref": "#/components/schemas/CreateCredentialConnectionResponse" } } } @@ -1942,6 +1935,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -1955,53 +1951,43 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, + } + }, + "/api/v2/credentials/{credentialId}": { "delete": { - "operationId": "deleteSecret", - "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", - "tags": ["Secrets"], + "operationId": "deleteCredential", + "summary": "Disconnect Credential", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Credentials"], "parameters": [ { - "name": "name", + "name": "credentialId", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Credential to disconnect.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, - "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Credential to disconnect." } }, { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "description": "Workspace expected to own the credential.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." - } - }, - { - "name": "scope", - "in": "query", - "required": true, - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", - "schema": { - "type": "string", - "enum": ["workspace", "personal"], - "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + "description": "Workspace expected to own the credential." } } ], "responses": { "200": { - "description": "The secret was deleted.", + "description": "The credential was disconnected.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2016,7 +2002,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteSecretResponse" + "$ref": "#/components/schemas/DeleteCredentialResponse" } } } @@ -2044,35 +2030,362 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - } }, - "headers": { - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", + "/api/v2/secrets": { + "get": { + "operationId": "listSecrets", + "summary": "List Secrets", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose secret metadata should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose secret metadata should be listed." + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Restrict results to one ownership scope.", + "schema": { + "description": "Restrict results to one ownership scope.", + "type": "string", + "enum": ["workspace", "personal"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the secret name.", + "schema": { + "description": "Case-insensitive substring match against the secret name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum secrets to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "Secret metadata visible to the caller.", + "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/ListSecretsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/secrets/{name}": { + "put": { + "operationId": "setSecret", + "summary": "Set Secret", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Secret to create, replace, or delete.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to create, replace, or delete." + } + } + ], + "requestBody": { + "required": true, + "description": "Ownership scope and write-only value for the secret.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SetSecretRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The existing secret value was replaced.", + "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/SetSecretResponse" + } + } + } + }, + "201": { + "description": "The secret was created.", + "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/SetSecretResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "delete": { + "operationId": "deleteSecret", + "summary": "Delete Secret", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", + "tags": ["Secrets"], + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "description": "Secret to create, replace, or delete.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret to create, replace, or delete." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace the request is authorized against. A workspace secret is deleted from it; a personal secret is deleted for the caller in all of their workspaces." + } + }, + { + "name": "scope", + "in": "query", + "required": true, + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace.", + "schema": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + } + } + ], + "responses": { + "200": { + "description": "The secret was deleted.", + "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/DeleteSecretResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", "description": "Requests remaining in the current window." } }, @@ -2279,7 +2592,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], @@ -3529,70 +3842,264 @@ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "description": "ISO 8601 timestamp when the tool was last updated." } - }, - "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], - "additionalProperties": false, - "title": "Custom tool", - "description": "A workspace custom tool and its callable function declaration." + }, + "required": ["id", "title", "schema", "code", "createdAt", "updatedAt"], + "additionalProperties": false, + "title": "Custom tool", + "description": "A workspace custom tool and its callable function declaration." + }, + "ListCustomToolsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CustomTool" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List custom tools response", + "description": "Custom tools defined in the workspace.", + "examples": [ + { + "data": [ + { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "CreateCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create custom tool response", + "description": "The created custom tool.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "CreateCustomToolRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the custom tool." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display title, unique within the workspace." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "function", + "description": "Function declaration discriminator." + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Function name presented to the model." + }, + "description": { + "description": "Optional explanation of what the function does.", + "type": "string" + }, + "parameters": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "JSON Schema type for the arguments, usually `object`." + }, + "properties": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Caller-defined JSON Schema for one tool argument." + }, + "description": "Caller-defined argument schemas keyed by argument name." + }, + "required": { + "description": "Names of required arguments.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["type", "properties"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "JSON Schema describing the arguments accepted by the tool." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["type", "function"], + "additionalProperties": { + "description": "Caller-defined extension value preserved by the public API." + }, + "description": "OpenAI-style function declaration describing the callable tool surface." + }, + "code": { + "type": "string", + "maxLength": 100000, + "description": "Tool implementation executed in the sandboxed function runtime." + } + }, + "required": ["workspaceId", "title", "schema", "code"], + "additionalProperties": false, + "title": "Create custom tool request", + "description": "Definition and implementation of a new custom tool.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] + } + } + }, + "code": "return { ok: true }" + } + ] }, - "ListCustomToolsResponse": { + "GetCustomToolResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2CustomTool" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomTool" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "List custom tools response", - "description": "Custom tools defined in the workspace.", + "title": "Get custom tool response", + "description": "One custom tool.", "examples": [ { - "data": [ - { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "title": "lookup_order", + "schema": { + "type": "function", + "function": { + "name": "lookup_order", + "description": "Look up an order by id", + "parameters": { + "type": "object", + "properties": { + "orderId": { + "type": "string" + } + }, + "required": ["orderId"] } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null + } + }, + "code": "return { ok: true }", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } } ] }, - "CreateCustomToolResponse": { + "UpdateCustomToolResponse": { "type": "object", "properties": { "data": { @@ -3602,8 +4109,8 @@ }, "required": ["data"], "additionalProperties": false, - "title": "Create custom tool response", - "description": "The created custom tool.", + "title": "Update custom tool response", + "description": "The updated custom tool.", "examples": [ { "data": { @@ -3625,29 +4132,30 @@ } } }, - "code": "return { ok: true }", + "code": "return { ok: false }", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "CreateCustomToolRequest": { + "UpdateCustomToolRequest": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the custom tool." + "description": "Workspace that owns the custom tool." }, "title": { + "description": "New display title for the tool.", "type": "string", "minLength": 1, - "maxLength": 200, - "description": "Display title, unique within the workspace." + "maxLength": 200 }, "schema": { + "description": "Replacement function declaration.", "type": "object", "properties": { "type": { @@ -3709,389 +4217,823 @@ "required": ["type", "function"], "additionalProperties": { "description": "Caller-defined extension value preserved by the public API." - }, - "description": "OpenAI-style function declaration describing the callable tool surface." + } }, "code": { + "description": "Replacement tool implementation.", "type": "string", - "maxLength": 100000, - "description": "Tool implementation executed in the sandboxed function runtime." + "maxLength": 100000 } }, - "required": ["workspaceId", "title", "schema", "code"], + "required": ["workspaceId"], "additionalProperties": false, - "title": "Create custom tool request", - "description": "Definition and implementation of a new custom tool.", + "title": "Update custom tool request", + "description": "Custom tool fields to change; at least one editable field is required.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }" + "code": "return { ok: false }" + } + ] + }, + "V2CustomToolDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the deleted custom tool." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the custom tool was deleted." + } + }, + "required": ["id", "deleted"], + "additionalProperties": false, + "title": "Delete custom tool data", + "description": "Custom tool deletion acknowledgement." + }, + "DeleteCustomToolResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CustomToolDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete custom tool response", + "description": "Acknowledgement that the custom tool was deleted.", + "examples": [ + { + "data": { + "id": "V1StGXR8Z5jdHi6BmyT", + "deleted": true + } } ] }, - "GetCustomToolResponse": { + "V2Credential": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique credential identifier." + }, + "type": { + "type": "string", + "enum": ["oauth", "service_account"], + "description": "Authenticated connection type." + }, + "displayName": { + "type": "string", + "description": "Credential display name." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Optional credential description." + }, + "providerId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Integration provider authenticated by this credential." + }, + "accountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Linked account identifier for OAuth credentials." + }, + "hasServiceAccountKey": { + "type": "boolean", + "description": "Whether a service-account payload is stored. Its contents are never returned." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the credential." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the credential was last updated." + } + }, + "required": [ + "id", + "type", + "displayName", + "description", + "providerId", + "accountId", + "hasServiceAccountKey", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Credential", + "description": "Public authenticated-connection metadata without secret material." + }, + "ListCredentialsResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" + "type": "array", + "items": { + "$ref": "#/components/schemas/V2Credential" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["data"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Get custom tool response", - "description": "One custom tool.", + "title": "List credentials response", + "description": "Credential metadata visible to the caller.", "examples": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } - }, - "code": "return { ok: true }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } + "data": [ + { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + ], + "nextCursor": null } ] }, - "UpdateCustomToolResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2CustomTool" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update custom tool response", - "description": "The updated custom tool.", - "examples": [ + "V2CredentialProvider": { + "oneOf": [ { - "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "title": "lookup_order", - "schema": { - "type": "function", - "function": { - "name": "lookup_order", - "description": "Look up an order by id", - "parameters": { - "type": "object", - "properties": { - "orderId": { - "type": "string" - } - }, - "required": ["orderId"] - } - } + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "oauth", + "description": "Browser-based OAuth connection method." }, - "code": "return { ok: false }", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - } - ] - }, - "UpdateCustomToolRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the custom tool." - }, - "title": { - "description": "New display title for the tool.", - "type": "string", - "minLength": 1, - "maxLength": 200 + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "supportsReconnect": { + "type": "boolean", + "description": "Whether existing credentials for this service can be reconnected." + }, + "authorizationOptions": { + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider identifier accepted by the connection endpoint." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable authorization-server label." + } + }, + "required": ["providerId", "label"], + "additionalProperties": false + }, + "description": "Authorization servers available for this OAuth service." + } + }, + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "supportsReconnect", + "authorizationOptions" + ], + "additionalProperties": false }, - "schema": { - "description": "Replacement function declaration.", + { "type": "object", "properties": { "type": { "type": "string", - "const": "function", - "description": "Function declaration discriminator." + "const": "service_account", + "description": "Direct service-account credential method." }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "description": "Function name presented to the model." - }, - "description": { - "description": "Optional explanation of what the function does.", - "type": "string" - }, - "parameters": { - "type": "object", - "properties": { - "type": { + "serviceId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Stable credential-provider identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Credential provider display name." + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Credential provider description." + }, + "providerFamily": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Owning provider family identifier." + }, + "available": { + "type": "boolean", + "description": "Whether this caller can connect the provider in the current deployment." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID accepted by credential creation." + }, + "docsUrl": { + "type": "string", + "format": "uri", + "description": "Setup guide for the provider." + }, + "helpText": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + }, + "requiresClientGeneratedCredentialId": { + "type": "boolean", + "description": "Whether the caller must generate and submit the credential ID before setup." + }, + "fields": { + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact create-body field name." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable field label." + }, + "placeholder": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Suggested input placeholder." + }, + "required": { + "type": "boolean", + "description": "Whether the field is required for the selected flow." + }, + "secret": { + "type": "boolean", + "description": "Whether the submitted field is write-only secret material." + }, + "multiline": { + "type": "boolean", + "description": "Whether the field is intended for multi-line input." + }, + "requiredForAuthMethods": { + "description": "Authentication methods for which this field is required.", + "minItems": 1, + "maxItems": 10, + "type": "array", + "items": { "type": "string", - "description": "JSON Schema type for the arguments, usually `object`." - }, - "properties": { + "minLength": 1, + "maxLength": 64 + } + }, + "options": { + "description": "Fixed values accepted by a selector field.", + "minItems": 1, + "maxItems": 20, + "type": "array", + "items": { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Caller-defined JSON Schema for one tool argument." + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Submitted option value." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Human-readable option label." + } }, - "description": "Caller-defined argument schemas keyed by argument name." - }, - "required": { - "description": "Names of required arguments.", - "type": "array", - "items": { - "type": "string" - } + "required": ["value", "label"], + "additionalProperties": false } }, - "required": ["type", "properties"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - }, - "description": "JSON Schema describing the arguments accepted by the tool." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." + "hint": { + "description": "Provider-specific setup guidance.", + "type": "string", + "minLength": 1, + "maxLength": 2000 + } + }, + "required": ["id", "label", "placeholder", "required", "secret", "multiline"], + "additionalProperties": false }, - "description": "OpenAI-style function definition." + "description": "Create-body fields accepted by this provider. Secret fields are write-only." } }, - "required": ["type", "function"], - "additionalProperties": { - "description": "Caller-defined extension value preserved by the public API." - } + "required": [ + "type", + "serviceId", + "name", + "description", + "providerFamily", + "available", + "providerId", + "docsUrl", + "requiresClientGeneratedCredentialId", + "fields" + ], + "additionalProperties": false + } + ], + "title": "Credential Provider", + "description": "An OAuth or service-account connection method available to a workspace." + }, + "ListCredentialProvidersResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2CredentialProvider" + }, + "description": "Items in the current page." }, - "code": { - "description": "Replacement tool implementation.", - "type": "string", - "maxLength": 100000 + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." } }, - "required": ["workspaceId"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Update custom tool request", - "description": "Custom tool fields to change; at least one editable field is required.", + "title": "List credential providers response", + "description": "OAuth and service-account connection methods.", "examples": [ { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "code": "return { ok: false }" + "data": [ + { + "type": "oauth", + "serviceId": "salesforce", + "name": "Salesforce", + "description": "Connect to Salesforce CRM data and operations.", + "providerFamily": "salesforce", + "available": true, + "supportsReconnect": true, + "authorizationOptions": [ + { + "providerId": "salesforce", + "label": "Production" + }, + { + "providerId": "salesforce-sandbox", + "label": "Sandbox" + } + ] + }, + { + "type": "service_account", + "serviceId": "zoom-service-account", + "providerId": "zoom-service-account", + "name": "Zoom server-to-server app", + "description": "Connect Zoom with a server-to-server app.", + "providerFamily": "zoom", + "available": true, + "docsUrl": "https://docs.sim.ai/integrations/zoom-service-account", + "requiresClientGeneratedCredentialId": false, + "fields": [ + { + "id": "clientId", + "label": "Client ID", + "placeholder": "Paste the client ID", + "required": true, + "secret": false, + "multiline": false + }, + { + "id": "clientSecret", + "label": "Client secret", + "placeholder": "Paste the client secret", + "required": true, + "secret": true, + "multiline": false + }, + { + "id": "orgId", + "label": "Account ID", + "placeholder": "Paste the account ID", + "required": true, + "secret": false, + "multiline": false + } + ] + } + ], + "nextCursor": null } ] }, - "V2CustomToolDeleteData": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the deleted custom tool." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the custom tool was deleted." - } - }, - "required": ["id", "deleted"], - "additionalProperties": false, - "title": "Delete custom tool data", - "description": "Custom tool deletion acknowledgement." - }, - "DeleteCustomToolResponse": { + "CreateServiceAccountCredentialResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/V2CustomToolDeleteData" + "$ref": "#/components/schemas/V2Credential" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete custom tool response", - "description": "Acknowledgement that the custom tool was deleted.", + "title": "Create service-account credential response", + "description": "Verified credential metadata without secret material.", "examples": [ { "data": { - "id": "V1StGXR8Z5jdHi6BmyT", - "deleted": true + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "type": "service_account", + "displayName": "Zoom service account", + "description": null, + "providerId": "zoom-service-account", + "accountId": null, + "hasServiceAccountKey": true, + "role": "admin", + "createdAt": "2026-06-01T09:14:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" } } ] }, - "V2Credential": { + "CreateServiceAccountCredentialRequest": { "type": "object", "properties": { - "id": { + "workspaceId": { "type": "string", - "description": "Unique credential identifier." + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." }, "type": { "type": "string", - "enum": ["oauth", "service_account"], - "description": "Authenticated connection type." + "const": "service_account", + "description": "Service-account credential discriminator." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact service-account provider ID returned by provider discovery." }, "displayName": { + "description": "Optional name; providers may derive one from the verified account identity.", "type": "string", - "description": "Credential display name." + "minLength": 1, + "maxLength": 255 }, "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Optional credential description." + "description": "Optional credential description.", + "type": "string", + "maxLength": 500 }, - "providerId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Integration provider authenticated by this credential." + "id": { + "description": "Required only when provider discovery requests a client-generated ID.", + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" }, - "accountId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Linked account identifier for OAuth credentials." + "serviceAccountJson": { + "description": "Write-only Google service-account JSON key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 65536 }, - "hasServiceAccountKey": { - "type": "boolean", - "description": "Whether a service-account payload is stored. Its contents are never returned." + "apiToken": { + "description": "Write-only provider API token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 }, - "role": { + "domain": { + "description": "Provider account domain.", "type": "string", - "enum": ["admin", "member"], - "description": "Caller role for the credential." + "minLength": 1, + "maxLength": 2048 }, - "createdAt": { + "signingSecret": { + "description": "Write-only webhook signing secret.", + "writeOnly": true, "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was created." + "minLength": 1, + "maxLength": 8192 }, - "updatedAt": { + "botToken": { + "description": "Write-only bot token.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "clientId": { + "description": "OAuth client identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "clientSecret": { + "description": "Write-only OAuth client secret.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "certificateId": { + "description": "Provider certificate mapping identifier.", + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "orgId": { + "description": "Provider organization ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "dataCenter": { + "description": "Provider data center.", + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "authMethod": { + "description": "Provider authentication method.", + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "privateKey": { + "description": "Write-only PEM private key.", + "writeOnly": true, + "type": "string", + "minLength": 1, + "maxLength": 8192 + }, + "username": { + "description": "Provider run-as username.", + "type": "string", + "minLength": 1, + "maxLength": 255 + } + }, + "required": ["workspaceId", "type", "providerId"], + "additionalProperties": false, + "title": "Create service-account credential request", + "description": "Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "type": "service_account", + "providerId": "zoom-service-account", + "displayName": "Zoom automation", + "clientId": "YOUR_CLIENT_ID", + "clientSecret": "YOUR_CLIENT_SECRET", + "orgId": "YOUR_ACCOUNT_ID" + } + ] + }, + "V2CredentialConnectionAuthorization": { + "type": "object", + "properties": { + "authorizationUrl": { + "type": "string", + "format": "uri", + "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." + }, + "expiresAt": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the credential was last updated." + "description": "ISO 8601 timestamp when the connection link expires." } }, - "required": [ - "id", - "type", - "displayName", - "description", - "providerId", - "accountId", - "hasServiceAccountKey", - "role", - "createdAt", - "updatedAt" - ], + "required": ["authorizationUrl", "expiresAt"], "additionalProperties": false, - "title": "Credential", - "description": "Public authenticated-connection metadata without secret material." + "title": "Credential Connection Authorization", + "description": "A short-lived browser entrypoint for an OAuth connection flow." }, - "ListCredentialsResponse": { + "CreateCredentialConnectionResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2Credential" + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialConnectionAuthorization" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create credential connection response", + "description": "Short-lived Sim browser entrypoint and its expiry.", + "examples": [ + { + "data": { + "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "expiresAt": "2026-06-20T14:17:11.000Z" + } + } + ] + }, + "CreateCredentialConnectionBody": { + "anyOf": [ + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that will own the credential." + }, + "providerId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Exact OAuth provider ID returned by credential-provider discovery." + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name shown for the new credential in Sim." + } }, - "description": "Items in the current page." + "required": ["workspaceId", "providerId", "displayName"], + "additionalProperties": false }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace expected to own the credential." }, - { - "type": "null" + "credentialId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Existing OAuth credential to reconnect in place." } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + }, + "required": ["workspaceId", "credentialId"], + "additionalProperties": false + } + ], + "title": "Create credential connection body", + "description": "For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved." + }, + "V2CredentialDeleteData": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Disconnected credential identifier." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the credential was disconnected." } }, - "required": ["data", "nextCursor"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "List credentials response", - "description": "Credential metadata visible to the caller.", + "title": "Delete credential data", + "description": "Credential disconnection acknowledgement." + }, + "DeleteCredentialResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CredentialDeleteData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Disconnect credential response", + "description": "Acknowledgement that the credential was disconnected.", "examples": [ { - "data": [ - { - "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", - "type": "service_account", - "displayName": "Zoom service account", - "description": null, - "providerId": "zoom-service-account", - "accountId": null, - "hasServiceAccountKey": true, - "role": "admin", - "createdAt": "2026-06-01T09:14:00.000Z", - "updatedAt": "2026-06-20T14:02:11.000Z" - } - ], - "nextCursor": null + "data": { + "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7", + "deleted": true + } } ] }, diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index aa729c5c616..c4a6b1ce6fd 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -3987,7 +3987,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index e0b56db6970..541863f37d3 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -2295,7 +2295,7 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." + "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address." } }, "required": ["code", "message"], diff --git a/apps/sim/app/api/auth/accounts/route.ts b/apps/sim/app/api/auth/accounts/route.ts index 016384aa9c7..fd95740bf08 100644 --- a/apps/sim/app/api/auth/accounts/route.ts +++ b/apps/sim/app/api/auth/accounts/route.ts @@ -1,61 +1,23 @@ -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, desc, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { connectedAccountsQuerySchema } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('AuthAccountsAPI') - -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { searchParams } = new URL(request.url) - const { provider } = connectedAccountsQuerySchema.parse({ - provider: searchParams.get('provider') || undefined, - }) - - const whereConditions = [eq(account.userId, session.user.id)] - - if (provider) { - whereConditions.push(eq(account.providerId, provider)) - } - - const accounts = await db - .select({ - id: account.id, - accountId: account.accountId, - providerId: account.providerId, - credentialDisplayName: credential.displayName, - }) - .from(account) - .leftJoin(credential, eq(credential.accountId, account.id)) - .where(and(...whereConditions)) - .orderBy(desc(account.updatedAt)) - - const seen = new Map() - for (const acc of accounts) { - if (!seen.has(acc.id)) { - seen.set(acc.id, acc) - } - } - - const accountsWithDisplayName = Array.from(seen.values()).map((acc) => ({ - id: acc.id, - accountId: acc.accountId, - providerId: acc.providerId, - displayName: acc.credentialDisplayName || acc.accountId || acc.providerId, - })) - - return NextResponse.json({ accounts: accountsWithDisplayName }) - } catch (error) { - logger.error('Failed to fetch accounts', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listConnectedAccountsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { listConnectedAccountsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listConnectedAccountsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listConnectedAccounts, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listConnectedAccountsUseCase, }) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.test.ts b/apps/sim/app/api/auth/instagram/authorize/route.test.ts new file mode 100644 index 00000000000..66cb5506c97 --- /dev/null +++ b/apps/sim/app/api/auth/instagram/authorize/route.test.ts @@ -0,0 +1,109 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createCredentialConnection: vi.fn(), + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { execute: mocks.createCredentialConnection }, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getCanonicalScopesForProvider: () => ['instagram_business_basic'], +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET } from '@/app/api/auth/instagram/authorize/route' + +describe('Instagram authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { INSTAGRAM_CLIENT_ID: 'instagram-client' }, + }) + mocks.createCredentialConnection.mockResolvedValue({ draftId: 'draft-created' }) + }) + + it('preserves an exact credential draft when workspaceId is also supplied', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1&draftId=draft-exact' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-exact' + ) + expect(mocks.createCredentialConnection).not.toHaveBeenCalled() + }) + + it('creates a credential draft for a legacy workspace-only launch', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(response.headers.get('set-cookie')).toContain( + 'instagram_credential_draft_id=draft-created' + ) + expect(response.headers.get('set-cookie')).toContain('Max-Age=900') + expect(mocks.createCredentialConnection).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'workspace-1', providerId: 'instagram' }, + request, + }) + }) + + it('returns a conflict when a different connection intent is already active', async () => { + mocks.createCredentialConnection.mockRejectedValue( + new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + ) + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/instagram/authorize?workspaceId=workspace-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ + error: 'A different OAuth connection flow is already active for this provider', + }) + }) +}) diff --git a/apps/sim/app/api/auth/instagram/authorize/route.ts b/apps/sim/app/api/auth/instagram/authorize/route.ts index b33a0c0c510..17f21e99e66 100644 --- a/apps/sim/app/api/auth/instagram/authorize/route.ts +++ b/apps/sim/app/api/auth/instagram/authorize/route.ts @@ -5,12 +5,13 @@ import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connection import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('InstagramAuthorize') @@ -18,8 +19,8 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' -const INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -27,6 +28,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') const { values: { INSTAGRAM_CLIENT_ID: clientId }, @@ -34,18 +37,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeInstagramContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl, workspaceId } = parsed.data.query + const { returnUrl, workspaceId, draftId } = parsed.data.query + let credentialDraftId = draftId - if (workspaceId) { - const access = await checkWorkspaceAccess(workspaceId, session.user.id) - if (!access.canWrite) { - return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + if (workspaceId && !draftId) { + try { + const connection = await createCredentialConnection.execute({ + principal: { kind: 'session', userId: session.user.id, sessionId }, + input: { workspaceId, providerId: 'instagram' }, + request, + }) + credentialDraftId = connection.draftId + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code === 'conflict') { + logger.warn('Rejected conflicting Instagram OAuth connection intent', { + userId: session.user.id, + workspaceId, + }) + return NextResponse.json({ error: classified.message }, { status: 409 }) + } + if (classified?.code === 'forbidden' || classified?.code === 'not_found') { + return NextResponse.json({ error: 'Workspace write access denied' }, { status: 403 }) + } + throw error } - await createConnectDraft({ - userId: session.user.id, - workspaceId, - providerId: 'instagram', - }) } const baseUrl = getBaseUrl() @@ -65,16 +81,30 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) + if (credentialDraftId) { + response.cookies.set(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, credentialDraftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) + } if (returnUrl && isSameOrigin(returnUrl)) { response.cookies.set(INSTAGRAM_RETURN_URL_COOKIE, returnUrl, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: INSTAGRAM_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: INSTAGRAM_STATE_COOKIE_PATH, }) } diff --git a/apps/sim/app/api/auth/oauth/connections/route.test.ts b/apps/sim/app/api/auth/oauth/connections/route.test.ts index 593079aa20c..80db8ab7a39 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.test.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.test.ts @@ -49,6 +49,7 @@ describe('OAuth Connections API Route', () => { it('should return connections successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ @@ -105,12 +106,13 @@ describe('OAuth Connections API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle user with no connections', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockResolvedValueOnce([]) @@ -128,6 +130,7 @@ describe('OAuth Connections API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) @@ -144,6 +147,7 @@ describe('OAuth Connections API Route', () => { it('should decode ID token for display name', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const mockAccounts = [ diff --git a/apps/sim/app/api/auth/oauth/connections/route.ts b/apps/sim/app/api/auth/oauth/connections/route.ts index 9af427f9c17..92813d0638c 100644 --- a/apps/sim/app/api/auth/oauth/connections/route.ts +++ b/apps/sim/app/api/auth/oauth/connections/route.ts @@ -1,139 +1,19 @@ -import { account, db, user } from '@sim/db' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { decodeJwt } from 'jose' -import { type NextRequest, NextResponse } from 'next/server' -import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { OAuthProvider } from '@/lib/oauth' -import { parseProvider } from '@/lib/oauth' - -const logger = createLogger('OAuthConnectionsAPI') - -interface GoogleIdToken { - email?: string - sub?: string - name?: string -} - -/** - * Get all OAuth connections for the current user - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - // Get the session - const session = await getSession() - - // Check if the user is authenticated - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - // Get all accounts for this user - const accounts = await db.select().from(account).where(eq(account.userId, session.user.id)) - - // Get the user's email for fallback - const userRecord = await db - .select({ email: user.email }) - .from(user) - .where(eq(user.id, session.user.id)) - .limit(1) - - const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null - - // Process accounts to determine connections - const connections: OAuthConnection[] = [] - - for (const acc of accounts) { - const { baseProvider, featureType } = parseProvider(acc.providerId as OAuthProvider) - const scopes = acc.scope ? acc.scope.split(/\s+/).filter(Boolean) : [] - - if (baseProvider) { - // Try multiple methods to get a user-friendly display name - let displayName = '' - - // Method 1: Try to extract email from ID token (works for Google, etc.) - if (acc.idToken) { - try { - const decoded = decodeJwt(acc.idToken) - if (decoded.email) { - displayName = decoded.email - } else if (decoded.name) { - displayName = decoded.name - } - } catch (_error) { - logger.warn(`[${requestId}] Error decoding ID token`, { - accountId: acc.id, - }) - } - } - - // Method 2: For GitHub, the accountId might be the username - if (!displayName && baseProvider === 'github') { - displayName = `${acc.accountId} (GitHub)` - } - - // Method 3: Use the user's email from our database - if (!displayName && userEmail) { - displayName = userEmail - } - - // Fallback: Use accountId with provider type as context - if (!displayName) { - displayName = `${acc.accountId} (${baseProvider})` - } - - // Create a unique connection key that includes the full provider ID - const connectionKey = acc.providerId - - // Find existing connection for this specific provider ID - const existingConnection = connections.find((conn) => conn.provider === connectionKey) - - const accountSummary = { - id: acc.id, - name: displayName, - } - - if (existingConnection) { - // Add account to existing connection - existingConnection.accounts = existingConnection.accounts || [] - existingConnection.accounts.push(accountSummary) - - existingConnection.scopes = Array.from( - new Set([...(existingConnection.scopes || []), ...scopes]) - ) - - const existingTimestamp = existingConnection.lastConnected - ? new Date(existingConnection.lastConnected).getTime() - : 0 - const candidateTimestamp = acc.updatedAt.getTime() - - if (candidateTimestamp > existingTimestamp) { - existingConnection.lastConnected = acc.updatedAt.toISOString() - } - } else { - // Create new connection - connections.push({ - provider: connectionKey, - baseProvider, - featureType, - isConnected: true, - scopes, - lastConnected: acc.updatedAt.toISOString(), - accounts: [accountSummary], - }) - } - } - } - - return NextResponse.json({ connections }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error fetching OAuth connections`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { listOAuthConnectionsContract } from '@/lib/api/contracts/oauth-connections' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { listOAuthConnectionsUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: listOAuthConnectionsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listOAuthConnections, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: () => ({}), + useCase: listOAuthConnectionsUseCase, }) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts index 757ea76c9df..e1dd3aa2eec 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.test.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.test.ts @@ -26,6 +26,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect provider successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -42,6 +43,7 @@ describe('OAuth Disconnect API Route', () => { it('should disconnect specific provider ID successfully', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', { @@ -67,12 +69,13 @@ describe('OAuth Disconnect API Route', () => { const data = await response.json() expect(response.status).toBe(401) - expect(data.error).toBe('User not authenticated') + expect(data.error).toBe('Unauthorized') }) it('should handle missing provider', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) const req = createMockRequest('POST', {}) @@ -87,6 +90,7 @@ describe('OAuth Disconnect API Route', () => { it('should handle database error', async () => { authMockFns.mockGetSession.mockResolvedValueOnce({ user: { id: 'user-123' }, + session: { id: 'session-1' }, }) dbChainMockFns.where.mockRejectedValueOnce(new Error('Database error')) diff --git a/apps/sim/app/api/auth/oauth/disconnect/route.ts b/apps/sim/app/api/auth/oauth/disconnect/route.ts index c3c145e60e7..d53f89128b7 100644 --- a/apps/sim/app/api/auth/oauth/disconnect/route.ts +++ b/apps/sim/app/api/auth/oauth/disconnect/route.ts @@ -1,132 +1,26 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { account, credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray, like, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { disconnectOAuthContract } from '@/lib/api/contracts/oauth-connections' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deleteCredential } from '@/lib/credentials/deletion' -import { providerIdsForService } from '@/lib/oauth/utils' -import { captureServerEvent } from '@/lib/posthog/server' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' +import { credentialUserOperations } from '@/lib/credentials/application/operations' export const dynamic = 'force-dynamic' -const logger = createLogger('OAuthDisconnectAPI') - -/** - * Disconnect an OAuth provider for the current user - */ -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - - if (!session?.user?.id) { - logger.warn(`[${requestId}] Unauthenticated disconnect request rejected`) - return NextResponse.json({ error: 'User not authenticated' }, { status: 401 }) - } - - const parsed = await parseRequest( - disconnectOAuthContract, - request, - {}, - { - validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid disconnect request`, { errors: error.issues }) - return NextResponse.json( - { error: getValidationErrorMessage(error, 'Validation failed') }, - { status: 400 } - ) - }, - } - ) - if (!parsed.success) return parsed.response - - const { provider, providerId, accountId } = parsed.data.body - - logger.info(`[${requestId}] Processing OAuth disconnect request`, { - provider, - hasProviderId: !!providerId, - }) - - // Delete credentials before their accounts so deleteCredential can clear - // stored references first. Otherwise FK CASCADE would orphan them silently. - const accountFilter = accountId - ? and(eq(account.userId, session.user.id), eq(account.id, accountId)) - : providerId - ? and(eq(account.userId, session.user.id), eq(account.providerId, providerId)) - : and( - eq(account.userId, session.user.id), - or( - // The prefix sweep already caught `{base}-{feature}` ids by - // accident; an alternate authorization server shares that shape, - // so name it explicitly rather than relying on the accident. - inArray(account.providerId, providerIdsForService(provider)), - like(account.providerId, `${provider}-%`) - ) - ) - - const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) - - const targetAccountIds = targetAccounts.map((a) => a.id) - - if (targetAccountIds.length > 0) { - const credentialsToDelete = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - providerId: credential.providerId, - }) - .from(credential) - .where(inArray(credential.accountId, targetAccountIds)) - - for (const cred of credentialsToDelete) { - await deleteCredential({ - credentialId: cred.id, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - reason: 'oauth_disconnect', - request, - }) - - captureServerEvent( - session.user.id, - 'credential_deleted', - { - credential_type: 'oauth', - provider_id: cred.providerId ?? providerId ?? provider, - workspace_id: cred.workspaceId, - }, - { groups: { workspace: cred.workspaceId } } - ) - } - - await db.delete(account).where(inArray(account.id, targetAccountIds)) - } - - recordAudit({ - workspaceId: null, - actorId: session.user.id, - action: AuditAction.OAUTH_DISCONNECTED, - resourceType: AuditResourceType.OAUTH, - resourceId: providerId ?? provider, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: provider, - description: `Disconnected OAuth provider: ${provider}`, - metadata: { provider, providerId }, - request, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error(`[${requestId}] Error disconnecting OAuth provider`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: disconnectOAuthContract, + auth: internalSessionAuth, + operation: credentialUserOperations.disconnectOAuth, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: disconnectOAuthUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 54f49a5e29f..f59ae8b4dc6 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,360 +1,247 @@ /** * @vitest-environment node */ -import { - createMockRequest, - dbChainMockFns, - resetDbChainMock, - resetEnvMock, - setEnv, -} from '@sim/testing' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockGetSession, - mockOAuth2LinkAccount, - mockCheckWorkspaceAccess, - mockGetCredentialActorContext, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockOAuth2LinkAccount: vi.fn(), - mockCheckWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + linkAccount: vi.fn(), + getBaseUrl: vi.fn(), + requireClient: vi.fn(), + createConnection: vi.fn(), + launchConnection: vi.fn(), })) vi.mock('@/lib/auth/auth', () => ({ - auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } }, - getSession: mockGetSession, + getSession: mocks.getSession, + auth: { api: { oAuth2LinkAccount: mocks.linkAccount } }, })) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - checkWorkspaceAccess: mockCheckWorkspaceAccess, +vi.mock('@/lib/core/utils/urls', () => ({ + SITE_URL: 'https://www.sim.ai', + getBaseUrl: mocks.getBaseUrl, })) - -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireClient, + wireServerFallback: () => ({ + configured: false, + providerIds: [], + providers: [], + execute: vi.fn(), + }), })) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]), - // Real implementation: a credential id matches its service's OAuth id, an - // alternate authorization server, or the family's service-account id. - credentialProviderMatchesService: ( - credentialProviderId: string, - service: { - providerId: string - serviceAccountProviderId?: string - additionalProviderIds?: readonly string[] - } - ) => - service.providerId === credentialProviderId || - service.serviceAccountProviderId === credentialProviderId || - (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.createConnection, + }, +})) +vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ + launchCredentialConnection: { + operation: { id: 'credentials.connections.launch' }, + execute: mocks.launchConnection, + }, })) import { GET } from '@/app/api/auth/oauth2/authorize/route' const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' -const LINK_URL = 'https://provider.example/authorize?state=abc' +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' -function authorizeRequest(query: Record) { - const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`) - for (const [key, value] of Object.entries(query)) { - url.searchParams.set(key, value) - } +function request(query: Record) { + const url = new URL('/api/auth/oauth2/authorize', BASE_URL) + for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value) return createMockRequest('GET', undefined, {}, url.toString()) } -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - displayName: 'Work Gmail', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } +function linkResponse(url = 'https://provider.example/authorize') { + return new Response(JSON.stringify({ url }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) } describe('OAuth2 authorize route', () => { - afterAll(() => { - resetEnvMock() - }) - beforeEach(() => { vi.clearAllMocks() - resetDbChainMock() - setEnv({ - NEXT_PUBLIC_APP_URL: BASE_URL, - GOOGLE_CLIENT_ID: 'google-client', - GOOGLE_CLIENT_SECRET: 'google-secret', - }) - mockGetSession.mockResolvedValue({ user: { id: USER_ID } }) - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, - }) - mockOAuth2LinkAccount.mockResolvedValue({ - ok: true, - status: 200, - json: async () => ({ url: LINK_URL }), - headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] }, + mocks.getBaseUrl.mockReturnValue(BASE_URL) + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, }) + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date('2026-08-14T12:00:00.000Z'), + authorizationUrl: `${BASE_URL}/api/auth/oauth2/authorize?draftId=draft-1`, + }) + mocks.launchConnection.mockResolvedValue({ + draft: { + id: 'draft-1', + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: null, + }, + }) + mocks.linkAccount.mockResolvedValue(linkResponse()) }) - describe('plain connect (no credentialId)', () => { - it('creates a draft with credentialId null and redirects to the provider', async () => { - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) + it('creates a canonical application draft for a legacy connect URL', async () => { + const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: USER_ID, - workspaceId: WORKSPACE_ID, + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, providerId: 'google-email' }, + }) + ) + expect(mocks.linkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ providerId: 'google-email', - credentialId: null, - }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: null }), - }) - ) - }) - - it('numbers the draft display name when the default collides with an existing credential', async () => { - dbChainMockFns.where - .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }])) - .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }])) - - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: "Justin's Gmail 2" }) - ) - }) - - it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => { - await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - - const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0] - expect(set).toHaveProperty('credentialId', null) - }) - - it('rejects an OAuth client that is not configured for the deployment', async () => { - setEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) - - it('redirects to login when unauthenticated', async () => { - mockGetSession.mockResolvedValue(null) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toContain('/login') - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) - - it('rejects without workspace write access', async () => { - mockCheckWorkspaceAccess.mockResolvedValue({ - hasAccess: true, - canWrite: false, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, + callbackURL: expect.stringContaining('credentialDraftId=draft-1'), + }), }) - - const response = await GET( - authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) - ) - - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=workspace_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + ) }) - describe('reconnect (credentialId present)', () => { - it('creates a reconnect draft carrying credentialId in values and upsert set', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) - - expect(response.headers.get('location')).toBe(LINK_URL) - expect(mockGetCredentialActorContext).toHaveBeenCalledWith( - CREDENTIAL_ID, - USER_ID, - expect.objectContaining({ workspaceAccess: expect.anything() }) - ) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ credentialId: CREDENTIAL_ID }) - ) - expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - set: expect.objectContaining({ credentialId: CREDENTIAL_ID }), - }) - ) + it('requires a configured OAuth client before creating a legacy draft', async () => { + mocks.requireClient.mockImplementationOnce(() => { + throw new Error('OAuth client is not configured') }) - it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { displayName: 'Renamed By User' } }) - ) + const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + expect(mocks.requireClient).toHaveBeenCalledWith('google-email') + expect(mocks.createConnection).not.toHaveBeenCalled() + }) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ displayName: 'Renamed By User' }) - ) - }) + it('launches an exact draft without creating another one', async () => { + const response = await GET(request({ draftId: 'draft-1' })) - it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => { - for (const providerId of ['trello', 'shopify']) { - const response = await GET( - authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID }) - ) + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.launchConnection).toHaveBeenCalledWith( + expect.objectContaining({ input: { draftId: 'draft-1' } }) + ) + expect(mocks.createConnection).not.toHaveBeenCalled() + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_reconnect_unsupported` - ) - } - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() + it('passes reconnect provider assertions through the application use case', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('rejects when the caller is not a credential admin and writes no draft', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) + await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + credentialId: 'credential-1', + assertedProviderId: 'google-email', + }, + }) + ) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + it('maps a provider mismatch without exposing the credential', async () => { + mocks.createConnection.mockRejectedValue( + new CredentialConnectionProviderMismatchError('google-email', 'slack') + ) - it('rejects when the credential belongs to a different workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) + const response = await GET( + request({ + providerId: 'slack', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=credential_provider_mismatch` + ) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) + it('maps credential and workspace authorization failures separately', async () => { + mocks.createConnection.mockRejectedValueOnce( + new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + ) + const credentialResponse = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', + }) + ) + mocks.createConnection.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Write permission required') + ) + const workspaceResponse = await GET( + request({ providerId: 'google-email', workspaceId: WORKSPACE_ID }) + ) + + expect(credentialResponse.headers.get('location')).toContain('credential_access_denied') + expect(workspaceResponse.headers.get('location')).toContain('workspace_access_denied') + }) + + it('keeps a reconnect workspace-role denial classified as workspace access', async () => { + mocks.createConnection.mockRejectedValue(new InsufficientWorkspacePermissionsError()) - it('rejects when the credential does not exist', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, + const response = await GET( + request({ + providerId: 'google-email', + workspaceId: WORKSPACE_ID, + credentialId: 'credential-1', }) + ) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: 'cred-missing', - }) - ) + expect(response.headers.get('location')).toBe( + `${BASE_URL}/workspace?error=workspace_access_denied` + ) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - }) + it('redirects a draft launch infrastructure failure through the browser error contract', async () => { + mocks.launchConnection.mockRejectedValue(new Error('Database unavailable')) - it('rejects a non-oauth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) + const response = await GET(request({ draftId: 'draft-1' })) - const response = await GET( - authorizeRequest({ - providerId: 'google-email', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + }) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_access_denied` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() + it('routes custom providers through the exact application draft', async () => { + mocks.createConnection.mockResolvedValue({ + providerId: 'trello', + workspaceId: WORKSPACE_ID, + draftId: 'draft-1', + expiresAt: new Date(), + authorizationUrl: '', }) - it('rejects when the query providerId does not match the credential provider', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const response = await GET( - authorizeRequest({ - providerId: 'slack', - workspaceId: WORKSPACE_ID, - credentialId: CREDENTIAL_ID, - }) - ) + const response = await GET(request({ providerId: 'trello', workspaceId: WORKSPACE_ID })) + const location = new URL(response.headers.get('location') ?? '') - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_provider_mismatch` - ) - expect(dbChainMockFns.values).not.toHaveBeenCalled() - expect(mockOAuth2LinkAccount).not.toHaveBeenCalled() - }) + expect(location.pathname).toBe('/api/auth/trello/authorize') + expect(location.searchParams.get('draftId')).toBe('draft-1') }) }) diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 063de2ca015..f9f1f616ac6 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -3,12 +3,15 @@ import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { createConnectDraft } from '@/lib/credentials/connect-draft' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' +import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' +import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-processor' const logger = createLogger('OAuth2Authorize') @@ -27,95 +30,109 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(loginUrl.toString()) } const userId = session.user.id + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const principal = { kind: 'session' as const, userId, sessionId } const parsed = await parseRequest(authorizeOAuth2Contract, request, {}) if (!parsed.success) return parsed.response - const { - providerId, - workspaceId, - callbackURL: requestedCallback, - credentialId, - } = parsed.data.query - - const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`) - ? requestedCallback - : `${baseUrl}/workspace` + const { draftId } = parsed.data.query + let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query try { - const access = await checkWorkspaceAccess(workspaceId, userId) - if (!access.canWrite) { - logger.warn('Workspace write access denied for OAuth2 authorize', { - userId, - workspaceId, - providerId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) - } - - let reconnectDisplayName: string | undefined - if (credentialId) { - // Trello and Shopify authorize through their own custom flows that bypass - // this endpoint, so a reconnect draft written here would linger unconsumed - // and could later be picked up by their token-store callbacks, silently - // rebinding the credential. Mirror the copilot tool and reject reconnect. - if (providerId === 'trello' || providerId === 'shopify') { - logger.warn('Reconnect not supported for custom-flow provider', { - userId, - workspaceId, - providerId, - credentialId, + let fromConnectionDraft = false + let connectionDraftId: string | undefined + if (draftId) { + try { + const { draft } = await launchCredentialConnection.execute({ + principal, + input: { draftId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`) + providerId = draft.providerId + workspaceId = draft.workspaceId + credentialId = draft.credentialId ?? undefined + connectionDraftId = draft.id + fromConnectionDraft = true + } catch (error) { + if (!(error instanceof OrchestrationError)) throw error + logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) + return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) } + } - // Reconnect: the OAuth callback will rebind this credential to the fresh - // account, so require the same credential-admin access as the draft POST - // route — workspace write alone must not be enough to swap someone's tokens. - const actor = await getCredentialActorContext(credentialId, userId, { - workspaceAccess: access, - }) - if ( - !actor.credential || - actor.credential.workspaceId !== workspaceId || - actor.credential.type !== 'oauth' || - !actor.isAdmin - ) { - logger.warn('Credential admin access denied for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) - } - if (actor.credential.providerId !== providerId) { - logger.warn('Provider mismatch for OAuth2 reconnect', { - userId, - workspaceId, - providerId, - credentialId, - credentialProviderId: actor.credential.providerId, + if (!providerId || !workspaceId) { + throw new Error('Validated OAuth authorization request is missing its target') + } + + requireConfiguredOAuthClient(providerId) + + const connectionCompleteUrl = new URL('/oauth/credential-connected', baseUrl) + connectionCompleteUrl.searchParams.set('result', 'connected') + const callbackURL = fromConnectionDraft + ? connectionCompleteUrl.toString() + : requestedCallback?.startsWith(`${baseUrl}/`) + ? requestedCallback + : `${baseUrl}/workspace` + + if (!fromConnectionDraft) { + try { + const connection = await createCredentialConnection.execute({ + principal, + input: credentialId + ? { workspaceId, credentialId, assertedProviderId: providerId } + : { workspaceId, providerId }, + request, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + providerId = connection.providerId + workspaceId = connection.workspaceId + credentialId = connection.credentialId + connectionDraftId = connection.draftId + } catch (error) { + if (error instanceof CredentialConnectionProviderMismatchError) { + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + } + if ( + credentialId && + error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' + ) { + return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + } + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.redirect( + `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` + ) + } + if (error instanceof OrchestrationError && error.code === 'forbidden') { + return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + } + throw error } - reconnectDisplayName = actor.credential.displayName } - requireConfiguredOAuthClient(providerId) + if (!connectionDraftId) { + throw new Error('OAuth authorization is missing its credential draft id') + } - // Create the draft before initiating the link so it is guaranteed to exist - // (and freshly clocked) when the OAuth callback's `account.create.after` - // hook runs. If this throws, we never start the OAuth flow. - await createConnectDraft({ - userId, - workspaceId, - providerId, - credentialId, - displayName: reconnectDisplayName, - }) + if (providerId === 'trello' || providerId === 'instagram' || providerId === 'shopify') { + const authorizeUrl = new URL(`/api/auth/${providerId}/authorize`, baseUrl) + authorizeUrl.searchParams.set('returnUrl', callbackURL) + authorizeUrl.searchParams.set('draftId', connectionDraftId) + return NextResponse.redirect(authorizeUrl) + } + + const stateCallbackUrl = new URL(callbackURL) + stateCallbackUrl.searchParams.set(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, connectionDraftId) const linkResponse = await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, + body: { + providerId, + callbackURL: stateCallbackUrl.toString(), + ...(fromConnectionDraft + ? { errorCallbackURL: `${baseUrl}/oauth/credential-connected?result=failed` } + : {}), + }, headers: request.headers, asResponse: true, }) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 4aea1372f83..19284a950fc 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -33,11 +33,16 @@ export const dynamic = 'force-dynamic' const INSTAGRAM_STATE_COOKIE = 'instagram_oauth_state' const INSTAGRAM_RETURN_URL_COOKIE = 'instagram_return_url' +const INSTAGRAM_CREDENTIAL_DRAFT_COOKIE = 'instagram_credential_draft_id' const INSTAGRAM_STATE_COOKIE_PATH = '/api/auth' function clearOAuthCookies(response: NextResponse) { response.cookies.delete({ name: INSTAGRAM_STATE_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) response.cookies.delete({ name: INSTAGRAM_RETURN_URL_COOKIE, path: INSTAGRAM_STATE_COOKIE_PATH }) + response.cookies.delete({ + name: INSTAGRAM_CREDENTIAL_DRAFT_COOKIE, + path: INSTAGRAM_STATE_COOKIE_PATH, + }) return response } @@ -54,6 +59,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { code, state, error, error_reason, error_description } = parsed.data.query + const draftId = request.cookies.get(INSTAGRAM_CREDENTIAL_DRAFT_COOKIE)?.value if (error) { logger.warn('Instagram OAuth denied by user', { @@ -293,17 +299,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'instagram', - accountId: persisted.id, - }) - } catch (draftError) { - logger.error('Failed to process credential draft for Instagram', { error: draftError }) - } + if (!persisted) { + throw new Error(`Instagram OAuth account ${igUserId} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'instagram', + accountId: persisted.id, + }) const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value const redirectUrl = diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts new file mode 100644 index 00000000000..e9aa534852a --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { hmacSha256Hex } from '@sim/security/hmac' +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCompleteShopifyOAuthConnection, mockGetSession, mockRequireConfiguredOAuthClient } = + vi.hoisted(() => ({ + mockCompleteShopifyOAuthConnection: vi.fn(), + mockGetSession: vi.fn(), + mockRequireConfiguredOAuthClient: vi.fn(), + })) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mockRequireConfiguredOAuthClient, +})) +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.test' })) +vi.mock('@/lib/oauth/shopify', () => ({ + completeShopifyOAuthConnection: mockCompleteShopifyOAuthConnection, +})) + +import { createShopifyOAuthState } from '@/lib/oauth/shopify-state' +import { GET } from '@/app/api/auth/oauth2/callback/shopify/route' + +const CLIENT_SECRET = 'shopify-client-secret' +const SHOP_DOMAIN = 'example.myshopify.com' + +function callbackRequest(state: string) { + const searchParams = new URLSearchParams({ + code: 'authorization-code', + shop: SHOP_DOMAIN, + state, + }) + const message = [...searchParams.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => `${key}=${value}`) + .join('&') + searchParams.set('hmac', hmacSha256Hex(message, CLIENT_SECRET)) + + return createMockRequest( + 'GET', + undefined, + { + cookie: + 'shopify_credential_draft_id=draft-from-shared-cookie; shopify_return_url=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected%3Fresult%3Dconnected', + }, + `https://sim.test/api/auth/oauth2/callback/shopify?${searchParams.toString()}` + ) +} + +describe('Shopify OAuth callback', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + mockRequireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client-id', + SHOPIFY_CLIENT_SECRET: CLIENT_SECRET, + }, + }) + mockCompleteShopifyOAuthConnection.mockResolvedValue(undefined) + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ access_token: 'shopify-token', scope: 'read_products' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) + ) + }) + + it('completes the credential draft carried by signed state instead of a shared cookie', async () => { + const state = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-from-state', + returnUrl: 'https://sim.test/oauth/credential-connected?result=connected', + clientSecret: CLIENT_SECRET, + }) + + const response = await GET(callbackRequest(state)) + + expect(mockCompleteShopifyOAuthConnection).toHaveBeenCalledWith({ + accessToken: 'shopify-token', + shopDomain: SHOP_DOMAIN, + scope: 'read_products', + userId: 'user-1', + draftId: 'draft-from-state', + signal: expect.any(AbortSignal), + }) + expect(response.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?result=connected&shopify_connected=true' + ) + }) + + it('keeps overlapping flows bound to their own return destinations', async () => { + const firstState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-first', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + clientSecret: CLIENT_SECRET, + }) + const secondState = createShopifyOAuthState({ + userId: 'user-1', + shopDomain: SHOP_DOMAIN, + draftId: 'draft-second', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + clientSecret: CLIENT_SECRET, + }) + + const firstResponse = await GET(callbackRequest(firstState)) + const secondResponse = await GET(callbackRequest(secondState)) + + expect(firstResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=first&shopify_connected=true' + ) + expect(secondResponse.headers.get('location')).toBe( + 'https://sim.test/oauth/credential-connected?flow=second&shopify_connected=true' + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ draftId: 'draft-first' }) + ) + expect(mockCompleteShopifyOAuthConnection).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ draftId: 'draft-second' }) + ) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 2292a76a9a6..8447e56d48d 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -10,12 +10,26 @@ import { getSession } from '@/lib/auth' import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' import { getBaseUrl } from '@/lib/core/utils/urls' +import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' +import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state' const logger = createLogger('ShopifyCallback') export const dynamic = 'force-dynamic' +function clearShopifyOAuthCookies(response: NextResponse): NextResponse { + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') + response.cookies.delete('shopify_pending_token') + response.cookies.delete('shopify_pending_shop') + response.cookies.delete('shopify_pending_scope') + response.cookies.delete('shopify_return_url') + return response +} + /** * Validates the HMAC signature from Shopify to ensure the request is authentic * @see https://shopify.dev/docs/apps/build/authentication-authorization/access-tokens/offline-access-tokens @@ -59,9 +73,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { shop: searchParams.get('shop') || undefined, }) - const storedState = request.cookies.get('shopify_oauth_state')?.value - const storedShop = request.cookies.get('shopify_shop_domain')?.value - const { values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret }, } = requireConfiguredOAuthClient('shopify') @@ -71,8 +82,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`) } - if (!state || state !== storedState) { - logger.error('State mismatch in Shopify OAuth callback') + if (!state) { + logger.error('Missing state in Shopify OAuth callback') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`) } @@ -81,7 +92,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`) } - const shopDomain = shop || storedShop + const shopDomain = shop if (!shopDomain) { logger.error('No shop domain available') return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) @@ -92,6 +103,13 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) } + const { draftId, returnUrl } = parseShopifyOAuthState({ + state, + userId: session.user.id, + shopDomain, + clientSecret, + }) + const tokenResponse = await fetch(`https://${shopDomain}/admin/oauth/access_token`, { method: 'POST', headers: { @@ -127,44 +145,31 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`) } - const storeUrl = new URL(`${baseUrl}/api/auth/oauth2/shopify/store`) - - const response = NextResponse.redirect(storeUrl) - - response.cookies.set('shopify_pending_token', accessToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.set('shopify_pending_shop', shopDomain, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - response.cookies.set('shopify_pending_scope', scope || '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60, - path: '/', - }) - - response.cookies.delete('shopify_oauth_state') - response.cookies.delete('shopify_shop_domain') + if (returnUrl && !isSameOrigin(returnUrl)) { + throw new Error('Shopify OAuth state contains an invalid return URL') + } + const redirectUrl = returnUrl ?? `${baseUrl}/workspace` + const finalUrl = new URL(redirectUrl) + finalUrl.searchParams.set('shopify_connected', 'true') - return response + return clearShopifyOAuthCookies(NextResponse.redirect(finalUrl)) } catch (error) { logger.error('Error in Shopify OAuth callback:', error) const errorCode = error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth' ? 'shopify_config_error' : 'shopify_callback_error' - return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + return clearShopifyOAuthCookies( + NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + ) } }) diff --git a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts index 182989f917a..d3c84d68883 100644 --- a/apps/sim/app/api/auth/oauth2/shopify/store/route.ts +++ b/apps/sim/app/api/auth/oauth2/shopify/store/route.ts @@ -1,7 +1,4 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { shopifyShopDomainSchema, @@ -11,9 +8,7 @@ import { getSession } from '@/lib/auth' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' -import { safeAccountInsert } from '@/lib/oauth/credential-service' -import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' +import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' const logger = createLogger('ShopifyStore') @@ -41,95 +36,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_missing_data`) } const { accessToken, shopDomain, scope, returnUrl } = parsedCookies.data + const draftId = request.cookies.get('shopify_credential_draft_id')?.value if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format in cookie', { shopDomain }) return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_domain`) } - const shopResponse = await fetch( - `https://${shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, - { - headers: { - 'X-Shopify-Access-Token': accessToken, - 'Content-Type': 'application/json', - }, - } - ) - - if (!shopResponse.ok) { - const errorText = await shopResponse.text() - logger.error('Invalid Shopify token', { - status: shopResponse.status, - error: errorText, - }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_token`) - } - - const shopData = await shopResponse.json() - const shopInfo = shopData.shop - const stableAccountId = shopInfo.id?.toString() || shopDomain - - const existing = await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), + await completeShopifyOAuthConnection({ + accessToken, + shopDomain, + scope, + userId: session.user.id, + draftId, + signal: request.signal, }) - const now = new Date() - - const accountData = { - accessToken: accessToken, - accountId: stableAccountId, - scope: scope || '', - updatedAt: now, - idToken: shopDomain, - } - - if (existing) { - await db.update(account).set(accountData).where(eq(account.id, existing.id)) - logger.info('Updated existing Shopify account', { accountId: existing.id }) - } else { - await safeAccountInsert( - { - id: `shopify_${session.user.id}_${Date.now()}`, - userId: session.user.id, - providerId: 'shopify', - accountId: accountData.accountId, - accessToken: accountData.accessToken, - scope: accountData.scope, - idToken: accountData.idToken, - createdAt: now, - updatedAt: now, - }, - { provider: 'Shopify', identifier: shopDomain } - ) - } - - const persisted = - existing ?? - (await db.query.account.findFirst({ - where: and( - eq(account.userId, session.user.id), - eq(account.providerId, 'shopify'), - eq(account.accountId, stableAccountId) - ), - })) - - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'shopify', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Shopify', { error }) - } - } - const redirectUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : `${baseUrl}/workspace` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') @@ -139,6 +61,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { response.cookies.delete('shopify_pending_shop') response.cookies.delete('shopify_pending_scope') response.cookies.delete('shopify_return_url') + response.cookies.delete('shopify_credential_draft_id') return response } catch (error) { diff --git a/apps/sim/app/api/auth/shopify/authorize/route.test.ts b/apps/sim/app/api/auth/shopify/authorize/route.test.ts new file mode 100644 index 00000000000..2e4ea50a32a --- /dev/null +++ b/apps/sim/app/api/auth/shopify/authorize/route.test.ts @@ -0,0 +1,80 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + requireConfiguredOAuthClient: vi.fn(), + createShopifyOAuthState: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: mocks.getSession, +})) + +vi.mock('@/lib/core/config/env-capabilities.server', () => ({ + requireConfiguredOAuthClient: mocks.requireConfiguredOAuthClient, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: () => 'https://sim.test', +})) + +vi.mock('@/lib/oauth/shopify-state', () => ({ + createShopifyOAuthState: mocks.createShopifyOAuthState, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getScopesForService: () => ['read_products'], +})) + +import { GET } from '@/app/api/auth/shopify/authorize/route' + +describe('Shopify authorize route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.requireConfiguredOAuthClient.mockReturnValue({ + values: { + SHOPIFY_CLIENT_ID: 'shopify-client', + SHOPIFY_CLIENT_SECRET: 'shopify-secret', + }, + }) + mocks.createShopifyOAuthState.mockReturnValue('signed-state') + }) + + it('binds the post-connect return URL to the signed flow state', async () => { + const request = createMockRequest( + 'GET', + undefined, + {}, + 'https://sim.test/api/auth/shopify/authorize?shop=test-store.myshopify.com&returnUrl=https%3A%2F%2Fsim.test%2Foauth%2Fcredential-connected&draftId=draft-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(307) + expect(mocks.createShopifyOAuthState).toHaveBeenCalledWith({ + userId: 'user-1', + shopDomain: 'test-store.myshopify.com', + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected', + clientSecret: 'shopify-secret', + }) + expect(response.headers.get('set-cookie')).toContain('shopify_return_url=;') + }) + + it('escapes a user-controlled draft id before embedding it in inline script', async () => { + const url = new URL('https://sim.test/api/auth/shopify/authorize') + url.searchParams.set('draftId', '') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + const html = await response.text() + + expect(response.status).toBe(200) + expect(html.match(/ @@ -168,8 +179,15 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() const redirectUri = `${baseUrl}/api/auth/oauth2/callback/shopify` - - const state = generateId() + const safeReturnUrl = returnUrl && isSameOrigin(returnUrl) ? returnUrl : undefined + + const state = createShopifyOAuthState({ + userId: session.user.id, + shopDomain: cleanShop, + draftId, + returnUrl: safeReturnUrl, + clientSecret, + }) const oauthUrl = `https://${cleanShop}/admin/oauth/authorize?` + @@ -189,31 +207,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const response = NextResponse.redirect(oauthUrl) - response.cookies.set('shopify_oauth_state', state, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - - response.cookies.set('shopify_shop_domain', cleanShop, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) + response.cookies.delete('shopify_oauth_state') + response.cookies.delete('shopify_shop_domain') + response.cookies.delete('shopify_credential_draft_id') - if (returnUrl && isSameOrigin(returnUrl)) { - response.cookies.set('shopify_return_url', returnUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 10, - path: '/', - }) - } + response.cookies.delete('shopify_return_url') return response } catch (error) { diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index b69c6caed2e..f98aaf5aab8 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -8,6 +8,7 @@ import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CREDENTIAL_DRAFT_TTL_SECONDS } from '@/lib/credentials/draft-constants' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' const logger = createLogger('TrelloAuthorize') @@ -16,8 +17,8 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' -const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -28,7 +29,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeTrelloContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl: requestedReturnUrl } = parsed.data.query + const { returnUrl: requestedReturnUrl, draftId } = parsed.data.query const apiKey = env.TRELLO_API_KEY @@ -57,15 +58,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) + if (draftId) { + response.cookies.set(TRELLO_CREDENTIAL_DRAFT_COOKIE, draftId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, + path: TRELLO_STATE_COOKIE_PATH, + }) + } else { + response.cookies.delete({ + name: TRELLO_CREDENTIAL_DRAFT_COOKIE, + path: TRELLO_STATE_COOKIE_PATH, + }) + } if (requestedReturnUrl && isSameOrigin(requestedReturnUrl)) { response.cookies.set(TRELLO_RETURN_URL_COOKIE, requestedReturnUrl, { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, + maxAge: CREDENTIAL_DRAFT_TTL_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) } else { diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 12233c934a4..22d9a04aefa 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -18,11 +18,13 @@ export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' +const TRELLO_CREDENTIAL_DRAFT_COOKIE = 'trello_credential_draft_id' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' function clearStateCookie(response: NextResponse) { response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) response.cookies.delete({ name: TRELLO_RETURN_URL_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) + response.cookies.delete({ name: TRELLO_CREDENTIAL_DRAFT_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) return response } @@ -37,6 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(storeTrelloTokenContract, request, {}) if (!parsed.success) return parsed.response const { token, state } = parsed.data.body + const draftId = request.cookies.get(TRELLO_CREDENTIAL_DRAFT_COOKIE)?.value const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value if (!cookieState || cookieState !== state) { @@ -135,17 +138,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ), })) - if (persisted) { - try { - await processCredentialDraft({ - userId: session.user.id, - providerId: 'trello', - accountId: persisted.id, - }) - } catch (error) { - logger.error('Failed to process credential draft for Trello', { error }) - } + if (!persisted) { + throw new Error(`Trello OAuth account ${trelloUser.id} was not persisted`) } + await processCredentialDraft({ + draftId, + userId: session.user.id, + providerId: 'trello', + accountId: persisted.id, + }) return clearStateCookie(NextResponse.json({ success: true })) } catch (error) { diff --git a/apps/sim/app/api/credentials/[id]/members/route.test.ts b/apps/sim/app/api/credentials/[id]/members/route.test.ts new file mode 100644 index 00000000000..c7e1d01fb5b --- /dev/null +++ b/apps/sim/app/api/credentials/[id]/members/route.test.ts @@ -0,0 +1,154 @@ +/** + * @vitest-environment node + */ +import { credential } from '@sim/db/schema' +import { + auditMock, + authMockFns, + createMockRequest, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listMembers: vi.fn(), + removeMember: vi.fn(), + upsertMember: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@/lib/credentials/members', () => ({ + leaveCredentialMembership: vi.fn(), + listCredentialMembers: mocks.listMembers, + listCredentialMembershipsForUser: vi.fn(), + removeCredentialMember: mocks.removeMember, + upsertCredentialMember: mocks.upsertMember, +})) + +import { DELETE, GET, POST } from '@/app/api/credentials/[id]/members/route' + +const CREDENTIAL_ID = 'credential-1' +const WORKSPACE_ID = 'workspace-1' +const routeContext = { params: Promise.resolve({ id: CREDENTIAL_ID }) } +const credentialRow = { + id: CREDENTIAL_ID, + workspaceId: WORKSPACE_ID, + type: 'oauth' as const, + displayName: 'Google account', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +describe('/api/credentials/[id]/members compatibility', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.loadWorkspace.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listMembers.mockResolvedValue([ + { + id: 'member-1', + userId: 'user-2', + role: 'member', + status: 'active', + joinedAt: new Date('2026-08-02T00:00:00.000Z'), + userName: 'Member', + userEmail: 'member@example.com', + }, + ]) + }) + + it('allows any workspace reader to list the credential roster', async () => { + queueTableRows(credential, [credentialRow]) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members` + ), + routeContext + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + members: [ + expect.objectContaining({ + id: 'member-1', + joinedAt: '2026-08-02T00:00:00.000Z', + }), + ], + }) + expect(mocks.listMembers).toHaveBeenCalledWith(credentialRow) + }) + + it('conceals an existing credential outside the caller workspace as not found', async () => { + queueTableRows(credential, [credentialRow]) + mocks.resolvePermission.mockResolvedValue(null) + + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members` + ), + routeContext + ) + + expect(response.status).toBe(404) + expect(await response.json()).toEqual({ error: 'Not found' }) + expect(mocks.listMembers).not.toHaveBeenCalled() + }) + + it('keeps nonexistent POST and DELETE targets behind the uniform admin denial', async () => { + queueTableRows(credential, []) + const postResponse = await POST( + createMockRequest('POST', { userId: 'user-2', role: 'member' }), + routeContext + ) + queueTableRows(credential, []) + const deleteResponse = await DELETE( + createMockRequest( + 'DELETE', + undefined, + {}, + `http://localhost/api/credentials/${CREDENTIAL_ID}/members?userId=user-2` + ), + routeContext + ) + + expect(postResponse.status).toBe(403) + expect(await postResponse.json()).toEqual({ error: 'Admin access required' }) + expect(deleteResponse.status).toBe(403) + expect(await deleteResponse.json()).toEqual({ error: 'Admin access required' }) + }) +}) diff --git a/apps/sim/app/api/credentials/[id]/members/route.ts b/apps/sim/app/api/credentials/[id]/members/route.ts index 7c87041c4d6..07e15694921 100644 --- a/apps/sim/app/api/credentials/[id]/members/route.ts +++ b/apps/sim/app/api/credentials/[id]/members/route.ts @@ -1,418 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { credential, credentialMember, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { + listWorkspaceCredentialMembersContract, + removeWorkspaceCredentialMemberContract, upsertWorkspaceCredentialMemberContract, - type WorkspaceCredentialMember, } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { deriveCredentialAdmin, isSharedCredentialType } from '@/lib/credentials/access' -import { captureServerEvent } from '@/lib/posthog/server' import { - getUserEntityPermissions, - getUsersWithPermissions, -} from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialMembersAPI') - -interface RouteContext { - params: Promise<{ id: string }> -} - -async function requireCredentialAdmin(credentialId: string, userId: string) { - const [cred] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred || cred.type === 'managed_oauth') { - return null - } - - const perm = await getUserEntityPermissions(userId, 'workspace', cred.workspaceId) - if (perm === null) return null - - const [membership] = await db - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - const isAdmin = deriveCredentialAdmin({ - credentialType: cred.type, - memberRole: membership?.status === 'active' ? membership.role : null, - workspaceCanAdmin: perm === 'admin', - }) - - if (!isAdmin) { - return null - } - return { credentialType: cred.type, workspaceId: cred.workspaceId } -} - -export const GET = withRouteHandler(async (_request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const [cred] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - - if (!cred || cred.type === 'managed_oauth') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const callerPerm = await getUserEntityPermissions( - session.user.id, - 'workspace', - cred.workspaceId - ) - if (callerPerm === null) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const explicitMembers = await db - .select({ - id: credentialMember.id, - userId: credentialMember.userId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - userName: user.name, - userEmail: user.email, - }) - .from(credentialMember) - .innerJoin(user, eq(credentialMember.userId, user.id)) - .where(eq(credentialMember.credentialId, credentialId)) - - const byUser = new Map( - explicitMembers.map((m) => [ - m.userId, - { - id: m.id, - userId: m.userId, - role: m.role, - status: m.status, - joinedAt: m.joinedAt ? m.joinedAt.toISOString() : null, - userName: m.userName, - userEmail: m.userEmail, - roleSource: 'explicit' as const, - }, - ]) - ) - - if (isSharedCredentialType(cred.type)) { - const workspaceMembers = await getUsersWithPermissions(cred.workspaceId) - for (const wsMember of workspaceMembers) { - if (wsMember.permissionType !== 'admin') continue - const existing = byUser.get(wsMember.userId) - if (existing) { - existing.role = 'admin' - existing.status = 'active' - existing.roleSource = 'workspace-admin' - } else { - byUser.set(wsMember.userId, { - id: `workspace-admin-${wsMember.userId}`, - userId: wsMember.userId, - role: 'admin', - status: 'active', - joinedAt: null, - userName: wsMember.name, - userEmail: wsMember.email, - roleSource: 'workspace-admin', - }) - } - } - } - - const members = Array.from(byUser.values()) - - return NextResponse.json({ members }) - } catch (error) { - logger.error('Failed to fetch credential members', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialMemberListErrorPolicy, + internalCredentialMemberMutationErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + listCredentialMembersUseCase, + removeCredentialMemberUseCase, + upsertCredentialMemberUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialMembersContract, + auth: internalSessionAuth, + operation: credentialOperations.listMembers, + rateLimit, + errorPolicy: internalCredentialMemberListErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: listCredentialMembersUseCase, + present: ({ members }) => ({ + members: members.map((member) => ({ + ...member, + joinedAt: member.joinedAt?.toISOString() ?? null, + })), + }), }) -export const POST = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - if (!isSharedCredentialType(admin.credentialType)) { - logger.warn('Credential member share denied', { - credentialId, - actorId: session.user.id, - reason: 'env_personal-cannot-be-shared', - }) - return NextResponse.json({ error: 'Personal secrets cannot be shared' }, { status: 400 }) - } - - const parsed = await parseRequest(upsertWorkspaceCredentialMemberContract, request, context) - if (!parsed.success) return parsed.response - - const { userId, role } = parsed.data.body - - const targetWorkspacePerm = await getUserEntityPermissions( - userId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin' && role !== 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be demoted' }, - { status: 400 } - ) - } - - const now = new Date() - - const [existing] = await db - .select({ id: credentialMember.id, status: credentialMember.status }) - .from(credentialMember) - .where( - and(eq(credentialMember.credentialId, credentialId), eq(credentialMember.userId, userId)) - ) - .limit(1) - - if (existing) { - const result = await db.transaction(async (tx) => { - const [current] = await tx - .select({ role: credentialMember.role, status: credentialMember.status }) - .from(credentialMember) - .where(eq(credentialMember.id, existing.id)) - .limit(1) - .for('update') - if ( - !isSharedCredentialType(admin.credentialType) && - current?.role === 'admin' && - current?.status === 'active' && - role !== 'admin' - ) { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - if (activeAdmins.length <= 1) return { ok: false as const } - } - await tx - .update(credentialMember) - .set({ role, status: 'active', updatedAt: now }) - .where(eq(credentialMember.id, existing.id)) - return { ok: true as const, fromRole: current?.role } - }) - if (!result.ok) { - return NextResponse.json({ error: 'Cannot demote the last admin' }, { status: 400 }) - } - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Changed credential member role to "${role}"`, - metadata: { targetUserId: userId, fromRole: result.fromRole, toRole: role }, - request, - }) - - return NextResponse.json({ success: true }) - } - - await db.insert(credentialMember).values({ - id: generateId(), - credentialId, - userId, - role, - status: 'active', - joinedAt: now, - invitedBy: session.user.id, - createdAt: now, - updatedAt: now, - }) - - captureServerEvent(session.user.id, 'credential_shared', { - credential_type: admin.credentialType, - role, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_ADDED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: `Shared credential with member as "${role}"`, - metadata: { targetUserId: userId, role }, - request, - }) - - return NextResponse.json({ success: true }, { status: 201 }) - } catch (error) { - logger.error('Failed to add credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const POST = defineInternalJsonRoute({ + contract: upsertWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.upsertMember, + rateLimit, + errorPolicy: internalCredentialMemberMutationErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: upsertCredentialMemberUseCase, + present: () => ({ success: true as const }), + statusForResult: ({ created }) => (created ? 201 : 200), }) -export const DELETE = withRouteHandler(async (request: NextRequest, context: RouteContext) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id: credentialId } = await context.params - const targetUserId = new URL(request.url).searchParams.get('userId') - if (!targetUserId) { - return NextResponse.json({ error: 'userId query parameter required' }, { status: 400 }) - } - - const admin = await requireCredentialAdmin(credentialId, session.user.id) - if (!admin) { - logger.warn('Credential member removal denied', { - credentialId, - actorId: session.user.id, - reason: 'not-admin', - }) - return NextResponse.json({ error: 'Admin access required' }, { status: 403 }) - } - - const [target] = await db - .select({ - id: credentialMember.id, - role: credentialMember.role, - }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, targetUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (!target) { - return NextResponse.json({ error: 'Member not found' }, { status: 404 }) - } - - if (isSharedCredentialType(admin.credentialType)) { - const targetWorkspacePerm = await getUserEntityPermissions( - targetUserId, - 'workspace', - admin.workspaceId - ) - if (targetWorkspacePerm === 'admin') { - return NextResponse.json( - { error: 'Workspace admins are automatically credential admins and cannot be removed' }, - { status: 400 } - ) - } - } - - const revoked = await db.transaction(async (tx) => { - if (!isSharedCredentialType(admin.credentialType) && target.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - .for('update') - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ status: 'revoked', updatedAt: new Date() }) - .where(eq(credentialMember.id, target.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json({ error: 'Cannot remove the last admin' }, { status: 400 }) - } - - captureServerEvent(session.user.id, 'credential_unshared', { - credential_type: admin.credentialType, - workspace_id: admin.workspaceId, - }) - - recordAudit({ - workspaceId: admin.workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.CREDENTIAL_MEMBER_REMOVED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - description: 'Removed credential member', - metadata: { targetUserId }, - request, - }) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error('Failed to remove credential member', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: removeWorkspaceCredentialMemberContract, + auth: internalSessionAuth, + operation: credentialOperations.removeMember, + rateLimit, + errorPolicy: internalCredentialMemberMutationErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, query }) => ({ credentialId: params.id, userId: query.userId }), + useCase: removeCredentialMemberUseCase, + present: () => ({ success: true as const }), }) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index ca1eee11b9c..d99ad8382c4 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -1,188 +1,63 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - type CredentialActorContext, - canUseCredential, - getCredentialActorContext, -} from '@/lib/credentials/access' + deleteWorkspaceCredentialContract, + getWorkspaceCredentialContract, + updateWorkspaceCredentialContract, +} from '@/lib/api/contracts/credentials' import { - isProviderOutageCode, - performDeleteCredential, - performUpdateCredential, -} from '@/lib/credentials/orchestration' - -const logger = createLogger('CredentialByIdAPI') - -function formatCredentialResponse(access: CredentialActorContext) { - const cred = access.credential - if (!cred) return null - - return { - id: cred.id, - workspaceId: cred.workspaceId, - type: cred.type, - displayName: cred.displayName, - description: cred.description, - providerId: cred.providerId, - accountId: cred.accountId, - envKey: cred.envKey, - envOwnerUserId: cred.envOwnerUserId, - createdBy: cred.createdBy, - createdAt: cred.createdAt, - updatedAt: cred.updatedAt, - role: access.isAdmin ? 'admin' : (access.member?.role ?? null), - status: access.member?.status ?? (access.isAdmin ? 'active' : null), - } -} - -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const access = await getCredentialActorContext(id, session.user.id) - if (!access.credential || access.credential.type === 'managed_oauth') { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - if (!canUseCredential(access)) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to fetch credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest(updateWorkspaceCredentialContract, request, context, { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - }) - if (!parsed.success) return parsed.response - - const { id } = parsed.data.params - const body = parsed.data.body - - const currentAccess = await getCredentialActorContext(id, session.user.id) - if (!currentAccess.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - - const result = await performUpdateCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - displayName: body.displayName, - description: body.description, - serviceAccountJson: body.serviceAccountJson, - signingSecret: body.signingSecret, - botToken: body.botToken, - apiToken: body.apiToken, - domain: body.domain, - clientId: body.clientId, - clientSecret: body.clientSecret, - certificateId: body.certificateId, - orgId: body.orgId, - dataCenter: body.dataCenter, - authMethod: body.authMethod, - privateKey: body.privateKey, - username: body.username, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'conflict' - ? 409 - : // A provider outage during reconnect is infra, not a bad - // request — mirror the create route and runtime token route. - // Every provider family names its own outage code, so this - // asks the shared predicate rather than matching one literal. - isProviderOutageCode(result.providerErrorCode) - ? 502 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json( - { - error: result.error, - ...(result.providerErrorCode ? { code: result.providerErrorCode } : {}), - }, - { status } - ) - } - - const access = await getCredentialActorContext(id, session.user.id) - return NextResponse.json({ credential: formatCredentialResponse(access) }, { status: 200 }) - } catch (error) { - logger.error('Failed to update credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { id } = await params - - try { - const currentAccess = await getCredentialActorContext(id, session.user.id) - if (!currentAccess.credential) { - return NextResponse.json({ error: 'Credential not found' }, { status: 404 }) - } - const result = await performDeleteCredential({ - credentialId: id, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - if (!result.success) { - const status = - result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'forbidden' - ? 403 - : result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'validation' - ? 400 - : 500 - return NextResponse.json({ error: result.error }, { status }) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to delete credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + getWorkspaceCredentialUseCase, + updateWorkspaceCredentialUseCase, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.read, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: getWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.update, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params, body }) => ({ credentialId: params.id, ...body }), + useCase: updateWorkspaceCredentialUseCase, + present: ({ credential, access }) => ({ + credential: toWorkspaceCredential(credential, access), + }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.delete, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ params }) => ({ credentialId: params.id }), + useCase: deleteCredentialUseCase, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/credentials/draft/route.ts b/apps/sim/app/api/credentials/draft/route.ts index 15fdfcb5d7f..545101f591c 100644 --- a/apps/sim/app/api/credentials/draft/route.ts +++ b/apps/sim/app/api/credentials/draft/route.ts @@ -1,100 +1,23 @@ -import { db } from '@sim/db' -import { pendingCredentialDraft } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createCredentialDraftContract } from '@/lib/api/contracts/credentials' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialDraftAPI') - -const DRAFT_TTL_MS = 15 * 60 * 1000 - -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createCredentialDraftContract, request, {}) - if (!parsed.success) return parsed.response - - const { workspaceId, providerId, displayName, description, credentialId } = parsed.data.body - const userId = session.user.id - - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { - return NextResponse.json({ error: 'Write permission required' }, { status: 403 }) - } - - if (credentialId) { - const access = await getCredentialActorContext(credentialId, userId, { workspaceAccess }) - if ( - !access.credential || - access.credential.type === 'managed_oauth' || - access.credential.workspaceId !== workspaceId || - !access.isAdmin - ) { - return NextResponse.json( - { error: 'Admin access required on the target credential' }, - { status: 403 } - ) - } - } - - const now = new Date() - - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - - await db - .insert(pendingCredentialDraft) - .values({ - id: generateId(), - userId, - workspaceId, - providerId, - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { - displayName, - description: description || null, - credentialId: credentialId || null, - expiresAt: new Date(now.getTime() + DRAFT_TTL_MS), - createdAt: now, - }, - }) - - logger.info('Credential draft saved', { - userId, - workspaceId, - providerId, - displayName, - credentialId: credentialId || null, - }) - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to save credential draft', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +export const POST = defineInternalJsonRoute({ + contract: createCredentialDraftContract, + auth: internalSessionAuth, + operation: credentialOperations.saveDraft, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: saveCredentialDraft, }) diff --git a/apps/sim/app/api/credentials/memberships/route.ts b/apps/sim/app/api/credentials/memberships/route.ts index 33227c66de0..6ee05aa1de6 100644 --- a/apps/sim/app/api/credentials/memberships/route.ts +++ b/apps/sim/app/api/credentials/memberships/route.ts @@ -1,123 +1,48 @@ -import { db } from '@sim/db' -import { credential, credentialMember } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, ne } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { leaveCredentialQuerySchema } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' - -const logger = createLogger('CredentialMembershipsAPI') - -export const GET = withRouteHandler(async () => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const memberships = await db - .select({ - membershipId: credentialMember.id, - credentialId: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - displayName: credential.displayName, - providerId: credential.providerId, - role: credentialMember.role, - status: credentialMember.status, - joinedAt: credentialMember.joinedAt, - }) - .from(credentialMember) - .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) - .where( - and(eq(credentialMember.userId, session.user.id), ne(credential.type, 'managed_oauth')) - ) - - return NextResponse.json({ memberships }, { status: 200 }) - } catch (error) { - logger.error('Failed to list credential memberships', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +import { + leaveCredentialMembershipContract, + listCredentialMembershipsContract, +} from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + leaveCredentialMembershipUseCase, + listCredentialMembershipsUseCase, +} from '@/lib/credentials/application/credential-members' +import { credentialUserOperations } from '@/lib/credentials/application/operations' + +const rateLimit = internalRateLimits.none({ reason: 'Preserve existing internal behavior' }) + +export const GET = defineInternalJsonRoute({ + contract: listCredentialMembershipsContract, + auth: internalSessionAuth, + operation: credentialUserOperations.listMemberships, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: () => ({}), + useCase: listCredentialMembershipsUseCase, + present: ({ memberships }) => ({ + memberships: memberships.map((membership) => ({ + ...membership, + joinedAt: membership.joinedAt?.toISOString() ?? null, + })), + }), }) -export const DELETE = withRouteHandler(async (request: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parseResult = leaveCredentialQuerySchema.safeParse({ - credentialId: new URL(request.url).searchParams.get('credentialId'), - }) - if (!parseResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { credentialId } = parseResult.data - const [membership] = await db - .select() - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.userId, session.user.id) - ) - ) - .limit(1) - - if (!membership) { - return NextResponse.json({ error: 'Membership not found' }, { status: 404 }) - } - - if (membership.status !== 'active') { - return NextResponse.json({ success: true }, { status: 200 }) - } - - const revoked = await db.transaction(async (tx) => { - if (membership.role === 'admin') { - const activeAdmins = await tx - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, credentialId), - eq(credentialMember.role, 'admin'), - eq(credentialMember.status, 'active') - ) - ) - - if (activeAdmins.length <= 1) { - return false - } - } - - await tx - .update(credentialMember) - .set({ - status: 'revoked', - updatedAt: new Date(), - }) - .where(eq(credentialMember.id, membership.id)) - - return true - }) - - if (!revoked) { - return NextResponse.json( - { error: 'Cannot leave credential as the last active admin' }, - { status: 400 } - ) - } - - return NextResponse.json({ success: true }, { status: 200 }) - } catch (error) { - logger.error('Failed to leave credential', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } +export const DELETE = defineInternalJsonRoute({ + contract: leaveCredentialMembershipContract, + auth: internalSessionAuth, + operation: credentialUserOperations.leaveMembership, + rateLimit, + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: leaveCredentialMembershipUseCase, }) diff --git a/apps/sim/app/api/credentials/route.test.ts b/apps/sim/app/api/credentials/route.test.ts index 3a105b71e57..2b0c2bfed45 100644 --- a/apps/sim/app/api/credentials/route.test.ts +++ b/apps/sim/app/api/credentials/route.test.ts @@ -18,11 +18,19 @@ import { TokenServiceAccountValidationError } from '@/lib/credentials/token-serv const { mockCheckWorkspaceAccess, + mockGetCredentialActorContext, mockGetCredentialCreationWorkspaceContext, + mockLoadWorkspace, + mockResolveWorkspacePermission, + mockSyncWorkspaceOAuthCredentials, mockVerifyAndBuildServiceAccountSecret, } = vi.hoisted(() => ({ mockCheckWorkspaceAccess: vi.fn(), + mockGetCredentialActorContext: vi.fn(), mockGetCredentialCreationWorkspaceContext: vi.fn(), + mockLoadWorkspace: vi.fn(), + mockResolveWorkspacePermission: vi.fn(), + mockSyncWorkspaceOAuthCredentials: vi.fn(), mockVerifyAndBuildServiceAccountSecret: vi.fn(), })) @@ -33,12 +41,34 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ checkWorkspaceAccess: mockCheckWorkspaceAccess, })) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mockLoadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolveWorkspacePermission, +})) + +vi.mock('@/lib/credentials/access', () => ({ + canUseCredential: (access: { member: unknown; isAdmin: boolean; hasWorkspaceAccess: boolean }) => + access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin), + getCredentialActorContext: mockGetCredentialActorContext, + isSharedCredentialType: (type: string) => type !== 'env_personal', + requireOrdinaryCredentialType: (type: string) => { + if (type === 'managed_oauth') throw new Error('Managed OAuth credential reached test surface') + return type + }, + SHARED_CREDENTIAL_TYPES: ['oauth', 'env_workspace', 'service_account'], +})) + vi.mock('@/lib/credentials/environment', () => ({ getCredentialCreationWorkspaceContext: mockGetCredentialCreationWorkspaceContext, })) vi.mock('@/lib/credentials/oauth', () => ({ - syncWorkspaceOAuthCredentialsForUser: vi.fn(), + syncWorkspaceOAuthCredentialsForUser: mockSyncWorkspaceOAuthCredentials, })) vi.mock('@/lib/oauth', () => ({ @@ -57,6 +87,12 @@ vi.mock('@/lib/credentials/service-account-secret', () => ({ import { GET, POST } from '@/app/api/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const WORKSPACE_CONTEXT = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: 'org-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', +} describe('GET /api/credentials', () => { beforeEach(() => { @@ -64,7 +100,10 @@ describe('GET /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('read') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -112,6 +151,53 @@ describe('GET /api/credentials', () => { }), ]) }) + + it('normalizes padded, blank, and duplicate legacy query values', async () => { + queueTableRows(credential, []) + const url = new URL('http://localhost:3000/api/credentials') + url.searchParams.append('workspaceId', ` ${WORKSPACE_ID} `) + url.searchParams.append('workspaceId', 'not-the-selected-value') + url.searchParams.set('type', '') + url.searchParams.set('providerId', '') + url.searchParams.set('credentialId', ' ') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentials: [] }) + expect(mockLoadWorkspace).toHaveBeenCalledWith(WORKSPACE_ID) + }) + + it('uses the legacy workspace-scoped id/account lookup without sync, filters, or shape drift', async () => { + queueTableRows(credential, []) + queueTableRows(credential, [ + { + id: 'credential-1', + displayName: 'Google account', + type: 'oauth', + providerId: 'google-email', + }, + ]) + const url = new URL('http://localhost:3000/api/credentials') + url.searchParams.set('workspaceId', WORKSPACE_ID) + url.searchParams.set('credentialId', ' account-1 ') + url.searchParams.set('type', 'env_workspace') + url.searchParams.set('providerId', 'different-provider') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + credential: { + id: 'credential-1', + displayName: 'Google account', + type: 'oauth', + providerId: 'google-email', + }, + }) + expect(mockSyncWorkspaceOAuthCredentials).not.toHaveBeenCalled() + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) }) describe('POST /api/credentials', () => { @@ -120,7 +206,10 @@ describe('POST /api/credentials', () => { resetDbChainMock() authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1', name: 'Test User', email: 'test@example.com' }, + session: { id: 'session-1' }, }) + mockLoadWorkspace.mockResolvedValue(WORKSPACE_CONTEXT) + mockResolveWorkspacePermission.mockResolvedValue('write') mockCheckWorkspaceAccess.mockResolvedValue({ hasAccess: true, canWrite: true, @@ -132,6 +221,27 @@ describe('POST /api/credentials', () => { memberUserIds: ['user-1'], canWrite: true, }) + mockGetCredentialActorContext.mockResolvedValue({ + credential: { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account', + displayName: 'Service account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted-blob', + createdBy: 'user-1', + createdAt: new Date('2026-08-11T00:00:00.000Z'), + updatedAt: new Date('2026-08-11T00:00:00.000Z'), + }, + member: { role: 'admin', status: 'active' }, + hasWorkspaceAccess: true, + canWriteWorkspace: true, + isAdmin: true, + }) }) describe('client-credential service accounts', () => { diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 991b76d712a..ea3203d398f 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -1,278 +1,49 @@ -import { db } from '@sim/db' -import { credential } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, ne } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceCredentialContract, - credentialsListGetQuerySchema, + listWorkspaceCredentialsContract, } from '@/lib/api/contracts/credentials' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' import { - performCreateCredential, - statusForCredentialOrchestrationError, -} from '@/lib/credentials/orchestration/credential-create' -import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('CredentialsAPI') - -/** - * Thrown by the inner duplicate guard inside the create transaction when a - * concurrent request slipped a row in between the outer existence check and - * our INSERT. The catch maps this to a 409 with a typed `code` so the UI can - * map to a friendly message. - */ -class DuplicateCredentialError extends Error { - constructor() { - super('duplicate_display_name') - this.name = 'DuplicateCredentialError' - } -} - -interface ExistingCredentialSourceParams { - workspaceId: string - type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account' - accountId?: string | null - envKey?: string | null - envOwnerUserId?: string | null - displayName?: string | null - providerId?: string | null -} - -type DbOrTx = typeof db | Parameters[0]>[0] - -async function findExistingCredentialBySourceWith( - exec: DbOrTx, - params: ExistingCredentialSourceParams -) { - const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params - - if (type === 'oauth' && accountId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'oauth'), - eq(credential.accountId, accountId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_workspace' && envKey) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_workspace'), - eq(credential.envKey, envKey) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'env_personal' && envKey && envOwnerUserId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'env_personal'), - eq(credential.envKey, envKey), - eq(credential.envOwnerUserId, envOwnerUserId) - ) - ) - .limit(1) - return row ?? null - } - - if (type === 'service_account' && displayName && providerId) { - const [row] = await exec - .select() - .from(credential) - .where( - and( - eq(credential.workspaceId, workspaceId), - eq(credential.type, 'service_account'), - eq(credential.providerId, providerId), - eq(credential.displayName, displayName) - ) - ) - .limit(1) - return row ?? null - } - - return null -} - -export const GET = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const { searchParams } = new URL(request.url) - const rawWorkspaceId = searchParams.get('workspaceId') - const rawType = searchParams.get('type') - const rawProviderId = searchParams.get('providerId') - const rawCredentialId = searchParams.get('credentialId') - const parseResult = credentialsListGetQuerySchema.safeParse({ - workspaceId: rawWorkspaceId?.trim(), - type: rawType?.trim() || undefined, - providerId: rawProviderId?.trim() || undefined, - credentialId: rawCredentialId?.trim() || undefined, - }) - - if (!parseResult.success) { - logger.warn(`[${requestId}] Invalid credential list request`, { - workspaceId: rawWorkspaceId, - type: rawType, - providerId: rawProviderId, - errors: parseResult.error.issues, - }) - return NextResponse.json( - { error: getValidationErrorMessage(parseResult.error) }, - { status: 400 } - ) - } - - const { workspaceId, type, providerId, credentialId: lookupCredentialId } = parseResult.data - const workspaceAccess = await checkWorkspaceAccess(workspaceId, session.user.id) - - if (!workspaceAccess.hasAccess) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) - } - - if (lookupCredentialId) { - let [row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where( - and( - eq(credential.id, lookupCredentialId), - eq(credential.workspaceId, workspaceId), - ne(credential.type, 'managed_oauth') - ) - ) - .limit(1) - - if (!row) { - ;[row] = await db - .select({ - id: credential.id, - displayName: credential.displayName, - type: credential.type, - providerId: credential.providerId, - }) - .from(credential) - .where( - and( - eq(credential.accountId, lookupCredentialId), - eq(credential.workspaceId, workspaceId), - ne(credential.type, 'managed_oauth') - ) - ) - .limit(1) - } - - return NextResponse.json({ credential: row ?? null }) - } - - if (!type || type === 'oauth') { - await syncWorkspaceOAuthCredentialsForUser({ workspaceId, userId: session.user.id }) - } - - const visible = await listVisibleWorkspaceCredentials({ - workspaceId, - userId: session.user.id, - workspaceAccess, - types: type ? [type] : undefined, - providerId, - }) - const credentials = visible.data.map(({ hasServiceAccountKey: _hasKey, ...rest }) => rest) - - return NextResponse.json({ credentials }) - } catch (error) { - logger.error(`[${requestId}] Failed to list credentials`, error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - } + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + credentialValidationParseOptions, + internalCredentialErrorPolicy, +} from '@/lib/credentials/api/route-policies' +import { + createWorkspaceCredential, + listInternalCredentials, +} from '@/lib/credentials/application/credential-crud' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { toWorkspaceCredential } from '@/lib/credentials/application/presentation' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceCredentialsContract, + auth: internalSessionAuth, + operation: credentialOperations.listInternal, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ query }) => query, + useCase: listInternalCredentials, + present: (result) => + result.mode === 'lookup' + ? { credential: result.credential } + : { credentials: result.credentials.map((row) => toWorkspaceCredential(row)) }, }) -export const POST = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - const session = await getSession() - - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest( - createWorkspaceCredentialContract, - request, - {}, - { - validationErrorResponse: (error) => - NextResponse.json({ error: getValidationErrorMessage(error) }, { status: 400 }), - } - ) - if (!parsed.success) return parsed.response - - const result = await performCreateCredential({ - ...parsed.data.body, - userId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success) { - logger.warn(`[${requestId}] Credential create rejected`, { - errorCode: result.errorCode, - providerErrorCode: result.providerErrorCode, - }) - const status = statusForCredentialOrchestrationError(result.errorCode, { - providerUnavailable: result.providerUnavailable, - }) - return NextResponse.json( - result.providerErrorCode - ? { code: result.providerErrorCode, error: result.error } - : { error: result.error }, - { status } - ) - } - - if (!result.credential) { - throw new Error('Credential creation succeeded without a credential') - } - - const responseBody = createWorkspaceCredentialContract.response.schema.parse({ - credential: { - ...result.credential, - createdAt: result.credential.createdAt.toISOString(), - updatedAt: result.credential.updatedAt.toISOString(), - }, - }) - - // An existing credential matched the source: an idempotent replay, not a create. - return NextResponse.json(responseBody, { status: result.created ? 201 : 200 }) +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceCredentialContract, + auth: internalSessionAuth, + operation: credentialOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }), + errorPolicy: internalCredentialErrorPolicy, + parseOptions: credentialValidationParseOptions, + mapInput: ({ body }) => body, + useCase: createWorkspaceCredential, + present: ({ credential, role, status }) => ({ + credential: { ...toWorkspaceCredential({ ...credential, role }), status }, + }), + statusForResult: ({ created }) => (created ? 201 : 200), }) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts new file mode 100644 index 00000000000..38362bef21f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/service-account', () => ({ + deleteCredentialUseCase: { + operation: { id: 'credentials.delete' }, + execute: mocks.execute, + }, +})) + +import { DELETE } from '@/app/api/v2/credentials/[credentialId]/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('DELETE /api/v2/credentials/[credentialId]', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ credential: { id: 'credential-1' } }) + }) + + it('disconnects a credential through the application operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/credential-1?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, { + params: Promise.resolve({ credentialId: 'credential-1' }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: 'credential-1', deleted: true } }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, credentialId: 'credential-1' }, + request, + }) + }) + + it('requires the asserted workspace scope', async () => { + const response = await DELETE( + new NextRequest('http://localhost:3000/api/v2/credentials/credential-1', { + method: 'DELETE', + }), + { params: Promise.resolve({ credentialId: 'credential-1' }) } + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/[credentialId]/route.ts b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts new file mode 100644 index 00000000000..0baea423259 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/[credentialId]/route.ts @@ -0,0 +1,30 @@ +import { v2DeleteCredentialContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialUseCase } from '@/lib/credentials/application/service-account' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Credential not found', +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: query.workspaceId, + credentialId: params.credentialId, + }), + useCase: deleteCredentialUseCase, + present: ({ credential }) => ({ data: { id: credential.id, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/credentials/connections/route.test.ts b/apps/sim/app/api/v2/credentials/connections/route.test.ts new file mode 100644 index 00000000000..1946b7fb81c --- /dev/null +++ b/apps/sim/app/api/v2/credentials/connections/route.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ + createCredentialConnection: { + operation: { id: 'credentials.connections.create' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/v2/credentials/connections/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + +describe('POST /api/v2/credentials/connections', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + }) + + it('creates a browser entrypoint for a named OAuth credential', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }), + }) + const response = await POST(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + expiresAt: '2026-08-12T20:15:00.000Z', + }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + providerId: 'google-email', + displayName: 'Work Gmail', + }, + request, + }) + }) + + it('requires a display name for new OAuth connections', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials/connections', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, providerId: 'google-email' }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/connections/route.ts b/apps/sim/app/api/v2/credentials/connections/route.ts new file mode 100644 index 00000000000..7fef7f6ce2d --- /dev/null +++ b/apps/sim/app/api/v2/credentials/connections/route.ts @@ -0,0 +1,32 @@ +import { v2CreateCredentialConnectionContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialConnectionErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateCredentialConnectionContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createConnection, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialConnectionErrorPolicy, + mapInput: ({ body }) => body, + useCase: createCredentialConnection, + present: ({ authorizationUrl, expiresAt }) => ({ + data: { + authorizationUrl, + expiresAt: expiresAt.toISOString(), + }, + }), +}) diff --git a/apps/sim/app/api/v2/credentials/providers/route.test.ts b/apps/sim/app/api/v2/credentials/providers/route.test.ts new file mode 100644 index 00000000000..648c3154db2 --- /dev/null +++ b/apps/sim/app/api/v2/credentials/providers/route.test.ts @@ -0,0 +1,140 @@ +/** + * @vitest-environment node + */ +import { + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2GateModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn() })) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) +vi.mock('@/lib/credentials/application/list-credential-providers', () => ({ + listCredentialProviders: { + operation: { id: 'credentials.providers.list' }, + execute: mocks.execute, + }, +})) + +import { GET } from '@/app/api/v2/credentials/providers/route' + +const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const providers = [ + { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'service_account' as const, + serviceId: 'salesforce-service-account', + providerId: 'salesforce-service-account', + name: 'Salesforce integration user app', + description: 'Connect Salesforce with an integration user app.', + providerFamily: 'salesforce', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/salesforce-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientSecret', + label: 'Consumer secret', + placeholder: 'Paste the consumer secret', + required: false, + secret: true, + multiline: false, + requiredForAuthMethods: ['client_credentials'], + }, + ], + }, +] + +describe('GET /api/v2/credentials/providers', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue(auth) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.execute.mockResolvedValue({ providers }) + }) + + it('returns OAuth and service-account connection methods', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}` + ) + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: providers, nextCursor: null }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID }, + request, + }) + }) + + it('rejects unsupported pagination instead of ignoring it', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&limit=1` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a provider-name search to the authorized application use case', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&search=%20Sales%20` + ) + + const response = await GET(request) + + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: auth.principal, + input: { workspaceId: WORKSPACE_ID, search: 'Sales' }, + request, + }) + }) + + it('rejects an empty provider search', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/credentials/providers?workspaceId=${WORKSPACE_ID}&search=` + ) + ) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/providers/route.ts b/apps/sim/app/api/v2/credentials/providers/route.ts new file mode 100644 index 00000000000..4fbe9de2f4f --- /dev/null +++ b/apps/sim/app/api/v2/credentials/providers/route.ts @@ -0,0 +1,27 @@ +import { v2ListCredentialProvidersContract } from '@/lib/api/contracts/v2/credentials' +import { + createV2ResourceConcealmentPolicy, + defineV2JsonRoute, + v2ApiKeyAuth, + v2RateLimits, +} from '@/lib/api/server/routes' +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const credentialProviderErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) + +export const GET = defineV2JsonRoute({ + contract: v2ListCredentialProvidersContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.listProviders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialProviderErrorPolicy, + mapInput: ({ query }) => query, + useCase: listCredentialProviders, + present: ({ providers }) => ({ data: providers, nextCursor: null }), +}) diff --git a/apps/sim/app/api/v2/credentials/route.test.ts b/apps/sim/app/api/v2/credentials/route.test.ts index 465d2cbd6be..c06ce24edcf 100644 --- a/apps/sim/app/api/v2/credentials/route.test.ts +++ b/apps/sim/app/api/v2/credentials/route.test.ts @@ -13,7 +13,8 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - execute: vi.fn(), + list: vi.fn(), + create: vi.fn(), })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -23,13 +24,20 @@ vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock) vi.mock('@/lib/credentials/application/list-workspace-credentials', () => ({ listWorkspaceCredentials: { operation: { id: 'credentials.connections.list' }, - execute: mocks.execute, + execute: mocks.list, + }, +})) + +vi.mock('@/lib/credentials/application/service-account', () => ({ + createServiceAccountCredentialUseCase: { + operation: { id: 'credentials.service_accounts.create' }, + execute: mocks.create, }, })) import { V2_DEFAULT_PAGE_SIZE } from '@/lib/api/contracts/v2/shared' import { REFILTERED_CURSOR_MESSAGE } from '@/lib/api/cursor-binding' -import { GET } from '@/app/api/v2/credentials/route' +import { GET, POST } from '@/app/api/v2/credentials/route' const WORKSPACE_ID = '11111111-2222-4333-8444-555555555555' const auth = { @@ -67,7 +75,7 @@ describe('GET /api/v2/credentials', () => { v2RouteMocks.gate.mockResolvedValue(null) v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: null, sortBy: 'createdAt', @@ -81,7 +89,7 @@ describe('GET /api/v2/credentials', () => { expect(response.status).toBe(400) expect(v2RouteMocks.authenticate).toHaveBeenCalled() expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('calls the application operation with the workspace principal', async () => { @@ -91,7 +99,7 @@ describe('GET /api/v2/credentials', () => { const response = await GET(request) expect(response.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: { workspaceId: WORKSPACE_ID, @@ -114,7 +122,7 @@ describe('GET /api/v2/credentials', () => { * map of param names and stays green when a route drops the stamp entirely. */ it('refuses a cursor minted under a different filter', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -129,7 +137,7 @@ describe('GET /api/v2/credentials', () => { const { nextCursor } = await minted.json() expect(nextCursor).toEqual(expect.any(String)) - mocks.execute.mockClear() + mocks.list.mockClear() const replayed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=slack&cursor=${encodeURIComponent(nextCursor)}` @@ -138,11 +146,11 @@ describe('GET /api/v2/credentials', () => { expect(replayed.status).toBe(400) expect((await replayed.json()).error.message).toBe(REFILTERED_CURSOR_MESSAGE) - expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() }) it('resumes a cursor replayed under the filters it was minted with', async () => { - mocks.execute.mockResolvedValue({ + mocks.list.mockResolvedValue({ credentials: [credential], nextCursorKeys: ['2026-01-01T00:00:00.000Z', 'credential-1'], sortBy: 'createdAt', @@ -156,7 +164,7 @@ describe('GET /api/v2/credentials', () => { ) const { nextCursor } = await minted.json() - mocks.execute.mockClear() + mocks.list.mockClear() const resumed = await GET( new NextRequest( `http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}&search=zoom&cursor=${encodeURIComponent(nextCursor)}` @@ -164,7 +172,7 @@ describe('GET /api/v2/credentials', () => { ) expect(resumed.status).toBe(200) - expect(mocks.execute).toHaveBeenCalledWith({ + expect(mocks.list).toHaveBeenCalledWith({ principal: auth.principal, input: expect.objectContaining({ search: 'zoom', @@ -202,7 +210,7 @@ describe('GET /api/v2/credentials', () => { }) it('hides repository errors that may contain secret details', async () => { - mocks.execute.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) + mocks.list.mockRejectedValueOnce(new Error('encryptedServiceAccountKey failed')) const response = await GET( new NextRequest(`http://localhost:3000/api/v2/credentials?workspaceId=${WORKSPACE_ID}`) @@ -214,3 +222,98 @@ describe('GET /api/v2/credentials', () => { }) }) }) + +describe('POST /api/v2/credentials', () => { + beforeEach(() => { + vi.clearAllMocks() + v2RouteMocks.authenticate.mockResolvedValue({ + ...auth, + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + keyType: 'personal', + }) + v2RouteMocks.gate.mockResolvedValue(null) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + mocks.create.mockResolvedValue({ + credential: { ...credential, encryptedServiceAccountKey: 'must-not-leak' }, + created: true, + hasServiceAccountKey: true, + role: 'admin', + auditMetadata: {}, + }) + }) + + it('creates a verified service-account credential without returning secrets', async () => { + const request = new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + clientId: 'client-id', + clientSecret: 'client-secret', + certificateId: undefined, + orgId: 'account-id', + }), + }) + const response = await POST(request) + const body = await response.json() + + expect(response.status).toBe(201) + expect(body.data).toMatchObject({ + id: 'credential-1', + type: 'service_account', + displayName: 'Zoom account', + providerId: 'zoom-service-account', + hasServiceAccountKey: true, + role: 'admin', + }) + expect(JSON.stringify(body)).not.toContain('client-secret') + expect(JSON.stringify(body)).not.toContain('must-not-leak') + expect(mocks.create).toHaveBeenCalledWith({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom account', + description: undefined, + id: undefined, + serviceAccountJson: undefined, + apiToken: undefined, + domain: undefined, + signingSecret: undefined, + botToken: undefined, + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + dataCenter: undefined, + authMethod: undefined, + privateKey: undefined, + username: undefined, + }, + request, + }) + }) + + it('rejects an unknown service-account provider before the use case', async () => { + const response = await POST( + new NextRequest('http://localhost:3000/api/v2/credentials', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'made-up-service-account', + serviceAccountJson: '{}', + }), + }) + ) + + expect(response.status).toBe(400) + expect(mocks.create).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/credentials/route.ts b/apps/sim/app/api/v2/credentials/route.ts index b0dde7a39ab..d3f8c12b27c 100644 --- a/apps/sim/app/api/v2/credentials/route.ts +++ b/apps/sim/app/api/v2/credentials/route.ts @@ -1,39 +1,26 @@ -import type { V2Credential } from '@/lib/api/contracts/v2/credentials' -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateServiceAccountCredentialContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { + createV2ResourceConcealmentPolicy, defineV2JsonRoute, v2ApiKeyAuth, - v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes' import { listWorkspaceCredentials } from '@/lib/credentials/application/list-workspace-credentials' import { credentialOperations } from '@/lib/credentials/application/operations' -import type { VisibleWorkspaceCredential } from '@/lib/credentials/queries' +import { toV2Credential } from '@/lib/credentials/application/presentation' +import { createServiceAccountCredentialUseCase } from '@/lib/credentials/application/service-account' import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** Serialize connection metadata field by field so encrypted columns can never reach the wire. */ -function toV2Credential(row: VisibleWorkspaceCredential): V2Credential { - if (row.type !== 'oauth' && row.type !== 'service_account') { - throw new Error(`Secret credential type ${row.type} reached the credentials API`) - } - - return { - id: row.id, - type: row.type, - displayName: row.displayName, - description: row.description, - providerId: row.providerId, - accountId: row.accountId, - hasServiceAccountKey: row.hasServiceAccountKey, - role: row.role, - createdAt: row.createdAt.toISOString(), - updatedAt: row.updatedAt.toISOString(), - } -} +const credentialWorkspaceErrorPolicy = createV2ResourceConcealmentPolicy({ + notFoundMessage: 'Workspace not found', +}) /** Every param that changes which credentials, in which order, this list returns. */ function credentialCursorFilters(query: { @@ -56,7 +43,7 @@ export const GET = defineV2JsonRoute({ auth: v2ApiKeyAuth, operation: credentialOperations.listConnections, rateLimit: v2RateLimits.publicApi, - errorPolicy: v2OrchestrationErrorPolicy, + errorPolicy: credentialWorkspaceErrorPolicy, mapInput: ({ query }) => ({ ...query, cursorKeys: readSortedCursor( @@ -77,3 +64,18 @@ export const GET = defineV2JsonRoute({ ), }), }) + +/** POST /api/v2/credentials — Create and verify a service-account credential. */ +export const POST = defineV2JsonRoute({ + contract: v2CreateServiceAccountCredentialContract, + auth: v2ApiKeyAuth, + operation: credentialOperations.createServiceAccount, + rateLimit: v2RateLimits.publicApi, + errorPolicy: credentialWorkspaceErrorPolicy, + mapInput: ({ body }) => body, + useCase: createServiceAccountCredentialUseCase, + present: ({ credential, hasServiceAccountKey, role }) => ({ + data: toV2Credential({ ...credential, hasServiceAccountKey, role }), + }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/app/oauth/credential-connected/page.test.tsx b/apps/sim/app/oauth/credential-connected/page.test.tsx new file mode 100644 index 00000000000..2a137ea39b7 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.test.tsx @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import CredentialConnectedPage from '@/app/oauth/credential-connected/page' + +describe('CredentialConnectedPage', () => { + it('confirms a successful connection', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Credential connected') + expect(markup).toContain('The credential is ready to use.') + }) + + it('does not claim success when the provider returns an error', async () => { + const page = await CredentialConnectedPage({ + searchParams: Promise.resolve({ result: 'connected', error: 'access_denied' }), + }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) + + it('does not claim success without an explicit success result', async () => { + const page = await CredentialConnectedPage({ searchParams: Promise.resolve({}) }) + + const markup = renderToStaticMarkup(page) + expect(markup).toContain('Connection failed') + expect(markup).not.toContain('Credential connected') + }) +}) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx new file mode 100644 index 00000000000..72606f82511 --- /dev/null +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -0,0 +1,39 @@ +import { ChipLink } from '@sim/emcn' +import type { Metadata } from 'next' +import { LogoShell } from '@/app/(landing)/components' + +export const metadata: Metadata = { + title: 'Credential connected', + robots: { index: false, follow: false }, +} + +interface CredentialConnectedPageProps { + searchParams: Promise> +} + +export default async function CredentialConnectedPage({ + searchParams, +}: CredentialConnectedPageProps) { + const params = await searchParams + const result = typeof params.result === 'string' ? params.result : undefined + const error = Array.isArray(params.error) ? params.error[0] : params.error + const connected = result === 'connected' && !error + + return ( + +
+

+ {connected ? 'Credential connected' : 'Connection failed'} +

+

+ {connected + ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' + : 'The credential could not be connected. Return to the app that started the connection and try again.'} +

+ + Open Sim + +
+
+ ) +} diff --git a/apps/sim/hooks/queries/credentials.ts b/apps/sim/hooks/queries/credentials.ts index ca094a48c24..8d931e4e20e 100644 --- a/apps/sim/hooks/queries/credentials.ts +++ b/apps/sim/hooks/queries/credentials.ts @@ -23,6 +23,7 @@ import { environmentKeys } from '@/hooks/queries/environment' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { fetchWorkspaceCredentialList, + requireWorkspaceCredentialListResponse, WORKSPACE_CREDENTIAL_LIST_STALE_TIME, } from '@/hooks/queries/utils/fetch-workspace-credentials' @@ -74,7 +75,7 @@ export function useWorkspaceCredentials(params: { }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) }, enabled: Boolean(workspaceId) && enabled, staleTime: WORKSPACE_CREDENTIAL_LIST_STALE_TIME, diff --git a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts index bf1dccfe9d3..aae9ce81307 100644 --- a/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts +++ b/apps/sim/hooks/queries/utils/fetch-workspace-credentials.ts @@ -1,8 +1,21 @@ import { requestJson } from '@/lib/api/client/request' -import { listWorkspaceCredentialsContract, type WorkspaceCredential } from '@/lib/api/contracts' +import { + type ContractJsonResponse, + listWorkspaceCredentialsContract, + type WorkspaceCredential, +} from '@/lib/api/contracts' export const WORKSPACE_CREDENTIAL_LIST_STALE_TIME = 60 * 1000 +export function requireWorkspaceCredentialListResponse( + data: ContractJsonResponse +): WorkspaceCredential[] { + if (!('credentials' in data)) { + throw new Error('Workspace credential list returned a lookup response') + } + return data.credentials +} + /** * Fetches the workspace credential list. * @@ -18,5 +31,5 @@ export async function fetchWorkspaceCredentialList( query: { workspaceId }, signal, }) - return data.credentials ?? [] + return requireWorkspaceCredentialListResponse(data) } diff --git a/apps/sim/hooks/use-oauth-return.ts b/apps/sim/hooks/use-oauth-return.ts index 5b2092c44cb..d49fd84f7ca 100644 --- a/apps/sim/hooks/use-oauth-return.ts +++ b/apps/sim/hooks/use-oauth-return.ts @@ -25,6 +25,7 @@ import { import { getDesktopBridge } from '@/lib/desktop' import { oauthConnectionsKeys } from '@/hooks/queries/oauth/oauth-connections' import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { requireWorkspaceCredentialListResponse } from '@/hooks/queries/utils/fetch-workspace-credentials' const OAUTH_CREDENTIAL_UPDATED_EVENT = 'oauth-credentials-updated' const SETTINGS_RETURN_URL_KEY = 'settings-return-url' @@ -39,7 +40,7 @@ async function resolveOAuthMessage(ctx: OAuthReturnContext): Promise { const data = await requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: ctx.workspaceId, type: 'oauth' }, }) - const oauthCredentials = data.credentials ?? [] + const oauthCredentials = requireWorkspaceCredentialListResponse(data) const forProvider = oauthCredentials.filter((c) => c.providerId === ctx.providerId) if (forProvider.length > ctx.preCount) { @@ -97,7 +98,7 @@ async function verifyOAuthChatAttempt(queryClient: QueryClient, attemptId: strin requestJson(listWorkspaceCredentialsContract, { query: { workspaceId: attempt.workspaceId, type: 'oauth' }, signal, - }).then((data) => data.credentials ?? []), + }).then(requireWorkspaceCredentialListResponse), staleTime: 0, }) diff --git a/apps/sim/lib/api/contracts/credentials.ts b/apps/sim/lib/api/contracts/credentials.ts index 944cbd54980..21942cf9291 100644 --- a/apps/sim/lib/api/contracts/credentials.ts +++ b/apps/sim/lib/api/contracts/credentials.ts @@ -47,23 +47,30 @@ export type WorkspaceCredentialRole = z.output export type WorkspaceCredential = z.output +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + +function trimmedOptionalQueryString>(schema: T) { + return firstQueryStringSchema + .transform((value) => value.trim() || undefined) + .pipe(schema.optional()) + .optional() +} + export const credentialsListQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), + workspaceId: firstQueryStringSchema + .transform((value) => value.trim()) + .pipe(z.string().uuid('Workspace ID must be a valid UUID')), + type: trimmedOptionalQueryString(workspaceCredentialTypeSchema), + providerId: trimmedOptionalQueryString(z.string()), + credentialId: trimmedOptionalQueryString(z.string()), }) export const credentialIdParamsSchema = z.object({ id: z.string().min(1), }) -export const credentialsListGetQuerySchema = z.object({ - workspaceId: z.string().uuid('Workspace ID must be a valid UUID'), - type: workspaceCredentialTypeSchema.optional(), - providerId: z.string().optional(), - credentialId: z.string().optional(), -}) - export const serviceAccountJsonSchema = z .string() .min(1, 'Service account JSON key is required') @@ -260,6 +267,18 @@ export const leaveCredentialQuerySchema = z.object({ credentialId: z.string().min(1), }) +export const credentialMembershipSchema = z.object({ + membershipId: z.string(), + credentialId: z.string(), + workspaceId: z.string(), + type: workspaceCredentialTypeSchema, + displayName: z.string(), + providerId: z.string().nullable(), + role: workspaceCredentialRoleSchema, + status: workspaceCredentialMemberStatusSchema, + joinedAt: z.string().nullable(), +}) + export const workspaceCredentialMemberSchema = z.object({ id: z.string(), userId: z.string(), @@ -306,6 +325,14 @@ export const oauthCredentialSchema = z.object({ scopes: z.array(z.string()).optional(), }) +export const workspaceCredentialLookupSchema = workspaceCredentialSchema.pick({ + id: true, + displayName: true, + type: true, + providerId: true, +}) +export type WorkspaceCredentialLookup = z.output + export const oauthCredentialsQuerySchema = z .object({ provider: z.string().nullish(), @@ -324,9 +351,10 @@ export const listWorkspaceCredentialsContract = defineRouteContract({ query: credentialsListQuerySchema, response: { mode: 'json', - schema: z.object({ - credentials: z.array(workspaceCredentialSchema), - }), + schema: z.union([ + z.object({ credentials: z.array(workspaceCredentialSchema) }), + z.object({ credential: workspaceCredentialLookupSchema.nullable() }), + ]), }, }) @@ -384,6 +412,7 @@ export const createWorkspaceCredentialContract = defineRouteContract({ body: createCredentialBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ credential: workspaceCredentialSchema, }), @@ -422,6 +451,7 @@ export const upsertWorkspaceCredentialMemberContract = defineRouteContract({ body: upsertWorkspaceCredentialMemberBodySchema, response: { mode: 'json', + status: [200, 201], schema: z.object({ success: z.literal(true), member: workspaceCredentialMemberSchema.optional(), @@ -441,3 +471,22 @@ export const removeWorkspaceCredentialMemberContract = defineRouteContract({ }), }, }) + +export const listCredentialMembershipsContract = defineRouteContract({ + method: 'GET', + path: '/api/credentials/memberships', + response: { + mode: 'json', + schema: z.object({ memberships: z.array(credentialMembershipSchema) }), + }, +}) + +export const leaveCredentialMembershipContract = defineRouteContract({ + method: 'DELETE', + path: '/api/credentials/memberships', + query: leaveCredentialQuerySchema, + response: { + mode: 'json', + schema: z.object({ success: z.literal(true) }), + }, +}) diff --git a/apps/sim/lib/api/contracts/oauth-connections.test.ts b/apps/sim/lib/api/contracts/oauth-connections.test.ts index db44ebfb81f..8e607b307fc 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.test.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.test.ts @@ -3,11 +3,23 @@ */ import { describe, expect, it } from 'vitest' import { + connectedAccountsQuerySchema, instagramAuthorizeQuerySchema, instagramCallbackQuerySchema, trelloAuthorizeQuerySchema, } from '@/lib/api/contracts/oauth-connections' +describe('Connected account query contracts', () => { + it('preserves first-value and blank-provider normalization', () => { + expect(connectedAccountsQuerySchema.parse({ provider: '' })).toEqual({ + provider: undefined, + }) + expect(connectedAccountsQuerySchema.parse({ provider: ['google', 'slack'] })).toEqual({ + provider: 'google', + }) + }) +}) + describe('Instagram OAuth query contracts', () => { it('accepts bounded authorize and callback values', () => { expect( diff --git a/apps/sim/lib/api/contracts/oauth-connections.ts b/apps/sim/lib/api/contracts/oauth-connections.ts index 234f86419ea..89d31c2fac4 100644 --- a/apps/sim/lib/api/contracts/oauth-connections.ts +++ b/apps/sim/lib/api/contracts/oauth-connections.ts @@ -32,8 +32,15 @@ export const disconnectOAuthBodySchema = z.object({ accountId: z.string().optional(), }) +const firstQueryStringSchema = z + .union([z.string(), z.array(z.string()).min(1)]) + .transform((value) => (Array.isArray(value) ? value[0] : value)) + export const connectedAccountsQuerySchema = z.object({ - provider: z.string().min(1).optional(), + provider: firstQueryStringSchema + .transform((value) => value || undefined) + .pipe(z.string().min(1).optional()) + .optional(), }) export const connectedAccountSchema = z.object({ @@ -49,12 +56,18 @@ export const trelloTokenBodySchema = z.object({ state: z.string().min(1, 'state is required'), }) +const oauthCredentialDraftIdSchema = z + .string() + .min(1, 'draftId is required') + .max(255, 'draftId must be at most 255 characters') + export const trelloAuthorizeQuerySchema = z.object({ returnUrl: z .string() .min(1, 'Return URL cannot be empty') .max(2048, 'Return URL is too long') .optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) const trelloCallbackQuerySchema = z @@ -137,6 +150,7 @@ export const oauthTokenPostContract = defineRouteContract({ export const shopifyAuthorizeQuerySchema = z.object({ shop: z.string().optional(), returnUrl: z.string().optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const shopifyCallbackQuerySchema = z.object({ @@ -226,6 +240,7 @@ export const instagramAuthorizeQuerySchema = z.object({ .max(MAX_OAUTH_RETURN_URL_LENGTH, 'Return URL is too long') .optional(), workspaceId: workspaceIdSchema.optional(), + draftId: oauthCredentialDraftIdSchema.optional(), }) export const authorizeInstagramContract = defineRouteContract({ @@ -270,12 +285,42 @@ export const instagramCallbackContract = defineRouteContract({ response: { mode: 'redirect' }, }) -export const authorizeOAuth2QuerySchema = z.object({ - providerId: z.string().min(1, 'providerId is required'), - workspaceId: workspaceIdSchema, - callbackURL: z.string().min(1).optional(), - credentialId: z.string().min(1).optional(), -}) +export const authorizeOAuth2QuerySchema = z + .object({ + draftId: oauthCredentialDraftIdSchema.optional(), + providerId: z.string().min(1, 'providerId is required').optional(), + workspaceId: workspaceIdSchema.optional(), + callbackURL: z.string().min(1).optional(), + credentialId: z.string().min(1).optional(), + }) + .superRefine((data, ctx) => { + if (data.draftId) { + for (const field of ['providerId', 'workspaceId', 'callbackURL', 'credentialId'] as const) { + if (data[field] !== undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [field], + message: `${field} cannot be combined with draftId`, + }) + } + } + return + } + if (!data.providerId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['providerId'], + message: 'providerId is required', + }) + } + if (!data.workspaceId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['workspaceId'], + message: 'workspaceId is required', + }) + } + }) export const authorizeOAuth2Contract = defineRouteContract({ method: 'GET', diff --git a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts index 5004b64c806..248c4069cc4 100644 --- a/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/__tests__/list-pagination.test.ts @@ -78,11 +78,14 @@ const PAGED_LISTS = [ * server reports. The MCP *server* list is not bounded that way — nothing caps * how many servers a workspace registers — which is why it is paged and does * not appear here. + * - The credential-provider catalog is bounded by the code-defined OAuth and + * service-account registries. * - A knowledge base has a fixed number of tag slots, so its tag vocabulary * cannot grow past them. * - A table's saved views and its dispatchable groups are capped per table. */ const FULL_SET_LISTS = [ + 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', 'GET /api/v2/knowledge/[id]/tags', 'GET /api/v2/knowledge/folders', diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 6694a626a3c..3f4c3b05bb0 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -1,14 +1,20 @@ import { z } from 'zod' import { workspaceCredentialRoleSchema } from '@/lib/api/contracts/credentials' -import { workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { noInputSchema, nonEmptyIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, + v2DataResponse, v2PaginationFields, v2SearchSchema, v2SortFields, v2TimestampSchema, } from '@/lib/api/contracts/v2/shared' +import { + getServiceAccountRequiredFields, + SERVICE_ACCOUNT_REQUIRED_FIELDS, +} from '@/lib/credentials/service-account-fields' +import { SLACK_CUSTOM_BOT_PROVIDER_ID } from '@/lib/oauth/types' /** Public credentials are authenticated connections, never raw environment secrets. */ export const v2CredentialTypeSchema = z @@ -44,6 +50,114 @@ export const v2CredentialSchema = z }) export type V2Credential = z.output +export const v2CredentialProviderAuthorizationOptionSchema = z + .object({ + providerId: z + .string() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider identifier accepted by the connection endpoint.'), + label: z + .string() + .min(1, 'label cannot be empty') + .max(255, 'label must be at most 255 characters') + .describe('Human-readable authorization-server label.'), + }) + .strict() +export type V2CredentialProviderAuthorizationOption = z.output< + typeof v2CredentialProviderAuthorizationOptionSchema +> + +export const v2CredentialProviderFieldOptionSchema = z + .object({ + value: z.string().min(1).max(255).describe('Submitted option value.'), + label: z.string().min(1).max(255).describe('Human-readable option label.'), + }) + .strict() + +export const v2CredentialProviderFieldSchema = z + .object({ + id: z.string().min(1).max(255).describe('Exact create-body field name.'), + label: z.string().min(1).max(255).describe('Human-readable field label.'), + placeholder: z.string().min(1).max(1000).describe('Suggested input placeholder.'), + required: z.boolean().describe('Whether the field is required for the selected flow.'), + secret: z.boolean().describe('Whether the submitted field is write-only secret material.'), + multiline: z.boolean().describe('Whether the field is intended for multi-line input.'), + requiredForAuthMethods: z + .array(z.string().min(1).max(64)) + .min(1) + .max(10) + .optional() + .describe('Authentication methods for which this field is required.'), + options: z + .array(v2CredentialProviderFieldOptionSchema) + .min(1) + .max(20) + .optional() + .describe('Fixed values accepted by a selector field.'), + hint: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + }) + .strict() + +const v2CredentialProviderBaseShape = { + serviceId: z.string().min(1).max(255).describe('Stable credential-provider identifier.'), + name: z.string().min(1).max(255).describe('Credential provider display name.'), + description: z.string().min(1).max(1000).describe('Credential provider description.'), + providerFamily: z.string().min(1).max(255).describe('Owning provider family identifier.'), + available: z + .boolean() + .describe('Whether this caller can connect the provider in the current deployment.'), +} as const + +export const v2OAuthCredentialProviderSchema = z + .object({ + type: z.literal('oauth').describe('Browser-based OAuth connection method.'), + ...v2CredentialProviderBaseShape, + supportsReconnect: z + .boolean() + .describe('Whether existing credentials for this service can be reconnected.'), + authorizationOptions: z + .array(v2CredentialProviderAuthorizationOptionSchema) + .min(1) + .max(10) + .describe('Authorization servers available for this OAuth service.'), + }) + .strict() + +export const v2ServiceAccountCredentialProviderSchema = z + .object({ + type: z.literal('service_account').describe('Direct service-account credential method.'), + ...v2CredentialProviderBaseShape, + providerId: z + .string() + .min(1) + .max(255) + .describe('Exact service-account provider ID accepted by credential creation.'), + docsUrl: z.string().url().describe('Setup guide for the provider.'), + helpText: z.string().min(1).max(2000).optional().describe('Provider-specific setup guidance.'), + requiresClientGeneratedCredentialId: z + .boolean() + .describe('Whether the caller must generate and submit the credential ID before setup.'), + fields: z + .array(v2CredentialProviderFieldSchema) + .min(1) + .max(20) + .describe('Create-body fields accepted by this provider. Secret fields are write-only.'), + }) + .strict() + +export const v2CredentialProviderSchema = z + .discriminatedUnion('type', [ + v2OAuthCredentialProviderSchema, + v2ServiceAccountCredentialProviderSchema, + ]) + .meta({ + id: 'V2CredentialProvider', + title: 'Credential Provider', + description: 'An OAuth or service-account connection method available to a workspace.', + }) +export type V2CredentialProvider = z.output + /** A credential's natural name field is `displayName`, so that is what `search` matches. */ export const v2CredentialSortFields = ['displayName', 'createdAt', 'updatedAt'] as const export type V2CredentialSortBy = (typeof v2CredentialSortFields)[number] @@ -66,11 +180,6 @@ export const v2ListCredentialsQuerySchema = z .strict() export type V2ListCredentialsQuery = z.output -/** - * Lists OAuth and service-account connections, keyset-paginated over the active - * sort. Credential mutations are intentionally absent. Nothing capped the - * per-workspace set before pagination, so the response grew without bound. - */ export const v2ListCredentialsContract = defineRouteContract({ method: 'GET', path: '/api/v2/credentials', @@ -80,3 +189,274 @@ export const v2ListCredentialsContract = defineRouteContract({ schema: v2CursorListResponse(v2CredentialSchema), }, }) + +export const v2ListCredentialProvidersQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe( + 'Workspace used to evaluate credential-provider availability and integration policy.' + ), + search: v2SearchSchema.describe( + 'Case-insensitive substring match against the credential provider name.' + ), + }) + .strict() +export type V2ListCredentialProvidersQuery = z.output + +export const v2ListCredentialProvidersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/credentials/providers', + query: v2ListCredentialProvidersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2CredentialProviderSchema, { paged: false }), + }, +}) + +const v2CreateCredentialConnectionByProviderSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact OAuth provider ID returned by credential-provider discovery.'), + displayName: z + .string({ error: 'displayName is required' }) + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .describe('Name shown for the new credential in Sim.'), + }) + .strict() + +const v2CreateCredentialConnectionByCredentialSchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + credentialId: z + .string({ error: 'credentialId is required' }) + .trim() + .min(1, 'credentialId cannot be empty') + .max(255, 'credentialId must be at most 255 characters') + .describe('Existing OAuth credential to reconnect in place.'), + }) + .strict() + +export const v2CreateCredentialConnectionBodySchema = z.union([ + v2CreateCredentialConnectionByProviderSchema, + v2CreateCredentialConnectionByCredentialSchema, +]) +export type V2CreateCredentialConnectionBody = z.output< + typeof v2CreateCredentialConnectionBodySchema +> + +export const v2CredentialConnectionAuthorizationSchema = z + .object({ + authorizationUrl: z + .string() + .url('authorizationUrl must be an absolute URL') + .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'), + expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'), + }) + .meta({ + id: 'V2CredentialConnectionAuthorization', + title: 'Credential Connection Authorization', + description: 'A short-lived browser entrypoint for an OAuth connection flow.', + }) +export type V2CredentialConnectionAuthorization = z.output< + typeof v2CredentialConnectionAuthorizationSchema +> + +export const v2CreateCredentialConnectionResponseSchema = v2DataResponse( + v2CredentialConnectionAuthorizationSchema +) +export type V2CreateCredentialConnectionResponse = z.output< + typeof v2CreateCredentialConnectionResponseSchema +> + +export const v2CreateCredentialConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials/connections', + query: noInputSchema, + body: v2CreateCredentialConnectionBodySchema, + response: { + mode: 'json', + schema: v2CreateCredentialConnectionResponseSchema, + }, +}) + +const v2ServiceAccountSecretFieldsShape = { + serviceAccountJson: z + .string() + .min(1) + .max(65_536) + .optional() + .describe('Write-only Google service-account JSON key.') + .meta({ writeOnly: true }), + apiToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only provider API token.') + .meta({ writeOnly: true }), + domain: z.string().trim().min(1).max(2048).optional().describe('Provider account domain.'), + signingSecret: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only webhook signing secret.') + .meta({ writeOnly: true }), + botToken: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only bot token.') + .meta({ writeOnly: true }), + clientId: z.string().trim().min(1).max(512).optional().describe('OAuth client identifier.'), + clientSecret: z + .string() + .trim() + .min(1) + .max(1024) + .optional() + .describe('Write-only OAuth client secret.') + .meta({ writeOnly: true }), + certificateId: z + .string() + .trim() + .min(1) + .max(512) + .optional() + .describe('Provider certificate mapping identifier.'), + orgId: z.string().trim().min(1).max(255).optional().describe('Provider organization ID.'), + dataCenter: z.string().trim().min(1).max(32).optional().describe('Provider data center.'), + authMethod: z + .string() + .trim() + .min(1) + .max(64) + .optional() + .describe('Provider authentication method.'), + privateKey: z + .string() + .trim() + .min(1) + .max(8192) + .optional() + .describe('Write-only PEM private key.') + .meta({ writeOnly: true }), + username: z.string().trim().min(1).max(255).optional().describe('Provider run-as username.'), +} as const + +export const v2CreateServiceAccountCredentialBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that will own the credential.'), + type: z.literal('service_account').describe('Service-account credential discriminator.'), + providerId: z + .string({ error: 'providerId is required' }) + .trim() + .min(1, 'providerId cannot be empty') + .max(255, 'providerId must be at most 255 characters') + .describe('Exact service-account provider ID returned by provider discovery.'), + displayName: z + .string() + .trim() + .min(1, 'displayName cannot be empty') + .max(255, 'displayName must be at most 255 characters') + .optional() + .describe('Optional name; providers may derive one from the verified account identity.'), + description: z + .string() + .trim() + .max(500, 'description must be at most 500 characters') + .optional() + .describe('Optional credential description.'), + id: z + .string() + .uuid('id must be a valid UUID') + .optional() + .describe('Required only when provider discovery requests a client-generated ID.'), + ...v2ServiceAccountSecretFieldsShape, + }) + .strict() + .superRefine((body, ctx) => { + if (!Object.hasOwn(SERVICE_ACCOUNT_REQUIRED_FIELDS, body.providerId)) { + ctx.addIssue({ + code: 'custom', + path: ['providerId'], + message: `Unknown service-account provider: ${body.providerId}`, + }) + return + } + if (body.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID && !body.id) { + ctx.addIssue({ + code: 'custom', + path: ['id'], + message: `id is required for ${SLACK_CUSTOM_BOT_PROVIDER_ID} credentials`, + }) + } + for (const field of getServiceAccountRequiredFields(body.providerId)) { + if (!body[field]) { + ctx.addIssue({ + code: 'custom', + path: [field], + message: `${field} is required for ${body.providerId} credentials`, + }) + } + } + }) +export type V2CreateServiceAccountCredentialBody = z.input< + typeof v2CreateServiceAccountCredentialBodySchema +> + +export const v2CreateServiceAccountCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/credentials', + query: noInputSchema, + body: v2CreateServiceAccountCredentialBodySchema, + response: { + mode: 'json', + status: [200, 201], + schema: v2DataResponse(v2CredentialSchema), + }, +}) + +export const v2CredentialParamsSchema = z + .object({ + credentialId: nonEmptyIdSchema.max(255).describe('Credential to disconnect.'), + }) + .strict() + +export const v2DeleteCredentialQuerySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace expected to own the credential.'), + }) + .strict() + +export const v2CredentialDeleteDataSchema = z + .object({ + id: nonEmptyIdSchema.describe('Disconnected credential identifier.'), + deleted: z.literal(true).describe('Whether the credential was disconnected.'), + }) + .meta({ + id: 'V2CredentialDeleteData', + title: 'Delete credential data', + description: 'Credential disconnection acknowledgement.', + }) + +export const v2DeleteCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/credentials/[credentialId]', + params: v2CredentialParamsSchema, + query: v2DeleteCredentialQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CredentialDeleteDataSchema), + }, +}) diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index a87fa3f3294..23db1528093 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -1,4 +1,10 @@ -import { v2ListCredentialsContract } from '@/lib/api/contracts/v2/credentials' +import { + v2CreateCredentialConnectionContract, + v2CreateServiceAccountCredentialContract, + v2DeleteCredentialContract, + v2ListCredentialProvidersContract, + v2ListCredentialsContract, +} from '@/lib/api/contracts/v2/credentials' import { v2CreateCustomToolContract, v2DeleteCustomToolContract, @@ -166,6 +172,63 @@ const CREDENTIAL_EXAMPLE = { updatedAt: '2026-06-20T14:02:11.000Z', } as const +const CREDENTIAL_PROVIDER_EXAMPLE = { + type: 'oauth', + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect to Salesforce CRM data and operations.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} as const + +const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'clientId', + label: 'Client ID', + placeholder: 'Paste the client ID', + required: true, + secret: false, + multiline: false, + }, + { + id: 'clientSecret', + label: 'Client secret', + placeholder: 'Paste the client secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'orgId', + label: 'Account ID', + placeholder: 'Paste the account ID', + required: true, + secret: false, + multiline: false, + }, + ], +} as const + +const CREDENTIAL_CONNECTION_EXAMPLE = { + authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', + expiresAt: '2026-06-20T14:17:11.000Z', +} as const + const SECRET_EXAMPLE = { name: 'STRIPE_API_KEY', scope: 'workspace', @@ -784,7 +847,7 @@ const declaredRoutes = [ operationId: 'listCredentials', summary: 'List Credentials', description: - 'List OAuth and service-account connections visible to the caller. Secret material is never returned. Credential mutations and single-resource reads are not exposed.', + 'List OAuth and service-account connections visible to the caller. Secret material is never returned.', errors: RESOURCE_ERRORS, success: { description: 'Credentials visible to the caller.' }, }), @@ -804,6 +867,135 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2ListCredentialProvidersContract, + resourceOperation('Credentials', { + operationId: 'listCredentialProviders', + summary: 'List Credential Providers', + description: `List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. ${FULL_SET_LIST}`, + errors: RESOURCE_ERRORS, + success: { description: 'Credential provider catalog with caller-specific availability.' }, + }), + { + query: documentedSchema( + v2ListCredentialProvidersContract.query, + 'ListCredentialProvidersQuery', + 'List credential providers query', + 'Workspace and optional provider-name search used to filter caller-specific availability.' + ), + response: documentedSchema( + v2ListCredentialProvidersContract.response.schema, + 'ListCredentialProvidersResponse', + 'List credential providers response', + 'OAuth and service-account connection methods.', + [ + { + data: [CREDENTIAL_PROVIDER_EXAMPLE, SERVICE_ACCOUNT_PROVIDER_EXAMPLE], + nextCursor: null, + }, + ] + ), + } + ), + defineOpenApiRoute( + v2CreateServiceAccountCredentialContract, + resourceOperation('Credentials', { + operationId: 'createServiceAccountCredential', + summary: 'Create Service-Account Credential', + description: `Verify and store one service-account credential. Use provider discovery to select a service-account provider and submit its required fields. Secret fields are write-only and are never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { + byStatus: { + 200: { description: 'An existing credential matched the verified source.' }, + 201: { description: 'The service-account credential was created.' }, + }, + }, + }), + { + query: v2CreateServiceAccountCredentialContract.query, + body: documentedSchema( + v2CreateServiceAccountCredentialContract.body, + 'CreateServiceAccountCredentialRequest', + 'Create service-account credential request', + 'Provider identifier, optional display metadata, and the write-only fields declared by provider discovery.', + [ + { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Zoom automation', + clientId: 'YOUR_CLIENT_ID', + clientSecret: 'YOUR_CLIENT_SECRET', + orgId: 'YOUR_ACCOUNT_ID', + }, + ] + ), + response: documentedSchema( + v2CreateServiceAccountCredentialContract.response.schema, + 'CreateServiceAccountCredentialResponse', + 'Create service-account credential response', + 'Verified credential metadata without secret material.', + [{ data: CREDENTIAL_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2CreateCredentialConnectionContract, + resourceOperation('Credentials', { + operationId: 'createCredentialConnection', + summary: 'Create Credential Connection', + description: `Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'A short-lived browser authorization URL.' }, + }), + { + query: v2CreateCredentialConnectionContract.query, + body: documentedSchema( + v2CreateCredentialConnectionContract.body, + 'CreateCredentialConnectionBody', + 'Create credential connection body', + 'For a new connection, provide providerId and displayName. For a reconnect, provide only credentialId; the existing display name is preserved.' + ), + response: documentedSchema( + v2CreateCredentialConnectionContract.response.schema, + 'CreateCredentialConnectionResponse', + 'Create credential connection response', + 'Short-lived Sim browser entrypoint and its expiry.', + [{ data: CREDENTIAL_CONNECTION_EXAMPLE }] + ), + } + ), + defineOpenApiRoute( + v2DeleteCredentialContract, + resourceOperation('Credentials', { + operationId: 'deleteCredential', + summary: 'Disconnect Credential', + description: `Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The credential was disconnected.' }, + }), + { + params: documentedSchema( + v2DeleteCredentialContract.params, + 'DeleteCredentialParams', + 'Disconnect credential path parameters', + 'Credential selected for disconnection.' + ), + query: documentedSchema( + v2DeleteCredentialContract.query, + 'DeleteCredentialQuery', + 'Disconnect credential query', + 'Workspace expected to own the credential.' + ), + response: documentedSchema( + v2DeleteCredentialContract.response.schema, + 'DeleteCredentialResponse', + 'Disconnect credential response', + 'Acknowledgement that the credential was disconnected.', + [{ data: { id: CREDENTIAL_EXAMPLE.id, deleted: true } }] + ), + } + ), defineOpenApiRoute( v2ListSecretsContract, resourceOperation('Secrets', { @@ -953,7 +1145,8 @@ export const resourcesOpenApiDocument = defineOpenApiDocument({ }, { name: 'Credentials', - description: 'List OAuth and service-account connections without secret material.', + description: + 'Discover providers, create service-account credentials, connect or reconnect OAuth accounts, disconnect credentials, and list connections without secret material.', }, { name: 'Secrets', diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index 5b8ff796ddf..df65bdfb1c3 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -65,12 +65,12 @@ import { * - **`search`** ({@link v2SearchSchema}) — a case-insensitive substring match * against the resource's *single* natural name field, and nothing else: * `name` for files/folders/workflows/tables/knowledge bases/MCP servers/ - * skills, `title` for custom tools, `filename` for knowledge documents - * (`GET /knowledge/{id}/documents`), and `displayName` for both credentials - * and secrets (`GET /secrets`, where the secret's name *is* the credential - * `displayName`). It never matches ids, descriptions, or content. `%` and `_` in the term are matched - * literally, not as wildcards. Empty is rejected rather than silently - * ignored — omit the param instead. + * skills/credential providers, `title` for custom tools, `filename` for + * knowledge documents (`GET /knowledge/{id}/documents`), and `displayName` + * for both credentials and secrets (`GET /secrets`, where the secret's name + * *is* the credential `displayName`). It never matches ids, descriptions, or + * content. `%` and `_` in the term are matched literally, not as wildcards. + * Empty is rejected rather than silently ignored — omit the param instead. * - **`sortBy` + `sortOrder`** ({@link v2SortFields}) — `sortBy` is a * per-resource enum, never a free string, because the value selects a column * in the query. `sortOrder` is `asc`/`desc`. Both always have a default, so @@ -93,9 +93,12 @@ import { * * Every one of these is pushed into SQL, except on `GET /skills` (which narrows the * static builtin registry with the same search term, merges it into the DB rows, - * then re-sorts the merged array) and `GET /files/folders` (which applies `parentPath` and `search` - * in JS; its sort is pushed into SQL like every other folder list). Both read a - * full result set to produce a page; neither is a pattern to copy. + * then re-sorts the merged array), `GET /files/folders` (which applies + * `parentPath` and `search` in JS; its sort is pushed into SQL like every other + * folder list), and `GET /credentials/providers` (whose bounded catalog is + * assembled from code-defined registries before its caller-specific + * availability is projected). These read a full result set to produce a page; + * none is a pattern to copy. * * ## Which lists are paged * @@ -111,7 +114,8 @@ import { * load; `GET /knowledge/{id}/tags`, capped by the fixed tag-slot table; * `GET /mcp-servers/{id}/tools`, capped by tool discovery itself; and * `GET /tables/{tableId}/views` and `GET /tables/{tableId}/groups`, capped per - * table. + * table; and the credential-provider catalog, bounded by code-defined OAuth + * and service-account registries. * * Adding `limit`/`cursor` to a full-set list is additive, but giving it a * *default* `limit` truncates callers reading the whole set today, so once v2 is diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 53c0e054622..7f10e36b703 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -383,4 +383,62 @@ describe('defineInternalJsonRoute', () => { __privateMetadata: { value: 'ok' }, }) }) + + it('selects a declared success status from the application result', async () => { + const replayableContract = defineRouteContract({ + method: 'POST', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + status: [200, 201], + }, + }) + const handler = defineInternalJsonRoute({ + contract: replayableContract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'created', created: true } + }, + }, + present: ({ value }) => ({ value }), + statusForResult: ({ created }) => (created ? 201 : 200), + }) + + const response = await handler( + new NextRequest('http://localhost/api/test/internal-json-route', { method: 'POST' }) + ) + + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ value: 'created' }) + }) + + it('fails closed when the application selects an undeclared success status', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + statusForResult: () => 201, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) + }) }) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index f9102d57217..8e362c35f66 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -230,6 +230,7 @@ type InternalJsonRouteOptions< params: Record }): void | Promise onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise + statusForResult?(result: NoInfer): number responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit finalizeResponse?(args: { request: NextRequest @@ -287,12 +288,6 @@ export function defineInternalJsonRoute< options.operation, options.useCase.operation ) - if (successStatuses.length !== 1) { - throw new Error( - `${options.contract.method} ${options.contract.path} internal JSON route requires one success status` - ) - } - const wrapped = withRouteHandler( async (request, context) => { if (!methodMatchesContract(request.method, options.contract.method)) { @@ -344,6 +339,12 @@ export function defineInternalJsonRoute< throw new Error('Internal JSON route response mode changed after initialization') } const validatedBody = responseSchema.schema.parse(body) as ContractJsonResponse + const responseStatus = options.statusForResult?.(result) ?? successStatus + if (!successStatuses.includes(responseStatus)) { + throw new Error( + `Internal JSON route produced undeclared success status ${responseStatus}; expected ${successStatuses.join(', ')}` + ) + } const headers = options.responseHeaders?.({ principal, input, result }) const finalization = options.finalizeResponse ? await options.finalizeResponse({ @@ -357,7 +358,7 @@ export function defineInternalJsonRoute< return NextResponse.json( appendFinalizedBodyFields(validatedBody, finalization?.bodyFields), { - status: successStatus, + status: responseStatus, headers: appendFinalizedHeaders(headers, finalization?.headers), } ) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index cdcbcd32903..7906d93707d 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -7,7 +7,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type BetterAuthOptions, betterAuth, type User } from 'better-auth' import { drizzleAdapter } from 'better-auth/adapters/drizzle' -import { APIError, createAuthMiddleware, getSessionFromCtx } from 'better-auth/api' +import { APIError, createAuthMiddleware, getOAuthState, getSessionFromCtx } from 'better-auth/api' import { nextCookies } from 'better-auth/next-js' import { admin, @@ -96,7 +96,10 @@ import { } from '@/lib/core/config/env-flags' import { PlatformEvents } from '@/lib/core/telemetry' import { getBaseUrl, isLocalhostUrl, parseOriginList } from '@/lib/core/utils/urls' -import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { + loadOAuthCredentialDraftBinding, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' import { sendEmail } from '@/lib/messaging/email/mailer' import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils' import { quickValidateEmail } from '@/lib/messaging/email/validation' @@ -523,20 +526,35 @@ export const auth = betterAuth({ } } - try { - await processCredentialDraft({ - userId: account.userId, - providerId: account.providerId, - accountId: account.id, - }) - } catch (error) { - logger.error('[account.create.after] Failed to process credential draft', { + const credentialDraftBinding = await loadOAuthCredentialDraftBinding(() => + getOAuthState() + ) + if (credentialDraftBinding.status === 'unavailable') { + logger.error('[account.create.after] Failed to read OAuth credential draft state', { userId: account.userId, providerId: account.providerId, - error, + error: credentialDraftBinding.error, }) } + if (credentialDraftBinding.status === 'available') { + try { + await processCredentialDraft({ + draftId: credentialDraftBinding.draftId, + userId: account.userId, + providerId: account.providerId, + accountId: account.id, + }) + } catch (error) { + logger.error('[account.create.after] Failed to process credential draft', { + userId: account.userId, + providerId: account.providerId, + error, + }) + if (credentialDraftBinding.draftId) throw error + } + } + try { const { ensureUserStatsExists } = await import('@/lib/billing/core/usage') await ensureUserStatsExists(account.userId) diff --git a/apps/sim/lib/copilot/application/execute-credential-use-case.ts b/apps/sim/lib/copilot/application/execute-credential-use-case.ts new file mode 100644 index 00000000000..cbebe99402a --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-credential-use-case.ts @@ -0,0 +1,14 @@ +import { createCopilotApplicationAdapter } from '@/lib/copilot/application/application-adapter' +import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/copilot/auth/application-delegation' +import { CREDENTIAL_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' + +export const executeCopilotCredentialUseCase = createCopilotApplicationAdapter({ + domain: 'credential', + delegation: { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + createDelegationId: (context) => `copilot-tool:${context.toolCallId}`, + }, + operations: credentialOperations, +}) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts index e7ea463273b..9b9ebb41da5 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-application-use-cases.test.ts @@ -8,6 +8,7 @@ const { mocks, useCases } = vi.hoisted(() => ({ custom: vi.fn(), mcp: vi.fn(), skill: vi.fn(), + credential: vi.fn(), capture: vi.fn(), }, useCases: { @@ -23,6 +24,8 @@ const { mocks, useCases } = vi.hoisted(() => ({ deleteSkill: { operation: { id: 'skills.delete' } }, listSkill: { operation: { id: 'skills.list_available' } }, updateSkill: { operation: { id: 'skills.update' } }, + updateCredential: { operation: { id: 'credentials.update' } }, + deleteManyCredentials: { operation: { id: 'credentials.delete_many' } }, }, })) @@ -35,6 +38,9 @@ vi.mock('@/lib/copilot/application/execute-mcp-server-use-case', () => ({ vi.mock('@/lib/copilot/application/execute-skill-use-case', () => ({ executeCopilotSkillUseCase: mocks.skill, })) +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.credential, +})) vi.mock('@/lib/custom-tools/application/use-cases', () => ({ deleteAvailableCustomToolUseCase: useCases.deleteCustom, listAvailableCustomToolsUseCase: useCases.listCustom, @@ -53,9 +59,16 @@ vi.mock('@/lib/skills/application/use-cases', () => ({ listAvailableSkillsUseCase: useCases.listSkill, updateSkillUseCase: useCases.updateSkill, })) +vi.mock('@/lib/credentials/application/credential-crud', () => ({ + updateWorkspaceCredentialUseCase: useCases.updateCredential, +})) +vi.mock('@/lib/credentials/application/delete-many-credentials', () => ({ + deleteManyCredentialsUseCase: useCases.deleteManyCredentials, +})) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) import type { ExecutionContext } from '@/lib/copilot/request/types' +import { executeManageCredential } from '@/lib/copilot/tools/handlers/management/manage-credential' import { executeManageCustomTool } from '@/lib/copilot/tools/handlers/management/manage-custom-tool' import { executeManageMcpTool } from '@/lib/copilot/tools/handlers/management/manage-mcp-tool' import { executeManageSkill } from '@/lib/copilot/tools/handlers/management/manage-skill' @@ -157,4 +170,43 @@ describe('Copilot management application boundaries', () => { } ) }) + + it('renames credentials through the shared credential use case', async () => { + mocks.credential.mockResolvedValue({ + credential: { id: 'credential-1', displayName: 'Renamed' }, + previousDisplayName: 'Original', + }) + + const result = await executeManageCredential( + { operation: 'rename', credentialId: 'credential-1', displayName: 'Renamed' }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { previousDisplayName: 'Original', displayName: 'Renamed' }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.updateCredential, { + credentialId: 'credential-1', + displayName: 'Renamed', + }) + }) + + it('keeps best-effort batch deletion inside one semantic application command', async () => { + mocks.credential.mockResolvedValue({ deleted: ['credential-1'], failed: ['credential-2'] }) + + const result = await executeManageCredential( + { operation: 'delete', credentialIds: ['credential-1', 'credential-2'] }, + context + ) + + expect(result).toMatchObject({ + success: true, + output: { deleted: ['credential-1'], failed: ['credential-2'] }, + }) + expect(mocks.credential).toHaveBeenCalledWith(context, useCases.deleteManyCredentials, { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }) + }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts index fb307feb7a4..07dc5d0bc44 100644 --- a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -1,84 +1,82 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { performDeleteCredential, performUpdateCredential } from '@/lib/credentials/orchestration' +import { updateWorkspaceCredentialUseCase } from '@/lib/credentials/application/credential-crud' +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' -export function executeManageCredential( +export async function executeManageCredential( rawParams: Record, context: ExecutionContext ): Promise { - const params = rawParams as { - operation: string - credentialId?: string - credentialIds?: string[] - displayName?: string + const operation = typeof rawParams.operation === 'string' ? rawParams.operation : '' + const credentialId = + typeof rawParams.credentialId === 'string' ? rawParams.credentialId : undefined + const displayName = typeof rawParams.displayName === 'string' ? rawParams.displayName : undefined + const rawCredentialIds = rawParams.credentialIds + if ( + rawCredentialIds !== undefined && + (!Array.isArray(rawCredentialIds) || rawCredentialIds.some((id) => typeof id !== 'string')) + ) { + return { success: false, error: 'credentialIds must be an array of strings' } } - const { operation, displayName } = params - return (async () => { - try { - if (!context?.userId) { - return { success: false, error: 'Authentication required' } - } - - switch (operation) { - case 'rename': { - const credentialId = params.credentialId - if (!credentialId) return { success: false, error: 'credentialId is required for rename' } - if (!displayName) return { success: false, error: 'displayName is required for rename' } + const credentialIds = rawCredentialIds as string[] | undefined + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } - const result = await performUpdateCredential({ + try { + switch (operation) { + case 'rename': { + if (!credentialId) { + return { success: false, error: 'credentialId is required for rename' } + } + if (!displayName) { + return { success: false, error: 'displayName is required for rename' } + } + const result = await executeCopilotCredentialUseCase( + context, + updateWorkspaceCredentialUseCase, + { credentialId, - userId: context.userId, displayName, - allowedTypes: ['oauth'], - }) - if (!result.success) { - return { success: false, error: result.error || 'Failed to rename credential' } - } - return { - success: true, - output: { - credentialId, - previousDisplayName: result.previousDisplayName, - displayName, - }, } + ) + return { + success: true, + output: { + credentialId: result.credential.id, + previousDisplayName: result.previousDisplayName, + displayName: result.credential.displayName, + }, } - case 'delete': { - const ids: string[] = - params.credentialIds ?? (params.credentialId ? [params.credentialId] : []) - if (ids.length === 0) - return { success: false, error: 'credentialId or credentialIds is required for delete' } - - const deleted: string[] = [] - const failed: string[] = [] - - for (const id of ids) { - const result = await performDeleteCredential({ - credentialId: id, - userId: context.userId, - allowedTypes: ['oauth'], - reason: 'copilot_delete', - }) - if (!result.success) { - failed.push(id) - continue - } - deleted.push(id) - } - - return { - success: deleted.length > 0, - output: { deleted, failed }, - } - } - default: + } + case 'delete': { + const ids = credentialIds ?? (credentialId ? [credentialId] : []) + if (ids.length === 0) { return { success: false, - error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + error: 'credentialId or credentialIds is required for delete', } + } + const result = await executeCopilotCredentialUseCase( + context, + deleteManyCredentialsUseCase, + { workspaceId, credentialIds: ids } + ) + return { + success: result.deleted.length > 0, + output: { deleted: result.deleted, failed: result.failed }, + } } - } catch (error) { - return { success: false, error: toError(error).message } + default: + return { + success: false, + error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + } } - })() + } catch (error) { + return { + success: false, + error: messageForCopilotApplicationError(error, 'Failed to manage credential'), + } + } } diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts index 77ff5a9922d..f3926a3ed11 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.test.ts @@ -2,324 +2,114 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { - mockEnsureWorkspaceAccess, - mockGetCredentialActorContext, - mockIsOAuthServiceDeploymentAvailable, - mockGetUserPermissionConfig, -} = vi.hoisted(() => ({ - mockEnsureWorkspaceAccess: vi.fn(), - mockGetCredentialActorContext: vi.fn(), - mockIsOAuthServiceDeploymentAvailable: vi.fn(() => true), - mockGetUserPermissionConfig: vi.fn(), +const mocks = vi.hoisted(() => ({ + execute: vi.fn(), + getBaseUrl: vi.fn(), })) -vi.mock('@/lib/copilot/tools/handlers/access', () => ({ - ensureWorkspaceAccess: mockEnsureWorkspaceAccess, +const useCases = vi.hoisted(() => ({ + prepare: { operation: { id: 'credentials.connections.prepare' } }, })) -vi.mock('@/lib/credentials/access', () => ({ - getCredentialActorContext: mockGetCredentialActorContext, +vi.mock('@/lib/copilot/application/execute-credential-use-case', () => ({ + executeCopilotCredentialUseCase: mocks.execute, })) - -vi.mock('@/lib/integrations/availability.server', () => ({ - isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable, -})) - -vi.mock('@/lib/core/config/env-flags', () => ({ - getAllowedIntegrationsFromEnv: vi.fn(() => null), -})) - -vi.mock('@/ee/access-control/utils/permission-check', () => ({ - getUserPermissionConfig: mockGetUserPermissionConfig, -})) - -vi.mock('@/lib/oauth/utils', () => ({ - getAllOAuthServices: vi.fn(() => [ - { serviceId: 'gmail', providerId: 'google-email', name: 'Gmail', authType: 'oauth' }, - { serviceId: 'slack', providerId: 'slack', name: 'Slack', authType: 'oauth' }, - { serviceId: 'trello', providerId: 'trello', name: 'Trello', authType: 'oauth' }, - { serviceId: 'shopify', providerId: 'shopify', name: 'Shopify', authType: 'oauth' }, - { - serviceId: 'claude-platform', - providerId: 'claude-platform', - name: 'Claude Platform', - authType: 'service_account', - }, - ]), +vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: mocks.getBaseUrl })) +vi.mock('@/lib/credentials/application/prepare-credential-connection', () => ({ + prepareCredentialConnection: useCases.prepare, })) import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeOAuthGetAuthLink } from '@/lib/copilot/tools/handlers/oauth' -const BASE_URL = 'https://sim.test' -const WORKSPACE_ID = 'ws-1' -const USER_ID = 'user-1' -const CREDENTIAL_ID = 'cred-1' - -const context = { - workspaceId: WORKSPACE_ID, - userId: USER_ID, +const context: ExecutionContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + workflowId: 'workflow-1', chatId: 'chat-1', -} as unknown as ExecutionContext - -const WORKSPACE_ACCESS = { - exists: true, - hasAccess: true, - canWrite: true, - canAdmin: false, - workspace: { id: WORKSPACE_ID }, -} - -function oauthCredentialActor(overrides: Record = {}) { - return { - credential: { - id: CREDENTIAL_ID, - workspaceId: WORKSPACE_ID, - type: 'oauth', - providerId: 'google-email', - ...((overrides.credential as Record) ?? {}), - }, - member: null, - hasWorkspaceAccess: true, - canWriteWorkspace: true, - isAdmin: true, - ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')), - } + toolCallId: 'call-1', + copilotToolExecution: true, + userPermission: 'write', } describe('executeOAuthGetAuthLink', () => { beforeEach(() => { vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) - }) - - describe('connect (no credentialId)', () => { - it('returns an authorize URL without a credentialId param', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(true) - const url = new URL((result.output as { oauth_url: string }).oauth_url) - expect(url.pathname).toBe('/api/auth/oauth2/authorize') - expect(url.searchParams.get('providerId')).toBe('google-email') - expect(url.searchParams.get('credentialId')).toBeNull() - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) - - it('rejects a provider whose OAuth client is not configured', async () => { - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(false) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not configured for this deployment') - }) - - it('rejects a provider disallowed for the workspace member', async () => { - mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] }) - - const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not allowed for this workspace member') - }) - - it('does not treat service-account-only metadata as OAuth', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'Claude Platform' }, context) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found') + mocks.getBaseUrl.mockReturnValue('https://sim.test') + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', }) }) - describe('reconnect (credentialId passed)', () => { - it('returns an authorize URL carrying the credentialId and a reconnect message', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) + it('uses the credential application adapter for a new connection', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'gmail' }, context) - expect(result.success).toBe(true) - const output = result.output as { oauth_url: string; message: string } - const url = new URL(output.oauth_url) - expect(url.searchParams.get('credentialId')).toBe(CREDENTIAL_ID) - expect(output.message).toContain('Reconnect') - expect(output.message).toContain(CREDENTIAL_ID) + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledWith(context, useCases.prepare, { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: undefined, }) + const url = new URL((result.output as { oauth_url: string }).oauth_url) + expect(url.pathname).toBe('/api/auth/oauth2/authorize') + expect(url.searchParams.get('providerId')).toBe('google-email') + expect(url.searchParams.get('workspaceId')).toBe('workspace-1') + expect(url.searchParams.has('credentialId')).toBe(false) + }) - it('reuses the already-resolved workspace access for the credential lookup', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(mockGetCredentialActorContext).toHaveBeenCalledWith(CREDENTIAL_ID, USER_ID, { - workspaceAccess: WORKSPACE_ACCESS, - }) - }) - - it('fails with an agent-visible error for a nonexistent credential', async () => { - mockGetCredentialActorContext.mockResolvedValue({ - credential: null, - member: null, - hasWorkspaceAccess: false, - canWriteWorkspace: false, - isAdmin: false, - }) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: 'cred-hallucinated' }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential belongs to another workspace', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { workspaceId: 'ws-other' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not found in this workspace') - }) - - it('fails when the credential is not an OAuth credential', async () => { - mockGetCredentialActorContext.mockResolvedValue( - oauthCredentialActor({ credential: { type: 'env_workspace' } }) - ) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('not an OAuth credential') - }) - - it('fails naming the actual provider when providerName does not match the credential', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor()) - - const result = await executeOAuthGetAuthLink( - { providerName: 'slack', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('google-email') + it('preserves the canonical credential ID for reconnect', async () => { + mocks.execute.mockResolvedValue({ + serviceName: 'Gmail', + providerId: 'google-email', + workspaceId: 'workspace-1', + credentialId: 'credential-1', }) - it('fails when the caller is not a credential admin', async () => { - mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false })) - - const result = await executeOAuthGetAuthLink( - { providerName: 'google-email', credentialId: CREDENTIAL_ID }, - context - ) - - expect(result.success).toBe(false) - expect(result.error).toContain('Admin access') - }) + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail', credentialId: 'credential-1' }, + context + ) - it('rejects reconnect for Trello and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'trello', credentialId: CREDENTIAL_ID }, - context - ) + const output = result.output as { oauth_url: string; message: string } + expect(new URL(output.oauth_url).searchParams.get('credentialId')).toBe('credential-1') + expect(output.message).toContain('re-authorizes credential credential-1 in place') + }) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + it('returns application validation errors without exposing infrastructure failures', async () => { + mocks.execute.mockRejectedValue(new OrchestrationError('not_found', 'Provider not found')) - it('rejects reconnect for Shopify and directs the user to the integrations page', async () => { - const result = await executeOAuthGetAuthLink( - { providerName: 'shopify', credentialId: CREDENTIAL_ID }, - context - ) + const result = await executeOAuthGetAuthLink({ providerName: 'missing' }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('integrations page') - expect(mockGetCredentialActorContext).not.toHaveBeenCalled() - }) + expect(result.success).toBe(false) + expect(result.error).toBe('Provider not found') }) -}) -describe('executeOAuthGetAuthLink service account rejection', () => { - beforeEach(() => { - vi.clearAllMocks() - process.env.NEXT_PUBLIC_APP_URL = BASE_URL - mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS) - mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true) - mockGetUserPermissionConfig.mockResolvedValue(null) + it('fails fast without trusted workspace context', async () => { + const result = await executeOAuthGetAuthLink( + { providerName: 'gmail' }, + { ...context, workspaceId: undefined } + ) + + expect(result).toEqual({ success: false, error: 'workspaceId is required' }) + expect(mocks.execute).not.toHaveBeenCalled() }) - /** - * Regression: a user asked for a "new custom bot", the agent correctly - * resolved that to `slack-custom-bot` and passed it here, and the fuzzy - * substring pass matched it to the Slack OAuth service — `slack-custom-bot` - * contains `slack`. The tool returned a personal-OAuth authorize URL and - * reported success, so the user connected their own account instead of a - * shared bot. Failing loudly is the point: a wrong link that looks right is - * worse than an error the agent can recover from. - */ - it('rejects a service account id with a coherent recovery message, not a workspace link', async () => { - const result = await executeOAuthGetAuthLink({ providerName: 'slack-custom-bot' }, context) + it('rejects service-account providers before OAuth resolution', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack custom bot' }, context) expect(result.success).toBe(false) - expect(result.error).toContain('service account') - expect(result.error).toContain('service_account credential tag') - const output = result.output as { setup_url?: string; oauth_url?: string; message: string } - // The rejection must not fall into the generic catch, which would attach a - // contradicting workspace oauth_url and a "connect manually" message — the - // agent would then surface a workspace link instead of the tag. - expect(output.setup_url).toBeUndefined() - expect(output.oauth_url).toBeUndefined() - expect(output.message).toContain('service_account credential tag') - expect(output.message).not.toContain('Connect manually') + expect(result.error).toContain('service account, not an OAuth provider') + expect(mocks.execute).not.toHaveBeenCalled() }) - it.each([ - 'notion-service-account', - 'salesforce-service-account', - 'google-service-account', - 'atlassian-service-account', - 'SLACK-CUSTOM-BOT', - // Readable forms must be normalized (spaces/underscores → hyphens) so they - // are caught too, not passed to the fuzzy OAuth resolver. - 'slack custom bot', - 'google service account', - 'notion_service_account', - ])('rejects %s', async (providerName) => { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(false) - expect(result.error).toContain('service_account credential tag') - }) + it('does not confuse integrations that also offer service accounts', async () => { + const result = await executeOAuthGetAuthLink({ providerName: 'slack' }, context) - it('still resolves ordinary OAuth providers for integrations that also offer a service account', async () => { - // `slack` and `notion` must keep working — the guard keys off the id being - // a service-account id, not off the integration having a service-account flow. - for (const providerName of ['slack', 'google-email']) { - const result = await executeOAuthGetAuthLink({ providerName }, context) - expect(result.success).toBe(true) - expect((result.output as { oauth_url: string }).oauth_url).toContain( - '/api/auth/oauth2/authorize' - ) - } + expect(result.success).toBe(true) + expect(mocks.execute).toHaveBeenCalledOnce() }) }) diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts index 5549efacd10..eb24cb86ab6 100644 --- a/apps/sim/lib/copilot/tools/handlers/oauth.ts +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -1,16 +1,9 @@ -import { toError } from '@sim/utils/errors' +import { messageForCopilotApplicationError } from '@/lib/copilot/application/error' +import { executeCopilotCredentialUseCase } from '@/lib/copilot/application/execute-credential-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' -import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' -import { getCredentialActorContext } from '@/lib/credentials/access' +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids' -import { isOAuthServiceAllowedByIntegrationTypes } from '@/lib/integrations/availability' -import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server' -import { getAllOAuthServices } from '@/lib/oauth/utils' -import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' export async function executeOAuthGetAuthLink( rawParams: Record, @@ -38,33 +31,26 @@ export async function executeOAuthGetAuthLink( `value instead (e.g. "slack") — it opens the service account setup form in chat.` return { success: false, error: message, output: { message } } } + const workspaceId = context.workspaceId + if (!workspaceId) return { success: false, error: 'workspaceId is required' } try { - if (!context.workspaceId || !context.userId) { - throw new Error('workspaceId and userId are required to generate an OAuth link') - } - const workspaceAccess = await ensureWorkspaceAccess( - context.workspaceId, - context.userId, - 'write' - ) - const permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId) - const configuredAllowedIntegrations = intersectIntegrationAllowlists( - permissionConfig?.allowedIntegrations ?? null, - getAllowedIntegrationsFromEnv() - ) - const allowedIntegrationTypes = configuredAllowedIntegrations - ? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase())) - : null - const result = await generateOAuthLink( - context.workspaceId, - context.workflowId, - context.chatId, + const result = await executeCopilotCredentialUseCase(context, prepareCredentialConnection, { + workspaceId, providerName, - baseUrl, - allowedIntegrationTypes, - credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined - ) + credentialId, + }) + const callbackURL = context.workflowId + ? `${baseUrl}/workspace/${workspaceId}/w/${context.workflowId}` + : context.chatId + ? `${baseUrl}/workspace/${workspaceId}/chat/${context.chatId}` + : `${baseUrl}/workspace/${workspaceId}` + const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) + authorizeUrl.searchParams.set('providerId', result.providerId) + authorizeUrl.searchParams.set('workspaceId', workspaceId) + authorizeUrl.searchParams.set('callbackURL', callbackURL) + if (result.credentialId) authorizeUrl.searchParams.set('credentialId', result.credentialId) + const action = credentialId ? 'reconnect' : 'connect' return { success: true, @@ -72,23 +58,24 @@ export async function executeOAuthGetAuthLink( message: credentialId ? `Reconnect authorization URL generated for ${result.serviceName}. Completing it re-authorizes credential ${credentialId} in place — its id stays the same.` : `Authorization URL generated for ${result.serviceName}.`, - oauth_url: result.url, - instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${result.url}`, + oauth_url: authorizeUrl.toString(), + instructions: `Open this URL in your browser to ${action} ${result.serviceName}: ${authorizeUrl.toString()}`, provider: result.serviceName, providerId: result.providerId, }, } } catch (err) { + const message = messageForCopilotApplicationError(err) const workspaceUrl = context.workspaceId ? `${baseUrl}/workspace/${context.workspaceId}` : `${baseUrl}/workspace` return { success: false, - error: toError(err).message, + error: message, output: { message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`, oauth_url: workspaceUrl, - error: toError(err).message, + error: message, }, } } @@ -109,140 +96,3 @@ export async function executeOAuthRequestAccess( }, } } - -/** - * Resolves a human-friendly provider name to a providerId and returns a - * browser-initiated authorize URL the user opens to connect the service. - * - * Steps: resolve provider → return the Sim `/api/auth/oauth2/authorize` URL. - * That endpoint (not this server-side handler) creates the credential draft and - * calls Better Auth, so the draft's TTL starts at click and the signed `state` - * cookie is planted in the user's browser and the OAuth callback's state check - * passes. - * - * When `reconnect` is set, the URL carries the existing credential id so the - * authorize endpoint creates a reconnect draft and the OAuth callback rebinds - * the credential in place instead of creating a new one. Validation happens - * here too (not just at click time) so a bad id fails in the tool result where - * the agent can see it, rather than as a silent browser redirect. - */ -async function generateOAuthLink( - workspaceId: string | undefined, - workflowId: string | undefined, - chatId: string | undefined, - providerName: string, - baseUrl: string, - allowedIntegrationTypes: ReadonlySet | null, - reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess } -): Promise<{ url: string; providerId: string; serviceName: string }> { - if (!workspaceId) { - throw new Error('workspaceId is required to generate an OAuth link') - } - - const allServices = getAllOAuthServices().filter((service) => service.authType === 'oauth') - const normalizedInput = providerName.toLowerCase().trim() - - const matched = - allServices.find((s) => s.providerId === normalizedInput) || - allServices.find((s) => s.name.toLowerCase() === normalizedInput) || - allServices.find( - (s) => - s.name.toLowerCase().includes(normalizedInput) || - normalizedInput.includes(s.name.toLowerCase()) - ) || - allServices.find( - (s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId) - ) - - if (!matched) { - const available = allServices.map((s) => s.name).join(', ') - throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`) - } - - const { providerId, name: serviceName } = matched - if (!isOAuthServiceAllowedByIntegrationTypes(matched.serviceId, allowedIntegrationTypes)) { - throw new Error(`${serviceName} is not allowed for this workspace member`) - } - if (!isOAuthServiceDeploymentAvailable(providerId)) { - throw new Error(`${serviceName} OAuth is not configured for this deployment`) - } - - if (reconnect) { - if (providerId === 'trello' || providerId === 'shopify') { - throw new Error( - `Reconnect is not supported for ${serviceName} from chat. Ask the user to open the ` + - `integrations page and press Reconnect on the credential there.` - ) - } - const actor = await getCredentialActorContext(reconnect.credentialId, reconnect.userId, { - workspaceAccess: reconnect.workspaceAccess, - }) - if (!actor.credential || actor.credential.workspaceId !== workspaceId) { - throw new Error( - `Credential "${reconnect.credentialId}" was not found in this workspace. Read ` + - `environment/credentials.json for valid credential ids.` - ) - } - if (actor.credential.type !== 'oauth') { - throw new Error( - `Credential "${reconnect.credentialId}" is not an OAuth credential and cannot be reconnected.` - ) - } - if (actor.credential.providerId !== providerId) { - throw new Error( - `Credential "${reconnect.credentialId}" belongs to provider "${actor.credential.providerId}", ` + - `not "${providerId}". Pass the matching providerName.` - ) - } - if (!actor.isAdmin) { - throw new Error('Admin access on the credential is required to reconnect it.') - } - } - - const callbackURL = - workflowId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` - : chatId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/chat/${chatId}` - : `${baseUrl}/workspace/${workspaceId}` - - if (providerId === 'trello') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/trello/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'instagram') { - const authorizeUrl = new URL(`${baseUrl}/api/auth/instagram/authorize`) - authorizeUrl.searchParams.set('returnUrl', callbackURL) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - return { url: authorizeUrl.toString(), providerId, serviceName } - } - if (providerId === 'shopify') { - const returnUrl = encodeURIComponent(callbackURL) - return { - url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`, - providerId, - serviceName, - } - } - - // Hand back a browser-initiated authorize URL rather than calling - // oAuth2LinkAccount here. Generating the link server-side would set Better - // Auth's signed `state` cookie on this server-to-server response instead of the - // user's browser, so the OAuth callback would fail with `state_mismatch`. The - // authorize endpoint runs the link inside the user's browser, planting the - // cookie correctly while keeping the callback's state check enabled. - // - // The pending credential draft is created by that authorize endpoint at click - // time (not here), so the draft's TTL starts when the user actually initiates - // the connect and reliably outlives the OAuth round-trip. - const authorizeUrl = new URL(`${baseUrl}/api/auth/oauth2/authorize`) - authorizeUrl.searchParams.set('providerId', providerId) - authorizeUrl.searchParams.set('workspaceId', workspaceId) - authorizeUrl.searchParams.set('callbackURL', callbackURL) - if (reconnect) { - authorizeUrl.searchParams.set('credentialId', reconnect.credentialId) - } - - return { url: authorizeUrl.toString(), providerId, serviceName } -} diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index aa2f4e5e494..067ad94ff10 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -181,6 +181,65 @@ describe('defineAuthorizedWorkspaceUseCase', () => { expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess']) }) + it('runs resource authorization after workspace authorization and before business effects', async () => { + mocks.resolvePermission.mockImplementation(async () => { + mocks.events.push('workspaceAuthorization') + return 'write' + }) + const execute = vi.fn(async () => { + mocks.events.push('execute') + return { ok: true as const } + }) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => { + mocks.events.push('canonicalLoad') + return canonicalContext + }, + authorizationOptions: {}, + authorizeResource() { + mocks.events.push('resourceAuthorization') + }, + execute, + projectAudit: () => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + }), + afterSuccess() { + mocks.events.push('afterSuccess') + }, + }) + + await useCase.authorize?.({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + ]) + expect(execute).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + + mocks.events.length = 0 + await expect( + useCase.execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + ).resolves.toEqual({ ok: true }) + expect(mocks.events).toEqual([ + 'canonicalLoad', + 'workspaceAuthorization', + 'resourceAuthorization', + 'execute', + 'audit', + 'afterSuccess', + ]) + }) + it('supports zero or many semantic audit entries', async () => { const buildUseCase = (auditCount: number) => defineAuthorizedWorkspaceUseCase({ diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts index a3286535edb..0d37830b458 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -56,6 +56,8 @@ export interface AuthorizedWorkspaceUseCaseDefinition< | (( args: AuthorizedWorkspaceUseCaseContext ) => WorkspaceAuthorizationOptions | Promise>) + /** Applies current domain-resource policy after workspace authorization. */ + authorizeResource?(args: AuthorizedWorkspaceUseCaseContext): void | Promise execute(args: AuthorizedWorkspaceUseCaseContext): Promise projectAudit?( args: AuthorizedWorkspaceUseCaseResultContext @@ -111,7 +113,8 @@ export function defineAuthorizedWorkspaceUseCase< >(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { /** * Everything that runs before the business transaction: allowed-principal - * check, canonical load, asserted-scope comparison, current access check. + * check, canonical load, asserted-scope comparison, current workspace and + * resource access checks. * * `execute` and `authorize` share it rather than each spelling it out, so a * `HEAD` probe cannot answer a different question from the `GET` it stands @@ -145,6 +148,7 @@ export function defineAuthorizedWorkspaceUseCase< context, authorizationOptions ) + await definition.authorizeResource?.(executionContext) return executionContext } diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 5ac8b5c55cc..2197f839434 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -48,6 +48,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'WORKSPACE_RESOURCE_LIMIT_REACHED', /** The workspace's organization does not permit public sharing. */ 'PUBLIC_SHARING_NOT_ALLOWED', + /** The caller can reach the workspace but cannot administer this credential. */ + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', /** The MCP server URL is outside the allowed domains or resolves internally. */ 'MCP_SERVER_URL_NOT_ALLOWED', ] as const @@ -83,6 +85,8 @@ export const FORBIDDEN_DETAIL_CODE_DESCRIPTIONS: Record { /** * Runs everything {@link execute} does up to and including resource * authorization, then stops — allowed-principal check, canonical load, - * asserted-scope comparison, current access check — but not the business - * transaction, the audit projection, or the after-success effects. + * asserted-scope comparison, current workspace access check, resource access + * check — but not the business transaction, the audit projection, or the + * after-success effects. * * It exists for one caller: a surface that must answer *"would this principal * be allowed?"* without causing what the answer would cause. `HEAD` on a route diff --git a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts index 917e87d666a..2fc567d9205 100644 --- a/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts +++ b/apps/sim/lib/credentials/__tests__/webhook-deactivation.test.ts @@ -15,7 +15,7 @@ vi.mock('@sim/db', () => ({ ...dbChainMock, ...schemaMock })) vi.mock('@sim/db/schema', () => schemaMock) vi.mock('drizzle-orm', () => drizzleOrmMock) -import { clearCredentialRefs } from '@/lib/credentials/deletion' +import { clearCredentialRefs, deleteConnectionCredential } from '@/lib/credentials/deletion' describe('credential-bound webhook deactivation', () => { beforeEach(() => { @@ -39,3 +39,41 @@ describe('credential-bound webhook deactivation', () => { expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.webhook.provider, 'slack') }) }) + +describe('deleteConnectionCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('deletes exactly one credential within its canonical workspace scope', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'credential-1' }]) + + const deleted = await deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + + expect(deleted).toBe(true) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.credential) + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.id, 'credential-1') + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.credential.workspaceId, 'workspace-1') + }) + + it('returns an idempotent no-op if a concurrent disconnect wins the delete', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + deleteConnectionCredential({ + credentialId: 'credential-1', + workspaceId: 'workspace-1', + reason: 'user_delete', + }) + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 9a149357a55..6f7bc25515e 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -12,6 +12,15 @@ type ActiveCredentialMember = typeof credentialMember.$inferSelect type CredentialRecord = typeof credential.$inferSelect export type CredentialType = (typeof credentialTypeEnum.enumValues)[number] +export type OrdinaryCredentialType = Exclude + +/** Narrows credentials exposed through ordinary user-managed credential surfaces. */ +export function requireOrdinaryCredentialType(type: CredentialType): OrdinaryCredentialType { + if (type === 'managed_oauth') { + throw new Error('Managed OAuth credential reached an ordinary credential surface') + } + return type +} /** * Credential types shared at the workspace level — every type except a user's diff --git a/apps/sim/lib/credentials/api/route-policies.ts b/apps/sim/lib/credentials/api/route-policies.ts new file mode 100644 index 00000000000..8e97f55c2ce --- /dev/null +++ b/apps/sim/lib/credentials/api/route-policies.ts @@ -0,0 +1,54 @@ +import { + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { getValidationErrorMessage, validationErrorResponse } from '@/lib/api/server/validation' +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { CredentialProviderOperationError } from '@/lib/credentials/application/credential-crud' + +export const credentialValidationParseOptions = { + validationErrorResponse: (error: Parameters[0]) => + validationErrorResponse(error, getValidationErrorMessage(error)), +} as const + +export const internalCredentialErrorPolicy = extendInternalErrorPolicy( + internalOrchestrationErrorPolicy, + (error) => { + if (!(error instanceof CredentialProviderOperationError)) return null + return internalErrorResponse(error.providerUnavailable ? 502 : 400, { + error: error.message, + code: error.providerErrorCode, + }) + } +) + +export const internalCredentialMemberListErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(404, { error: 'Not found' }) + } + return null + } +) + +export const internalCredentialMemberMutationErrorPolicy = extendInternalErrorPolicy( + internalCredentialErrorPolicy, + (error) => { + if ( + error instanceof NoWorkspaceAccessError || + (error instanceof ForbiddenOperationError && + error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED') || + (error instanceof OrchestrationError && error.code === 'not_found') + ) { + return internalErrorResponse(403, { error: 'Admin access required' }) + } + return null + } +) diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts index 7038b7f2eb3..fdcca435bb6 100644 --- a/apps/sim/lib/credentials/application/authorization.ts +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -2,8 +2,18 @@ import type { Principal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import type { ManagedOAuthCredentialApplicationContext } from '@/lib/credentials/managed-oauth' +export const CREDENTIAL_DELEGATION_AUDIENCE = 'sim:credentials' export const MANAGED_OAUTH_DELEGATION_AUDIENCE = 'sim:managed-oauth-credentials' +export const credentialDelegationPolicy = { + audience: CREDENTIAL_DELEGATION_AUDIENCE, + isWithinScope: () => true, +} as const satisfies WorkspaceDelegationPolicy<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +}> + export const managedOAuthCredentialDelegationPolicy = { audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, isWithinScope: ( diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts new file mode 100644 index 00000000000..dbfe9f99157 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineCredentialOperation } from '@/lib/credentials/application/operations' + +const mocks = vi.hoisted(() => ({ + resolvePermission: vi.fn(), + getActor: vi.fn(), + execute: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) + +const memberOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_member', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' +) +const adminOperation = defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' +) +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + credential, +} + +function createUseCase(operation: typeof memberOperation | typeof adminOperation) { + return defineAuthorizedCredentialUseCase({ + operation, + resolveContext: async () => ({ ...context }), + execute: mocks.execute, + }) +} + +describe('defineAuthorizedCredentialUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolvePermission.mockResolvedValue('read') + mocks.execute.mockResolvedValue({ ok: true }) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member', status: 'active' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + }) + + it('allows an active credential member for member-level reads', async () => { + await expect( + createUseCase(memberOperation).execute({ principal, input: undefined }) + ).resolves.toEqual({ ok: true }) + expect(mocks.execute).toHaveBeenCalledOnce() + }) + + it('requires credential admin independently of workspace read access', async () => { + await expect( + createUseCase(adminOperation).execute({ principal, input: undefined }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authorizes the workspace before resolving credential membership', async () => { + await createUseCase(memberOperation).execute({ principal, input: undefined }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/authorized-credential-use-case.ts b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts new file mode 100644 index 00000000000..635fc9eb621 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-credential-use-case.ts @@ -0,0 +1,82 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + ForbiddenOperationError, + type WorkspaceAuthorizationContext, +} from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialActorContext } from '@/lib/credentials/access' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import type { CredentialOperation } from '@/lib/credentials/application/operations' +import type { CredentialRow } from '@/lib/credentials/queries' + +export interface CredentialAuthorizationContext extends WorkspaceAuthorizationContext { + credential: CredentialRow + credentialAccess?: CredentialActorContext +} + +export function requireCredentialAccess( + context: CredentialAuthorizationContext +): CredentialActorContext { + if (!context.credentialAccess) { + throw new Error('Credential use case executed without resource authorization') + } + return context.credentialAccess +} + +type AuthorizedCredentialUseCaseDefinition< + O extends CredentialOperation, + I, + C extends CredentialAuthorizationContext, + R, +> = Omit< + AuthorizedWorkspaceUseCaseDefinition, + 'authorizationOptions' | 'authorizeResource' +> + +export function defineAuthorizedCredentialUseCase< + const O extends CredentialOperation, + I, + C extends CredentialAuthorizationContext, + R, +>(definition: AuthorizedCredentialUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async authorizeResource({ principal, context }) { + const actor = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if ( + !actor.credential || + actor.credential.workspaceId !== context.workspaceId || + !actor.hasWorkspaceAccess + ) { + throw new OrchestrationError('not_found', 'Credential not found') + } + context.credentialAccess = actor + switch (definition.operation.minimumCredentialRole) { + case 'member': + if (!actor.member && !actor.isAdmin) { + throw new OrchestrationError('forbidden', 'Credential access required') + } + return + case 'admin': + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Credential admin permission required' + ) + } + return + default: + throw new Error( + `Unsupported credential role: ${definition.operation.minimumCredentialRole}` + ) + } + }, + }) +} diff --git a/apps/sim/lib/credentials/application/authorized-user-use-case.ts b/apps/sim/lib/credentials/application/authorized-user-use-case.ts new file mode 100644 index 00000000000..95fd6e08712 --- /dev/null +++ b/apps/sim/lib/credentials/application/authorized-user-use-case.ts @@ -0,0 +1,102 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import { resolvePrincipalAuditAttribution, type SessionPrincipal } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialUserOperation } from '@/lib/credentials/application/operations' + +export interface CredentialUserAuditEntry { + workspaceId: string | null + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +interface CredentialUserUseCaseDefinition { + operation: O + execute(args: { + principal: SessionPrincipal + input: I + request?: OrchestrationRequestContext + }): Promise + projectAudit?(args: { + principal: SessionPrincipal + input: I + result: R + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] + projectErrorAudit?(args: { + principal: SessionPrincipal + input: I + error: unknown + }): CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined + afterSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise + afterError?(args: { principal: SessionPrincipal; input: I; error: unknown }): void | Promise +} + +function recordCredentialUserAudit( + principal: SessionPrincipal, + operation: CredentialUserOperation, + projected: CredentialUserAuditEntry | CredentialUserAuditEntry[] | undefined, + request?: OrchestrationRequestContext +): void { + if (!projected) return + const attribution = resolvePrincipalAuditAttribution(principal) + const entries = Array.isArray(projected) ? projected : [projected] + for (const entry of entries) { + recordAudit({ + workspaceId: entry.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: operation.id, + actor: attribution.actor, + }, + request, + }) + } +} + +/** Defines a current-user credential operation that cannot borrow workspace identity. */ +export function defineAuthorizedCredentialUserUseCase< + const O extends CredentialUserOperation, + I, + R, +>(definition: CredentialUserUseCaseDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + if (principal.kind !== 'session') { + throw new OrchestrationError('forbidden', 'Session authentication required') + } + try { + const result = await definition.execute({ principal, input, request }) + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectAudit?.({ principal, input, result }), + request + ) + await definition.afterSuccess?.({ principal, input, result }) + return result + } catch (error) { + recordCredentialUserAudit( + principal, + definition.operation, + definition.projectErrorAudit?.({ principal, input, error }), + request + ) + await definition.afterError?.({ principal, input, error }) + throw error + } + }, + } +} diff --git a/apps/sim/lib/credentials/application/connection-target.test.ts b/apps/sim/lib/credentials/application/connection-target.test.ts new file mode 100644 index 00000000000..143a068961e --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.test.ts @@ -0,0 +1,151 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + listCatalog: vi.fn(), + getWorkspaceCredential: vi.fn(), + getCredentialActorContext: vi.fn(), +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableOAuthCredentialProvider: ( + catalog: Array<{ + available: boolean + authorizationOptions: Array<{ providerId: string }> + }>, + providerId: string + ) => { + const provider = catalog.find((entry) => + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) throw Object.assign(new Error('Unknown OAuth provider'), { code: 'validation' }) + if (!provider.available) + throw Object.assign(new Error('OAuth provider is unavailable'), { code: 'conflict' }) + return provider + }, +})) + +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getWorkspaceCredential, +})) + +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getCredentialActorContext, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + credentialProviderMatchesService: ( + credentialProviderId: string, + service: { providerId: string; additionalProviderIds?: readonly string[] } + ) => + credentialProviderId === service.providerId || + (service.additionalProviderIds?.includes(credentialProviderId) ?? false), +})) + +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' + +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const salesforceProvider = { + type: 'oauth' as const, + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth', + providerId: 'salesforce-sandbox', + displayName: 'Sandbox CRM', +} + +describe('resolveCredentialConnectionTarget', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listCatalog.mockResolvedValue([salesforceProvider]) + mocks.getWorkspaceCredential.mockResolvedValue(credential) + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: true }) + }) + + it('accepts an exact authorization option for a new connection', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: 'salesforce-sandbox', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + }) + expect(mocks.getWorkspaceCredential).not.toHaveBeenCalled() + }) + + it('loads reconnect credentials through the asserted workspace and requires admin access', async () => { + mocks.getCredentialActorContext.mockResolvedValue({ credential, isAdmin: false }) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + + expect(mocks.getWorkspaceCredential).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }) + expect(mocks.getCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1') + }) + + it('preserves the credential authorization-server ID on reconnect', async () => { + const result = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + + expect(result).toEqual({ + provider: salesforceProvider, + providerId: 'salesforce-sandbox', + credentialId: 'credential-1', + displayName: 'Sandbox CRM', + }) + }) + + it('rejects providers whose custom flow cannot reconnect', async () => { + mocks.listCatalog.mockResolvedValue([{ ...salesforceProvider, supportsReconnect: false }]) + + await expect( + resolveCredentialConnectionTarget({ + principal, + context, + credentialId: 'credential-1', + }) + ).rejects.toMatchObject({ code: 'conflict' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/connection-target.ts b/apps/sim/lib/credentials/application/connection-target.ts new file mode 100644 index 00000000000..a7cb618ceab --- /dev/null +++ b/apps/sim/lib/credentials/application/connection-target.ts @@ -0,0 +1,107 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, + requireAvailableOAuthCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { getWorkspaceCredential } from '@/lib/credentials/queries' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolvedCredentialConnectionTarget { + provider: OAuthCredentialProviderCatalogEntry + providerId: string + credentialId?: string + displayName?: string +} + +export class CredentialConnectionProviderMismatchError extends OrchestrationError { + constructor() { + super('validation', 'Credential provider does not match the requested OAuth provider') + this.name = 'CredentialConnectionProviderMismatchError' + } +} + +export async function resolveCredentialConnectionTarget(params: { + principal: Principal + context: ActiveWorkspaceApplicationContext + providerId?: string + credentialId?: string + assertedProviderId?: string +}): Promise { + const { principal, context, providerId, credentialId, assertedProviderId } = params + if (Boolean(providerId) === Boolean(credentialId)) { + throw new Error('Credential connection requires exactly one target identifier') + } + + const catalog = await listCredentialProviderCatalog(principal, context) + if (providerId) { + return { + provider: requireAvailableOAuthCredentialProvider(catalog, providerId), + providerId, + } + } + + if (!credentialId) throw new Error('Credential reconnect target is missing its credential ID') + const userId = requirePrincipalSubjectUserId(principal) + const targetCredentialId = credentialId + const credential = await getWorkspaceCredential({ + workspaceId: context.workspaceId, + credentialId: targetCredentialId, + }) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + if (credential.type !== 'oauth' || !credential.providerId) { + throw new OrchestrationError('validation', 'Only OAuth credentials can be reconnected') + } + const credentialProviderId = credential.providerId + if (assertedProviderId && assertedProviderId !== credentialProviderId) { + throw new CredentialConnectionProviderMismatchError() + } + + const actor = await getCredentialActorContext(targetCredentialId, userId) + if (!actor.credential || actor.credential.workspaceId !== context.workspaceId) { + throw new OrchestrationError('not_found', 'Credential not found') + } + if (!actor.isAdmin) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access on the credential is required to reconnect it' + ) + } + + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + credentialProviderMatchesService(credentialProviderId, { + providerId: entry.authorizationOptions[0].providerId, + additionalProviderIds: entry.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${credentialProviderId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `OAuth provider is unavailable: ${credentialProviderId}` + ) + } + if (!provider.supportsReconnect) { + throw new OrchestrationError( + 'conflict', + `OAuth provider does not support reconnecting credentials: ${credentialProviderId}` + ) + } + + return { + provider, + providerId: credentialProviderId, + credentialId: credential.id, + displayName: credential.displayName, + } +} diff --git a/apps/sim/lib/credentials/application/create-credential-connection.test.ts b/apps/sim/lib/credentials/application/create-credential-connection.test.ts new file mode 100644 index 00000000000..9c3eabbbed8 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.test.ts @@ -0,0 +1,133 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), + createDraft: vi.fn(), + getBaseUrl: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +vi.mock('@/lib/core/utils/urls', () => ({ + getBaseUrl: mocks.getBaseUrl, +})) + +import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} + +describe('createCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + mocks.createDraft.mockResolvedValue({ + id: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + }) + mocks.getBaseUrl.mockReturnValue('https://sim.ai') + }) + + it('rejects workspace keys before canonical workspace loading', async () => { + await expect( + createCredentialConnection.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) + + it('creates a user-bound draft and returns its canonical connection context', async () => { + const result = await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: undefined, + displayName: 'Work Gmail', + displayNameDefinesIntent: true, + }) + expect(result).toEqual({ + authorizationUrl: 'https://sim.ai/api/auth/oauth2/authorize?draftId=draft-1', + draftId: 'draft-1', + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + providerId: 'google-email', + workspaceId: 'workspace-1', + }) + }) + + it("preserves an existing credential's name on reconnect", async () => { + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + + await createCredentialConnection.execute({ + principal: personalPrincipal, + input: { workspaceId: 'workspace-1', credentialId: 'credential-1' }, + }) + + expect(mocks.createDraft).toHaveBeenCalledWith({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + displayNameDefinesIntent: false, + }) + }) +}) diff --git a/apps/sim/lib/credentials/application/create-credential-connection.ts b/apps/sim/lib/credentials/application/create-credential-connection.ts new file mode 100644 index 00000000000..7a0d8491168 --- /dev/null +++ b/apps/sim/lib/credentials/application/create-credential-connection.ts @@ -0,0 +1,69 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateCredentialConnectionInput = { + workspaceId: string +} & ( + | { providerId: string; displayName?: string; credentialId?: never } + | { + credentialId: string + assertedProviderId?: string + providerId?: never + displayName?: never + } +) + +export interface CreateCredentialConnectionResult { + authorizationUrl: string + draftId: string + expiresAt: Date + providerId: string + workspaceId: string + credentialId?: string +} + +export const createCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createConnection, + resolveContext: async ({ input }: { input: CreateCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: input.providerId, + credentialId: input.credentialId, + assertedProviderId: 'assertedProviderId' in input ? input.assertedProviderId : undefined, + }) + const displayName = input.providerId ? input.displayName : target.displayName + + const draft = await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: context.workspaceId, + providerId: target.providerId, + credentialId: target.credentialId, + displayName, + displayNameDefinesIntent: input.providerId !== undefined && displayName !== undefined, + }) + const authorizationUrl = new URL('/api/auth/oauth2/authorize', getBaseUrl()) + authorizationUrl.searchParams.set('draftId', draft.id) + return { + authorizationUrl: authorizationUrl.toString(), + draftId: draft.id, + expiresAt: draft.expiresAt, + providerId: target.providerId, + workspaceId: context.workspaceId, + ...(target.credentialId ? { credentialId: target.credentialId } : {}), + } + }, +}) diff --git a/apps/sim/lib/credentials/application/credential-context.ts b/apps/sim/lib/credentials/application/credential-context.ts new file mode 100644 index 00000000000..d5fbf242545 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-context.ts @@ -0,0 +1,32 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { CredentialAuthorizationContext } from '@/lib/credentials/application/authorized-credential-use-case' +import { getCredentialById, getWorkspaceCredential } from '@/lib/credentials/queries' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ResolveCredentialApplicationContextInput { + credentialId: string + assertedWorkspaceId?: string +} + +/** Loads a credential canonically and verifies any asserted workspace scope. */ +export async function resolveCredentialApplicationContext( + input: ResolveCredentialApplicationContextInput +): Promise { + const assertedWorkspace = input.assertedWorkspaceId + ? await loadActiveWorkspaceApplicationContext(input.assertedWorkspaceId) + : null + if (input.assertedWorkspaceId && !assertedWorkspace) { + throw new OrchestrationError('not_found', 'Credential not found') + } + const credential = assertedWorkspace + ? await getWorkspaceCredential({ + workspaceId: assertedWorkspace.workspaceId, + credentialId: input.credentialId, + }) + : await getCredentialById(input.credentialId) + if (!credential) throw new OrchestrationError('not_found', 'Credential not found') + const workspace = + assertedWorkspace ?? (await loadActiveWorkspaceApplicationContext(credential.workspaceId)) + if (!workspace) throw new OrchestrationError('not_found', 'Credential not found') + return { ...workspace, credential } +} diff --git a/apps/sim/lib/credentials/application/credential-crud.ts b/apps/sim/lib/credentials/application/credential-crud.ts new file mode 100644 index 00000000000..cd1d065d1e2 --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-crud.ts @@ -0,0 +1,244 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access' +import { + defineAuthorizedCredentialUseCase, + requireCredentialAccess, +} from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth' +import { + createCredentialRecord, + isProviderOutageCode, + type PerformCreateCredentialParams, + type PerformCredentialResult, + type PerformUpdateCredentialParams, + updateCredentialRecord, +} from '@/lib/credentials/orchestration' +import { + type CredentialRow, + findWorkspaceCredentialLookup, + listVisibleWorkspaceCredentials, + type VisibleWorkspaceCredential, + type WorkspaceCredentialLookup, +} from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +export class CredentialProviderOperationError extends OrchestrationError { + constructor( + message: string, + readonly providerErrorCode: string, + readonly providerUnavailable: boolean + ) { + super('validation', message) + this.name = 'CredentialProviderOperationError' + } +} + +function throwCredentialMutationFailure(result: { + success: boolean + error?: string + errorCode?: PerformCredentialResult['errorCode'] + providerErrorCode?: string + providerUnavailable?: boolean +}): never { + if (result.providerErrorCode) { + throw new CredentialProviderOperationError( + result.error ?? result.providerErrorCode, + result.providerErrorCode, + result.providerUnavailable === true || isProviderOutageCode(result.providerErrorCode) + ) + } + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential mutation failed') + case 'forbidden': + throw new OrchestrationError('forbidden', result.error ?? 'Credential mutation forbidden') + default: + throw new Error(result.error ?? 'Credential mutation failed') + } +} + +export interface ListInternalCredentialsInput { + workspaceId: string + type?: CredentialRow['type'] + providerId?: string + credentialId?: string +} + +export type ListInternalCredentialsResult = + | { mode: 'list'; credentials: VisibleWorkspaceCredential[] } + | { mode: 'lookup'; credential: WorkspaceCredentialLookup | null } + +export const listInternalCredentials = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listInternal, + resolveContext: async ({ input }: { input: ListInternalCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context }): Promise { + if (input.credentialId) { + return { + mode: 'lookup', + credential: await findWorkspaceCredentialLookup({ + workspaceId: context.workspaceId, + credentialId: input.credentialId, + }), + } + } + + const userId = requirePrincipalSubjectUserId(principal) + if (!input.type || input.type === 'oauth') { + await syncWorkspaceOAuthCredentialsForUser({ workspaceId: context.workspaceId, userId }) + } + const workspaceAccess = await checkWorkspaceAccess(context.workspaceId, userId) + const page = await listVisibleWorkspaceCredentials({ + workspaceId: context.workspaceId, + userId, + workspaceAccess, + types: input.type ? [input.type] : undefined, + providerId: input.providerId, + }) + return { mode: 'list', credentials: page.data } + }, +}) + +export type CreateWorkspaceCredentialInput = Omit< + PerformCreateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'request' +> + +export interface CreateWorkspaceCredentialResult { + credential: CredentialRow + created: boolean + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + auditMetadata: Record +} + +export const createWorkspaceCredential = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.create, + resolveContext: async ({ input }: { input: CreateWorkspaceCredentialInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input }): Promise { + const userId = requirePrincipalSubjectUserId(principal) + const result = await createCredentialRecord({ ...input, userId }, { authorizeWorkspace: false }) + if (!result.success) throwCredentialMutationFailure(result) + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + const access = await getCredentialActorContext(result.credential.id, userId) + if (!access.credential || !canUseCredential(access)) { + throw new Error('Created credential is not visible to its creator') + } + const role = access.isAdmin ? 'admin' : access.member?.role + const status = access.member?.status ?? (access.isAdmin ? 'active' : undefined) + if (!role || !status) throw new Error('Created credential has no active actor membership') + return { + credential: access.credential, + created: result.created === true, + role, + status, + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface GetWorkspaceCredentialInput { + credentialId: string +} + +export const getWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.read, + resolveContext: ({ input }: { input: GetWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ context }) { + return { credential: context.credential, access: requireCredentialAccess(context) } + }, +}) + +export type UpdateWorkspaceCredentialInput = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> + +export const updateWorkspaceCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.update, + resolveContext: ({ input }: { input: UpdateWorkspaceCredentialInput }) => + resolveCredentialApplicationContext(input), + async execute({ principal, input, context }) { + if (principal.kind === 'delegated' && context.credential.type !== 'oauth') { + throw new OrchestrationError('validation', 'Copilot can update only oauth credentials') + } + const result = await updateCredentialRecord({ ...input, credential: context.credential }) + if (!result.success) throwCredentialMutationFailure(result) + const access = await getCredentialActorContext( + context.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!access.credential || !access.isAdmin) { + throw new Error('Updated credential is no longer visible to its administrator') + } + return { + credential: access.credential, + access, + previousDisplayName: context.credential.displayName, + updatedFields: result.updatedFields ?? [], + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + resourceName: context.credential.displayName, + description: `Updated ${context.credential.type} credential "${context.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: context.credential.type, + updatedFields: result.updatedFields, + }, + }), +}) diff --git a/apps/sim/lib/credentials/application/credential-members.ts b/apps/sim/lib/credentials/application/credential-members.ts new file mode 100644 index 00000000000..561ae6a130d --- /dev/null +++ b/apps/sim/lib/credentials/application/credential-members.ts @@ -0,0 +1,150 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId, type SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { + credentialOperations, + credentialUserOperations, +} from '@/lib/credentials/application/operations' +import { + leaveCredentialMembership, + listCredentialMembers, + listCredentialMembershipsForUser, + removeCredentialMember, + upsertCredentialMember, +} from '@/lib/credentials/members' +import { captureServerEvent } from '@/lib/posthog/server' + +interface CredentialMemberResourceInput { + credentialId: string +} + +function resolveSessionCredentialContext( + _principal: SessionPrincipal, + input: CredentialMemberResourceInput +) { + return resolveCredentialApplicationContext(input) +} + +export const listCredentialMembersUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listMembers, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: CredentialMemberResourceInput + }) => resolveSessionCredentialContext(principal, input), + authorizationOptions: {}, + async execute({ context }) { + return { members: await listCredentialMembers(context.credential) } + }, +}) + +export interface UpsertCredentialMemberInput extends CredentialMemberResourceInput { + userId: string + role: 'admin' | 'member' +} + +export const upsertCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.upsertMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: UpsertCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ principal, input, context }) { + const result = await upsertCredentialMember({ + credential: context.credential, + actorUserId: requirePrincipalSubjectUserId(principal), + targetUserId: input.userId, + role: input.role, + }) + return { ...result, targetUserId: input.userId, role: input.role } + }, + projectAudit: ({ context, result }) => ({ + action: result.created + ? AuditAction.CREDENTIAL_MEMBER_ADDED + : AuditAction.CREDENTIAL_MEMBER_ROLE_CHANGED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: result.created + ? `Shared credential with member as "${result.role}"` + : `Changed credential member role to "${result.role}"`, + metadata: { + targetUserId: result.targetUserId, + ...(result.created + ? { role: result.role } + : { fromRole: result.previousRole, toRole: result.role }), + }, + }), + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_shared', { + credential_type: context.credential.type, + role: result.role, + workspace_id: context.workspaceId, + }) + }, +}) + +export interface RemoveCredentialMemberInput extends CredentialMemberResourceInput { + userId: string +} + +export const removeCredentialMemberUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.removeMember, + resolveContext: ({ + principal, + input, + }: { + principal: SessionPrincipal + input: RemoveCredentialMemberInput + }) => resolveSessionCredentialContext(principal, input), + async execute({ input, context }) { + await removeCredentialMember({ credential: context.credential, targetUserId: input.userId }) + return { success: true as const, targetUserId: input.userId } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.CREDENTIAL_MEMBER_REMOVED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: context.credential.id, + description: 'Removed credential member', + metadata: { targetUserId: result.targetUserId }, + }), + afterSuccess: ({ principal, context }) => { + captureServerEvent(requirePrincipalSubjectUserId(principal), 'credential_unshared', { + credential_type: context.credential.type, + workspace_id: context.workspaceId, + }) + }, +}) + +export const listCredentialMembershipsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listMemberships, + async execute({ principal }) { + return { memberships: await listCredentialMembershipsForUser(principal.userId) } + }, +}) + +export interface LeaveCredentialMembershipInput { + credentialId: string +} + +export const leaveCredentialMembershipUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.leaveMembership, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: LeaveCredentialMembershipInput + }) { + await leaveCredentialMembership({ userId: principal.userId, credentialId: input.credentialId }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.test.ts b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts new file mode 100644 index 00000000000..c3154f33db1 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.test.ts @@ -0,0 +1,124 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { deleteManyCredentialsUseCase } from '@/lib/credentials/application/delete-many-credentials' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} + +function oauthCredential(id: string, workspaceId = 'workspace-1') { + return { + id, + workspaceId, + type: 'oauth' as const, + displayName: `OAuth ${id}`, + description: null, + providerId: 'google-email', + accountId: `account-${id}`, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-14T12:00:00.000Z'), + updatedAt: new Date('2026-08-14T12:00:00.000Z'), + } +} + +describe('deleteManyCredentialsUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.deleteCredential.mockResolvedValue(true) + }) + + it('deletes only OAuth credentials administered in the delegated workspace', async () => { + const allowed = oauthCredential('credential-1') + mocks.getActor + .mockResolvedValueOnce({ + credential: allowed, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + .mockResolvedValueOnce({ + credential: oauthCredential('credential-2', 'workspace-2'), + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + const result = await deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-2'], + }, + }) + + expect(result).toEqual({ + deleted: ['credential-1'], + failed: ['credential-2'], + deletedCredentials: [allowed], + }) + expect(mocks.deleteCredential).toHaveBeenCalledOnce() + expect(mocks.deleteCredential).toHaveBeenCalledWith({ + credential: allowed, + reason: 'copilot_delete', + }) + }) + + it('rejects duplicate IDs before loading any credential', async () => { + await expect( + deleteManyCredentialsUseCase.execute({ + principal, + input: { + workspaceId: 'workspace-1', + credentialIds: ['credential-1', 'credential-1'], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.deleteCredential).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/delete-many-credentials.ts b/apps/sim/lib/credentials/application/delete-many-credentials.ts new file mode 100644 index 00000000000..993f1795ef2 --- /dev/null +++ b/apps/sim/lib/credentials/application/delete-many-credentials.ts @@ -0,0 +1,114 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +const logger = createLogger('DeleteManyCredentialsApplication') +const MAX_CREDENTIAL_DELETE_BATCH = 20 + +export interface DeleteManyCredentialsInput { + workspaceId: string + credentialIds: string[] +} + +export interface DeleteManyCredentialsResult { + deleted: string[] + failed: string[] + deletedCredentials: CredentialRow[] +} + +export const deleteManyCredentialsUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.deleteMany, + resolveContext: async ({ input }: { input: DeleteManyCredentialsInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + async execute({ principal, input, context }): Promise { + if (input.credentialIds.length === 0) { + throw new OrchestrationError('validation', 'At least one credential ID is required') + } + if (input.credentialIds.length > MAX_CREDENTIAL_DELETE_BATCH) { + throw new OrchestrationError( + 'validation', + `At most ${MAX_CREDENTIAL_DELETE_BATCH} credentials can be deleted at once` + ) + } + if (new Set(input.credentialIds).size !== input.credentialIds.length) { + throw new OrchestrationError('validation', 'Credential IDs must be unique') + } + + const userId = requirePrincipalSubjectUserId(principal) + const deleted: string[] = [] + const failed: string[] = [] + const deletedCredentials: CredentialRow[] = [] + + for (const credentialId of input.credentialIds) { + try { + const access = await getCredentialActorContext(credentialId, userId) + if ( + !access.credential || + access.credential.workspaceId !== context.workspaceId || + !access.hasWorkspaceAccess || + !access.isAdmin || + access.credential.type !== 'oauth' + ) { + failed.push(credentialId) + continue + } + const didDelete = await deleteCredentialRecord({ + credential: access.credential, + reason: 'copilot_delete', + }) + if (!didDelete) { + failed.push(credentialId) + continue + } + deleted.push(credentialId) + deletedCredentials.push(access.credential) + } catch (error) { + logger.error('Failed to delete credential in Copilot batch', { credentialId, error }) + failed.push(credentialId) + } + } + + return { deleted, failed, deletedCredentials } + }, + projectAudit: ({ result }) => + result.deletedCredentials.map((credential) => ({ + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (copilot_delete)`, + metadata: { + reason: 'copilot_delete', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })), + afterSuccess: ({ principal, context, result }) => { + for (const credential of result.deletedCredentials) { + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + } + }, +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.test.ts b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts new file mode 100644 index 00000000000..d983cdf3d53 --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.test.ts @@ -0,0 +1,93 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getActiveDraft: vi.fn(), + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/credentials/connect-draft', () => ({ + getActiveConnectDraft: mocks.getActiveDraft, +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const draft = { + id: 'draft-1', + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: "User's Gmail", + description: null, + credentialId: null, + expiresAt: new Date('2026-08-12T20:15:00.000Z'), + createdAt: new Date('2026-08-12T20:00:00.000Z'), +} +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('launchCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getActiveDraft.mockResolvedValue(draft) + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.resolveTarget.mockResolvedValue({ + provider: { serviceId: 'gmail' }, + providerId: 'google-email', + }) + }) + + it('loads the exact draft for the signed-in user and reauthorizes its target', async () => { + const result = await launchCredentialConnection.execute({ + principal, + input: { draftId: 'draft-1' }, + }) + + expect(mocks.getActiveDraft).toHaveBeenCalledWith('draft-1', 'user-1') + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: { ...workspaceContext, draft }, + providerId: 'google-email', + credentialId: undefined, + }) + expect(result).toEqual({ draft }) + }) + + it('rejects an invalid or expired draft before loading a workspace', async () => { + mocks.getActiveDraft.mockResolvedValue(null) + + await expect( + launchCredentialConnection.execute({ principal, input: { draftId: 'draft-missing' } }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/launch-credential-connection.ts b/apps/sim/lib/credentials/application/launch-credential-connection.ts new file mode 100644 index 00000000000..1a19cafecee --- /dev/null +++ b/apps/sim/lib/credentials/application/launch-credential-connection.ts @@ -0,0 +1,52 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { type ConnectDraft, getActiveConnectDraft } from '@/lib/credentials/connect-draft' +import { + type ActiveWorkspaceApplicationContext, + loadActiveWorkspaceApplicationContext, +} from '@/lib/workspaces/application/workspace-context' + +export interface LaunchCredentialConnectionInput { + draftId: string +} + +interface LaunchCredentialConnectionContext extends ActiveWorkspaceApplicationContext { + draft: ConnectDraft +} + +export interface LaunchCredentialConnectionResult { + draft: ConnectDraft +} + +export const launchCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.launchConnection, + resolveContext: async ({ + principal, + input, + }: { + principal: { kind: 'session'; userId: string; sessionId: string } + input: LaunchCredentialConnectionInput + }): Promise => { + const draft = await getActiveConnectDraft(input.draftId, principal.userId) + if (!draft) + throw new OrchestrationError('not_found', 'OAuth connection link is invalid or expired') + const workspace = await loadActiveWorkspaceApplicationContext(draft.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return { ...workspace, draft } + }, + authorizationOptions: {}, + execute: async ({ principal, context }): Promise => { + const target = await resolveCredentialConnectionTarget({ + principal, + context, + providerId: context.draft.credentialId ? undefined : context.draft.providerId, + credentialId: context.draft.credentialId ?? undefined, + }) + if (target.providerId !== context.draft.providerId) { + throw new OrchestrationError('conflict', 'OAuth connection provider no longer matches') + } + return { draft: context.draft } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.test.ts b/apps/sim/lib/credentials/application/list-credential-providers.test.ts new file mode 100644 index 00000000000..bc6bba937e3 --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.test.ts @@ -0,0 +1,108 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) + +import { listCredentialProviders } from '@/lib/credentials/application/list-credential-providers' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('listCredentialProviders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('read') + mocks.listCatalog.mockResolvedValue([]) + }) + + it('allows sessions to inspect deployment availability', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) + + it('allows workspace keys to inspect deployment availability', async () => { + const principal = { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'key-1', + } + + await listCredentialProviders.execute({ principal, input: { workspaceId: 'workspace-1' } }) + + expect(mocks.listCatalog).toHaveBeenCalledWith(principal, workspaceContext) + }) + + it('searches provider names case-insensitively without matching ids or descriptions', async () => { + const salesforce = { + name: 'Salesforce', + serviceId: 'salesforce', + description: 'Connect a CRM.', + } + const google = { + name: 'Google', + serviceId: 'salesforce-migration', + description: 'Migrate Salesforce records.', + } + mocks.listCatalog.mockResolvedValue([salesforce, google]) + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + const result = await listCredentialProviders.execute({ + principal, + input: { workspaceId: 'workspace-1', search: 'SaLeS' }, + }) + + expect(result.providers).toEqual([salesforce]) + }) + + it('fails fast on a blank search from a non-HTTP caller', async () => { + const principal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + } + + await expect( + listCredentialProviders.execute({ + principal, + input: { workspaceId: 'workspace-1', search: ' ' }, + }) + ).rejects.toThrow('search cannot be empty') + expect(mocks.listCatalog).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/list-credential-providers.ts b/apps/sim/lib/credentials/application/list-credential-providers.ts new file mode 100644 index 00000000000..ded16457d4d --- /dev/null +++ b/apps/sim/lib/credentials/application/list-credential-providers.ts @@ -0,0 +1,41 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + type CredentialProviderCatalogEntry, + listCredentialProviderCatalog, +} from '@/lib/credentials/application/provider-catalog' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface ListCredentialProvidersInput { + workspaceId: string + search?: string +} + +export interface ListCredentialProvidersResult { + providers: CredentialProviderCatalogEntry[] +} + +export const listCredentialProviders = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.listProviders, + resolveContext: async ({ input }: { input: ListCredentialProvidersInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const search = input.search?.trim().toLowerCase() + if (input.search !== undefined && !search) { + throw new OrchestrationError('validation', 'search cannot be empty') + } + + const providers = await listCredentialProviderCatalog(principal, context) + return { + providers: search + ? providers.filter((provider) => provider.name.toLowerCase().includes(search)) + : providers, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts index f0dc64e48e3..c22e024cd61 100644 --- a/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts +++ b/apps/sim/lib/credentials/application/list-workspace-credentials.test.ts @@ -54,21 +54,21 @@ describe('listWorkspaceCredentials', () => { mocks.loadWorkspace.mockResolvedValue(workspaceContext) mocks.resolvePermission.mockResolvedValue('read') mocks.checkWorkspaceAccess.mockResolvedValue({ hasAccess: true, canAdmin: false }) - mocks.listVisible.mockResolvedValue([]) - mocks.listForWorkspacePrincipal.mockResolvedValue([]) + mocks.listVisible.mockResolvedValue({ data: [], nextCursorKeys: null }) + mocks.listForWorkspacePrincipal.mockResolvedValue({ data: [], nextCursorKeys: null }) }) - it('rejects unsupported principals before canonical workspace loading', async () => { + it('preserves per-credential visibility for sessions', async () => { const principal: SessionPrincipal = { kind: 'session', userId: 'user-1', sessionId: 'session-1', } - await expect(listWorkspaceCredentials.execute({ principal, input })).rejects.toMatchObject({ - code: 'forbidden', - }) - expect(mocks.loadWorkspace).not.toHaveBeenCalled() + await listWorkspaceCredentials.execute({ principal, input }) + expect(mocks.listVisible).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1', types: ['oauth', 'service_account'] }) + ) }) it('lists shared connections for a workspace key without creator identity', async () => { diff --git a/apps/sim/lib/credentials/application/oauth-accounts.test.ts b/apps/sim/lib/credentials/application/oauth-accounts.test.ts new file mode 100644 index 00000000000..bf77fae5662 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { account, credential } from '@sim/db/schema' +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + deleteCredential: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/credentials/orchestration', () => ({ + deleteCredentialRecord: mocks.deleteCredential, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { disconnectOAuthUseCase } from '@/lib/credentials/application/oauth-accounts' + +const firstCredential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, + displayName: 'First Google account', + description: null, + providerId: 'google-email', + accountId: 'account-1', + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: null, + createdBy: 'user-1', + createdAt: new Date('2026-08-01T00:00:00.000Z'), + updatedAt: new Date('2026-08-01T00:00:00.000Z'), +} + +describe('OAuth account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits and captures committed deletions before rethrowing a later failure', async () => { + const secondCredential = { + ...firstCredential, + id: 'credential-2', + displayName: 'Second Google account', + accountId: 'account-2', + } + queueTableRows(account, [{ id: 'account-1' }, { id: 'account-2' }]) + queueTableRows(credential, [firstCredential, secondCredential]) + mocks.deleteCredential + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('Second credential delete failed')) + + await expect( + disconnectOAuthUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { provider: 'google' }, + }) + ).rejects.toMatchObject({ + name: 'OAuthDisconnectPartialFailureError', + credentials: [firstCredential], + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledTimes(1) + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'credential.deleted', + resourceId: firstCredential.id, + metadata: expect.objectContaining({ reason: 'oauth_disconnect' }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ + provider_id: 'google-email', + workspace_id: 'workspace-1', + }), + { groups: { workspace: 'workspace-1' } } + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/oauth-accounts.ts b/apps/sim/lib/credentials/application/oauth-accounts.ts new file mode 100644 index 00000000000..ff144a47524 --- /dev/null +++ b/apps/sim/lib/credentials/application/oauth-accounts.ts @@ -0,0 +1,132 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { SessionPrincipal } from '@sim/auth/principal' +import { defineAuthorizedCredentialUserUseCase } from '@/lib/credentials/application/authorized-user-use-case' +import { credentialUserOperations } from '@/lib/credentials/application/operations' +import { + disconnectOAuthAccounts, + listConnectedAccountsForUser, + listOAuthConnectionsForUser, + OAuthDisconnectPartialFailureError, +} from '@/lib/credentials/oauth-accounts' +import { captureServerEvent } from '@/lib/posthog/server' + +export const listOAuthConnectionsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listOAuthConnections, + async execute({ principal }) { + return { connections: await listOAuthConnectionsForUser(principal.userId) } + }, +}) + +export interface ListConnectedAccountsInput { + provider?: string +} + +export const listConnectedAccountsUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.listConnectedAccounts, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: ListConnectedAccountsInput + }) { + return { + accounts: await listConnectedAccountsForUser({ + userId: principal.userId, + provider: input.provider, + }), + } + }, +}) + +export interface DisconnectOAuthInput { + provider: string + providerId?: string + accountId?: string +} + +function projectDeletedCredentialAudit( + credentials: OAuthDisconnectPartialFailureError['credentials'] +) { + return credentials.map((credential) => ({ + workspaceId: credential.workspaceId, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credential.id, + resourceName: credential.displayName, + description: `Deleted oauth credential "${credential.displayName}" (oauth_disconnect)`, + metadata: { + reason: 'oauth_disconnect', + credentialType: credential.type, + providerId: credential.providerId, + accountId: credential.accountId, + }, + })) +} + +function captureDeletedCredentialEvents( + userId: string, + credentials: OAuthDisconnectPartialFailureError['credentials'], + provider: string, + providerId?: string +): void { + for (const credential of credentials) { + captureServerEvent( + userId, + 'credential_deleted', + { + credential_type: 'oauth', + provider_id: credential.providerId ?? providerId ?? provider, + workspace_id: credential.workspaceId, + }, + { groups: { workspace: credential.workspaceId } } + ) + } +} + +export const disconnectOAuthUseCase = defineAuthorizedCredentialUserUseCase({ + operation: credentialUserOperations.disconnectOAuth, + async execute({ + principal, + input, + }: { + principal: SessionPrincipal + input: DisconnectOAuthInput + }) { + const result = await disconnectOAuthAccounts({ userId: principal.userId, ...input }) + return { ...result, ...input, success: true as const } + }, + projectAudit: ({ result }) => [ + ...projectDeletedCredentialAudit(result.credentials), + { + workspaceId: null, + action: AuditAction.OAUTH_DISCONNECTED, + resourceType: AuditResourceType.OAUTH, + resourceId: result.providerId ?? result.provider, + resourceName: result.provider, + description: `Disconnected OAuth provider: ${result.provider}`, + metadata: { provider: result.provider, providerId: result.providerId }, + }, + ], + projectErrorAudit: ({ error }) => + error instanceof OAuthDisconnectPartialFailureError + ? projectDeletedCredentialAudit(error.credentials) + : undefined, + afterSuccess: ({ principal, result }) => { + captureDeletedCredentialEvents( + principal.userId, + result.credentials, + result.provider, + result.providerId + ) + }, + afterError: ({ principal, input, error }) => { + if (!(error instanceof OAuthDisconnectPartialFailureError)) return + captureDeletedCredentialEvents( + principal.userId, + error.credentials, + input.provider, + input.providerId + ) + }, +}) diff --git a/apps/sim/lib/credentials/application/operations.test.ts b/apps/sim/lib/credentials/application/operations.test.ts new file mode 100644 index 00000000000..26c363580f9 --- /dev/null +++ b/apps/sim/lib/credentials/application/operations.test.ts @@ -0,0 +1,36 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { defineWorkspaceOperation } from '@/lib/core/application' +import { + credentialOperations, + defineCredentialOperation, +} from '@/lib/credentials/application/operations' + +describe('credential operations', () => { + it('declares credential admin as the delete authority and workspace read as reach', () => { + expect(credentialOperations.delete).toMatchObject({ + id: 'credentials.delete', + minimumRole: 'read', + minimumCredentialRole: 'admin', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], + }) + expect(Object.isFrozen(credentialOperations.delete)).toBe(true) + }) + + it('rejects actorless workspace keys for credential admin operations', () => { + const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'credentials.test_admin', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], + }) + + expect(() => defineCredentialOperation(workspaceKeyOperation, 'admin')).toThrow( + 'Credential operation credentials.test_admin requires a user-bearing principal' + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/operations.ts b/apps/sim/lib/credentials/application/operations.ts index 3f1fad25074..43d7d531b40 100644 --- a/apps/sim/lib/credentials/application/operations.ts +++ b/apps/sim/lib/credentials/application/operations.ts @@ -1,11 +1,146 @@ -import { defineWorkspaceOperation } from '@/lib/core/application' +import type { ApplicationOperation } from '@/lib/core/application' +import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' + +export type CredentialRole = 'member' | 'admin' + +export type CredentialOperation = O & { + readonly minimumCredentialRole: CredentialRole +} + +/** Adds credential-resource policy to a workspace-scoped operation. */ +export function defineCredentialOperation< + const O extends WorkspaceOperation, + const R extends CredentialRole, +>( + operation: O, + minimumCredentialRole: R +): CredentialOperation & { + readonly minimumCredentialRole: R +} { + if (operation.principalKinds.includes('workspace_api_key')) { + throw new Error(`Credential operation ${operation.id} requires a user-bearing principal`) + } + return Object.freeze({ ...operation, minimumCredentialRole }) +} + +const HUMAN_AND_COPILOT_PRINCIPALS = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const credentialOperations = { + listInternal: defineWorkspaceOperation({ + id: 'credentials.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listProviders: defineWorkspaceOperation({ + id: 'credentials.providers.list', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), listConnections: defineWorkspaceOperation({ id: 'credentials.connections.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ['personal_api_key', 'workspace_api_key'], + principalKinds: ['session', 'personal_api_key', 'workspace_api_key'], + }), + createConnection: defineWorkspaceOperation({ + id: 'credentials.connections.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + prepareConnection: defineWorkspaceOperation({ + id: 'credentials.connections.prepare', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + createServiceAccount: defineWorkspaceOperation({ + id: 'credentials.service_accounts.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session', 'personal_api_key'], + }), + read: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'member' + ), + create: defineWorkspaceOperation({ + id: 'credentials.create', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + update: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.update', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + delete: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.delete', + minimumRole: 'read', + workspaceApiKey: 'deny', + ...HUMAN_AND_COPILOT_PRINCIPALS, + }), + 'admin' + ), + deleteMany: defineWorkspaceOperation({ + id: 'credentials.delete_many', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + saveDraft: defineWorkspaceOperation({ + id: 'credentials.drafts.save', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + listMembers: defineWorkspaceOperation({ + id: 'credentials.members.list', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + upsertMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.upsert', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + removeMember: defineCredentialOperation( + defineWorkspaceOperation({ + id: 'credentials.members.remove', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + 'admin' + ), + launchConnection: defineWorkspaceOperation({ + id: 'credentials.connections.launch', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], }), useManagedOAuth: defineWorkspaceOperation({ id: 'credentials.managed_oauth.use', @@ -15,3 +150,23 @@ export const credentialOperations = { delegatedServices: ['executor'], }), } as const + +export interface CredentialUserOperation + extends ApplicationOperation { + readonly principalKinds: readonly ['session'] +} + +function defineCredentialUserOperation( + id: Id +): CredentialUserOperation { + if (!id.trim()) throw new Error('Credential user operation ID must not be empty') + return Object.freeze({ id, principalKinds: Object.freeze(['session'] as const) }) +} + +export const credentialUserOperations = { + listMemberships: defineCredentialUserOperation('credentials.memberships.list'), + leaveMembership: defineCredentialUserOperation('credentials.memberships.leave'), + listOAuthConnections: defineCredentialUserOperation('credentials.oauth_connections.list'), + listConnectedAccounts: defineCredentialUserOperation('credentials.accounts.list'), + disconnectOAuth: defineCredentialUserOperation('credentials.oauth_connections.disconnect'), +} as const diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts new file mode 100644 index 00000000000..4d2e8f8ed27 --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.test.ts @@ -0,0 +1,118 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + listCatalog: vi.fn(), + resolveTarget: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, +})) +vi.mock('@/lib/credentials/application/connection-target', () => ({ + resolveCredentialConnectionTarget: mocks.resolveTarget, +})) + +import { prepareCredentialConnection } from '@/lib/credentials/application/prepare-credential-connection' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:credentials', + issuedAt: new Date('2026-08-14T12:00:00.000Z'), + expiresAt: new Date('2030-08-14T12:05:00.000Z'), +} +const gmailProvider = { + type: 'oauth' as const, + serviceId: 'gmail', + name: 'Gmail', + description: 'Gmail OAuth', + providerFamily: 'google', + available: true, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'google-email', label: 'Gmail' }], +} + +describe('prepareCredentialConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.listCatalog.mockResolvedValue([gmailProvider]) + }) + + it('resolves a provider inside delegated workspace policy', async () => { + const result = await prepareCredentialConnection.execute({ + principal, + input: { workspaceId: 'workspace-1', providerName: 'gmail' }, + }) + + expect(result).toEqual({ providerId: 'google-email', serviceName: 'Gmail' }) + }) + + it('uses the credential target as the reconnect authority', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'google-email', + credentialId: 'credential-1', + }) + + const result = await prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + + expect(result).toEqual({ + providerId: 'google-email', + serviceName: 'Gmail', + credentialId: 'credential-1', + }) + expect(mocks.resolveTarget).toHaveBeenCalledWith({ + principal, + context: workspace, + credentialId: 'credential-1', + }) + }) + + it('rejects a reconnect whose requested provider does not match the credential', async () => { + mocks.resolveTarget.mockResolvedValue({ + providerId: 'slack', + credentialId: 'credential-1', + }) + + await expect( + prepareCredentialConnection.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerName: 'gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + }) +}) diff --git a/apps/sim/lib/credentials/application/prepare-credential-connection.ts b/apps/sim/lib/credentials/application/prepare-credential-connection.ts new file mode 100644 index 00000000000..cedd08e89fd --- /dev/null +++ b/apps/sim/lib/credentials/application/prepare-credential-connection.ts @@ -0,0 +1,109 @@ +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialDelegationPolicy } from '@/lib/credentials/application/authorization' +import { resolveCredentialConnectionTarget } from '@/lib/credentials/application/connection-target' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + type OAuthCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' +import { credentialProviderMatchesService } from '@/lib/oauth/utils' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface PrepareCredentialConnectionInput { + workspaceId: string + providerName: string + credentialId?: string +} + +export interface PrepareCredentialConnectionResult { + providerId: string + serviceName: string + credentialId?: string +} + +function resolveRequestedProvider( + providers: readonly OAuthCredentialProviderCatalogEntry[], + providerName: string +): OAuthCredentialProviderCatalogEntry { + const requested = providerName.toLowerCase().trim() + if (!requested) throw new OrchestrationError('validation', 'OAuth provider is required') + + const provider = + providers.find((entry) => + entry.authorizationOptions.some((option) => option.providerId.toLowerCase() === requested) + ) ?? + providers.find( + (entry) => + entry.serviceId.toLowerCase() === requested || entry.name.toLowerCase() === requested + ) ?? + providers.find( + (entry) => + entry.name.toLowerCase().includes(requested) || + requested.includes(entry.name.toLowerCase()) || + entry.authorizationOptions.some( + (option) => + option.providerId.toLowerCase().includes(requested) || + requested.includes(option.providerId.toLowerCase()) + ) + ) + + if (!provider) + throw new OrchestrationError('validation', `OAuth provider not found: ${providerName}`) + if (!provider.available) { + throw new OrchestrationError('conflict', `${provider.name} is not available in this workspace`) + } + return provider +} + +export const prepareCredentialConnection = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.prepareConnection, + resolveContext: async ({ input }: { input: PrepareCredentialConnectionInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: { delegation: credentialDelegationPolicy }, + execute: async ({ principal, input, context }): Promise => { + const providers = (await listCredentialProviderCatalog(principal, context)).filter( + (entry): entry is OAuthCredentialProviderCatalogEntry => entry.type === 'oauth' + ) + const requestedProvider = resolveRequestedProvider(providers, input.providerName) + const requestedProviderId = requestedProvider.authorizationOptions[0]?.providerId + if (!requestedProviderId) { + throw new Error(`OAuth provider ${requestedProvider.serviceId} has no authorization option`) + } + + if (!input.credentialId) { + return { + providerId: requestedProviderId, + serviceName: requestedProvider.name, + } + } + + const target = await resolveCredentialConnectionTarget({ + principal, + context, + credentialId: input.credentialId, + }) + if ( + !credentialProviderMatchesService(target.providerId, { + providerId: requestedProviderId, + additionalProviderIds: requestedProvider.authorizationOptions + .slice(1) + .map((option) => option.providerId), + }) + ) { + throw new OrchestrationError( + 'validation', + `Credential belongs to provider ${target.providerId}, not ${requestedProviderId}` + ) + } + + return { + providerId: target.providerId, + serviceName: requestedProvider.name, + credentialId: target.credentialId, + } + }, +}) diff --git a/apps/sim/lib/credentials/application/presentation.ts b/apps/sim/lib/credentials/application/presentation.ts new file mode 100644 index 00000000000..b4fbe43c3c5 --- /dev/null +++ b/apps/sim/lib/credentials/application/presentation.ts @@ -0,0 +1,59 @@ +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' +import type { V2Credential } from '@/lib/api/contracts/v2/credentials' +import { + type CredentialActorContext, + requireOrdinaryCredentialType, +} from '@/lib/credentials/access' +import type { CredentialRow, VisibleWorkspaceCredential } from '@/lib/credentials/queries' + +type PublicCredentialSource = + | VisibleWorkspaceCredential + | (CredentialRow & { hasServiceAccountKey: boolean; role: 'admin' | 'member' }) + +/** Serializes connection metadata field by field so encrypted columns can never reach the wire. */ +export function toV2Credential(row: PublicCredentialSource): V2Credential { + if (row.type !== 'oauth' && row.type !== 'service_account') { + throw new Error(`Secret credential type ${row.type} reached the credentials API`) + } + + return { + id: row.id, + type: row.type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + hasServiceAccountKey: row.hasServiceAccountKey, + role: row.role, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + } +} + +/** Serializes credential metadata for the internal workspace surface. */ +export function toWorkspaceCredential( + row: CredentialRow | VisibleWorkspaceCredential, + access?: CredentialActorContext +): WorkspaceCredential { + const type = requireOrdinaryCredentialType(row.type) + const role = access?.isAdmin + ? 'admin' + : (access?.member?.role ?? ('role' in row ? row.role : undefined)) + const status = access?.member?.status ?? (access?.isAdmin ? 'active' : undefined) + return { + id: row.id, + workspaceId: row.workspaceId, + type, + displayName: row.displayName, + description: row.description, + providerId: row.providerId, + accountId: row.accountId, + envKey: row.envKey, + envOwnerUserId: row.envOwnerUserId, + createdBy: row.createdBy, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + ...(role ? { role } : {}), + ...(status ? { status } : {}), + } +} diff --git a/apps/sim/lib/credentials/application/provider-catalog.test.ts b/apps/sim/lib/credentials/application/provider-catalog.test.ts new file mode 100644 index 00000000000..f485a80465d --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.test.ts @@ -0,0 +1,237 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getBlockVisibility: vi.fn(), + getAllowedIntegrationsFromEnv: vi.fn(), + getUserPermissionConfig: vi.fn(), + createVisibility: vi.fn(), + getAllOAuthServices: vi.fn(), + getServiceConfigByServiceId: vi.fn(), +})) + +vi.mock('@/lib/core/config/block-visibility', () => ({ + getBlockVisibility: mocks.getBlockVisibility, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + getAllowedIntegrationsFromEnv: mocks.getAllowedIntegrationsFromEnv, +})) + +vi.mock('@/ee/access-control/utils/permission-check', () => ({ + getUserPermissionConfig: mocks.getUserPermissionConfig, +})) + +vi.mock('@/lib/permission-groups/integration-allowlist', () => ({ + intersectIntegrationAllowlists: ( + permissionGroup: readonly string[] | null, + deployment: readonly string[] | null + ) => { + if (!permissionGroup) return deployment + if (!deployment) return permissionGroup + return permissionGroup.filter((type) => deployment.includes(type)) + }, +})) + +vi.mock('@/lib/integrations/credential-visibility.server', () => ({ + createIntegrationCredentialVisibility: mocks.createVisibility, +})) + +vi.mock('@/lib/oauth/utils', () => ({ + getAllOAuthServices: mocks.getAllOAuthServices, + getServiceConfigByServiceId: mocks.getServiceConfigByServiceId, +})) + +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, + type ServiceAccountCredentialProviderCatalogEntry, +} from '@/lib/credentials/application/provider-catalog' + +const personalPrincipal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const context = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', +} +const services = [ + { + serviceId: 'salesforce', + providerId: 'salesforce', + additionalProviderIds: ['salesforce-sandbox'], + name: 'Salesforce', + description: 'Connect Salesforce.', + baseProvider: 'salesforce', + authType: 'oauth' as const, + }, + { + serviceId: 'trello', + providerId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + baseProvider: 'trello', + authType: 'oauth' as const, + }, + { + serviceId: 'claude-platform', + providerId: 'claude-platform-service-account', + serviceAccountProviderId: 'claude-platform-service-account', + name: 'Claude Platform', + description: 'Run Claude Platform Managed Agents from your workflows.', + baseProvider: 'claude-platform', + authType: 'service_account' as const, + }, +] + +describe('listCredentialProviderCatalog', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getAllOAuthServices.mockReturnValue(services) + mocks.getAllowedIntegrationsFromEnv.mockReturnValue(['salesforce']) + mocks.getUserPermissionConfig.mockResolvedValue({ + allowedIntegrations: ['salesforce', 'trello'], + }) + mocks.getBlockVisibility.mockResolvedValue({ + revealed: new Set(), + disabled: new Set(), + previewTagged: new Set(), + }) + mocks.createVisibility.mockReturnValue({ + isOAuthServiceVisible: (service: { serviceId: string }) => service.serviceId === 'salesforce', + isCredentialVisible: ({ providerId }: { providerId: string }) => + providerId === 'claude-platform-service-account', + }) + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { + providerIdLabels: { + salesforce: 'Production', + 'salesforce-sandbox': 'Sandbox', + }, + } + } + if (serviceId === 'trello') return {} + return null + }) + }) + + it('projects OAuth services, authorization options, and reconnect capability', async () => { + const catalog = await listCredentialProviderCatalog(personalPrincipal, context) + + expect(catalog).toEqual([ + { + type: 'oauth', + serviceId: 'salesforce', + name: 'Salesforce', + description: 'Connect Salesforce.', + providerFamily: 'salesforce', + available: true, + supportsReconnect: true, + authorizationOptions: [ + { providerId: 'salesforce', label: 'Production' }, + { providerId: 'salesforce-sandbox', label: 'Sandbox' }, + ], + }, + { + type: 'oauth', + serviceId: 'trello', + name: 'Trello', + description: 'Connect Trello.', + providerFamily: 'trello', + available: false, + supportsReconnect: true, + authorizationOptions: [{ providerId: 'trello', label: 'Trello' }], + }, + { + type: 'service_account', + serviceId: 'claude-platform-service-account', + providerId: 'claude-platform-service-account', + name: 'Claude Platform API key', + description: 'Connect Claude Platform with a API key.', + providerFamily: 'claude-platform', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/managed-agent', + requiresClientGeneratedCredentialId: false, + fields: [ + { + id: 'apiToken', + label: 'API key', + placeholder: 'sk-ant-...', + required: true, + secret: true, + multiline: false, + hint: 'Claude Platform API keys usually start with sk-ant-.', + }, + ], + }, + ]) + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('does not borrow a human permission group for workspace API keys', async () => { + await listCredentialProviderCatalog( + { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + context + ) + + expect(mocks.getUserPermissionConfig).not.toHaveBeenCalled() + expect(mocks.createVisibility).toHaveBeenCalledWith( + expect.objectContaining({ allowedIntegrationTypes: new Set(['salesforce']) }) + ) + }) + + it('fails fast when a multi-server provider lacks complete labels', async () => { + mocks.getServiceConfigByServiceId.mockImplementation((serviceId: string) => { + if (serviceId === 'salesforce') { + return { providerIdLabels: { salesforce: 'Production' } } + } + if (serviceId === 'trello') return {} + return null + }) + + await expect(listCredentialProviderCatalog(personalPrincipal, context)).rejects.toThrow( + 'OAuth provider salesforce-sandbox is missing its authorization option label' + ) + }) +}) + +describe('requireAvailableServiceAccountCredentialProvider', () => { + const provider: ServiceAccountCredentialProviderCatalogEntry = { + type: 'service_account', + serviceId: 'zoom-service-account', + providerId: 'zoom-service-account', + name: 'Zoom server-to-server app', + description: 'Connect Zoom with a server-to-server app.', + providerFamily: 'zoom', + available: true, + docsUrl: 'https://docs.sim.ai/integrations/zoom-service-account', + requiresClientGeneratedCredentialId: false, + fields: [], + } + + it('returns an available service-account provider', () => { + expect(requireAvailableServiceAccountCredentialProvider([provider], provider.providerId)).toBe( + provider + ) + }) + + it('rejects a service-account provider hidden by workspace policy', () => { + expect(() => + requireAvailableServiceAccountCredentialProvider( + [{ ...provider, available: false }], + provider.providerId + ) + ).toThrow('Service-account provider is unavailable: zoom-service-account') + }) +}) diff --git a/apps/sim/lib/credentials/application/provider-catalog.ts b/apps/sim/lib/credentials/application/provider-catalog.ts new file mode 100644 index 00000000000..7dcdc507dbf --- /dev/null +++ b/apps/sim/lib/credentials/application/provider-catalog.ts @@ -0,0 +1,356 @@ +import type { Principal } from '@sim/auth/principal' +import { getBlockVisibility } from '@/lib/core/config/block-visibility' +import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, + type ClientCredentialAccountField, +} from '@/lib/credentials/client-credential-accounts/descriptors' +import { + TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, + type TokenServiceAccountField, +} from '@/lib/credentials/token-service-accounts/descriptors' +import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server' +import { + ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, + GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID, + type OAuthServiceMetadata, + SLACK_CUSTOM_BOT_PROVIDER_ID, +} from '@/lib/oauth/types' +import { getAllOAuthServices, getServiceConfigByServiceId } from '@/lib/oauth/utils' +import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' +import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' + +export interface CredentialProviderAuthorizationOption { + providerId: string + label: string +} + +export interface CredentialProviderFieldOption { + value: string + label: string +} + +export interface CredentialProviderField { + id: string + label: string + placeholder: string + required: boolean + secret: boolean + multiline: boolean + requiredForAuthMethods?: string[] + options?: CredentialProviderFieldOption[] + hint?: string +} + +interface CredentialProviderCatalogBase { + type: 'oauth' | 'service_account' + serviceId: string + name: string + description: string + providerFamily: string + available: boolean +} + +export interface OAuthCredentialProviderCatalogEntry extends CredentialProviderCatalogBase { + type: 'oauth' + supportsReconnect: boolean + authorizationOptions: CredentialProviderAuthorizationOption[] +} + +export interface ServiceAccountCredentialProviderCatalogEntry + extends CredentialProviderCatalogBase { + type: 'service_account' + providerId: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId: boolean + fields: CredentialProviderField[] +} + +export type CredentialProviderCatalogEntry = + | OAuthCredentialProviderCatalogEntry + | ServiceAccountCredentialProviderCatalogEntry + +interface CredentialProviderCatalogContext { + workspaceId: string + workspaceOrganizationId: string | null +} + +interface ServiceAccountDescriptor { + name: string + description: string + docsUrl: string + helpText?: string + requiresClientGeneratedCredentialId?: boolean + fields: CredentialProviderField[] +} + +const GOOGLE_SERVICE_ACCOUNT_DOCS_URL = 'https://docs.sim.ai/integrations/google-service-account' +const ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL = + 'https://docs.sim.ai/integrations/atlassian-service-account' + +function providerField( + field: TokenServiceAccountField | ClientCredentialAccountField +): CredentialProviderField { + return { + id: field.id, + label: field.label, + placeholder: field.placeholder, + required: !('optional' in field && field.optional), + secret: field.secret, + multiline: 'multiline' in field && field.multiline === true, + ...('requiredForAuthMethods' in field && field.requiredForAuthMethods + ? { requiredForAuthMethods: [...field.requiredForAuthMethods] } + : {}), + ...('options' in field && field.options ? { options: [...field.options] } : {}), + ...('hint' in field && field.hint + ? { hint: field.hint } + : 'hintMessage' in field && field.hintMessage + ? { hint: field.hintMessage } + : {}), + } +} + +function getServiceAccountDescriptor(providerId: string): ServiceAccountDescriptor { + if (providerId === GOOGLE_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Google service account', + description: 'Connect Google APIs with a service-account JSON key.', + docsUrl: GOOGLE_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'serviceAccountJson', + label: 'JSON key', + placeholder: 'Paste the service-account JSON key', + required: true, + secret: true, + multiline: true, + }, + ], + } + } + if (providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) { + return { + name: 'Atlassian service account', + description: 'Connect Jira and Confluence with an Atlassian API token.', + docsUrl: ATLASSIAN_SERVICE_ACCOUNT_DOCS_URL, + fields: [ + { + id: 'apiToken', + label: 'API token', + placeholder: 'Paste the API token', + required: true, + secret: true, + multiline: false, + }, + { + id: 'domain', + label: 'Site domain', + placeholder: 'your-team.atlassian.net', + required: true, + secret: false, + multiline: false, + }, + ], + } + } + if (providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + return { + name: 'Slack custom bot', + description: 'Connect a reusable Slack app with its signing secret and bot token.', + docsUrl: 'https://docs.sim.ai/integrations/slack', + requiresClientGeneratedCredentialId: true, + fields: [ + { + id: 'signingSecret', + label: 'Signing secret', + placeholder: 'Paste the signing secret', + required: true, + secret: true, + multiline: false, + }, + { + id: 'botToken', + label: 'Bot token', + placeholder: 'xoxb-...', + required: true, + secret: true, + multiline: false, + }, + ], + } + } + + const tokenDescriptor = Object.hasOwn(TOKEN_SERVICE_ACCOUNT_DESCRIPTORS, providerId) + ? TOKEN_SERVICE_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof TOKEN_SERVICE_ACCOUNT_DESCRIPTORS + ] + : undefined + if (tokenDescriptor) { + return { + name: `${tokenDescriptor.serviceLabel} ${tokenDescriptor.connectNoun}`, + description: `Connect ${tokenDescriptor.serviceLabel} with a ${tokenDescriptor.tokenNoun}.`, + docsUrl: tokenDescriptor.docsUrl, + helpText: tokenDescriptor.helpText, + fields: tokenDescriptor.fields.map(providerField), + } + } + + const clientDescriptor = Object.hasOwn(CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS, providerId) + ? CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS[ + providerId as keyof typeof CLIENT_CREDENTIAL_ACCOUNT_DESCRIPTORS + ] + : undefined + if (clientDescriptor) { + return { + name: `${clientDescriptor.serviceLabel} ${clientDescriptor.connectNoun}`, + description: `Connect ${clientDescriptor.serviceLabel} with a ${clientDescriptor.connectNoun}.`, + docsUrl: clientDescriptor.docsUrl, + helpText: clientDescriptor.helpText, + fields: clientDescriptor.fields.map(providerField), + } + } + + throw new Error(`Service-account provider ${providerId} is missing its canonical descriptor`) +} + +function principalUserId(principal: Principal): string | undefined { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + if (principal.kind === 'delegated') return principal.subjectUserId + return undefined +} + +async function allowedIntegrationTypes( + principal: Principal, + workspaceId: string +): Promise | null> { + const userId = principalUserId(principal) + const permissionConfig = userId ? await getUserPermissionConfig(userId, workspaceId) : null + const integrations = intersectIntegrationAllowlists( + permissionConfig?.allowedIntegrations ?? null, + getAllowedIntegrationsFromEnv() + ) + return integrations ? new Set(integrations.map((type) => type.toLowerCase())) : null +} + +export async function listCredentialProviderCatalog( + principal: Principal, + context: CredentialProviderCatalogContext +): Promise { + const userId = principalUserId(principal) + const [allowedIntegrations, blockVisibility] = await Promise.all([ + allowedIntegrationTypes(principal, context.workspaceId), + getBlockVisibility({ + ...(userId ? { userId } : {}), + ...(context.workspaceOrganizationId ? { orgId: context.workspaceOrganizationId } : {}), + }), + ]) + const services = getAllOAuthServices() + const oauthServices = services.filter((service) => service.authType === 'oauth') + const visibility = createIntegrationCredentialVisibility({ + allowedIntegrationTypes: allowedIntegrations, + blockVisibility, + oauthServices: services, + }) + + const oauthEntries: OAuthCredentialProviderCatalogEntry[] = oauthServices.map((service) => { + const config = getServiceConfigByServiceId(service.serviceId) + if (!config) { + throw new Error(`OAuth service ${service.serviceId} is missing its canonical configuration`) + } + const providerIds = [service.providerId, ...(service.additionalProviderIds ?? [])] + if (providerIds.length > 1 && !config.providerIdLabels) { + throw new Error(`OAuth service ${service.serviceId} is missing provider option labels`) + } + const authorizationOptions = providerIds.map((providerId) => { + const label = providerIds.length === 1 ? service.name : config.providerIdLabels?.[providerId] + if (!label) { + throw new Error(`OAuth provider ${providerId} is missing its authorization option label`) + } + return { providerId, label } + }) + + return { + type: 'oauth', + serviceId: service.serviceId, + name: service.name, + description: service.description, + providerFamily: service.baseProvider, + available: visibility.isOAuthServiceVisible(service), + supportsReconnect: true, + authorizationOptions, + } + }) + + const serviceAccountOwners = new Map() + for (const service of services) { + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId && !serviceAccountOwners.has(serviceAccountProviderId)) { + serviceAccountOwners.set(serviceAccountProviderId, service) + } + } + + const serviceAccountEntries: ServiceAccountCredentialProviderCatalogEntry[] = [ + ...serviceAccountOwners, + ].map(([providerId, owner]) => { + const descriptor = getServiceAccountDescriptor(providerId) + return { + type: 'service_account', + serviceId: providerId, + providerId, + name: descriptor.name, + description: descriptor.description, + providerFamily: owner.baseProvider, + available: visibility.isCredentialVisible({ providerId, type: 'service_account' }), + docsUrl: descriptor.docsUrl, + ...(descriptor.helpText ? { helpText: descriptor.helpText } : {}), + requiresClientGeneratedCredentialId: descriptor.requiresClientGeneratedCredentialId === true, + fields: descriptor.fields, + } + }) + + return [...oauthEntries, ...serviceAccountEntries] +} + +export function requireAvailableOAuthCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): OAuthCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is OAuthCredentialProviderCatalogEntry => + entry.type === 'oauth' && + entry.authorizationOptions.some((option) => option.providerId === providerId) + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown OAuth provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError('conflict', `OAuth provider is unavailable: ${providerId}`) + } + return provider +} + +export function requireAvailableServiceAccountCredentialProvider( + catalog: readonly CredentialProviderCatalogEntry[], + providerId: string +): ServiceAccountCredentialProviderCatalogEntry { + const provider = catalog.find( + (entry): entry is ServiceAccountCredentialProviderCatalogEntry => + entry.type === 'service_account' && entry.providerId === providerId + ) + if (!provider) { + throw new OrchestrationError('validation', `Unknown service-account provider: ${providerId}`) + } + if (!provider.available) { + throw new OrchestrationError( + 'conflict', + `Service-account provider is unavailable: ${providerId}` + ) + } + return provider +} diff --git a/apps/sim/lib/credentials/application/save-credential-draft.test.ts b/apps/sim/lib/credentials/application/save-credential-draft.test.ts new file mode 100644 index 00000000000..5eb547fb6b9 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getActor: vi.fn(), + createDraft: vi.fn(), +})) + +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/credentials/connect-draft', () => ({ + createConnectDraft: mocks.createDraft, +})) + +import { saveCredentialDraft } from '@/lib/credentials/application/save-credential-draft' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const credential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'oauth' as const, +} + +describe('saveCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.createDraft.mockResolvedValue({ id: 'draft-1' }) + }) + + it('authorizes workspace access before resolving reconnect credential access', async () => { + await saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + + expect(mocks.resolvePermission.mock.invocationCallOrder[0]).toBeLessThan( + mocks.getActor.mock.invocationCallOrder[0] + ) + expect(mocks.createDraft).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + credentialId: 'credential-1', + displayNameDefinesIntent: false, + }) + ) + }) + + it('rejects a reconnect outside the asserted workspace', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, workspaceId: 'workspace-2' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) + + it('rejects managed OAuth credentials as reconnect targets', async () => { + mocks.getActor.mockResolvedValue({ + credential: { ...credential, type: 'managed_oauth' }, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await expect( + saveCredentialDraft.execute({ + principal, + input: { + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Managed Gmail', + credentialId: 'credential-1', + }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.createDraft).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/credentials/application/save-credential-draft.ts b/apps/sim/lib/credentials/application/save-credential-draft.ts new file mode 100644 index 00000000000..d737d81ebf6 --- /dev/null +++ b/apps/sim/lib/credentials/application/save-credential-draft.ts @@ -0,0 +1,67 @@ +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase, ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { createConnectDraft } from '@/lib/credentials/connect-draft' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export interface SaveCredentialDraftInput { + workspaceId: string + providerId: string + displayName: string + description?: string + credentialId?: string +} + +interface SaveCredentialDraftContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string + credentialAccess?: CredentialActorContext +} + +export const saveCredentialDraft = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.saveDraft, + async resolveContext({ + input, + }: { + input: SaveCredentialDraftInput + }): Promise { + const workspace = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace + }, + authorizationOptions: {}, + async authorizeResource({ principal, input, context }) { + if (!input.credentialId) return + context.credentialAccess = await getCredentialActorContext( + input.credentialId, + requirePrincipalSubjectUserId(principal) + ) + if ( + !context.credentialAccess?.credential || + context.credentialAccess.credential.type === 'managed_oauth' || + context.credentialAccess.credential.workspaceId !== context.workspaceId || + !context.credentialAccess.isAdmin + ) { + throw new ForbiddenOperationError( + 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + 'Admin access required on the target credential' + ) + } + }, + async execute({ principal, input }) { + await createConnectDraft({ + userId: requirePrincipalSubjectUserId(principal), + workspaceId: input.workspaceId, + providerId: input.providerId, + displayName: input.displayName, + description: input.description, + credentialId: input.credentialId, + displayNameDefinesIntent: input.credentialId === undefined, + }) + return { success: true as const } + }, +}) diff --git a/apps/sim/lib/credentials/application/service-account.test.ts b/apps/sim/lib/credentials/application/service-account.test.ts new file mode 100644 index 00000000000..6dc2ece8096 --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.test.ts @@ -0,0 +1,326 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const mocks = vi.hoisted(() => ({ + loadWorkspace: vi.fn(), + resolvePermission: vi.fn(), + create: vi.fn(), + listCatalog: vi.fn(), + requireProvider: vi.fn(), + getCredential: vi.fn(), + getActor: vi.fn(), + delete: vi.fn(), + deleteRecord: vi.fn(), + capture: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/workspaces/application/workspace-context', () => ({ + loadActiveWorkspaceApplicationContext: mocks.loadWorkspace, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || permission === 'write' || permission === required, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/credentials/orchestration', () => ({ + createServiceAccountCredential: mocks.create, + deleteConnectionCredential: mocks.delete, + deleteCredentialRecord: mocks.deleteRecord, +})) +vi.mock('@/lib/credentials/application/provider-catalog', () => ({ + listCredentialProviderCatalog: mocks.listCatalog, + requireAvailableServiceAccountCredentialProvider: mocks.requireProvider, +})) +vi.mock('@/lib/credentials/queries', () => ({ + getWorkspaceCredential: mocks.getCredential, +})) +vi.mock('@/lib/credentials/access', () => ({ + getCredentialActorContext: mocks.getActor, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture })) + +import { + createServiceAccountCredentialUseCase, + deleteCredentialUseCase, +} from '@/lib/credentials/application/service-account' + +const WORKSPACE_ID = 'workspace-1' +const workspace = { + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} +const principal = { + kind: 'personal_api_key' as const, + userId: 'user-1', + keyId: 'key-1', +} +const credential = { + id: 'credential-1', + workspaceId: WORKSPACE_ID, + type: 'service_account' as const, + displayName: 'Zoom account', + description: null, + providerId: 'zoom-service-account', + accountId: null, + envKey: null, + envOwnerUserId: null, + encryptedServiceAccountKey: 'encrypted', + createdBy: 'user-1', + createdAt: new Date('2026-08-12T20:00:00.000Z'), + updatedAt: new Date('2026-08-12T20:00:00.000Z'), +} + +describe('credential service-account application operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('write') + mocks.getCredential.mockResolvedValue(credential) + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + mocks.create.mockResolvedValue({ + success: true, + credential, + created: true, + auditMetadata: { tenantId: 'tenant-1' }, + }) + mocks.listCatalog.mockResolvedValue([{ providerId: 'zoom-service-account' }]) + mocks.delete.mockResolvedValue(true) + mocks.deleteRecord.mockResolvedValue(true) + mocks.requireProvider.mockReturnValue({ + type: 'service_account', + providerId: 'zoom-service-account', + available: true, + }) + }) + + it('rejects workspace keys before canonical loading on create', async () => { + await expect( + createServiceAccountCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('creates through the verified service-account primitive', async () => { + const result = await createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + + expect(result).toMatchObject({ + credential, + created: true, + hasServiceAccountKey: true, + role: 'admin', + }) + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: WORKSPACE_ID, + userId: 'user-1', + providerId: 'zoom-service-account', + }) + ) + }) + + it('rejects service-account providers hidden by workspace policy', async () => { + mocks.requireProvider.mockImplementation(() => { + throw new OrchestrationError( + 'conflict', + 'Service-account provider is unavailable: zoom-service-account' + ) + }) + + await expect( + createServiceAccountCredentialUseCase.execute({ + principal, + input: { + workspaceId: WORKSPACE_ID, + type: 'service_account', + providerId: 'zoom-service-account', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-id', + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.create).not.toHaveBeenCalled() + }) + + it('requires credential admin access before disconnecting', async () => { + mocks.getActor.mockResolvedValue({ + credential, + member: { role: 'member' }, + hasWorkspaceAccess: true, + isAdmin: false, + }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'CREDENTIAL_ADMIN_ACCESS_REQUIRED', + }) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('rejects workspace keys before canonical loading on disconnect', async () => { + await expect( + deleteCredentialUseCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'WORKSPACE_KEY_OPERATION_NOT_PERMITTED', + }) + expect(mocks.loadWorkspace).not.toHaveBeenCalled() + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('allows an explicit credential admin with workspace read access to disconnect', async () => { + mocks.resolvePermission.mockResolvedValue('read') + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).resolves.toEqual({ credential, deleted: true }) + expect(mocks.delete).toHaveBeenCalledOnce() + }) + + it('applies credential admin policy during authorization-only checks', async () => { + await deleteCredentialUseCase.authorize?.({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(mocks.getActor).toHaveBeenCalledWith(credential.id, principal.userId) + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('enforces personal-key workspace policy before credential authorization', async () => { + mocks.loadWorkspace.mockResolvedValue({ ...workspace, allowPersonalApiKeys: false }) + + await expect( + deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }) + expect(mocks.getActor).not.toHaveBeenCalled() + expect(mocks.delete).not.toHaveBeenCalled() + }) + + it('disconnects an administered credential', async () => { + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: true }) + expect(mocks.delete).toHaveBeenCalledWith({ + credentialId: credential.id, + workspaceId: WORKSPACE_ID, + reason: 'user_delete', + }) + }) + + it('treats a concurrent disconnect as an idempotent success', async () => { + mocks.delete.mockResolvedValue(false) + + const result = await deleteCredentialUseCase.execute({ + principal, + input: { workspaceId: WORKSPACE_ID, credentialId: credential.id }, + }) + + expect(result).toEqual({ credential, deleted: false }) + }) + + it.each([ + ['env_personal', 'personal'], + ['env_workspace', 'workspace'], + ] as const)('preserves %s deletion audit and analytics dimensions', async (type, label) => { + const envCredential = { + ...credential, + type, + displayName: 'MY_API_KEY', + providerId: null, + envKey: 'MY_API_KEY', + envOwnerUserId: type === 'env_personal' ? 'user-1' : null, + encryptedServiceAccountKey: null, + } + mocks.getCredential.mockResolvedValue(envCredential) + mocks.getActor.mockResolvedValue({ + credential: envCredential, + member: { role: 'admin' }, + hasWorkspaceAccess: true, + isAdmin: true, + }) + + await deleteCredentialUseCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, credentialId: envCredential.id }, + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + description: `Deleted ${label} env credential "MY_API_KEY"`, + metadata: expect.objectContaining({ + credentialType: type, + envKey: 'MY_API_KEY', + }), + }) + ) + expect(mocks.capture).toHaveBeenCalledWith( + 'user-1', + 'credential_deleted', + expect.objectContaining({ provider_id: 'MY_API_KEY' }), + expect.anything() + ) + }) +}) diff --git a/apps/sim/lib/credentials/application/service-account.ts b/apps/sim/lib/credentials/application/service-account.ts new file mode 100644 index 00000000000..078cb50f04d --- /dev/null +++ b/apps/sim/lib/credentials/application/service-account.ts @@ -0,0 +1,210 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' +import { ForbiddenOperationError } from '@/lib/core/application/forbidden' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { HttpError } from '@/lib/core/utils/http-error' +import { getCredentialActorContext } from '@/lib/credentials/access' +import { defineAuthorizedCredentialUseCase } from '@/lib/credentials/application/authorized-credential-use-case' +import { resolveCredentialApplicationContext } from '@/lib/credentials/application/credential-context' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { + listCredentialProviderCatalog, + requireAvailableServiceAccountCredentialProvider, +} from '@/lib/credentials/application/provider-catalog' +import { + type CreateServiceAccountCredentialParams, + createServiceAccountCredential, + deleteConnectionCredential, + deleteCredentialRecord, +} from '@/lib/credentials/orchestration' +import type { CredentialRow } from '@/lib/credentials/queries' +import { captureServerEvent } from '@/lib/posthog/server' +import { loadActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/workspace-context' + +export type CreateServiceAccountInput = Omit< + CreateServiceAccountCredentialParams, + 'userId' | 'request' +> + +export interface CreateServiceAccountResult { + credential: CredentialRow + created: boolean + hasServiceAccountKey: boolean + role: 'admin' | 'member' + auditMetadata: Record +} + +class CredentialProviderUnavailableError extends HttpError { + readonly statusCode = 503 + + constructor() { + super('Credential provider is temporarily unavailable') + this.name = 'CredentialProviderUnavailableError' + } +} + +export const createServiceAccountCredentialUseCase = defineAuthorizedWorkspaceUseCase({ + operation: credentialOperations.createServiceAccount, + resolveContext: async ({ input }: { input: CreateServiceAccountInput }) => { + const context = await loadActiveWorkspaceApplicationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context + }, + authorizationOptions: {}, + async execute({ principal, input, context, request }): Promise { + const catalog = await listCredentialProviderCatalog(principal, context) + requireAvailableServiceAccountCredentialProvider(catalog, input.providerId) + const result = await createServiceAccountCredential({ + ...input, + workspaceId: context.workspaceId, + userId: requirePrincipalSubjectUserId(principal), + request, + }) + if (!result.success) { + if (result.providerUnavailable) throw new CredentialProviderUnavailableError() + switch (result.errorCode) { + case 'validation': + case 'not_found': + case 'conflict': + throw new OrchestrationError(result.errorCode, result.error ?? 'Credential create failed') + case 'forbidden': + throw new ForbiddenOperationError( + 'INSUFFICIENT_WORKSPACE_ROLE', + result.error ?? 'Write permission required' + ) + default: + throw new Error('Failed to create service-account credential') + } + } + if (!result.credential) { + throw new Error('Credential creation succeeded without a credential') + } + const actor = await getCredentialActorContext( + result.credential.id, + requirePrincipalSubjectUserId(principal) + ) + if (!actor.credential || (!actor.member && !actor.isAdmin)) { + throw new Error('Created credential is not visible to its creator') + } + return { + credential: result.credential, + created: result.created === true, + hasServiceAccountKey: Boolean(result.credential.encryptedServiceAccountKey), + role: actor.isAdmin ? 'admin' : 'member', + auditMetadata: result.auditMetadata ?? {}, + } + }, + projectAudit: ({ result }) => + result.created + ? { + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created service_account credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + } + : [], + afterSuccess: ({ principal, context, result }) => { + if (!result.created) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_connected', + { + credential_type: 'service_account', + provider_id: result.credential.providerId ?? 'service_account', + workspace_id: context.workspaceId, + }, + { + groups: { workspace: context.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + }, +}) + +export interface DeleteCredentialInput { + workspaceId?: string + credentialId: string +} + +export interface DeleteCredentialResult { + credential: CredentialRow + deleted: boolean +} + +export const deleteCredentialUseCase = defineAuthorizedCredentialUseCase({ + operation: credentialOperations.delete, + resolveContext: ({ input }: { input: DeleteCredentialInput }) => + resolveCredentialApplicationContext({ + credentialId: input.credentialId, + assertedWorkspaceId: input.workspaceId, + }), + async execute({ principal, context }): Promise { + const allowedTypes = + principal.kind === 'session' + ? ['oauth', 'env_workspace', 'env_personal', 'service_account'] + : principal.kind === 'delegated' + ? ['oauth'] + : ['oauth', 'service_account'] + if (!allowedTypes.includes(context.credential.type)) { + throw new OrchestrationError( + 'validation', + `Only ${allowedTypes.join(', ')} credentials can be managed by this caller` + ) + } + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const deleted = + context.credential.type === 'oauth' || context.credential.type === 'service_account' + ? await deleteConnectionCredential({ + credentialId: context.credential.id, + workspaceId: context.workspaceId, + reason, + }) + : await deleteCredentialRecord({ credential: context.credential, reason }) + return { credential: context.credential, deleted } + }, + projectAudit: ({ principal, result }) => { + if (!result.deleted) return [] + const reason = principal.kind === 'delegated' ? 'copilot_delete' : 'user_delete' + const description = + result.credential.type === 'env_personal' + ? `Deleted personal env credential "${result.credential.envKey}"` + : result.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${result.credential.envKey}"` + : `Deleted ${result.credential.type} credential "${result.credential.displayName}" (${reason})` + return { + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description, + metadata: { + reason, + credentialType: result.credential.type, + providerId: result.credential.providerId, + accountId: result.credential.accountId, + envKey: result.credential.envKey, + }, + } + }, + afterSuccess: ({ principal, context, result }) => { + if (!result.deleted) return + captureServerEvent( + requirePrincipalSubjectUserId(principal), + 'credential_deleted', + { + credential_type: result.credential.type, + provider_id: + result.credential.providerId ?? result.credential.envKey ?? result.credential.id, + workspace_id: context.workspaceId, + }, + { groups: { workspace: context.workspaceId } } + ) + }, +}) diff --git a/apps/sim/lib/credentials/connect-draft.test.ts b/apps/sim/lib/credentials/connect-draft.test.ts new file mode 100644 index 00000000000..539a7cac8c5 --- /dev/null +++ b/apps/sim/lib/credentials/connect-draft.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGenerateId } = vi.hoisted(() => ({ + mockGenerateId: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: mockGenerateId })) + +import { createConnectDraft } from '@/lib/credentials/connect-draft' + +describe('createConnectDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockGenerateId.mockReturnValue('new-draft-id') + }) + + it('refreshes the expiry without changing an active connection intent', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + const result = await createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + displayName: 'Work Gmail', + displayNameDefinesIntent: true, + }) + + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ id: 'new-draft-id' }) + ) + const conflict = dbChainMockFns.onConflictDoUpdate.mock.calls[0]?.[0] as + | { set?: Record; setWhere?: unknown } + | undefined + expect(conflict?.set).not.toHaveProperty('id') + expect(conflict?.set).not.toHaveProperty('displayName') + expect(conflict?.set).not.toHaveProperty('credentialId') + expect(conflict?.setWhere).toBeDefined() + expect(result).toEqual({ id: 'active-draft-id', expiresAt }) + }) + + it('refreshes a reconnect target when its mutable display name changes', async () => { + const expiresAt = new Date('2026-08-13T20:15:00.000Z') + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'active-draft-id', expiresAt }]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Renamed Gmail', + }) + ).resolves.toEqual({ id: 'active-draft-id', expiresAt }) + + expect(drizzleOrmMock.eq).not.toHaveBeenCalledWith( + schemaMock.pendingCredentialDraft.displayName, + 'Renamed Gmail' + ) + }) + + it('fails fast when an active draft has a different connection intent', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + + await expect( + createConnectDraft({ + userId: 'user-1', + workspaceId: 'workspace-1', + providerId: 'google-email', + credentialId: 'credential-1', + displayName: 'Existing Gmail', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'A different OAuth connection flow is already active for this provider', + }) + }) +}) diff --git a/apps/sim/lib/credentials/connect-draft.ts b/apps/sim/lib/credentials/connect-draft.ts index 2e72f796526..bda705b4744 100644 --- a/apps/sim/lib/credentials/connect-draft.ts +++ b/apps/sim/lib/credentials/connect-draft.ts @@ -2,12 +2,20 @@ import { db } from '@sim/db' import { credential, pendingCredentialDraft, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, eq, lt } from 'drizzle-orm' +import { and, eq, gt, isNull, lt } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { defaultCredentialDisplayName } from '@/lib/credentials/display-name' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' import { credentialProviderMatchesService, getAllOAuthServices } from '@/lib/oauth/utils' const logger = createLogger('OAuthConnectDraft') -const DRAFT_TTL_MS = 15 * 60 * 1000 + +export type ConnectDraft = typeof pendingCredentialDraft.$inferSelect + +export interface CreatedConnectDraft { + id: string + expiresAt: Date +} /** * Creates the pending credential draft at OAuth click time so custom and @@ -21,7 +29,10 @@ export async function createConnectDraft(params: { credentialId?: string /** Reconnect only: the credential's actual name, so audit records stay accurate. */ displayName?: string -}): Promise { + description?: string + /** Whether an explicitly requested name distinguishes this new-connection intent. */ + displayNameDefinesIntent?: boolean +}): Promise { const { userId, workspaceId, providerId, credentialId } = params let displayName = params.displayName @@ -32,61 +43,48 @@ export async function createConnectDraft(params: { const service = getAllOAuthServices().find((s) => credentialProviderMatchesService(providerId, s) ) - const serviceName = service?.name ?? providerId + if (!service) throw new Error(`Cannot create OAuth draft for unknown provider ${providerId}`) + const serviceName = service.name - let userName: string | null = null - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - userName = row?.name ?? null - } catch (error) { - // Cosmetic only — fall back to the "My {Service}" default - logger.warn('User name lookup failed for connect draft display name', { - userId, - workspaceId, - providerId, - error, - }) - } + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (!row) throw new Error(`Cannot create OAuth draft for missing user ${userId}`) + const userName = row.name // Auto-number against existing workspace credentials so repeat connects for // the same provider stay distinguishable — same behavior as the connect - // modal, which computes this client-side. Best effort: on failure the name - // simply skips deduplication. - let takenNames: ReadonlySet = new Set() - try { - const rows = await db - .select({ displayName: credential.displayName }) - .from(credential) - .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) - takenNames = new Set(rows.map((row) => row.displayName.toLowerCase())) - } catch (error) { - // Cosmetic only — proceed without collision numbering - logger.warn('Credential name lookup failed for connect draft deduplication', { - userId, - workspaceId, - providerId, - error, - }) - } + // modal, which computes this client-side. + const rows = await db + .select({ displayName: credential.displayName }) + .from(credential) + .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth'))) + const takenNames = new Set(rows.map((credentialRow) => credentialRow.displayName.toLowerCase())) displayName = defaultCredentialDisplayName(userName, serviceName, takenNames) } const now = new Date() - const expiresAt = new Date(now.getTime() + DRAFT_TTL_MS) + const expiresAt = new Date(now.getTime() + CREDENTIAL_DRAFT_TTL_MS) await db .delete(pendingCredentialDraft) .where( and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) ) - await db + const id = generateId() + const sameTarget = credentialId + ? eq(pendingCredentialDraft.credentialId, credentialId) + : isNull(pendingCredentialDraft.credentialId) + const sameIntent = params.displayNameDefinesIntent + ? and(sameTarget, eq(pendingCredentialDraft.displayName, displayName)) + : sameTarget + const [draft] = await db .insert(pendingCredentialDraft) .values({ - id: generateId(), + id, userId, workspaceId, providerId, displayName, + description: params.description?.trim() || null, credentialId: credentialId ?? null, expiresAt, createdAt: now, @@ -97,11 +95,17 @@ export async function createConnectDraft(params: { pendingCredentialDraft.providerId, pendingCredentialDraft.workspaceId, ], - // credentialId must be written on BOTH paths: a plain connect that reuses a - // stale reconnect draft row would otherwise silently rebind the old - // credential instead of creating a new one. - set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now }, + set: { expiresAt, createdAt: now }, + setWhere: sameIntent, }) + .returning({ id: pendingCredentialDraft.id, expiresAt: pendingCredentialDraft.expiresAt }) + + if (!draft) { + throw new OrchestrationError( + 'conflict', + 'A different OAuth connection flow is already active for this provider' + ) + } logger.info('Created OAuth connect credential draft', { userId, @@ -109,4 +113,23 @@ export async function createConnectDraft(params: { providerId, credentialId: credentialId ?? null, }) + return draft +} + +export async function getActiveConnectDraft( + draftId: string, + userId: string +): Promise { + const [draft] = await db + .select() + .from(pendingCredentialDraft) + .where( + and( + eq(pendingCredentialDraft.id, draftId), + eq(pendingCredentialDraft.userId, userId), + gt(pendingCredentialDraft.expiresAt, new Date()) + ) + ) + .limit(1) + return draft ?? null } diff --git a/apps/sim/lib/credentials/deletion.ts b/apps/sim/lib/credentials/deletion.ts index 618e51b0d1a..f16902ddf04 100644 --- a/apps/sim/lib/credentials/deletion.ts +++ b/apps/sim/lib/credentials/deletion.ts @@ -23,6 +23,12 @@ interface DeleteCredentialParams { request?: NextRequest } +export interface DeleteConnectionCredentialParams { + credentialId: string + workspaceId: string + reason: CredentialDeleteReason +} + /** * Clears all stored references to the credential, deletes the row, and * records an audit entry. Idempotent when the row no longer exists. @@ -71,6 +77,30 @@ export async function deleteCredential(params: DeleteCredentialParams): Promise< logger.info('Deleted credential', { credentialId, workspaceId: row.workspaceId, reason }) } +/** Clears references and deletes one connection without surface audit attribution. */ +export async function deleteConnectionCredential( + params: DeleteConnectionCredentialParams +): Promise { + const { credentialId, workspaceId } = params + await clearCredentialRefs(credentialId, workspaceId) + const deleted = await db + .delete(schema.credential) + .where( + and(eq(schema.credential.id, credentialId), eq(schema.credential.workspaceId, workspaceId)) + ) + .returning({ id: schema.credential.id }) + if (deleted.length > 1) throw new Error('Credential deletion affected multiple rows') + + if (deleted.length === 1) { + logger.info('Deleted credential', { + credentialId, + workspaceId, + reason: params.reason, + }) + } + return deleted.length === 1 +} + /** * Clears stored references to a credential across mutable workspace state * (editor blocks, copilot checkpoints, knowledge connectors) and frozen diff --git a/apps/sim/lib/credentials/draft-constants.ts b/apps/sim/lib/credentials/draft-constants.ts new file mode 100644 index 00000000000..ff7525a35f9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-constants.ts @@ -0,0 +1,2 @@ +export const CREDENTIAL_DRAFT_TTL_MS = 15 * 60 * 1000 +export const CREDENTIAL_DRAFT_TTL_SECONDS = CREDENTIAL_DRAFT_TTL_MS / 1000 diff --git a/apps/sim/lib/credentials/draft-hooks.test.ts b/apps/sim/lib/credentials/draft-hooks.test.ts new file mode 100644 index 00000000000..bf2e88a15a9 --- /dev/null +++ b/apps/sim/lib/credentials/draft-hooks.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { auditMock, auditMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + clearDeadFlag: vi.fn(), +})) + +vi.mock('@sim/audit', () => auditMock) +vi.mock('@/lib/oauth/terminal-errors', () => ({ clearDeadFlag: mocks.clearDeadFlag })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) + +import { handleReconnectCredential } from '@/lib/credentials/draft-hooks' + +describe('handleReconnectCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('audits a reconnect with the credential current name instead of draft presentation', async () => { + queueTableRows(schemaMock.credential, [ + { id: 'credential-1', accountId: null, displayName: 'Renamed Gmail' }, + ]) + queueTableRows(schemaMock.credential, []) + + await handleReconnectCredential({ + draft: { credentialId: 'credential-1' }, + newAccountId: 'account-new', + workspaceId: 'workspace-1', + userId: 'user-1', + now: new Date('2026-08-14T18:00:00.000Z'), + }) + + expect(auditMockFns.mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + resourceId: 'credential-1', + resourceName: 'Renamed Gmail', + description: 'Reconnected OAuth credential "Renamed Gmail" to a new account', + }) + ) + }) +}) diff --git a/apps/sim/lib/credentials/draft-hooks.ts b/apps/sim/lib/credentials/draft-hooks.ts index e467f56609e..704a22c25fb 100644 --- a/apps/sim/lib/credentials/draft-hooks.ts +++ b/apps/sim/lib/credentials/draft-hooks.ts @@ -105,7 +105,7 @@ export async function handleCreateCredentialFromDraft(params: { * the dead flag. Callers treat that timestamp as proof the reconnect landed. */ export async function handleReconnectCredential(params: { - draft: { credentialId: string | null; workspaceId: string; displayName: string } + draft: { credentialId: string | null } newAccountId: string workspaceId: string userId: string @@ -115,19 +115,21 @@ export async function handleReconnectCredential(params: { if (!draft.credentialId) return const [existingCredential] = await db - .select({ id: schema.credential.id, accountId: schema.credential.accountId }) + .select({ + id: schema.credential.id, + accountId: schema.credential.accountId, + displayName: schema.credential.displayName, + }) .from(schema.credential) .where(eq(schema.credential.id, draft.credentialId)) .limit(1) if (!existingCredential) { - logger.warn('Credential not found for reconnect, skipping', { - credentialId: draft.credentialId, - }) - return + throw new Error(`Cannot reconnect missing credential ${draft.credentialId}`) } const oldAccountId = existingCredential.accountId + const displayName = existingCredential.displayName const accountChanged = oldAccountId !== newAccountId if (accountChanged) { @@ -144,12 +146,9 @@ export async function handleReconnectCredential(params: { .limit(1) if (conflicting) { - logger.warn('New account already used by another credential, skipping reconnect', { - credentialId: draft.credentialId, - newAccountId, - conflictingCredentialId: conflicting.id, - }) - return + throw new Error( + `Cannot reconnect credential ${draft.credentialId}: account ${newAccountId} is already used by credential ${conflicting.id}` + ) } } @@ -177,10 +176,10 @@ export async function handleReconnectCredential(params: { action: AuditAction.CREDENTIAL_RECONNECTED, resourceType: AuditResourceType.CREDENTIAL, resourceId: draft.credentialId, - resourceName: draft.displayName, + resourceName: displayName, description: accountChanged - ? `Reconnected OAuth credential "${draft.displayName}" to a new account` - : `Reconnected OAuth credential "${draft.displayName}"`, + ? `Reconnected OAuth credential "${displayName}" to a new account` + : `Reconnected OAuth credential "${displayName}"`, metadata: { oldAccountId, newAccountId }, }) diff --git a/apps/sim/lib/credentials/draft-processor.test.ts b/apps/sim/lib/credentials/draft-processor.test.ts new file mode 100644 index 00000000000..2cf5c13014d --- /dev/null +++ b/apps/sim/lib/credentials/draft-processor.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { + dbChainMockFns, + drizzleOrmMock, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockHandleCreateCredentialFromDraft, mockHandleReconnectCredential } = vi.hoisted(() => ({ + mockHandleCreateCredentialFromDraft: vi.fn(), + mockHandleReconnectCredential: vi.fn(), +})) + +vi.mock('@/lib/credentials/draft-hooks', () => ({ + handleCreateCredentialFromDraft: mockHandleCreateCredentialFromDraft, + handleReconnectCredential: mockHandleReconnectCredential, +})) + +import { + loadOAuthCredentialDraftBinding, + parseCredentialDraftIdFromCallbackUrl, + processCredentialDraft, +} from '@/lib/credentials/draft-processor' + +function credentialDraft(id: string, workspaceId: string) { + return { + id, + userId: 'user-1', + workspaceId, + providerId: 'google-email', + displayName: 'Work Gmail', + description: null, + credentialId: null, + expiresAt: new Date('2026-08-14T18:15:00.000Z'), + createdAt: new Date('2026-08-14T18:00:00.000Z'), + } +} + +describe('processCredentialDraft', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('processes only the exact draft bound to the OAuth state', async () => { + const draft = credentialDraft('draft-2', 'workspace-2') + queueTableRows(schemaMock.pendingCredentialDraft, [draft]) + + await processCredentialDraft({ + draftId: 'draft-2', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + + expect(drizzleOrmMock.eq).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft.id, 'draft-2') + expect(mockHandleCreateCredentialFromDraft).toHaveBeenCalledWith({ + draft, + accountId: 'account-1', + providerId: 'google-email', + userId: 'user-1', + now: expect.any(Date), + }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(schemaMock.pendingCredentialDraft) + }) + + it('fails closed when a legacy callback has multiple active drafts', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, [ + credentialDraft('draft-1', 'workspace-1'), + credentialDraft('draft-2', 'workspace-2'), + ]) + + await expect( + processCredentialDraft({ + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process an ambiguous OAuth credential draft for user user-1 and provider google-email' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + }) + + it('fails when an exact draft is missing or expired', async () => { + queueTableRows(schemaMock.pendingCredentialDraft, []) + + await expect( + processCredentialDraft({ + draftId: 'draft-missing', + userId: 'user-1', + providerId: 'google-email', + accountId: 'account-1', + }) + ).rejects.toThrow( + 'Cannot process missing or expired OAuth credential draft draft-missing for user user-1' + ) + + expect(mockHandleCreateCredentialFromDraft).not.toHaveBeenCalled() + expect(mockHandleReconnectCredential).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) +}) + +describe('parseCredentialDraftIdFromCallbackUrl', () => { + it('extracts the exact draft id from a valid callback URL', () => { + expect( + parseCredentialDraftIdFromCallbackUrl( + 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-1' + ) + ).toBe('draft-1') + }) + + it('fails closed for malformed or non-string callback state', () => { + expect(() => parseCredentialDraftIdFromCallbackUrl({})).toThrow( + 'OAuth state callback URL must be a string' + ) + expect(() => parseCredentialDraftIdFromCallbackUrl('not a URL')).toThrow() + }) +}) + +describe('loadOAuthCredentialDraftBinding', () => { + it('returns the exact draft id when OAuth state is readable', async () => { + await expect( + loadOAuthCredentialDraftBinding(async () => ({ + callbackURL: 'https://sim.test/oauth/credential-connected?credentialDraftId=draft-exact', + })) + ).resolves.toEqual({ status: 'available', draftId: 'draft-exact' }) + }) + + it('marks unreadable OAuth state unavailable instead of permitting legacy draft fallback', async () => { + const stateError = new Error('OAuth state is unavailable') + + await expect( + loadOAuthCredentialDraftBinding(async () => { + throw stateError + }) + ).resolves.toEqual({ status: 'unavailable', error: stateError }) + }) + + it('marks malformed callback state unavailable without throwing from the account hook', async () => { + const binding = await loadOAuthCredentialDraftBinding(async () => ({ callbackURL: null })) + + expect(binding.status).toBe('unavailable') + }) +}) diff --git a/apps/sim/lib/credentials/draft-processor.ts b/apps/sim/lib/credentials/draft-processor.ts index b7b9f5cd931..0b60ccac106 100644 --- a/apps/sim/lib/credentials/draft-processor.ts +++ b/apps/sim/lib/credentials/draft-processor.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import * as schema from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, sql } from 'drizzle-orm' +import { and, desc, eq, sql } from 'drizzle-orm' import { handleCreateCredentialFromDraft, handleReconnectCredential, @@ -9,33 +9,89 @@ import { const logger = createLogger('CredentialDraftProcessor') +export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' + +interface OAuthStateWithCallbackUrl { + callbackURL?: unknown +} + +type OAuthCredentialDraftBinding = + | { status: 'available'; draftId?: string } + | { status: 'unavailable'; error: unknown } + +/** Extracts a draft binding from Better Auth state and rejects malformed callback state. */ +export function parseCredentialDraftIdFromCallbackUrl(callbackUrl: unknown): string | undefined { + if (callbackUrl === undefined) return undefined + if (typeof callbackUrl !== 'string') { + throw new Error('OAuth state callback URL must be a string') + } + return new URL(callbackUrl).searchParams.get(OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM) ?? undefined +} + +/** Reads an exact draft binding without falling back when OAuth state is unavailable. */ +export async function loadOAuthCredentialDraftBinding( + loadOAuthState: () => Promise +): Promise { + try { + const oauthState = await loadOAuthState() + return { + status: 'available', + draftId: parseCredentialDraftIdFromCallbackUrl(oauthState?.callbackURL), + } + } catch (error) { + return { status: 'unavailable', error } + } +} + interface ProcessCredentialDraftParams { + draftId?: string userId: string providerId: string accountId: string } /** - * Looks up a pending credential draft for the given user/provider and processes it. + * Looks up a pending credential draft and processes it. + * Draft-backed OAuth launches pass the exact id. Legacy callers without one are + * accepted only when the user/provider pair has a single active draft. * Creates a new credential or reconnects an existing one depending on the draft state. * Used by Better Auth's `account.create.after` hook and custom OAuth flows (Shopify, Trello). */ export async function processCredentialDraft(params: ProcessCredentialDraftParams): Promise { - const { userId, providerId, accountId } = params + const { draftId, userId, providerId, accountId } = params + + const predicates = [ + eq(schema.pendingCredentialDraft.userId, userId), + eq(schema.pendingCredentialDraft.providerId, providerId), + sql`${schema.pendingCredentialDraft.expiresAt} > NOW()`, + ] + if (draftId) { + predicates.push(eq(schema.pendingCredentialDraft.id, draftId)) + } - const [draft] = await db + const drafts = await db .select() .from(schema.pendingCredentialDraft) - .where( - and( - eq(schema.pendingCredentialDraft.userId, userId), - eq(schema.pendingCredentialDraft.providerId, providerId), - sql`${schema.pendingCredentialDraft.expiresAt} > NOW()` - ) + .where(and(...predicates)) + .orderBy(desc(schema.pendingCredentialDraft.createdAt)) + .limit(draftId ? 1 : 2) + + if (!draftId && drafts.length > 1) { + throw new Error( + `Cannot process an ambiguous OAuth credential draft for user ${userId} and provider ${providerId}` ) - .limit(1) + } + + const [draft] = drafts - if (!draft) return + if (!draft) { + if (draftId) { + throw new Error( + `Cannot process missing or expired OAuth credential draft ${draftId} for user ${userId}` + ) + } + return + } const now = new Date() diff --git a/apps/sim/lib/credentials/members.test.ts b/apps/sim/lib/credentials/members.test.ts new file mode 100644 index 00000000000..fae51df0525 --- /dev/null +++ b/apps/sim/lib/credentials/members.test.ts @@ -0,0 +1,20 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it } from 'vitest' +import { listCredentialMembershipsForUser } from '@/lib/credentials/members' + +describe('listCredentialMembershipsForUser', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it('excludes managed OAuth credentials from ordinary memberships', async () => { + dbChainMockFns.where.mockResolvedValue([]) + + await listCredentialMembershipsForUser('user-1') + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) +}) diff --git a/apps/sim/lib/credentials/members.ts b/apps/sim/lib/credentials/members.ts new file mode 100644 index 00000000000..7c3f68047d8 --- /dev/null +++ b/apps/sim/lib/credentials/members.ts @@ -0,0 +1,266 @@ +import { db } from '@sim/db' +import { credential, credentialMember, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, ne } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { isSharedCredentialType, requireOrdinaryCredentialType } from '@/lib/credentials/access' +import type { CredentialRow } from '@/lib/credentials/queries' +import { + getUserEntityPermissions, + getUsersWithPermissions, +} from '@/lib/workspaces/permissions/utils' + +export interface CredentialMemberView { + id: string + userId: string + role: 'admin' | 'member' + status: 'active' | 'pending' | 'revoked' + joinedAt: Date | null + userName: string | null + userEmail: string | null + roleSource: 'explicit' | 'workspace-admin' +} + +export async function listCredentialMembers( + credential: CredentialRow +): Promise { + const explicitMembers = await db + .select({ + id: credentialMember.id, + userId: credentialMember.userId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + userName: user.name, + userEmail: user.email, + }) + .from(credentialMember) + .innerJoin(user, eq(credentialMember.userId, user.id)) + .where(eq(credentialMember.credentialId, credential.id)) + + const byUser = new Map( + explicitMembers.map((member) => [member.userId, { ...member, roleSource: 'explicit' as const }]) + ) + + if (isSharedCredentialType(credential.type)) { + const workspaceMembers = await getUsersWithPermissions(credential.workspaceId) + for (const workspaceMember of workspaceMembers) { + if (workspaceMember.permissionType !== 'admin') continue + const existing = byUser.get(workspaceMember.userId) + if (existing) { + existing.role = 'admin' + existing.status = 'active' + existing.roleSource = 'workspace-admin' + } else { + byUser.set(workspaceMember.userId, { + id: `workspace-admin-${workspaceMember.userId}`, + userId: workspaceMember.userId, + role: 'admin', + status: 'active', + joinedAt: null, + userName: workspaceMember.name, + userEmail: workspaceMember.email, + roleSource: 'workspace-admin', + }) + } + } + } + + return Array.from(byUser.values()) +} + +export interface UpsertCredentialMemberParams { + credential: CredentialRow + actorUserId: string + targetUserId: string + role: 'admin' | 'member' +} + +export interface UpsertCredentialMemberResult { + created: boolean + previousRole?: 'admin' | 'member' +} + +export async function upsertCredentialMember( + params: UpsertCredentialMemberParams +): Promise { + if (!isSharedCredentialType(params.credential.type)) { + throw new OrchestrationError('validation', 'Personal secrets cannot be shared') + } + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === null) { + throw new OrchestrationError( + 'validation', + 'Target user must belong to the credential workspace' + ) + } + if (targetWorkspacePermission === 'admin' && params.role !== 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be demoted' + ) + } + + const [existing] = await db + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId) + ) + ) + .limit(1) + const now = new Date() + if (existing) { + const previousRole = await db.transaction(async (tx) => { + const [current] = await tx + .select({ role: credentialMember.role }) + .from(credentialMember) + .where(eq(credentialMember.id, existing.id)) + .limit(1) + .for('update') + if (!current) throw new Error('Credential membership disappeared during update') + await tx + .update(credentialMember) + .set({ role: params.role, status: 'active', updatedAt: now }) + .where(eq(credentialMember.id, existing.id)) + return current.role + }) + return { created: false, previousRole } + } + + await db.insert(credentialMember).values({ + id: generateId(), + credentialId: params.credential.id, + userId: params.targetUserId, + role: params.role, + status: 'active', + joinedAt: now, + invitedBy: params.actorUserId, + createdAt: now, + updatedAt: now, + }) + return { created: true } +} + +export async function removeCredentialMember(params: { + credential: CredentialRow + targetUserId: string +}): Promise { + const [target] = await db + .select({ id: credentialMember.id, role: credentialMember.role }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.userId, params.targetUserId), + eq(credentialMember.status, 'active') + ) + ) + .limit(1) + if (!target) throw new OrchestrationError('not_found', 'Member not found') + + if (isSharedCredentialType(params.credential.type)) { + const targetWorkspacePermission = await getUserEntityPermissions( + params.targetUserId, + 'workspace', + params.credential.workspaceId + ) + if (targetWorkspacePermission === 'admin') { + throw new OrchestrationError( + 'validation', + 'Workspace admins are automatically credential admins and cannot be removed' + ) + } + } + + const revoked = await db.transaction(async (tx) => { + if (!isSharedCredentialType(params.credential.type) && target.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credential.id), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, target.id)) + return true + }) + if (!revoked) throw new OrchestrationError('validation', 'Cannot remove the last admin') +} + +export async function listCredentialMembershipsForUser(userId: string) { + const rows = await db + .select({ + membershipId: credentialMember.id, + credentialId: credential.id, + workspaceId: credential.workspaceId, + type: credential.type, + displayName: credential.displayName, + providerId: credential.providerId, + role: credentialMember.role, + status: credentialMember.status, + joinedAt: credentialMember.joinedAt, + }) + .from(credentialMember) + .innerJoin(credential, eq(credentialMember.credentialId, credential.id)) + .where(and(eq(credentialMember.userId, userId), ne(credential.type, 'managed_oauth'))) + return rows.map((row) => ({ ...row, type: requireOrdinaryCredentialType(row.type) })) +} + +export async function leaveCredentialMembership(params: { + userId: string + credentialId: string +}): Promise { + const [membership] = await db + .select() + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.userId, params.userId) + ) + ) + .limit(1) + if (!membership) throw new OrchestrationError('not_found', 'Membership not found') + if (membership.status !== 'active') return + + const revoked = await db.transaction(async (tx) => { + if (membership.role === 'admin') { + const activeAdmins = await tx + .select({ id: credentialMember.id }) + .from(credentialMember) + .where( + and( + eq(credentialMember.credentialId, params.credentialId), + eq(credentialMember.role, 'admin'), + eq(credentialMember.status, 'active') + ) + ) + .for('update') + if (activeAdmins.length <= 1) return false + } + await tx + .update(credentialMember) + .set({ status: 'revoked', updatedAt: new Date() }) + .where(eq(credentialMember.id, membership.id)) + return true + }) + if (!revoked) { + throw new OrchestrationError('validation', 'Cannot leave credential as the last active admin') + } +} diff --git a/apps/sim/lib/credentials/oauth-accounts.ts b/apps/sim/lib/credentials/oauth-accounts.ts new file mode 100644 index 00000000000..705d926d5bc --- /dev/null +++ b/apps/sim/lib/credentials/oauth-accounts.ts @@ -0,0 +1,152 @@ +import { db } from '@sim/db' +import { account, credential, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { and, desc, eq, inArray, like, or } from 'drizzle-orm' +import { decodeJwt } from 'jose' +import type { OAuthConnection } from '@/lib/api/contracts/oauth-connections' +import { deleteCredentialRecord } from '@/lib/credentials/orchestration' +import type { OAuthProvider } from '@/lib/oauth' +import { parseProvider } from '@/lib/oauth' +import { providerIdsForService } from '@/lib/oauth/utils' + +const logger = createLogger('CredentialOAuthAccounts') + +interface GoogleIdToken { + email?: string + name?: string +} + +export async function listOAuthConnectionsForUser(userId: string): Promise { + const [accounts, userRecord] = await Promise.all([ + db.select().from(account).where(eq(account.userId, userId)), + db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1), + ]) + const userEmail = userRecord[0]?.email ?? null + const connections: OAuthConnection[] = [] + + for (const accountRow of accounts) { + const { baseProvider, featureType } = parseProvider(accountRow.providerId as OAuthProvider) + if (!baseProvider) continue + const scopes = accountRow.scope?.split(/\s+/).filter(Boolean) ?? [] + let displayName = '' + if (accountRow.idToken) { + try { + const decoded = decodeJwt(accountRow.idToken) + displayName = decoded.email || decoded.name || '' + } catch (error) { + logger.warn('Failed to decode OAuth account ID token', { accountId: accountRow.id, error }) + } + } + if (!displayName && baseProvider === 'github') { + displayName = `${accountRow.accountId} (GitHub)` + } + displayName ||= userEmail || `${accountRow.accountId} (${baseProvider})` + + const existing = connections.find((connection) => connection.provider === accountRow.providerId) + if (existing) { + existing.accounts.push({ id: accountRow.id, name: displayName }) + existing.scopes = Array.from(new Set([...existing.scopes, ...scopes])) + if (accountRow.updatedAt.getTime() > new Date(existing.lastConnected).getTime()) { + existing.lastConnected = accountRow.updatedAt.toISOString() + } + continue + } + connections.push({ + provider: accountRow.providerId, + baseProvider, + featureType, + isConnected: true, + scopes, + lastConnected: accountRow.updatedAt.toISOString(), + accounts: [{ id: accountRow.id, name: displayName }], + }) + } + + return connections +} + +export async function listConnectedAccountsForUser(params: { userId: string; provider?: string }) { + const whereConditions = [eq(account.userId, params.userId)] + if (params.provider) whereConditions.push(eq(account.providerId, params.provider)) + const rows = await db + .select({ + id: account.id, + accountId: account.accountId, + providerId: account.providerId, + credentialDisplayName: credential.displayName, + }) + .from(account) + .leftJoin(credential, eq(credential.accountId, account.id)) + .where(and(...whereConditions)) + .orderBy(desc(account.updatedAt)) + + const seen = new Map() + for (const row of rows) { + if (!seen.has(row.id)) seen.set(row.id, row) + } + return Array.from(seen.values()).map((row) => ({ + id: row.id, + accountId: row.accountId, + providerId: row.providerId, + displayName: row.credentialDisplayName || row.accountId || row.providerId, + })) +} + +export interface DisconnectOAuthAccountsParams { + userId: string + provider: string + providerId?: string + accountId?: string +} + +export class OAuthDisconnectPartialFailureError extends Error { + constructor( + readonly credentials: Array, + cause: unknown + ) { + const error = toError(cause) + super(error.message, { cause: error }) + this.name = 'OAuthDisconnectPartialFailureError' + } +} + +export async function disconnectOAuthAccounts(params: DisconnectOAuthAccountsParams) { + const accountFilter = params.accountId + ? and(eq(account.userId, params.userId), eq(account.id, params.accountId)) + : params.providerId + ? and(eq(account.userId, params.userId), eq(account.providerId, params.providerId)) + : and( + eq(account.userId, params.userId), + or( + inArray(account.providerId, providerIdsForService(params.provider)), + like(account.providerId, `${params.provider}-%`) + ) + ) + const targetAccounts = await db.select({ id: account.id }).from(account).where(accountFilter) + const targetAccountIds = targetAccounts.map((row) => row.id) + if (targetAccountIds.length === 0) return { credentials: [] } + + const credentialRows = await db + .select() + .from(credential) + .where(inArray(credential.accountId, targetAccountIds)) + const deletedCredentials: typeof credentialRows = [] + try { + for (const credentialRow of credentialRows) { + if (credentialRow.type !== 'oauth') { + throw new Error(`OAuth account ${credentialRow.accountId} owns a non-OAuth credential`) + } + const deleted = await deleteCredentialRecord({ + credential: credentialRow, + reason: 'oauth_disconnect', + }) + if (deleted) deletedCredentials.push(credentialRow) + } + await db.delete(account).where(inArray(account.id, targetAccountIds)) + } catch (error) { + if (deletedCredentials.length === 0) throw error + throw new OAuthDisconnectPartialFailureError(deletedCredentials, error) + } + return { credentials: deletedCredentials } +} diff --git a/apps/sim/lib/credentials/orchestration/credential-create.ts b/apps/sim/lib/credentials/orchestration/credential-create.ts index 6120e5bec76..f795e1846e3 100644 --- a/apps/sim/lib/credentials/orchestration/credential-create.ts +++ b/apps/sim/lib/credentials/orchestration/credential-create.ts @@ -2,12 +2,14 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { db } from '@sim/db' import { account, credential, credentialMember } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import { normalizeCredentialEnvKey } from '@/lib/api/contracts/credentials' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { decryptSecret } from '@/lib/core/security/encryption' import { getCredentialActorContext } from '@/lib/credentials/access' import { AtlassianValidationError } from '@/lib/credentials/atlassian-service-account' import { getCredentialCreationWorkspaceContext } from '@/lib/credentials/environment' @@ -82,7 +84,7 @@ export interface PerformCreateCredentialParams { * secrets exist, so the id must be known up front. */ id?: string - request?: NextRequest + request?: OrchestrationRequestContext } export interface PerformCreateCredentialResult { @@ -96,6 +98,8 @@ export interface PerformCreateCredentialResult { credential?: CredentialRow /** False when an existing credential matched the source and was returned instead. */ created?: boolean + /** Verified provider identity metadata for the application audit projection. */ + auditMetadata?: Record } interface ExistingCredentialSourceParams { @@ -183,6 +187,18 @@ async function findExistingCredentialBySourceWith( return null } +async function serviceAccountSecretsMatch( + existingEncryptedSecret: string | null, + submittedEncryptedSecret: string | null +): Promise { + if (!existingEncryptedSecret || !submittedEncryptedSecret) return false + const [existing, submitted] = await Promise.all([ + decryptSecret(existingEncryptedSecret), + decryptSecret(submittedEncryptedSecret), + ]) + return safeCompare(existing.decrypted, submitted.decrypted) +} + function failure( error: string, errorCode: CredentialOrchestrationErrorCode, @@ -191,14 +207,17 @@ function failure( return { success: false, error, errorCode, ...extra } } -export async function performCreateCredential( - params: PerformCreateCredentialParams +export async function createCredentialRecord( + params: PerformCreateCredentialParams, + options: { authorizeWorkspace: boolean } ): Promise { const { workspaceId, type, userId } = params try { - const workspaceAccess = await checkWorkspaceAccess(workspaceId, userId) - if (!workspaceAccess.canWrite) { + const workspaceAccess = options.authorizeWorkspace + ? await checkWorkspaceAccess(workspaceId, userId) + : undefined + if (workspaceAccess && !workspaceAccess.canWrite) { return failure('Write permission required', 'forbidden') } @@ -320,12 +339,11 @@ export async function performCreateCredential( ) } - /** - * Token service-account creates always carry a fresh token that must be - * stored — falling through to the existing-credential path would return - * the old credential as success and silently drop the submitted token. - */ - if (resolvedProviderId && isTokenServiceAccountProviderId(resolvedProviderId)) { + if ( + type === 'service_account' && + resolvedProviderId && + isTokenServiceAccountProviderId(resolvedProviderId) + ) { return failure( `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, 'conflict', @@ -334,13 +352,34 @@ export async function performCreateCredential( } const access = await getCredentialActorContext(existingCredential.id, userId, { - workspaceAccess, + ...(workspaceAccess ? { workspaceAccess } : {}), }) if (!access.member && !access.isAdmin) { return failure('A credential with this source already exists in this workspace', 'conflict') } + /** + * Non-token service accounts may replay only the exact stored secret. A + * source match with rotated secret material must not report success while + * silently retaining the old ciphertext. Compare only after credential + * access is established so the encrypted value stays behind its resource + * authorization boundary. + */ + if ( + type === 'service_account' && + !(await serviceAccountSecretsMatch( + existingCredential.encryptedServiceAccountKey, + resolvedEncryptedServiceAccountKey + )) + ) { + return failure( + `A credential named "${resolvedDisplayName}" already exists in this workspace. Give this one a different name.`, + 'conflict', + { providerErrorCode: 'duplicate_display_name' } + ) + } + const shouldUpdateDisplayName = type === 'oauth' && resolvedDisplayName && @@ -486,37 +525,7 @@ export async function performCreateCredential( .where(eq(credential.id, credentialId)) .limit(1) - captureServerEvent( - userId, - 'credential_connected', - { credential_type: type, provider_id: resolvedProviderId ?? type, workspace_id: workspaceId }, - { - groups: { workspace: workspaceId }, - setOnce: { first_credential_connected_at: new Date().toISOString() }, - } - ) - - recordAudit({ - workspaceId, - actorId: userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_CREATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: credentialId, - resourceName: resolvedDisplayName, - description: `Created ${type} credential "${resolvedDisplayName}"`, - metadata: { - // Provider metadata spreads first so this path's own keys stay - // authoritative and can never be shadowed, matching the update path. - ...extraAuditMetadata, - credentialType: type, - providerId: resolvedProviderId, - }, - request: params.request, - }) - - return { success: true, credential: created, created: true } + return { success: true, credential: created, created: true, auditMetadata: extraAuditMetadata } } catch (error: unknown) { if (error instanceof AtlassianValidationError) { logger.warn(`Atlassian credential rejected: ${error.code}`, { @@ -572,6 +581,64 @@ export async function performCreateCredential( } } +export type CreateServiceAccountCredentialParams = Omit< + PerformCreateCredentialParams, + 'type' | 'actorName' | 'actorEmail' +> & { providerId: string } + +/** Creates and verifies one service-account credential without surface side effects. */ +export function createServiceAccountCredential( + params: CreateServiceAccountCredentialParams +): Promise { + return createCredentialRecord( + { ...params, type: 'service_account' }, + { authorizeWorkspace: false } + ) +} + +/** Preserves the legacy internal surface's analytics and audit behavior. */ +export async function performCreateCredential( + params: PerformCreateCredentialParams +): Promise { + const result = await createCredentialRecord(params, { authorizeWorkspace: true }) + if (!result.success || !result.created) return result + if (!result.credential) throw new Error('Credential creation succeeded without a credential') + + captureServerEvent( + params.userId, + 'credential_connected', + { + credential_type: result.credential.type, + provider_id: result.credential.providerId ?? result.credential.type, + workspace_id: result.credential.workspaceId, + }, + { + groups: { workspace: result.credential.workspaceId }, + setOnce: { first_credential_connected_at: new Date().toISOString() }, + } + ) + + recordAudit({ + workspaceId: result.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: result.credential.id, + resourceName: result.credential.displayName, + description: `Created ${result.credential.type} credential "${result.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: result.credential.type, + providerId: result.credential.providerId, + }, + request: params.request, + }) + + return result +} + /** * Provider error codes that mean the upstream service could not be reached, * rather than that the caller's secret was rejected. Each provider family names diff --git a/apps/sim/lib/credentials/orchestration/index.test.ts b/apps/sim/lib/credentials/orchestration/index.test.ts index 65da20e8275..8ea6c8954b8 100644 --- a/apps/sim/lib/credentials/orchestration/index.test.ts +++ b/apps/sim/lib/credentials/orchestration/index.test.ts @@ -17,6 +17,7 @@ const { mockVerifyAndBuildServiceAccountSecret, mockIsClientCredentialAccountProviderId, mockGetClientCredentialAccountDescriptor, + mockDeleteConnectionCredential, } = vi.hoisted(() => ({ mockRecordAudit: vi.fn(), mockGetCredentialActorContext: vi.fn(), @@ -26,6 +27,7 @@ const { // Only a descriptor carrying `defaultAuthMethod` is multi-grant; single-grant // providers must not trigger the stored-blob read for authMethod/username. mockGetClientCredentialAccountDescriptor: vi.fn(() => undefined), + mockDeleteConnectionCredential: vi.fn(), })) vi.mock('@sim/audit', () => ({ @@ -47,7 +49,9 @@ vi.mock('@/lib/credentials/client-credential-accounts/descriptors', () => ({ isClientCredentialAccountProviderId: mockIsClientCredentialAccountProviderId, getClientCredentialAccountDescriptor: mockGetClientCredentialAccountDescriptor, })) -vi.mock('@/lib/credentials/deletion', () => ({ deleteCredential: vi.fn() })) +vi.mock('@/lib/credentials/deletion', () => ({ + deleteConnectionCredential: mockDeleteConnectionCredential, +})) vi.mock('@/lib/credentials/environment', () => ({ deleteWorkspaceEnvCredentials: vi.fn(), syncPersonalEnvCredentialsForUser: vi.fn(), @@ -60,7 +64,11 @@ vi.mock('@/lib/credentials/token-service-accounts/errors', () => ({ })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) -import { performUpdateCredential } from '@/lib/credentials/orchestration' +import { + createServiceAccountCredential, + deleteCredentialRecord, + performUpdateCredential, +} from '@/lib/credentials/orchestration' const OLD_EMAIL = 'old-sa@old-project.iam.gserviceaccount.com' const NEW_EMAIL = 'new-sa@new-project.iam.gserviceaccount.com' @@ -415,4 +423,131 @@ describe('performUpdateCredential — service-account secret rotation', () => { expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockRecordAudit).not.toHaveBeenCalled() }) + + it('conceals managed OAuth credentials from the ordinary update path', async () => { + mockCredential({ type: 'managed_oauth' }) + + const result = await performUpdateCredential({ + credentialId: 'cred-1', + userId: 'user-1', + description: 'should not update', + }) + + expect(result).toMatchObject({ success: false, errorCode: 'not_found' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) + +describe('createServiceAccountCredential', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('rejects an existing service-account source instead of discarding the submitted secret', async () => { + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'new-cipher', + displayName: 'Production Zoom', + auditMetadata: {}, + principal: { kind: 'tenant', id: 'account-1' }, + }) + queueTableRows(schemaMock.credential, [ + { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + encryptedServiceAccountKey: 'old-cipher', + }, + ]) + mockDecryptSecret + .mockResolvedValueOnce({ decrypted: 'stored-secret' }) + .mockResolvedValueOnce({ decrypted: 'rotated-secret' }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client-id', + clientSecret: 'rotated-client-secret', + orgId: 'account-1', + }) + + expect(result).toMatchObject({ + success: false, + errorCode: 'conflict', + providerErrorCode: 'duplicate_display_name', + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(mockGetCredentialActorContext).toHaveBeenCalledWith('credential-1', 'user-1', {}) + }) + + it('returns an accessible credential for an exact non-token secret replay', async () => { + const existingCredential = { + id: 'credential-1', + workspaceId: 'workspace-1', + type: 'service_account', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + encryptedServiceAccountKey: 'stored-cipher', + } + mockVerifyAndBuildServiceAccountSecret.mockResolvedValue({ + providerId: 'zoom-service-account', + encryptedServiceAccountKey: 'replay-cipher', + displayName: 'Production Zoom', + auditMetadata: {}, + principal: { kind: 'tenant', id: 'account-1' }, + }) + queueTableRows(schemaMock.credential, [existingCredential]) + mockDecryptSecret.mockResolvedValue({ decrypted: 'same-secret' }) + mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true }) + + const result = await createServiceAccountCredential({ + workspaceId: 'workspace-1', + userId: 'user-1', + providerId: 'zoom-service-account', + displayName: 'Production Zoom', + clientId: 'client-id', + clientSecret: 'client-secret', + orgId: 'account-1', + }) + + expect(result).toMatchObject({ + success: true, + credential: existingCredential, + created: false, + }) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) + +describe('deleteCredentialRecord', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('rejects deleting a custom Slack bot used by an active Credential Group', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([{ id: 'group-1' }]) + + await expect( + deleteCredentialRecord({ + credential: { + id: 'cred-1', + workspaceId: 'ws-1', + type: 'service_account', + providerId: 'slack-custom-bot', + } as never, + reason: 'user_delete', + }) + ).rejects.toMatchObject({ + code: 'conflict', + message: 'Remove this custom Slack bot from its Credential Groups before deleting it.', + }) + expect(mockDeleteConnectionCredential).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/credentials/orchestration/index.ts b/apps/sim/lib/credentials/orchestration/index.ts index ff34fd0a244..84fd54888fb 100644 --- a/apps/sim/lib/credentials/orchestration/index.ts +++ b/apps/sim/lib/credentials/orchestration/index.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, sql } from 'drizzle-orm' import type { NextRequest } from 'next/server' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { decryptSecret } from '@/lib/core/security/encryption' import { listSlackCredentialGroupConfigurationsForBot } from '@/lib/credential-groups/provider-configuration' import { @@ -23,7 +24,7 @@ import { getClientCredentialAccountDescriptor, isClientCredentialAccountProviderId, } from '@/lib/credentials/client-credential-accounts/descriptors' -import { type CredentialDeleteReason, deleteCredential } from '@/lib/credentials/deletion' +import { type CredentialDeleteReason, deleteConnectionCredential } from '@/lib/credentials/deletion' import { slackCustomBotDisplayName } from '@/lib/credentials/display-name' import { deleteWorkspaceEnvCredentials, @@ -42,8 +43,13 @@ import { import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('CredentialOrchestration') +type CredentialRow = typeof credential.$inferSelect +export { deleteConnectionCredential } from '@/lib/credentials/deletion' export { + type CreateServiceAccountCredentialParams, + createCredentialRecord, + createServiceAccountCredential, isProviderOutageCode, type PerformCreateCredentialParams, type PerformCreateCredentialResult, @@ -170,41 +176,26 @@ export interface PerformCredentialResult { workspaceId?: string updatedFields?: string[] previousDisplayName?: string + auditMetadata?: Record } -export async function performUpdateCredential( - params: PerformUpdateCredentialParams +export type UpdateCredentialRecordParams = Omit< + PerformUpdateCredentialParams, + 'userId' | 'actorName' | 'actorEmail' | 'allowedTypes' | 'reason' | 'request' +> & { credential: CredentialRow } + +/** Updates one already-authorized credential without surface authorization or audit. */ +export async function updateCredentialRecord( + params: UpdateCredentialRecordParams ): Promise { try { - const access = await getCredentialActorContext(params.credentialId, params.userId) - if (!access.credential) { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (access.credential.type === 'managed_oauth') { - return { success: false, error: 'Credential not found', errorCode: 'not_found' } - } - if (!access.hasWorkspaceAccess || !access.isAdmin) { - return { - success: false, - error: 'Credential admin permission required', - errorCode: 'forbidden', - } - } - if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { - return { - success: false, - error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, - errorCode: 'validation', - } - } - const updates: Record = {} if (params.description !== undefined) { updates.description = params.description ?? null } if ( params.displayName !== undefined && - (access.credential.type === 'oauth' || access.credential.type === 'service_account') + (params.credential.type === 'oauth' || params.credential.type === 'service_account') ) { updates.displayName = params.displayName } @@ -229,8 +220,8 @@ export async function performUpdateCredential( params.username !== undefined let rotatedSlackBotUserId: string | undefined let rotatedAuditMetadata: Record | undefined - if (hasRotationSecret && access.credential.type === 'service_account') { - const providerId = access.credential.providerId ?? '' + if (hasRotationSecret && params.credential.type === 'service_account') { + const providerId = params.credential.providerId ?? '' // A reconnect rebuilds the secret blob from the submitted fields only, and // the modal never prefills (secrets are never echoed back). For an actual @@ -260,15 +251,15 @@ export async function performUpdateCredential( // One read + decrypt at most, and only for the providers that can use it. const storedBlob = needsStoredDataCenter || needsStoredAuthMethod || needsStoredUsername || needsStoredIdentity - ? await readStoredSecretBlob(access.credential.id) + ? await readStoredSecretBlob(params.credential.id) : null try { const slackConfigurations = providerId === SLACK_CUSTOM_BOT_PROVIDER_ID ? await listSlackCredentialGroupConfigurationsForBot({ - workspaceId: access.credential.workspaceId, - slackBotCredentialId: access.credential.id, + workspaceId: params.credential.workspaceId, + slackBotCredentialId: params.credential.id, }) : [] if (slackConfigurations.length > 0) { @@ -326,7 +317,7 @@ export async function performUpdateCredential( const previousIdentity = deriveStoredDisplayName(storedBlob) if ( previousIdentity !== undefined && - previousIdentity === access.credential.displayName && + previousIdentity === params.credential.displayName && secret.displayName && secret.displayName !== previousIdentity ) { @@ -360,7 +351,7 @@ export async function performUpdateCredential( } if (Object.keys(updates).length === 0) { - if (access.credential.type === 'oauth' || access.credential.type === 'service_account') { + if (params.credential.type === 'oauth' || params.credential.type === 'service_account') { return { success: false, error: 'No updatable fields provided.', errorCode: 'validation' } } return { @@ -389,31 +380,12 @@ export async function performUpdateCredential( } const updatedFields = auditUpdatedFields(updates) - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_UPDATED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, - // Provider metadata first: the orchestration's own keys stay authoritative - // and can never be shadowed by a builder's audit payload. - metadata: { - ...rotatedAuditMetadata, - credentialType: access.credential.type, - updatedFields, - }, - request: params.request, - }) - return { success: true, - workspaceId: access.credential.workspaceId, + workspaceId: params.credential.workspaceId, updatedFields, - previousDisplayName: access.credential.displayName, + previousDisplayName: params.credential.displayName, + auditMetadata: rotatedAuditMetadata, } } catch (error) { if (error instanceof Error && error.message.includes('unique')) { @@ -428,6 +400,168 @@ export async function performUpdateCredential( } } +/** Preserves the legacy callers while application adapters migrate to the manager above. */ +export async function performUpdateCredential( + params: PerformUpdateCredentialParams +): Promise { + const access = await getCredentialActorContext(params.credentialId, params.userId) + if (!access.credential) { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (access.credential.type === 'managed_oauth') { + return { success: false, error: 'Credential not found', errorCode: 'not_found' } + } + if (!access.hasWorkspaceAccess || !access.isAdmin) { + return { + success: false, + error: 'Credential admin permission required', + errorCode: 'forbidden', + } + } + if (params.allowedTypes && !params.allowedTypes.includes(access.credential.type)) { + return { + success: false, + error: `Only ${params.allowedTypes.join(', ')} credentials can be managed with this tool.`, + errorCode: 'validation', + } + } + + const result = await updateCredentialRecord({ ...params, credential: access.credential }) + if (!result.success) return result + + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + ...result.auditMetadata, + credentialType: access.credential.type, + updatedFields: result.updatedFields, + }, + request: params.request, + }) + + return result +} + +export interface DeleteCredentialRecordParams { + credential: CredentialRow + reason: CredentialDeleteReason +} + +/** Deletes one already-authorized credential and its backing secret source. */ +export async function deleteCredentialRecord( + params: DeleteCredentialRecordParams +): Promise { + const { credential: credentialRow } = params + + if (credentialRow.type === 'managed_oauth') { + throw new OrchestrationError('not_found', 'Credential not found') + } + + if (credentialRow.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { + const [binding] = await db + .select({ id: credentialGroup.id }) + .from(credentialGroup) + .where( + and( + eq(credentialGroup.workspaceId, credentialRow.workspaceId), + sql`EXISTS ( + SELECT 1 + FROM jsonb_array_elements(${credentialGroup.options}) AS option + WHERE option->>'slackBotCredentialId' = ${credentialRow.id} + AND option->>'status' = 'active' + )` + ) + ) + .limit(1) + if (binding) { + throw new OrchestrationError( + 'conflict', + 'Remove this custom Slack bot from its Credential Groups before deleting it.' + ) + } + } + + if (credentialRow.type === 'env_personal') { + if (!credentialRow.envKey || !credentialRow.envOwnerUserId) { + throw new Error('Personal environment credential is missing its source identity') + } + const [personalRow] = await db + .select({ variables: environment.variables }) + .from(environment) + .where(eq(environment.userId, credentialRow.envOwnerUserId)) + .limit(1) + const current = { ...((personalRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(environment) + .values({ + id: credentialRow.envOwnerUserId, + userId: credentialRow.envOwnerUserId, + variables: current, + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [environment.userId], + set: { variables: current, updatedAt: new Date() }, + }) + await syncPersonalEnvCredentialsForUser({ + userId: credentialRow.envOwnerUserId, + envKeys: Object.keys(current), + }) + return true + } + + if (credentialRow.type === 'env_workspace') { + if (!credentialRow.envKey) { + throw new Error('Workspace environment credential is missing its source identity') + } + const [workspaceRow] = await db + .select({ + id: workspaceEnvironment.id, + createdAt: workspaceEnvironment.createdAt, + variables: workspaceEnvironment.variables, + }) + .from(workspaceEnvironment) + .where(eq(workspaceEnvironment.workspaceId, credentialRow.workspaceId)) + .limit(1) + const current = { ...((workspaceRow?.variables as Record | null) ?? {}) } + delete current[credentialRow.envKey] + await db + .insert(workspaceEnvironment) + .values({ + id: workspaceRow?.id ?? generateId(), + workspaceId: credentialRow.workspaceId, + variables: current, + createdAt: workspaceRow?.createdAt ?? new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [workspaceEnvironment.workspaceId], + set: { variables: current, updatedAt: new Date() }, + }) + await deleteWorkspaceEnvCredentials({ + workspaceId: credentialRow.workspaceId, + removedKeys: [credentialRow.envKey], + }) + return true + } + + return deleteConnectionCredential({ + credentialId: credentialRow.id, + workspaceId: credentialRow.workspaceId, + reason: params.reason, + }) +} + +/** Preserves the legacy callers while application adapters migrate to the manager above. */ export async function performDeleteCredential( params: CredentialActorParams ): Promise { @@ -454,176 +588,60 @@ export async function performDeleteCredential( } } - if (access.credential.providerId === SLACK_CUSTOM_BOT_PROVIDER_ID) { - const [binding] = await db - .select({ id: credentialGroup.id }) - .from(credentialGroup) - .where( - and( - eq(credentialGroup.workspaceId, access.credential.workspaceId), - sql`EXISTS ( - SELECT 1 - FROM jsonb_array_elements(${credentialGroup.options}) AS option - WHERE option->>'slackBotCredentialId' = ${access.credential.id} - AND option->>'status' = 'active' - )` - ) - ) - .limit(1) - if (binding) { - return { - success: false, - error: 'Remove this custom Slack bot from its Credential Groups before deleting it.', - errorCode: 'conflict', - } - } - } - - if (access.credential.type === 'env_personal' && access.credential.envKey) { - const ownerUserId = access.credential.envOwnerUserId - if (!ownerUserId) { - return { success: false, error: 'Invalid personal secret owner', errorCode: 'validation' } - } - - const [personalRow] = await db - .select({ variables: environment.variables }) - .from(environment) - .where(eq(environment.userId, ownerUserId)) - .limit(1) - - const current = ((personalRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(environment) - .values({ id: ownerUserId, userId: ownerUserId, variables: current, updatedAt: new Date() }) - .onConflictDoUpdate({ - target: [environment.userId], - set: { variables: current, updatedAt: new Date() }, - }) - - await syncPersonalEnvCredentialsForUser({ - userId: ownerUserId, - envKeys: Object.keys(current), - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_personal', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted personal env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - if (access.credential.type === 'env_workspace' && access.credential.envKey) { - const [workspaceRow] = await db - .select({ - id: workspaceEnvironment.id, - createdAt: workspaceEnvironment.createdAt, - variables: workspaceEnvironment.variables, - }) - .from(workspaceEnvironment) - .where(eq(workspaceEnvironment.workspaceId, access.credential.workspaceId)) - .limit(1) - - const current = ((workspaceRow?.variables as Record | null) ?? {}) as Record< - string, - string - > - if (access.credential.envKey in current) delete current[access.credential.envKey] - - await db - .insert(workspaceEnvironment) - .values({ - id: workspaceRow?.id || generateId(), - workspaceId: access.credential.workspaceId, - variables: current, - createdAt: workspaceRow?.createdAt || new Date(), - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [workspaceEnvironment.workspaceId], - set: { variables: current, updatedAt: new Date() }, - }) - - await deleteWorkspaceEnvCredentials({ - workspaceId: access.credential.workspaceId, - removedKeys: [access.credential.envKey], - }) - - captureServerEvent( - params.userId, - 'credential_deleted', - { - credential_type: 'env_workspace', - provider_id: access.credential.envKey, - workspace_id: access.credential.workspaceId, - }, - { groups: { workspace: access.credential.workspaceId } } - ) - - recordAudit({ - workspaceId: access.credential.workspaceId, - actorId: params.userId, - actorName: params.actorName ?? undefined, - actorEmail: params.actorEmail ?? undefined, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.CREDENTIAL, - resourceId: params.credentialId, - resourceName: access.credential.displayName, - description: `Deleted workspace env credential "${access.credential.envKey}"`, - metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, - request: params.request, - }) - - return { success: true, workspaceId: access.credential.workspaceId } - } - - await deleteCredential({ - credentialId: params.credentialId, - actorId: params.userId, - actorName: params.actorName, - actorEmail: params.actorEmail, - reason: params.reason ?? 'user_delete', - request: params.request, - }) + const reason = params.reason ?? 'user_delete' + await deleteCredentialRecord({ credential: access.credential, reason }) captureServerEvent( params.userId, 'credential_deleted', { - credential_type: access.credential.type as 'oauth' | 'service_account', - provider_id: access.credential.providerId ?? params.credentialId, + credential_type: access.credential.type, + provider_id: + access.credential.providerId ?? access.credential.envKey ?? params.credentialId, workspace_id: access.credential.workspaceId, }, { groups: { workspace: access.credential.workspaceId } } ) + const envDescription = + access.credential.type === 'env_personal' + ? `Deleted personal env credential "${access.credential.envKey}"` + : access.credential.type === 'env_workspace' + ? `Deleted workspace env credential "${access.credential.envKey}"` + : `Deleted ${access.credential.type} credential "${access.credential.displayName}" (${reason})` + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: params.userId, + actorName: params.actorName ?? undefined, + actorEmail: params.actorEmail ?? undefined, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: params.credentialId, + resourceName: access.credential.displayName, + description: envDescription, + metadata: { + reason, + credentialType: access.credential.type, + providerId: access.credential.providerId, + accountId: access.credential.accountId, + envKey: access.credential.envKey, + }, + request: params.request, + }) + return { success: true, workspaceId: access.credential.workspaceId } } catch (error) { + const orchestrationError = asOrchestrationError(error) + if (orchestrationError) { + if (orchestrationError.code !== 'not_found' && orchestrationError.code !== 'conflict') { + throw orchestrationError + } + return { + success: false, + error: orchestrationError.message, + errorCode: orchestrationError.code, + } + } logger.error('Failed to delete credential', { error }) return { success: false, error: 'Internal server error', errorCode: 'internal' } } diff --git a/apps/sim/lib/credentials/queries.test.ts b/apps/sim/lib/credentials/queries.test.ts index e5ff19c7d1a..efe7459628e 100644 --- a/apps/sim/lib/credentials/queries.test.ts +++ b/apps/sim/lib/credentials/queries.test.ts @@ -4,6 +4,9 @@ import { dbChainMockFns, drizzleOrmMock, resetDbChainMock, schemaMock } from '@sim/testing' import { beforeEach, describe, expect, it } from 'vitest' import { + findWorkspaceCredentialLookup, + getCredentialById, + getWorkspaceCredential, listVisibleWorkspaceCredentials, listWorkspacePrincipalCredentials, } from '@/lib/credentials/queries' @@ -130,3 +133,31 @@ describe('listWorkspacePrincipalCredentials', () => { ).rejects.toBe(failure) }) }) + +describe('ordinary credential lookups', () => { + beforeEach(() => { + resetDbChainMock() + }) + + it.each([ + [ + 'workspace credential', + () => getWorkspaceCredential({ workspaceId: 'workspace-1', credentialId: 'credential-1' }), + ], + ['credential by id', () => getCredentialById('credential-1')], + [ + 'legacy id/account lookup', + () => + findWorkspaceCredentialLookup({ + workspaceId: 'workspace-1', + credentialId: 'credential-1', + }), + ], + ])('excludes managed OAuth from the %s path', async (_name, lookup) => { + dbChainMockFns.limit.mockResolvedValue([]) + + await lookup() + + expect(drizzleOrmMock.ne).toHaveBeenCalledWith(schemaMock.credential.type, 'managed_oauth') + }) +}) diff --git a/apps/sim/lib/credentials/queries.ts b/apps/sim/lib/credentials/queries.ts index 92122ebf37f..4781e70b8f6 100644 --- a/apps/sim/lib/credentials/queries.ts +++ b/apps/sim/lib/credentials/queries.ts @@ -15,7 +15,12 @@ import { textKey, timestampKey, } from '@/lib/api/list-query' -import { isSharedCredentialType, SHARED_CREDENTIAL_TYPES } from '@/lib/credentials/access' +import { + isSharedCredentialType, + type OrdinaryCredentialType, + requireOrdinaryCredentialType, + SHARED_CREDENTIAL_TYPES, +} from '@/lib/credentials/access' import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils' /** @@ -42,6 +47,13 @@ export interface VisibleWorkspaceCredential { role: 'admin' | 'member' } +export interface WorkspaceCredentialLookup { + id: string + displayName: string + type: OrdinaryCredentialType + providerId: string | null +} + const credentialIdKey = textKey(credential.id, (row) => row.id) /** @@ -264,3 +276,75 @@ export async function listWorkspacePrincipalCredentials(params: { return keysetPage(keys, mapped, limit) } +/** + * A single credential scoped to a workspace, or null when it does not exist + * there. Scoping by workspace is what keeps a credential id from another tenant + * from resolving at all. + */ +export async function getWorkspaceCredential(params: { + workspaceId: string + credentialId: string +}): Promise { + const [row] = await db + .select() + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + return row ?? null +} + +/** Preserves the internal route's legacy id-first, account-id-second lookup semantics. */ +export async function findWorkspaceCredentialLookup(params: { + workspaceId: string + credentialId: string +}): Promise { + const projection = { + id: credential.id, + displayName: credential.displayName, + type: credential.type, + providerId: credential.providerId, + } + const [byId] = await db + .select(projection) + .from(credential) + .where( + and( + eq(credential.id, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + if (byId) return { ...byId, type: requireOrdinaryCredentialType(byId.type) } + + const [byAccountId] = await db + .select(projection) + .from(credential) + .where( + and( + eq(credential.accountId, params.credentialId), + eq(credential.workspaceId, params.workspaceId), + ne(credential.type, 'managed_oauth') + ) + ) + .limit(1) + return byAccountId + ? { ...byAccountId, type: requireOrdinaryCredentialType(byAccountId.type) } + : null +} + +/** Canonical credential lookup used before its workspace scope is known. */ +export async function getCredentialById(credentialId: string): Promise { + const [row] = await db + .select() + .from(credential) + .where(and(eq(credential.id, credentialId), ne(credential.type, 'managed_oauth'))) + .limit(1) + return row ?? null +} diff --git a/apps/sim/lib/integrations/credential-visibility.server.ts b/apps/sim/lib/integrations/credential-visibility.server.ts index 8bb9720552d..1837aa67571 100644 --- a/apps/sim/lib/integrations/credential-visibility.server.ts +++ b/apps/sim/lib/integrations/credential-visibility.server.ts @@ -62,16 +62,21 @@ export function createIntegrationCredentialVisibility({ else ownersByProviderId.set(providerId, [service]) } - for (const service of oauthOwners) { - addOwner(oauthOwnersByProviderId, service.providerId, service) - // A second authorization server for the same service (`salesforce-sandbox`) - // issues ordinary OAuth credentials, so they own visibility exactly like - // the primary provider's do. - for (const extraProviderId of service.additionalProviderIds ?? []) { - addOwner(oauthOwnersByProviderId, extraProviderId, service) + for (const service of oauthServices) { + if (service.authType === 'oauth') { + addOwner(oauthOwnersByProviderId, service.providerId, service) + // A second authorization server for the same service (`salesforce-sandbox`) + // issues ordinary OAuth credentials, so they own visibility exactly like + // the primary provider's do. + for (const extraProviderId of service.additionalProviderIds ?? []) { + addOwner(oauthOwnersByProviderId, extraProviderId, service) + } } - if (service.serviceAccountProviderId) { - addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service) + const serviceAccountProviderId = + service.serviceAccountProviderId ?? + (service.authType === 'service_account' ? service.providerId : undefined) + if (serviceAccountProviderId) { + addOwner(serviceAccountOwnersByProviderId, serviceAccountProviderId, service) } } diff --git a/apps/sim/lib/oauth/shopify-state.test.ts b/apps/sim/lib/oauth/shopify-state.test.ts new file mode 100644 index 00000000000..058bfca32dc --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' +import { createShopifyOAuthState, parseShopifyOAuthState } from '@/lib/oauth/shopify-state' + +const CLIENT_SECRET = 'shopify-client-secret' +const USER_ID = 'user-1' +const SHOP_DOMAIN = 'example.myshopify.com' + +function parse( + state: string, + overrides: { userId?: string; shopDomain?: string; now?: Date } = {} +) { + return parseShopifyOAuthState({ + state, + userId: overrides.userId ?? USER_ID, + shopDomain: overrides.shopDomain ?? SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + now: overrides.now, + }) +} + +describe('Shopify OAuth state', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('keeps overlapping connection drafts bound to their own state', () => { + const first = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + clientSecret: CLIENT_SECRET, + }) + const second = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + clientSecret: CLIENT_SECRET, + }) + + expect(parse(first)).toEqual({ + draftId: 'draft-1', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=first', + }) + expect(parse(second)).toEqual({ + draftId: 'draft-2', + returnUrl: 'https://sim.test/oauth/credential-connected?flow=second', + }) + }) + + it('rejects tampered, cross-user, and cross-shop state', () => { + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + draftId: 'draft-1', + clientSecret: CLIENT_SECRET, + }) + const [payload, signature] = state.split('.') + + expect(() => parse(`${payload}x.${signature}`)).toThrow( + 'Shopify OAuth state signature is invalid' + ) + expect(() => parse(state, { userId: 'user-2' })).toThrow( + 'Shopify OAuth state belongs to a different user' + ) + expect(() => parse(state, { shopDomain: 'other.myshopify.com' })).toThrow( + 'Shopify OAuth state belongs to a different shop' + ) + }) + + it('rejects expired state', () => { + const issuedAt = new Date('2026-08-14T18:00:00.000Z') + vi.spyOn(Date, 'now').mockReturnValue(issuedAt.getTime()) + const state = createShopifyOAuthState({ + userId: USER_ID, + shopDomain: SHOP_DOMAIN, + clientSecret: CLIENT_SECRET, + }) + + expect(parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS) })).toEqual( + {} + ) + expect(() => + parse(state, { now: new Date(issuedAt.getTime() + CREDENTIAL_DRAFT_TTL_MS + 1) }) + ).toThrow('Shopify OAuth state is expired') + }) +}) diff --git a/apps/sim/lib/oauth/shopify-state.ts b/apps/sim/lib/oauth/shopify-state.ts new file mode 100644 index 00000000000..75c7f4b5771 --- /dev/null +++ b/apps/sim/lib/oauth/shopify-state.ts @@ -0,0 +1,111 @@ +import { safeCompare } from '@sim/security/compare' +import { hmacSha256Hex } from '@sim/security/hmac' +import { generateId } from '@sim/utils/id' +import { CREDENTIAL_DRAFT_TTL_MS } from '@/lib/credentials/draft-constants' + +const SHOPIFY_OAUTH_STATE_VERSION = 1 + +interface ShopifyOAuthStatePayload { + v: typeof SHOPIFY_OAUTH_STATE_VERSION + nonce: string + userId: string + shopDomain: string + draftId?: string + returnUrl?: string + issuedAt: number +} + +interface CreateShopifyOAuthStateParams { + userId: string + shopDomain: string + draftId?: string + returnUrl?: string + clientSecret: string +} + +interface ParseShopifyOAuthStateParams { + state: string + userId: string + shopDomain: string + clientSecret: string + now?: Date +} + +function isShopifyOAuthStatePayload(value: unknown): value is ShopifyOAuthStatePayload { + if (!value || typeof value !== 'object') return false + const payload = value as Record + return ( + payload.v === SHOPIFY_OAUTH_STATE_VERSION && + typeof payload.nonce === 'string' && + payload.nonce.length > 0 && + typeof payload.userId === 'string' && + payload.userId.length > 0 && + typeof payload.shopDomain === 'string' && + payload.shopDomain.length > 0 && + (payload.draftId === undefined || + (typeof payload.draftId === 'string' && payload.draftId.length > 0)) && + (payload.returnUrl === undefined || + (typeof payload.returnUrl === 'string' && payload.returnUrl.length > 0)) && + typeof payload.issuedAt === 'number' && + Number.isSafeInteger(payload.issuedAt) + ) +} + +/** Creates a signed, user-bound Shopify state token carrying the exact credential draft. */ +export function createShopifyOAuthState(params: CreateShopifyOAuthStateParams): string { + const payload: ShopifyOAuthStatePayload = { + v: SHOPIFY_OAUTH_STATE_VERSION, + nonce: generateId(), + userId: params.userId, + shopDomain: params.shopDomain, + ...(params.draftId ? { draftId: params.draftId } : {}), + ...(params.returnUrl ? { returnUrl: params.returnUrl } : {}), + issuedAt: Date.now(), + } + const encoded = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const signature = hmacSha256Hex(encoded, params.clientSecret) + return `${encoded}.${signature}` +} + +/** Verifies Shopify state integrity, expiry, user ownership, and shop binding. */ +export function parseShopifyOAuthState(params: ParseShopifyOAuthStateParams): { + draftId?: string + returnUrl?: string +} { + const [encoded, signature, extra] = params.state.split('.') + if (!encoded || !signature || extra !== undefined) { + throw new Error('Shopify OAuth state is malformed') + } + + const expectedSignature = hmacSha256Hex(encoded, params.clientSecret) + if (!safeCompare(signature, expectedSignature)) { + throw new Error('Shopify OAuth state signature is invalid') + } + + let decoded: unknown + try { + decoded = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) + } catch { + throw new Error('Shopify OAuth state payload is invalid') + } + if (!isShopifyOAuthStatePayload(decoded)) { + throw new Error('Shopify OAuth state payload is invalid') + } + + if (decoded.userId !== params.userId) { + throw new Error('Shopify OAuth state belongs to a different user') + } + if (decoded.shopDomain !== params.shopDomain) { + throw new Error('Shopify OAuth state belongs to a different shop') + } + + const now = params.now?.getTime() ?? Date.now() + if (decoded.issuedAt > now || now - decoded.issuedAt > CREDENTIAL_DRAFT_TTL_MS) { + throw new Error('Shopify OAuth state is expired') + } + + return { + ...(decoded.draftId ? { draftId: decoded.draftId } : {}), + ...(decoded.returnUrl ? { returnUrl: decoded.returnUrl } : {}), + } +} diff --git a/apps/sim/lib/oauth/shopify.ts b/apps/sim/lib/oauth/shopify.ts new file mode 100644 index 00000000000..29a7299b874 --- /dev/null +++ b/apps/sim/lib/oauth/shopify.ts @@ -0,0 +1,114 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { safeAccountInsert } from '@/lib/oauth/credential-service' +import { SHOPIFY_API_VERSION } from '@/tools/shopify/constants' + +const logger = createLogger('ShopifyOAuth') + +interface CompleteShopifyOAuthConnectionParams { + accessToken: string + shopDomain: string + scope?: string + userId: string + draftId?: string + signal?: AbortSignal +} + +function getShopifyAccountId(value: unknown): string { + if (!value || typeof value !== 'object') { + throw new Error('Shopify shop response must be an object') + } + const shop = (value as { shop?: unknown }).shop + if (!shop || typeof shop !== 'object') { + throw new Error('Shopify shop response is missing shop data') + } + const id = (shop as { id?: unknown }).id + if ((typeof id !== 'string' && typeof id !== 'number') || String(id).length === 0) { + throw new Error('Shopify shop response is missing its account id') + } + return String(id) +} + +/** Persists a verified Shopify account and completes its exact credential draft. */ +export async function completeShopifyOAuthConnection( + params: CompleteShopifyOAuthConnectionParams +): Promise { + const shopResponse = await fetch( + `https://${params.shopDomain}/admin/api/${SHOPIFY_API_VERSION}/shop.json`, + { + headers: { + 'X-Shopify-Access-Token': params.accessToken, + 'Content-Type': 'application/json', + }, + signal: params.signal, + } + ) + + if (!shopResponse.ok) { + const errorText = await shopResponse.text() + throw new Error(`Shopify token validation failed (${shopResponse.status}): ${errorText}`) + } + + const stableAccountId = getShopifyAccountId(await shopResponse.json()) + const existing = await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + }) + + const now = new Date() + const accountData = { + accessToken: params.accessToken, + accountId: stableAccountId, + scope: params.scope ?? '', + updatedAt: now, + idToken: params.shopDomain, + } + + if (existing) { + await db.update(account).set(accountData).where(eq(account.id, existing.id)) + logger.info('Updated existing Shopify account', { accountId: existing.id }) + } else { + await safeAccountInsert( + { + id: generateId(), + userId: params.userId, + providerId: 'shopify', + accountId: accountData.accountId, + accessToken: accountData.accessToken, + scope: accountData.scope, + idToken: accountData.idToken, + createdAt: now, + updatedAt: now, + }, + { provider: 'Shopify', identifier: params.shopDomain } + ) + } + + const persisted = + existing ?? + (await db.query.account.findFirst({ + where: and( + eq(account.userId, params.userId), + eq(account.providerId, 'shopify'), + eq(account.accountId, stableAccountId) + ), + })) + + if (!persisted) { + throw new Error(`Shopify OAuth account ${stableAccountId} was not persisted`) + } + + await processCredentialDraft({ + draftId: params.draftId, + userId: params.userId, + providerId: 'shopify', + accountId: persisted.id, + }) +} diff --git a/findings.txt b/findings.txt new file mode 100644 index 00000000000..92450d24ace --- /dev/null +++ b/findings.txt @@ -0,0 +1,19 @@ +# Behavior change (resolved) + +- [HIGH][RESOLVED] `apps/sim/app/api/auth/shopify/authorize/route.ts:44` introduced an authenticated reflected-XSS path. Inline script values now escape `<` as a Unicode escape, with a regression test using a closing-script payload. + +- [HIGH][RESOLVED] `apps/sim/app/api/credentials/[id]/members/route.ts:24` changed roster authorization and concealment. Listing is workspace-read authorized again, inaccessible credentials are concealed as `404 Not found`, and missing POST/DELETE targets retain the uniform `403 Admin access required` response. + +- [HIGH][RESOLVED] OAuth disconnect deferred audit and analytics until every destructive step finished. A typed partial-failure now carries committed deletions through the application boundary, which records their audit and PostHog effects before rethrowing the original failure. + +- [MEDIUM][RESOLVED] Shopify return destinations were stored in one browser-wide cookie. Each return URL now travels in its own signed, user/shop-bound state token, and overlapping callbacks are tested independently. + +- [MEDIUM][RESOLVED] Reconnects mapped every forbidden operation to credential denial. Only `CREDENTIAL_ADMIN_ACCESS_REQUIRED` now maps to `credential_access_denied`; workspace-role failures map to `workspace_access_denied`. + +- [MEDIUM][RESOLVED] Draft-backed OAuth launch ran outside the browser redirect error boundary. Launch and target resolution now run inside it, so unknown failures redirect to `/workspace?error=oauth_link_failed`. + +- [MEDIUM][RESOLVED] Credential lookup was folded into filtered listing. The application use case now has a dedicated workspace-authorized, ID-first/account-ID-second lookup branch that skips sync and filters and returns exactly `{ credential }`. + +- [MEDIUM][RESOLVED] Environment deletion lost its per-type audit and analytics projection. Personal/workspace descriptions, `envKey` metadata, and the PostHog provider dimension are restored within the shared use case. + +- [LOW][RESOLVED] Credentials and connected-account queries lost legacy normalization. Their shared contracts now restore trimming/blank handling where previously supported and first-value-wins behavior for duplicate query keys. diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index a4239c9c42d..95effcf86d2 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1118, - zodRoutes: 1118, + totalRoutes: 1121, + zodRoutes: 1121, nonZodRoutes: 0, } as const diff --git a/scripts/openapi/documents.test.ts b/scripts/openapi/documents.test.ts index 21ebac6ba87..2fb9c71e064 100644 --- a/scripts/openapi/documents.test.ts +++ b/scripts/openapi/documents.test.ts @@ -37,7 +37,7 @@ const EXPECTED_OPERATION_COUNTS = new Map([ ['apps/docs/openapi-v2-tables.json', 44], ['apps/docs/openapi-v2-knowledge.json', 21], ['apps/docs/openapi-v2-billing.json', 2], - ['apps/docs/openapi-v2-resources.json', 22], + ['apps/docs/openapi-v2-resources.json', 26], ]) function getOperation(spec: JsonObject, path: string, method: string): JsonObject { @@ -169,7 +169,7 @@ describe('generated OpenAPI documents', () => { }) } } - expect(totalOperations).toBe(135) + expect(totalOperations).toBe(139) }) it('documents mixed workflow execution and resume responses', () => {