diff --git a/apps/docs/content/docs/platform/enterprise/meta.json b/apps/docs/content/docs/platform/enterprise/meta.json index 48e14db8d21..8b095acff3a 100644 --- a/apps/docs/content/docs/platform/enterprise/meta.json +++ b/apps/docs/content/docs/platform/enterprise/meta.json @@ -4,6 +4,7 @@ "index", "self-hosted", "sso", + "scim", "verified-domains", "session-policies", "access-control", diff --git a/apps/docs/content/docs/platform/enterprise/scim.mdx b/apps/docs/content/docs/platform/enterprise/scim.mdx new file mode 100644 index 00000000000..c64ee14d26c --- /dev/null +++ b/apps/docs/content/docs/platform/enterprise/scim.mdx @@ -0,0 +1,198 @@ +--- +title: Directory provisioning (SCIM) +description: Create, update, and deactivate Sim members automatically from your identity provider +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { FAQ } from '@/components/ui/faq' + +Directory provisioning connects your identity provider to Sim over SCIM 2.0. Your provider creates members when someone joins, updates them when their details change, and deactivates them the moment they leave — without anyone touching Sim. + +It pairs with [SSO](/platform/enterprise/sso). SSO proves who someone is when they sign in. Directory provisioning decides who exists and what they can reach, before and after that. + + + Included with Enterprise plans. Requires [SSO](/platform/enterprise/sso) to be enabled, because provisioning is configured from the SSO settings page, and at least one [verified domain](/platform/enterprise/verified-domains) for your organization. Self-hosted deployments get it with the other enterprise features through `ENTERPRISE_ENABLED=true`, or turn just this feature on or off with `SCIM_ENABLED` and `NEXT_PUBLIC_SCIM_ENABLED`, alongside the [SSO variables](/platform/enterprise/sso#self-hosted-setup). + + +## What it does + +| Your provider does this | Sim does this | +| --- | --- | +| Assigns a person to the Sim app | Creates their account and adds them to your organization as a Member | +| Updates their name or email | Updates the Sim account, and ends their sessions if the address changed | +| Deactivates them | Blocks sign-in and stops their personal API keys. Everything they own, and every grant they hold, is left untouched; shared workspace keys keep working | +| Reactivates them | Restores access exactly as it was | +| Removes them from the app | Removes their organization membership, ends their sessions, deletes their personal API keys, and reassigns what they owned | +| Adds them to a group | Grants whatever that group maps to | + +Deactivation is reversible and never destructive. Someone on leave keeps their workflows, their credentials, and their workspace history; they simply cannot sign in. + +## Turn it on + + + + +### Verify your domain + +Sim only provisions people whose email is in a domain your organization has verified. See [Verified domains](/platform/enterprise/verified-domains). + +This is what stops another tenant's directory from claiming an address it does not own. + + + +### Enable directory provisioning + +In **Settings → SSO → Directory provisioning**, turn it on. Sim shows your SCIM base URL: + +``` +https:///api/scim/v2 +``` + + + +### Issue a token + +Choose whether the token expires (never, 90 days, or a year) and select **Issue token**. It appears once — copy it straight into your provider. + +Two tokens can be active at a time, so you can rotate without downtime: issue the new one, update your provider, confirm a sync succeeds, then revoke the old one. + + + +### Configure your provider + + + + +In your Okta app, open **Provisioning → Integration** and select **Configure API Integration**. + +- **SCIM connector base URL**: `https:///api/scim/v2` +- **Unique identifier field for users**: `userName` +- **Supported provisioning actions**: Push New Users, Push Profile Updates, Push Groups +- **Authentication Mode**: HTTP Header, with your Sim token + +Select **Test API Credentials**, then save. Under **Provisioning → To App**, enable Create Users, Update User Attributes, and Deactivate Users. + +Okta never deletes users over SCIM. Unassigning someone, or deactivating them in Okta, sends a deactivation — which Sim applies as a suspension. + + + + +In your enterprise application, open **Provisioning** and set Provisioning Mode to **Automatic**. + +- **Tenant URL**: `https:///api/scim/v2` +- **Secret Token**: your Sim token + +Select **Test Connection**, then save and start provisioning. + +Entra runs an initial cycle over everyone in scope, then incremental cycles roughly every 40 minutes. Removing someone from the app sends a deactivation; a permanent delete in Entra sends a removal about 30 days later. + + + + +Add a **SCIM Provisioner with SAML** app. + +- **SCIM Base URL**: `https:///api/scim/v2` +- **SCIM Bearer Token**: your Sim token + +Enable provisioning and choose what happens when a user is removed. Suspend maps to a Sim suspension; Delete removes their membership. + + + + +Add a **Custom SCIM** identity management integration. + +- **Base URL**: `https:///api/scim/v2` +- **Token Key**: your Sim token + +Enable group sync if you plan to map groups. + + + + + + +### Map your groups + +Groups mean nothing to Sim until you say what they stand for. In **Settings → SSO → Directory provisioning → Group mappings**, point each pushed group at one or more of: + +- a **permission group**, which governs models, integrations, and capabilities +- a **workspace**, at Read, Write, or Admin +- the **organization admin role** + +A group can carry several mappings. When two groups grant the same workspace at different levels, the stronger one wins. The organization's default permission group cannot be a target: it governs by having no members. + +Turning on **Match permission groups by name** maps a pushed group to an existing permission group of the same name automatically, and remaps it when the group is renamed. Nothing is created. + +Mapping a permission group to a directory group switches that permission group to explicit membership permanently: it governs exactly the people in it, and an empty group governs nobody. A permission group that governed everyone in its workspaces stops doing so the moment it is mapped, so map groups you created for the directory rather than your organization-wide ones. + + + + + +## How access is withdrawn + +Sim records every grant it makes on your behalf. When someone leaves a group, what the directory granted is taken back. + +**Managed membership locking**, on by default, makes the directory the source of truth for provisioned members: Sim refuses invitations, workspace grants, workspace role changes, and organization role changes for them, because the next sync would revert them anyway. Access a member already held by hand when a mapping started covering it counts as directory access from then on, so it is withdrawn with the mapping. Removals stay possible so an administrator can always act in an emergency. + +With locking off, manual access layers on top of directory access: access granted by hand stays when a group is left, and a workspace role raised by hand above what the directory set is left alone. + +## Provisioning and SSO together + +A member the directory created can sign in with SSO immediately; the two resolve to the same account through your verified domain. + +If you want the directory to be the only way in, enable **Disable just-in-time provisioning** in the connection settings. Sim then refuses to create membership for someone signing in who was never provisioned. + +## Watching a sync + +**Settings → SSO → Directory provisioning → Activity** lists recent authenticated requests with their status and, for a failure, what was wrong. Providers report a failed cycle without saying what they sent, so this is usually the fastest way to see the cause. A request that fails to authenticate has no connection to log against, so a wrong or revoked token shows up only as your provider's own authentication error. + +Sim also re-applies every group mapping once an hour, so drift cannot persist. You can run it on demand with **Reconcile now**, which is also how a change to the connection settings reaches members before the next sync. + +## Reference + +- Base URL: `https:///api/scim/v2` +- Authentication: `Authorization: Bearer ` +- Resources: `/Users`, `/Groups`, plus `/ServiceProviderConfig`, `/ResourceTypes`, and `/Schemas` +- Filters: `eq` only, up to ten terms joined with `and`. Users: `id`, `userName`, `externalId`, `emails.value` (also `emails[type eq "work"].value`), `active`. Groups: `id`, `displayName`, `externalId` +- Limits: 1,500 requests per minute per connection, 1 MB per request, 5,000 members per group +- `userName` is stored and returned lower-cased; top-level attributes and schema extensions Sim does not model (custom attributes included) are kept and returned as sent, and a PUT preserves ones it omits +- Group display names are unique within a connection, ignoring case +- Page size: up to 100 per request + + diff --git a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx index 35b214c1227..1233f0cea70 100644 --- a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx +++ b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx @@ -48,6 +48,7 @@ Three features do not need a flag at all: **custom branding**, **session policie | Organizations | `ORGANIZATIONS_ENABLED` | `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | | Permission groups | `ACCESS_CONTROL_ENABLED` | `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | | SAML and OIDC sign-in | `SSO_ENABLED` | `NEXT_PUBLIC_SSO_ENABLED` | +| Directory provisioning (SCIM) | `SCIM_ENABLED` | `NEXT_PUBLIC_SCIM_ENABLED` | | Custom branding — on by default | `WHITELABELING_ENABLED` | `NEXT_PUBLIC_WHITELABELING_ENABLED` | | Audit logs | `AUDIT_LOGS_ENABLED` | `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | | Usage tracking | `USAGE_MONITORING_ENABLED` | `NEXT_PUBLIC_USAGE_MONITORING_ENABLED` | diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index e520282b8a9..a89267d677d 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -278,7 +278,7 @@ With **Automatic** provisioning, no invitation is required for organization memb Sign-in must start from Sim. Launching from your identity provider's app portal (Microsoft's **My Apps**, Okta's dashboard tile) sends an unsolicited assertion, which Sim rejects. This is deliberate — accepting them would let anyone replay an assertion into your tenant — but it means an IdP-initiated test fails even when the configuration is correct. -SSO provisioning creates internal organization members but does not grant workspace access. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved. +SSO provisioning creates internal organization members but does not grant workspace access. To grant workspace access from your identity provider, use [directory provisioning](/platform/enterprise/scim) and map a pushed group to a workspace. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved. Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported. @@ -305,7 +305,7 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "Does disabling someone in the identity provider remove their Sim access?", - answer: "No. Disabling the IdP account blocks future SSO authentication, but Sim does not currently receive SCIM deprovisioning or IdP logout events to remove membership or revoke active Sim sessions. Remove or suspend the user in Sim as part of offboarding." + answer: "With [directory provisioning](/platform/enterprise/scim) connected, yes: your identity provider sends the deactivation, and Sim blocks sign-in and stops their API keys while leaving everything they own intact. With SSO alone, disabling the IdP account only blocks future SSO authentication — remove or suspend the user in Sim as part of offboarding." }, { question: "Can I still use email/password login after enabling SSO?", @@ -342,6 +342,10 @@ Self-hosted deployments use environment variables instead of the billing/plan ch SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true +# Optional: directory provisioning (SCIM), configured from the SSO settings page +SCIM_ENABLED=true +NEXT_PUBLIC_SCIM_ENABLED=true + # Required if you want users auto-added to your organization on first SSO sign-in ORGANIZATIONS_ENABLED=true NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index f93ff491e09..9fa71d93911 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -251,6 +251,7 @@ Enterprise features are unlocked by configuration rather than billing on self-ho |----------|-------------| | `ENTERPRISE_ENABLED`, `NEXT_PUBLIC_ENTERPRISE_ENABLED` | Enable the whole enterprise feature set | | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Enable SAML and OIDC single sign-on on its own. See [Authentication](/platform/self-hosting/authentication#sso-saml-and-oidc) | +| `SCIM_ENABLED`, `NEXT_PUBLIC_SCIM_ENABLED` | Enable directory provisioning on its own. Needs SSO. See [Directory provisioning](/platform/enterprise/scim) | | `INSTANCE_ORG_NAME` | Name of the organization every user joins automatically at signup | | `INSTANCE_ORG_SLUG` | Slug for that organization (derived from the name when omitted) | | `INSTANCE_ORG_OWNER_EMAIL` | Owner of that organization (defaults to the first user to sign up) | diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 44329cd3cd4..9934dac3b61 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -488,7 +488,8 @@ "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", "PERMISSION_GROUP_CAPABILITY_BLOCKED", "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE" + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" ], "title": "Forbidden detail code", "description": "Stable cause code for an actionable `403` response." diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 39fbafcb0bd..7469f66274a 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -3186,7 +3186,8 @@ "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", "PERMISSION_GROUP_CAPABILITY_BLOCKED", "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE" + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" ], "title": "Forbidden detail code", "description": "Stable cause code for an actionable `403` response." diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 333e6c7bf43..1bf86bebb74 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -4618,7 +4618,8 @@ "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", "PERMISSION_GROUP_CAPABILITY_BLOCKED", "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE" + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" ], "title": "Forbidden detail code", "description": "Stable cause code for an actionable `403` response." diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index debf4510947..cc6d257fda7 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -824,7 +824,8 @@ "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", "PERMISSION_GROUP_CAPABILITY_BLOCKED", "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE" + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" ], "title": "Forbidden detail code", "description": "Stable cause code for an actionable `403` response." diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 714ecc09cbb..7644014d99c 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -5023,7 +5023,8 @@ "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", "PERMISSION_GROUP_CAPABILITY_BLOCKED", "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE" + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" ], "title": "Forbidden detail code", "description": "Stable cause code for an actionable `403` response." diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index e1452f2466c..dbc31b6c6c8 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -5034,7 +5034,8 @@ "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", "PERMISSION_GROUP_CAPABILITY_BLOCKED", "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE" + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" ], "title": "Forbidden detail code", "description": "Stable cause code for an actionable `403` response." diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 1b6540e94fb..93898c40177 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -4018,7 +4018,8 @@ "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", "PERMISSION_GROUP_CAPABILITY_BLOCKED", "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE" + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" ], "title": "Forbidden detail code", "description": "Stable cause code for an actionable `403` response." diff --git a/apps/sim/app/api/cron/scim-reconcile/route.ts b/apps/sim/app/api/cron/scim-reconcile/route.ts new file mode 100644 index 00000000000..c429bdddb96 --- /dev/null +++ b/apps/sim/app/api/cron/scim-reconcile/route.ts @@ -0,0 +1,31 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isScimDeploymentEnabled } from '@/ee/scim/lib/entitlement' +import { runScimReconcileSweep } from '@/ee/scim/lib/reconcile/job' + +const logger = createLogger('CronScimReconcile') + +/** + * Sweeps directory connections for drift between what their group mappings say + * a member should have and what SCIM actually granted them. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'SCIM reconciliation') + if (authError) return authError + + if (!isScimDeploymentEnabled()) { + return NextResponse.json({ success: true, connections: 0, skipped: 'disabled' }) + } + + try { + const sweep = await runScimReconcileSweep() + logger.info('SCIM reconciliation sweep complete', sweep) + return NextResponse.json({ success: true, ...sweep }) + } catch (error) { + logger.error('SCIM reconciliation sweep failed', { error: getErrorMessage(error) }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 4616418550a..d6008ad1748 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -259,6 +259,11 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom 'forbidden', 'Credential Group enrollment principals cannot create uploads' ) + case 'scim_connection': + throw new UploadSessionError( + 'forbidden', + 'Directory provisioning credentials cannot create uploads' + ) } } diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 1afe2fb1731..50e265f208f 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -11,13 +11,19 @@ import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' import { + acquireOrganizationUserMutationLocks, removeExternalUserFromOrganizationWorkspaces, removeUserFromOrganization, WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR, } from '@/lib/billing/organizations/membership' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' +import { ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isRetryableTransactionError } from '@/lib/db/transaction' +import { changeMemberRoleTx } from '@/lib/organizations/members/lifecycle' import { captureServerEvent } from '@/lib/posthog/server' +import { assertMembershipNotScimManaged } from '@/ee/scim/lib/managed-membership' const logger = createLogger('OrganizationMemberAPI') @@ -208,16 +214,27 @@ export const PUT = withRouteHandler( ) } - const updatedMember = await db - .update(member) - .set({ role }) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) - .returning() - - if (updatedMember.length === 0) { - return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 }) - } + /** + * The member is re-read under the organization's mutation lock, so a + * concurrent promotion to owner — or a directory provisioning this very + * member — cannot slip between the checks and the write. When the + * organization has made its identity provider the source of truth, a role + * set here is reverted by the next sync; refusing says so. + */ + const roleChange = await db.transaction(async (tx) => { + await acquireOrganizationUserMutationLocks(tx, { + userId: memberId, + organizationIds: [organizationId], + }) + await assertMembershipNotScimManaged({ organizationId, userId: memberId, executor: tx }) + return changeMemberRoleTx(tx, { organizationId, userId: memberId, role }) + }) + /** + * The audit row and analytics event fire whether or not the role actually + * moved, exactly as this route did before the write went through the + * shared primitive. Callers assert on those side effects. + */ logger.info('Organization member role updated', { organizationId, memberId, @@ -254,13 +271,33 @@ export const PUT = withRouteHandler( success: true, message: 'Member role updated successfully', data: { - id: updatedMember[0].id, - userId: updatedMember[0].userId, - role: updatedMember[0].role, + id: targetMember[0].id, + userId: targetMember[0].userId, + role: roleChange.changed ? roleChange.to : roleChange.role, updatedBy: session.user.id, }, }) } catch (error) { + if (error instanceof ForbiddenOperationError) { + return NextResponse.json( + { error: error.message, details: { code: error.detailCode } }, + { status: 403 } + ) + } + if (error instanceof OrchestrationError) { + return NextResponse.json( + { error: error.message }, + { status: statusForOrchestrationError(error.code) } + ) + } + /** The role change now serializes on the organization lock; a timeout is "retry", not a fault. */ + if (isRetryableTransactionError(error)) { + return NextResponse.json( + { error: 'The organization is busy; retry in a moment' }, + { status: 409 } + ) + } + logger.error('Failed to update organization member role', { organizationId: (await context.params).id, memberId: (await context.params).memberId, @@ -406,6 +443,7 @@ export const DELETE = withRouteHandler( userId: targetUserId, organizationId, memberId: targetMember[0].id, + spareSessionToken: session.session.token, }) if (!result.success) { diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts index 7cec2121fb2..5f7257295d2 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -10,15 +10,17 @@ import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permi import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findScopeConflicts, + type ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { authorizeOrgAccessControl, - findScopeConflicts, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, - type ScopeConflict, } from '@/app/api/organizations/[id]/permission-groups/utils' const logger = createLogger('OrganizationPermissionGroupBulkMembers') diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts index 27a95cf0c7f..b2a6ee6fc42 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts @@ -10,19 +10,21 @@ import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type AllMembersConflict, + findAllMembersWorkspaceConflict, + findScopeConflicts, + type ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { isOrganizationMember } from '@/lib/workspaces/permissions/utils' import { - type AllMembersConflict, authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, - findScopeConflicts, formatAllMembersConflictError, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, - type ScopeConflict, } from '@/app/api/organizations/[id]/permission-groups/utils' const logger = createLogger('OrganizationPermissionGroupMembers') @@ -281,7 +283,7 @@ export const DELETE = withRouteHandler( throw new Error('MEMBER_NOT_FOUND') } - if (!lockedGroup.isDefault) { + if (!lockedGroup.isDefault && lockedGroup.membershipMode === 'inherit') { const [memberCountRow] = await tx .select({ value: count() }) .from(permissionGroupMember) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts index fcf5b0ee280..416c1353ee6 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts @@ -10,6 +10,12 @@ import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type AllMembersConflict, + findAllMembersWorkspaceConflict, + findScopeConflicts, + type ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { type PermissionGroupConfig, @@ -17,16 +23,12 @@ import { } from '@/lib/permission-groups/fields' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { - type AllMembersConflict, authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, - findScopeConflicts, findWorkspacesNotInOrganization, formatAllMembersConflictError, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, - type ScopeConflict, } from '@/app/api/organizations/[id]/permission-groups/utils' const logger = createLogger('OrganizationPermissionGroup') diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts index dd8e9400fe3..42cfc1ae1a4 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts @@ -15,6 +15,10 @@ import { createPermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type AllMembersConflict, + findAllMembersWorkspaceConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { DEFAULT_PERMISSION_GROUP_CONFIG, @@ -23,9 +27,7 @@ import { } from '@/lib/permission-groups/fields' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { - type AllMembersConflict, authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, findWorkspacesNotInOrganization, formatAllMembersConflictError, getWorkspacesForGroups, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 6e762e3bd2a..62c0ddb18cc 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -1,8 +1,7 @@ /** * @vitest-environment node */ -import { permissionGroup, permissionGroupMember } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ @@ -18,11 +17,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner, })) -import { - authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, - findScopeConflicts, -} from '@/app/api/organizations/[id]/permission-groups/utils' +import { authorizeOrgAccessControl } from '@/app/api/organizations/[id]/permission-groups/utils' afterAll(resetDbChainMock) @@ -66,111 +61,3 @@ describe('authorizeOrgAccessControl', () => { expect(response).toBeNull() }) }) - -describe('findScopeConflicts', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - const baseParams = { - organizationId: 'org-1', - excludeGroupId: 'group-1', - workspaceIds: ['ws-1'], - candidateUserIds: ['user-1'], - } - - const conflictRow = (userId: string, otherGroupName = 'Marketing') => ({ - userId, - userName: 'User One', - userEmail: `${userId}@example.com`, - otherGroupId: 'group-2', - otherGroupName, - }) - - it('returns no conflicts when there are no candidate users', async () => { - queueTableRows(permissionGroupMember, [conflictRow('user-1')]) - - const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] }) - - expect(conflicts).toEqual([]) - }) - - it('returns no conflicts when there are no target workspaces', async () => { - queueTableRows(permissionGroupMember, [conflictRow('user-1')]) - - const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] }) - - expect(conflicts).toEqual([]) - }) - - it('flags a candidate already in another group that shares a workspace', async () => { - queueTableRows(permissionGroupMember, [conflictRow('user-1')]) - - const conflicts = await findScopeConflicts(baseParams) - - expect(conflicts.map((c) => c.userId)).toEqual(['user-1']) - expect(conflicts[0].conflictingGroupName).toBe('Marketing') - }) - - it('returns at most one conflict per user', async () => { - queueTableRows(permissionGroupMember, [ - conflictRow('user-1', 'Marketing'), - conflictRow('user-1', 'Sales'), - ]) - - const conflicts = await findScopeConflicts(baseParams) - - expect(conflicts).toHaveLength(1) - expect(conflicts[0].conflictingGroupName).toBe('Marketing') - }) - - it('returns no conflicts when the query finds no overlapping memberships', async () => { - const conflicts = await findScopeConflicts(baseParams) - - expect(conflicts).toEqual([]) - }) -}) - -describe('findAllMembersWorkspaceConflict', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - const baseParams = { - organizationId: 'org-1', - excludeGroupId: 'group-1', - workspaceIds: ['ws-1', 'ws-2'], - } - - it('returns null when there are no target workspaces', async () => { - queueTableRows(permissionGroup, [ - { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ]) - - const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] }) - - expect(conflict).toBeNull() - }) - - it('returns the conflicting all-members group sharing a workspace', async () => { - queueTableRows(permissionGroup, [ - { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ]) - - const conflict = await findAllMembersWorkspaceConflict(baseParams) - - expect(conflict).toEqual({ - conflictingGroupId: 'group-2', - conflictingGroupName: 'Marketing', - workspaceName: 'Acme', - }) - }) - - it('returns null when no other all-members group targets the workspaces', async () => { - const conflict = await findAllMembersWorkspaceConflict(baseParams) - - expect(conflict).toBeNull() - }) -}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts index 9b26470f470..f96ac8dacf7 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts @@ -1,15 +1,13 @@ import { db } from '@sim/db' -import { - permissionGroup, - permissionGroupMember, - permissionGroupWorkspace, - user, - workspace, -} from '@sim/db/schema' -import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm' +import { permissionGroup, permissionGroupWorkspace, workspace } from '@sim/db/schema' +import { and, asc, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import type { DbOrTx } from '@/lib/db/types' +import type { + AllMembersConflict, + ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' /** A workspace reference (id + display name). */ @@ -58,6 +56,7 @@ export async function loadGroupInOrganization( createdAt: permissionGroup.createdAt, updatedAt: permissionGroup.updatedAt, isDefault: permissionGroup.isDefault, + membershipMode: permissionGroup.membershipMode, }) .from(permissionGroup) .where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId))) @@ -129,125 +128,6 @@ export async function listOrganizationWorkspaces(organizationId: string): Promis } /** A member whose other group membership would conflict with a candidate scope. */ -export interface ScopeConflict { - userId: string - userName: string | null - userEmail: string | null - /** The group the member already belongs to that causes the conflict. */ - conflictingGroupId: string - conflictingGroupName: string -} - -/** - * Which of `candidateUserIds` would be governed by two groups on the same - * workspace: each is already an explicit member of another non-default group - * that shares one of `workspaceIds`. The candidate group (`excludeGroupId`) and - * the org default group are ignored — the default never governs through - * membership. Returns at most one conflict per user. - */ -export async function findScopeConflicts( - params: { - organizationId: string - excludeGroupId: string - workspaceIds: string[] - candidateUserIds: string[] - }, - executor: DbOrTx = db -): Promise { - const { organizationId, excludeGroupId, workspaceIds, candidateUserIds } = params - if (candidateUserIds.length === 0 || workspaceIds.length === 0) return [] - - const rows = await executor - .select({ - userId: permissionGroupMember.userId, - userName: user.name, - userEmail: user.email, - otherGroupId: permissionGroup.id, - otherGroupName: permissionGroup.name, - }) - .from(permissionGroupMember) - .innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id)) - .innerJoin( - permissionGroupWorkspace, - eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) - ) - .leftJoin(user, eq(permissionGroupMember.userId, user.id)) - .where( - and( - eq(permissionGroupMember.organizationId, organizationId), - inArray(permissionGroupMember.userId, candidateUserIds), - ne(permissionGroupMember.permissionGroupId, excludeGroupId), - eq(permissionGroup.isDefault, false), - inArray(permissionGroupWorkspace.workspaceId, workspaceIds) - ) - ) - - const conflictByUser = new Map() - for (const row of rows) { - if (conflictByUser.has(row.userId)) continue - conflictByUser.set(row.userId, { - userId: row.userId, - userName: row.userName, - userEmail: row.userEmail, - conflictingGroupId: row.otherGroupId, - conflictingGroupName: row.otherGroupName, - }) - } - return Array.from(conflictByUser.values()) -} - -/** An existing all-members group that already governs everyone in a shared workspace. */ -export interface AllMembersConflict { - conflictingGroupId: string - conflictingGroupName: string - workspaceName: string -} - -/** - * For a group that will govern *all members* of `workspaceIds` (a non-default - * group with no explicit members), return the first other non-default - * all-members group already targeting one of those workspaces, or `null`. Two - * all-members groups on one workspace would both claim everyone there, so this - * is rejected at assignment time. The candidate group (`excludeGroupId`) is - * ignored. - */ -export async function findAllMembersWorkspaceConflict( - params: { organizationId: string; excludeGroupId: string; workspaceIds: string[] }, - executor: DbOrTx = db -): Promise { - const { organizationId, excludeGroupId, workspaceIds } = params - if (workspaceIds.length === 0) return null - - const [row] = await executor - .select({ - conflictingGroupId: permissionGroup.id, - conflictingGroupName: permissionGroup.name, - workspaceName: workspace.name, - }) - .from(permissionGroup) - .innerJoin( - permissionGroupWorkspace, - eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) - ) - .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) - .where( - and( - eq(permissionGroup.organizationId, organizationId), - eq(permissionGroup.isDefault, false), - ne(permissionGroup.id, excludeGroupId), - inArray(permissionGroupWorkspace.workspaceId, workspaceIds), - sql`not exists ( - select 1 from ${permissionGroupMember} - where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} - )` - ) - ) - .orderBy(asc(workspace.name)) - .limit(1) - - return row ?? null -} - /** * Human-readable 409 message for a scope/membership conflict, naming the member * and the group they already belong to that overlaps the requested workspaces. diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index 45b2ad9437a..8d1fc44d1dc 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -57,6 +57,7 @@ const MEMBER_ROWS = [ userName: 'Admin User', userEmail: 'admin@example.com', userImage: null, + userSuspendedAt: null, }, { memberId: 'member-reader', @@ -66,6 +67,7 @@ const MEMBER_ROWS = [ userName: 'Reader User', userEmail: 'reader@example.com', userImage: 'https://example.com/reader.png', + userSuspendedAt: null, }, ] @@ -118,6 +120,7 @@ describe('GET /api/organizations/[id]/roster', () => { name: 'Admin User', email: 'admin@example.com', image: null, + suspendedAt: null, workspaces: [], }, { @@ -128,6 +131,7 @@ describe('GET /api/organizations/[id]/roster', () => { name: 'Reader User', email: 'reader@example.com', image: 'https://example.com/reader.png', + suspendedAt: null, workspaces: [], }, ], @@ -167,6 +171,7 @@ describe('GET /api/organizations/[id]/roster', () => { userName: 'External User', userEmail: 'external@example.com', userImage: null, + userSuspendedAt: null, workspaceId: 'workspace-1', permission: 'read', createdAt: new Date('2026-03-01T00:00:00.000Z'), diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index a7d7bba1868..e4def40fdcb 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -83,6 +83,7 @@ export const GET = withRouteHandler( userName: user.name, userEmail: user.email, userImage: user.image, + userSuspendedAt: user.suspendedAt, }) .from(member) .innerJoin(user, eq(member.userId, user.id)) @@ -96,6 +97,7 @@ export const GET = withRouteHandler( name: row.userName, email: row.userEmail, image: row.userImage, + suspendedAt: row.userSuspendedAt?.toISOString() ?? null, workspaces: [] as RosterWorkspaceAccess[], })) @@ -189,6 +191,7 @@ export const GET = withRouteHandler( userName: user.name, userEmail: user.email, userImage: user.image, + userSuspendedAt: user.suspendedAt, workspaceId: permissions.entityId, permission: permissions.permissionType, createdAt: permissions.createdAt, @@ -218,6 +221,7 @@ export const GET = withRouteHandler( name: string email: string image: string | null + suspendedAt: string | null workspaces: RosterWorkspaceAccess[] } >() @@ -247,6 +251,7 @@ export const GET = withRouteHandler( name: row.userName, email: row.userEmail, image: row.userImage, + suspendedAt: row.userSuspendedAt?.toISOString() ?? null, workspaces: [workspaceAccess], }) } diff --git a/apps/sim/app/api/organizations/[id]/scim/activity/route.ts b/apps/sim/app/api/organizations/[id]/scim/activity/route.ts new file mode 100644 index 00000000000..05ddcb54075 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/activity/route.ts @@ -0,0 +1,24 @@ +import { listScimActivityContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listScimActivity } from '@/ee/scim/lib/application/admin/connection' + +/** Recent provisioning requests, so a failing sync can be diagnosed from Sim. */ +export const GET = defineInternalJsonRoute({ + contract: listScimActivityContract, + auth: internalSessionAuth, + operation: listScimActivity.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization settings read, admission unchanged from its siblings.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + ...(query.limit !== undefined ? { limit: query.limit } : {}), + }), + useCase: listScimActivity, +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/credentials/[credentialId]/route.ts b/apps/sim/app/api/organizations/[id]/scim/credentials/[credentialId]/route.ts new file mode 100644 index 00000000000..c81be93ca9a --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/credentials/[credentialId]/route.ts @@ -0,0 +1,22 @@ +import { revokeScimCredentialContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { revokeScimCredential } from '@/ee/scim/lib/application/admin/credentials' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeScimCredentialContract, + auth: internalSessionAuth, + operation: revokeScimCredential.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-credential-revoke' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + organizationId: params.id, + credentialId: params.credentialId, + }), + useCase: revokeScimCredential, + present: ({ success }) => ({ success }), +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/credentials/route.ts b/apps/sim/app/api/organizations/[id]/scim/credentials/route.ts new file mode 100644 index 00000000000..8c79e5fc297 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/credentials/route.ts @@ -0,0 +1,26 @@ +import { issueScimCredentialContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { issueScimCredential } from '@/ee/scim/lib/application/admin/credentials' + +/** Issues a bearer credential. The secret is returned once and never stored. */ +export const POST = defineInternalJsonRoute({ + contract: issueScimCredentialContract, + auth: internalSessionAuth, + operation: issueScimCredential.operation, + rateLimit: internalRateLimits.user({ + bucketName: 'scim-credential-issue', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + ...(body.expiresInDays !== undefined ? { expiresInDays: body.expiresInDays } : {}), + }), + useCase: issueScimCredential, + present: ({ secret, credential }) => ({ secret, credential }), +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/mappings/[mappingId]/route.ts b/apps/sim/app/api/organizations/[id]/scim/mappings/[mappingId]/route.ts new file mode 100644 index 00000000000..a73704e8865 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/mappings/[mappingId]/route.ts @@ -0,0 +1,19 @@ +import { deleteScimGroupMappingContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { deleteScimGroupMapping } from '@/ee/scim/lib/application/admin/mappings' + +export const DELETE = defineInternalJsonRoute({ + contract: deleteScimGroupMappingContract, + auth: internalSessionAuth, + operation: deleteScimGroupMapping.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-mapping-delete' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, mappingId: params.mappingId }), + useCase: deleteScimGroupMapping, + present: ({ success, reconciledUsers }) => ({ success, reconciledUsers }), +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/mappings/route.ts b/apps/sim/app/api/organizations/[id]/scim/mappings/route.ts new file mode 100644 index 00000000000..f0931d1db52 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/mappings/route.ts @@ -0,0 +1,38 @@ +import { + listScimGroupMappingsContract, + upsertScimGroupMappingContract, +} from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + listScimGroupMappings, + upsertScimGroupMapping, +} from '@/ee/scim/lib/application/admin/mappings' + +/** What each directory group means inside Sim. */ + +export const GET = defineInternalJsonRoute({ + contract: listScimGroupMappingsContract, + auth: internalSessionAuth, + operation: listScimGroupMappings.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization settings read, admission unchanged from its siblings.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: listScimGroupMappings, +}) + +export const POST = defineInternalJsonRoute({ + contract: upsertScimGroupMappingContract, + auth: internalSessionAuth, + operation: upsertScimGroupMapping.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-mapping-upsert' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: upsertScimGroupMapping, +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/reconcile/route.ts b/apps/sim/app/api/organizations/[id]/scim/reconcile/route.ts new file mode 100644 index 00000000000..09761cd6381 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/reconcile/route.ts @@ -0,0 +1,27 @@ +import { reconcileScimConnectionContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { reconcileScimConnection } from '@/ee/scim/lib/application/admin/connection' + +/** + * Re-applies every group mapping to every provisioned user. + * + * Idempotent, so an administrator can run it after changing mappings without + * waiting for the scheduled pass. + */ +export const POST = defineInternalJsonRoute({ + contract: reconcileScimConnectionContract, + auth: internalSessionAuth, + operation: reconcileScimConnection.operation, + rateLimit: internalRateLimits.user({ + bucketName: 'scim-reconcile', + config: { maxTokens: 5, refillRate: 2, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: reconcileScimConnection, +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/route.ts b/apps/sim/app/api/organizations/[id]/scim/route.ts new file mode 100644 index 00000000000..96835be4afa --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/route.ts @@ -0,0 +1,48 @@ +import { + configureScimConnectionContract, + getScimConnectionContract, +} from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + configureScimConnection, + getScimConnection, +} from '@/ee/scim/lib/application/admin/connection' + +/** + * The organization's directory-provisioning connection. + * + * Session-authenticated settings surface, distinct from the SCIM protocol + * endpoints under `/api/scim/v2` that the identity provider itself calls. + */ + +export const GET = defineInternalJsonRoute({ + contract: getScimConnectionContract, + auth: internalSessionAuth, + operation: getScimConnection.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization settings read, admission unchanged from its siblings.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getScimConnection, +}) + +export const PUT = defineInternalJsonRoute({ + contract: configureScimConnectionContract, + auth: internalSessionAuth, + operation: configureScimConnection.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-configure' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + ...(body.status !== undefined ? { status: body.status } : {}), + ...(body.settings !== undefined ? { settings: body.settings } : {}), + }), + useCase: configureScimConnection, + present: ({ connection }) => ({ connection }), +}) diff --git a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts index 32385b2421f..13f61580a3f 100644 --- a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts @@ -148,6 +148,7 @@ export const POST = withRouteHandler( userId: session.user.id, organizationId, memberId: selfMember.id, + spareSessionToken: session.session.token, }) if (!removeResult.success) { diff --git a/apps/sim/app/api/scim/v2/Groups/[id]/route.ts b/apps/sim/app/api/scim/v2/Groups/[id]/route.ts new file mode 100644 index 00000000000..fc1343e359f --- /dev/null +++ b/apps/sim/app/api/scim/v2/Groups/[id]/route.ts @@ -0,0 +1,53 @@ +import { + deleteScimGroupContract, + getScimGroupContract, + patchScimGroupContract, + replaceScimGroupContract, +} from '@/lib/api/contracts/scim' +import { + deleteScimGroup, + getScimGroup, + patchScimGroup, + replaceScimGroup, +} from '@/ee/scim/lib/application/groups/manage-groups' +import { toCanonicalGroup } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** One Group resource. */ + +export const GET = defineScimRoute({ + contract: getScimGroupContract, + operation: getScimGroup.operation, + useCase: getScimGroup, + mapInput: ({ params, query }) => ({ + groupId: params.id, + projection: parseAttributeProjection(query), + }), + present: (resource) => resource, +}) + +export const PUT = defineScimRoute({ + contract: replaceScimGroupContract, + operation: replaceScimGroup.operation, + useCase: replaceScimGroup, + mapInput: ({ params, body }) => { + return { groupId: params.id, group: toCanonicalGroup(body) } + }, + present: (result) => result.resource, +}) + +/** Answers 204: Microsoft asks that a group patch not echo the member list. */ +export const PATCH = defineScimRoute({ + contract: patchScimGroupContract, + operation: patchScimGroup.operation, + useCase: patchScimGroup, + mapInput: ({ params, body }) => ({ groupId: params.id, operations: body.Operations }), +}) + +export const DELETE = defineScimRoute({ + contract: deleteScimGroupContract, + operation: deleteScimGroup.operation, + useCase: deleteScimGroup, + mapInput: ({ params }) => ({ groupId: params.id }), +}) diff --git a/apps/sim/app/api/scim/v2/Groups/route.ts b/apps/sim/app/api/scim/v2/Groups/route.ts new file mode 100644 index 00000000000..1bfa92d5a46 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Groups/route.ts @@ -0,0 +1,31 @@ +import { createScimGroupContract, listScimGroupsContract } from '@/lib/api/contracts/scim' +import { createScimGroup, listScimGroups } from '@/ee/scim/lib/application/groups/manage-groups' +import { toCanonicalGroup } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection, toListResponse } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** The Group collection. */ + +export const GET = defineScimRoute({ + contract: listScimGroupsContract, + operation: listScimGroups.operation, + useCase: listScimGroups, + mapInput: ({ query }) => ({ + filter: query.filter, + startIndex: query.startIndex, + count: query.count, + projection: parseAttributeProjection(query), + }), + present: (result) => toListResponse(result.resources, result.totalResults, result.startIndex), +}) + +export const POST = defineScimRoute({ + contract: createScimGroupContract, + operation: createScimGroup.operation, + useCase: createScimGroup, + mapInput: ({ body }) => { + return { group: toCanonicalGroup(body) } + }, + present: (result) => result.resource, + headers: (result, { baseUrl }) => ({ Location: `${baseUrl}/Groups/${result.groupId}` }), +}) diff --git a/apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts b/apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts new file mode 100644 index 00000000000..d4016ff07ec --- /dev/null +++ b/apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts @@ -0,0 +1,12 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { resourceTypes } from '@/ee/scim/lib/protocol/discovery' +import { notFound } from '@/ee/scim/lib/protocol/errors' + +export const GET = defineScimDiscoveryRoute((baseUrl, params) => { + const id = typeof params.id === 'string' ? params.id : '' + const match = resourceTypes(baseUrl).find( + (resource) => resource.id.toLowerCase() === id.toLowerCase() + ) + if (!match) throw notFound(`Resource type ${id} not found`) + return match +}) diff --git a/apps/sim/app/api/scim/v2/ResourceTypes/route.ts b/apps/sim/app/api/scim/v2/ResourceTypes/route.ts new file mode 100644 index 00000000000..6050e6ce2b7 --- /dev/null +++ b/apps/sim/app/api/scim/v2/ResourceTypes/route.ts @@ -0,0 +1,7 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { resourceTypes } from '@/ee/scim/lib/protocol/discovery' +import { toListResponse } from '@/ee/scim/lib/protocol/resources' + +export const GET = defineScimDiscoveryRoute((baseUrl) => + toListResponse(resourceTypes(baseUrl), resourceTypes(baseUrl).length, 1) +) diff --git a/apps/sim/app/api/scim/v2/Schemas/[id]/route.ts b/apps/sim/app/api/scim/v2/Schemas/[id]/route.ts new file mode 100644 index 00000000000..c0c5010c01c --- /dev/null +++ b/apps/sim/app/api/scim/v2/Schemas/[id]/route.ts @@ -0,0 +1,12 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { schemaDefinitions } from '@/ee/scim/lib/protocol/discovery' +import { notFound } from '@/ee/scim/lib/protocol/errors' + +export const GET = defineScimDiscoveryRoute((baseUrl, params) => { + const id = typeof params.id === 'string' ? decodeURIComponent(params.id) : '' + const match = schemaDefinitions(baseUrl).find( + (schema) => schema.id.toLowerCase() === id.toLowerCase() + ) + if (!match) throw notFound(`Schema ${id} not found`) + return match +}) diff --git a/apps/sim/app/api/scim/v2/Schemas/route.ts b/apps/sim/app/api/scim/v2/Schemas/route.ts new file mode 100644 index 00000000000..3202e1555e0 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Schemas/route.ts @@ -0,0 +1,7 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { schemaDefinitions } from '@/ee/scim/lib/protocol/discovery' +import { toListResponse } from '@/ee/scim/lib/protocol/resources' + +export const GET = defineScimDiscoveryRoute((baseUrl) => + toListResponse(schemaDefinitions(baseUrl), schemaDefinitions(baseUrl).length, 1) +) diff --git a/apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts b/apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts new file mode 100644 index 00000000000..05b68cd122c --- /dev/null +++ b/apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts @@ -0,0 +1,5 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { serviceProviderConfig } from '@/ee/scim/lib/protocol/discovery' + +/** Unauthenticated by design: a provider negotiates before it holds a credential. */ +export const GET = defineScimDiscoveryRoute((baseUrl) => serviceProviderConfig(baseUrl)) diff --git a/apps/sim/app/api/scim/v2/Users/[id]/route.ts b/apps/sim/app/api/scim/v2/Users/[id]/route.ts new file mode 100644 index 00000000000..b3da9f3de24 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Users/[id]/route.ts @@ -0,0 +1,50 @@ +import { + deleteScimUserContract, + getScimUserContract, + patchScimUserContract, + replaceScimUserContract, +} from '@/lib/api/contracts/scim' +import { deprovisionScimUser } from '@/ee/scim/lib/application/users/deprovision-user' +import { getScimUser } from '@/ee/scim/lib/application/users/read-users' +import { patchScimUser, replaceScimUser } from '@/ee/scim/lib/application/users/update-user' +import { toCanonicalUser } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** One User resource. */ + +export const GET = defineScimRoute({ + contract: getScimUserContract, + operation: getScimUser.operation, + useCase: getScimUser, + mapInput: ({ params, query }) => ({ + scimUserId: params.id, + projection: parseAttributeProjection(query), + }), + present: (resource) => resource, +}) + +export const PUT = defineScimRoute({ + contract: replaceScimUserContract, + operation: replaceScimUser.operation, + useCase: replaceScimUser, + mapInput: ({ params, body }) => { + return { scimUserId: params.id, attributes: toCanonicalUser(body) } + }, + present: (result) => result.resource, +}) + +export const PATCH = defineScimRoute({ + contract: patchScimUserContract, + operation: patchScimUser.operation, + useCase: patchScimUser, + mapInput: ({ params, body }) => ({ scimUserId: params.id, operations: body.Operations }), + present: (result) => result.resource, +}) + +export const DELETE = defineScimRoute({ + contract: deleteScimUserContract, + operation: deprovisionScimUser.operation, + useCase: deprovisionScimUser, + mapInput: ({ params }) => ({ scimUserId: params.id }), +}) diff --git a/apps/sim/app/api/scim/v2/Users/route.ts b/apps/sim/app/api/scim/v2/Users/route.ts new file mode 100644 index 00000000000..93b5338d1d6 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Users/route.ts @@ -0,0 +1,38 @@ +import { createScimUserContract, listScimUsersContract } from '@/lib/api/contracts/scim' +import { provisionScimUser } from '@/ee/scim/lib/application/users/provision-user' +import { listScimUsers } from '@/ee/scim/lib/application/users/read-users' +import { toCanonicalUser } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection, toListResponse } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** + * The User collection. + * + * Adapters only: authentication, rate policy, contract parsing, and rendering + * live in the route builder, and every decision about identity, membership, and + * access lives in `ee/scim/lib/application`. + */ + +export const GET = defineScimRoute({ + contract: listScimUsersContract, + operation: listScimUsers.operation, + useCase: listScimUsers, + mapInput: ({ query }) => ({ + filter: query.filter, + startIndex: query.startIndex, + count: query.count, + projection: parseAttributeProjection(query), + }), + present: (result) => toListResponse(result.resources, result.totalResults, result.startIndex), +}) + +export const POST = defineScimRoute({ + contract: createScimUserContract, + operation: provisionScimUser.operation, + useCase: provisionScimUser, + mapInput: ({ body }) => { + return { attributes: toCanonicalUser(body) } + }, + present: (result) => result.resource, + headers: (result, { baseUrl }) => ({ Location: `${baseUrl}/Users/${result.scimUserId}` }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts index 36683982480..4e58f47cc1d 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts @@ -16,9 +16,18 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockSyncWorkspaceEnvCredentials, mockGetEffectiveWorkspacePermission } = vi.hoisted(() => ({ +const { + mockSyncWorkspaceEnvCredentials, + mockGetEffectiveWorkspacePermission, + mockAssertMembershipNotScimManaged, +} = vi.hoisted(() => ({ mockSyncWorkspaceEnvCredentials: vi.fn(), mockGetEffectiveWorkspacePermission: vi.fn(), + mockAssertMembershipNotScimManaged: vi.fn(), +})) + +vi.mock('@/ee/scim/lib/managed-membership', () => ({ + assertMembershipNotScimManaged: mockAssertMembershipNotScimManaged, })) vi.mock('@sim/audit', () => auditMock) @@ -37,6 +46,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission, })) +import { ForbiddenOperationError } from '@/lib/core/application' import { PATCH } from '@/app/api/workspaces/[id]/permissions/route' const mockGetSession = authMockFns.mockGetSession @@ -558,6 +568,37 @@ describe('workspace permissions route', () => { }) }) + it('refuses a role change for a member the directory manages', async () => { + queueOrgWorkspace([], [permissionRow(MEMBER_ID, 'read')]) + mockAssertMembershipNotScimManaged.mockRejectedValueOnce( + new ForbiddenOperationError('SCIM_MANAGED_MEMBERSHIP', 'Managed by the directory') + ) + + const response = await PATCH( + createMockRequest('PATCH', { updates: [{ userId: MEMBER_ID, permissions: 'write' }] }), + routeContext + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ error: 'Managed by the directory' }) + expect(mockAssertMembershipNotScimManaged).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ORG_ID, userId: MEMBER_ID }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('does not consult the directory for a personal workspace', async () => { + queuePersonalWorkspace([permissionRow(ADMIN_ID, 'admin'), permissionRow(MEMBER_ID, 'read')]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'perm-1' }]) + + await PATCH( + createMockRequest('PATCH', { updates: [{ userId: MEMBER_ID, permissions: 'write' }] }), + routeContext + ) + + expect(mockAssertMembershipNotScimManaged).not.toHaveBeenCalled() + }) + it('refuses to change the role of an organization admin', async () => { queueOrgWorkspace([{ userId: MEMBER_ID }], [permissionRow(MEMBER_ID, 'admin')]) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 4d5809de87d..05fe91930fc 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/api/contracts/workspaces' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { ForbiddenOperationError } from '@/lib/core/application' import { HttpError } from '@/lib/core/utils/http-error' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' @@ -24,6 +25,7 @@ import { getWorkspaceWithOwner, hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' +import { assertMembershipNotScimManaged } from '@/ee/scim/lib/managed-membership' const logger = createLogger('WorkspacesPermissionsAPI') @@ -92,6 +94,16 @@ class WorkspaceBusyError extends HttpError { } } +/** A target whose membership the organization's directory owns; the guard's own wording names the remedy. */ +class DirectoryManagedMemberError extends HttpError { + readonly statusCode = 403 + + constructor(cause: ForbiddenOperationError) { + super(cause.message) + this.name = 'DirectoryManagedMemberError' + } +} + /** * Bounds the wait on the row locks below so a stuck holder fails fast * (SQLSTATE 55P03) instead of parking a pooled connection indefinitely. @@ -376,6 +388,15 @@ export const PATCH = withRouteHandler( orgAdminUserIds = new Set( lockedMembers.filter((row) => isOrgAdminRole(row.role)).map((row) => row.userId) ) + /** + * A directory that owns membership also owns the workspace role it set, + * so the change is refused here for the same reason an invitation is: + * the next sync would revert it. Checked under the member lock so a + * connection enabled mid-request cannot slip a change past it. + */ + for (const userId of targetUserIds) { + await assertMembershipNotScimManaged({ organizationId, userId, executor: tx }) + } } /** @@ -526,6 +547,7 @@ export const PATCH = withRouteHandler( }) throw new WorkspaceBusyError() } + if (error instanceof ForbiddenOperationError) throw new DirectoryManagedMemberError(error) throw error }) diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index f41989b9c8e..3a0303158ab 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { batchWorkspaceInvitationsContract } from '@/lib/api/contracts/invitations' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { ForbiddenOperationError } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { createWorkspaceInvitation, @@ -99,6 +100,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => { failed.push({ email: error.email ?? normalizedEmail, error: error.message }) continue } + /** A directory-managed address is refused with its reason, like any other per-email refusal. */ + if (error instanceof ForbiddenOperationError) { + failed.push({ email: normalizedEmail, error: error.message }) + continue + } /** * One bad address must not discard the invitations that already diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 1a028d91813..64d47166fcb 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -10,18 +10,13 @@ import { getSession } from '@/lib/auth' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' -import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' +import { revokeWorkspaceAccessTx } from '@/lib/workspaces/access/workspace-access' import { hasWorkspaceAdminAccess, isOrganizationAdminOrOwner, } from '@/lib/workspaces/permissions/utils' -import { - reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, - transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx, - WorkspaceBillingAccountRemovalError, -} from '@/lib/workspaces/utils' +import { WorkspaceBillingAccountRemovalError } from '@/lib/workspaces/utils' const logger = createLogger('WorkspaceMemberAPI') @@ -147,41 +142,11 @@ export const DELETE = withRouteHandler( } } - const { ownershipTransferred, workflowOwnershipReassignment } = await db.transaction( - async (tx) => { - const didTransferOwnership = - await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({ - tx, - workspaceId, - departingUserId: userId, - }) - - const workflowOwnershipReassignment = - await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({ - tx, - workspaceIds: [workspaceId], - departingUserId: userId, - }) - if (workflowOwnershipReassignment.unresolved.length > 0) { - throw new WorkspaceBillingAccountRemovalError() - } - - await tx - .delete(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - - await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) - await removeWorkspaceSkillMembershipsTx(tx, workspaceId, userId) - - return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment } - } + const revocation = await db.transaction((tx) => + revokeWorkspaceAccessTx(tx, { workspaceId, userId }) ) + if (!revocation.revoked) throw new WorkspaceBillingAccountRemovalError() + const { ownershipTransferred } = revocation /** * Seats are tied to organization membership (one per member), so a @@ -214,6 +179,8 @@ export const DELETE = withRouteHandler( organizationId, memberId: orgMembership.id, requireNoOrgWorkspaceAccess: true, + /** Leaving a workspace must not sign the leaver out of Sim. */ + spareSessionToken: session.session.token, }) if (removal.success && removal.removed) { @@ -270,7 +237,6 @@ export const DELETE = withRouteHandler( removedUserRole: userPermission?.permissionType ?? 'owner', selfRemoval: isSelf, ownershipTransferred, - workflowOwnershipReassignment, organizationRemoval, seatReduction, }, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx index cadb43c8967..3952ac18e48 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx @@ -131,7 +131,11 @@ export function OrganizationMemberLists({ name={member.name} email={member.email} image={member.image} - status={`Joined ${formatDate(new Date(member.createdAt))}`} + status={ + member.suspendedAt + ? 'Deactivated by your directory' + : `Joined ${formatDate(new Date(member.createdAt))}` + } roleControl={ editable ? ( }> = [] ) { - queueTableRows(permissionGroup, workspaceGroups) + /** Every row carries the column default the resolver reads, as a real row would. */ + queueTableRows( + permissionGroup, + workspaceGroups.map((row) => ({ membershipMode: 'inherit', ...row })) + ) queueTableRows(permissionGroup, defaultGroup) } diff --git a/apps/sim/ee/scim/components/options.ts b/apps/sim/ee/scim/components/options.ts new file mode 100644 index 00000000000..9005a3421a9 --- /dev/null +++ b/apps/sim/ee/scim/components/options.ts @@ -0,0 +1,48 @@ +import type { ScimGroupMappingView } from '@/lib/api/contracts/organization-scim' + +/** Option lists and setting descriptions for the directory provisioning settings section. */ + +export type MappingTargetKind = ScimGroupMappingView['targetKind'] +export type WorkspacePermission = NonNullable + +export const TARGET_KIND_OPTIONS = [ + { value: 'permission_group', label: 'Permission group' }, + { value: 'workspace', label: 'Workspace' }, + { value: 'org_role', label: 'Organization admin' }, +] as const + +export const PERMISSION_OPTIONS = [ + { value: 'read', label: 'Read' }, + { value: 'write', label: 'Write' }, + { value: 'admin', label: 'Admin' }, +] as const + +export const SETTING_TOGGLES = [ + { + key: 'lockManualMembership', + label: 'Lock managed membership', + description: + 'Refuse invitations, role changes, and manual grants for members the directory provisions. The next sync would revert them anyway.', + }, + { + key: 'disableJit', + label: 'Disable just-in-time provisioning', + description: + 'Refuse membership for someone signing in with SSO who the directory never provisioned. The directory becomes the only way in.', + }, + { + key: 'autoMapPermissionGroupsByName', + label: 'Match permission groups by name', + description: + 'When a pushed group has the same name as one of your permission groups, map them automatically. Nothing is created.', + }, +] as const + +/** Credential lifetimes offered at issue time; `never` matches what Okta and Entra expect by default. */ +export const CREDENTIAL_EXPIRY_OPTIONS = [ + { value: 'never', label: 'Never expires' }, + { value: '90', label: 'Expires in 90 days' }, + { value: '365', label: 'Expires in 1 year' }, +] as const + +export type CredentialExpiry = (typeof CREDENTIAL_EXPIRY_OPTIONS)[number]['value'] diff --git a/apps/sim/ee/scim/components/scim-section.tsx b/apps/sim/ee/scim/components/scim-section.tsx new file mode 100644 index 00000000000..53a701cbd69 --- /dev/null +++ b/apps/sim/ee/scim/components/scim-section.tsx @@ -0,0 +1,626 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipConfirmModal, + ChipCopyInput, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + ChipSelect, + ChipTag, + Switch, + toast, +} from '@sim/emcn' +import { Key, X } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { + ScimActivityEntry, + ScimConnectionView, + ScimCredentialView, + ScimGroupMappingBody, + ScimGroupMappingView, +} from '@/lib/api/contracts/organization-scim' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { + useOrganizationWorkspaces, + usePermissionGroups, +} from '@/ee/access-control/hooks/permission-groups' +import { SettingRow } from '@/ee/components/setting-row' +import { + CREDENTIAL_EXPIRY_OPTIONS, + type CredentialExpiry, + type MappingTargetKind, + PERMISSION_OPTIONS, + SETTING_TOGGLES, + TARGET_KIND_OPTIONS, + type WorkspacePermission, +} from '@/ee/scim/components/options' +import { + useConfigureScimConnection, + useDeleteScimGroupMapping, + useIssueScimCredential, + useReconcileScimConnection, + useRevokeScimCredential, + useScimActivity, + useScimConnection, + useScimGroupMappings, + useUpsertScimGroupMapping, +} from '@/ee/scim/hooks/scim' + +interface ScimSectionProps { + organizationId: string +} + +const RELATIVE_TIME = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) + +/** Renders "3 minutes ago" for the activity list and credential rows. */ +function formatRelative(iso: string | null): string { + if (!iso) return 'never' + const deltaMs = new Date(iso).getTime() - Date.now() + const minutes = Math.round(deltaMs / 60_000) + if (Math.abs(minutes) < 60) return RELATIVE_TIME.format(minutes, 'minute') + const hours = Math.round(minutes / 60) + if (Math.abs(hours) < 48) return RELATIVE_TIME.format(hours, 'hour') + return RELATIVE_TIME.format(Math.round(hours / 24), 'day') +} + +function describeMapping( + mapping: ScimGroupMappingView, + names: { permissionGroups: Map; workspaces: Map } +): string { + switch (mapping.targetKind) { + case 'permission_group': + return names.permissionGroups.get(mapping.permissionGroupId ?? '') ?? 'Permission group' + case 'workspace': { + const name = names.workspaces.get(mapping.workspaceId ?? '') ?? 'Workspace' + const level = PERMISSION_OPTIONS.find((o) => o.value === mapping.permissionType)?.label + return level ? `${name} · ${level}` : name + } + case 'org_role': + return 'Organization admin' + } +} + +interface CredentialRowProps { + credential: ScimCredentialView + onRevoke: (credential: ScimCredentialView) => void +} + +function CredentialRow({ credential, onRevoke }: CredentialRowProps) { + const expiry = credential.expiresAt + ? `expires ${formatRelative(credential.expiresAt)}` + : 'no expiry' + return ( + } + title={{credential.tokenPrefix}…} + description={`Last used ${formatRelative(credential.lastUsedAt)} · ${expiry}`} + trailing={ + onRevoke(credential), destructive: true }]} + /> + } + /> + ) +} + +interface AddMappingProps { + organizationId: string + groupId: string + permissionGroups: Array<{ id: string; name: string }> + workspaces: Array<{ id: string; name: string }> +} + +function AddMapping({ organizationId, groupId, permissionGroups, workspaces }: AddMappingProps) { + const upsertMapping = useUpsertScimGroupMapping() + const [targetKind, setTargetKind] = useState('permission_group') + const [targetId, setTargetId] = useState('') + const [permission, setPermission] = useState('read') + + function buildBody(): ScimGroupMappingBody | null { + switch (targetKind) { + case 'permission_group': + return targetId ? { groupId, targetKind, permissionGroupId: targetId } : null + case 'workspace': + return targetId + ? { groupId, targetKind, workspaceId: targetId, permissionType: permission } + : null + case 'org_role': + return { groupId, targetKind, role: 'admin' } + } + } + + const body = buildBody() + + async function handleAdd() { + if (!body) return + try { + const result = await upsertMapping.mutateAsync({ organizationId, body }) + setTargetId('') + toast.success( + result.reconciledUsers === 0 + ? 'Mapping added' + : `Mapping added and applied to ${result.reconciledUsers} member${result.reconciledUsers === 1 ? '' : 's'}` + ) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to add mapping')) + } + } + + const targetOptions = targetKind === 'permission_group' ? permissionGroups : workspaces + + return ( +
+ { + setTargetKind(next as MappingTargetKind) + setTargetId('') + }} + options={[...TARGET_KIND_OPTIONS]} + /> + {targetKind !== 'org_role' && ( + ({ value: target.id, label: target.name }))} + /> + )} + {targetKind === 'workspace' && ( + setPermission(next as WorkspacePermission)} + options={[...PERMISSION_OPTIONS]} + /> + )} + + {upsertMapping.isPending ? 'Adding...' : 'Add mapping'} + +
+ ) +} + +interface GroupMappingsProps { + organizationId: string +} + +function GroupMappings({ organizationId }: GroupMappingsProps) { + const { + data: groups, + isLoading, + isError, + error, + isFetching, + refetch, + } = useScimGroupMappings(organizationId) + const { data: allPermissionGroups = [] } = usePermissionGroups(organizationId) + /** The default group governs by having no members, so it cannot be a membership target. */ + const permissionGroups = allPermissionGroups.filter((group) => !group.isDefault) + const { data: workspaces = [] } = useOrganizationWorkspaces(organizationId) + const deleteMapping = useDeleteScimGroupMapping() + + const names = { + permissionGroups: new Map(permissionGroups.map((group) => [group.id, group.name])), + workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace.name])), + } + + async function handleRemove(mapping: ScimGroupMappingView) { + try { + await deleteMapping.mutateAsync({ organizationId, mappingId: mapping.id }) + toast.success('Mapping removed') + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to remove mapping')) + } + } + + if (isLoading) { + return Loading groups... + } + if (isError) { + return ( + void refetch()} + variant='inline' + /> + ) + } + if (!groups || groups.length === 0) { + return ( + + No groups yet. Push a group from your identity provider and it appears here. + + ) + } + + return ( +
+ {groups.map((group) => ( +
+ +
+ {group.mappings.length > 0 && ( +
+ {group.mappings.map((mapping) => ( + void handleRemove(mapping)} + > + {describeMapping(mapping, names)} + + ))} +
+ )} + +
+
+ ))} +
+ ) +} + +interface ActivityListProps { + organizationId: string +} + +function ActivityList({ organizationId }: ActivityListProps) { + const { + data: entries, + isLoading, + isError, + error, + isFetching, + refetch, + } = useScimActivity(organizationId) + + if (isLoading) { + return Loading activity... + } + if (isError) { + return ( + void refetch()} + variant='inline' + /> + ) + } + if (!entries || entries.length === 0) { + return No requests yet. + } + + return ( +
+ {entries.map((entry: ScimActivityEntry) => { + const failed = entry.status >= 400 + return ( +
+
+ + {entry.status} + + + {entry.method} {entry.path} + + + {formatRelative(entry.createdAt)} + +
+ {failed && entry.detail && ( +

+ {entry.scimType ? `${entry.scimType}: ` : ''} + {entry.detail} +

+ )} +
+ ) + })} +
+ ) +} + +interface ConnectionDetailsProps { + organizationId: string + connection: ScimConnectionView +} + +function ConnectionDetails({ organizationId, connection }: ConnectionDetailsProps) { + const configure = useConfigureScimConnection() + const issueCredential = useIssueScimCredential() + const revokeCredential = useRevokeScimCredential() + const reconcile = useReconcileScimConnection() + + const [issuedSecret, setIssuedSecret] = useState(null) + const [credentialExpiry, setCredentialExpiry] = useState('never') + const [pendingRevoke, setPendingRevoke] = useState(null) + + async function handleToggleSetting(key: (typeof SETTING_TOGGLES)[number]['key'], value: boolean) { + try { + /** Only the changed key is sent; the server merges it, so a concurrent edit elsewhere is not reverted. */ + await configure.mutateAsync({ organizationId, settings: { [key]: value } }) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to update setting')) + } + } + + async function handleIssue() { + try { + const result = await issueCredential.mutateAsync({ + organizationId, + ...(credentialExpiry === 'never' ? {} : { expiresInDays: Number(credentialExpiry) }), + }) + setIssuedSecret(result.secret) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to issue token')) + } + } + + async function handleConfirmRevoke() { + if (!pendingRevoke) return + try { + await revokeCredential.mutateAsync({ organizationId, credentialId: pendingRevoke.id }) + setPendingRevoke(null) + toast.success('Token revoked') + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to revoke token')) + } + } + + async function handleReconcile() { + try { + const report = await reconcile.mutateAsync(organizationId) + const corrections = report.grantsAdded + report.grantsRemoved + toast.success( + corrections === 0 + ? `Checked ${report.reconciledUsers} members; nothing to correct` + : `Checked ${report.reconciledUsers} members; corrected ${corrections} grant${corrections === 1 ? '' : 's'}` + ) + } catch (error) { + toast.error(getErrorMessage(error, 'Reconciliation failed')) + } + } + + return ( + <> + + + + + +

+ {connection.userCount} provisioned member{connection.userCount === 1 ? '' : 's'},{' '} + {connection.groupCount} group{connection.groupCount === 1 ? '' : 's'}. Last request{' '} + {formatRelative(connection.lastRequestAt)}; last reconciled{' '} + {formatRelative(connection.reconciledAt)}. +

+
+ + {SETTING_TOGGLES.map((toggle) => ( + + void handleToggleSetting(toggle.key, checked)} + disabled={configure.isPending} + /> + + ))} + + +
+ {connection.credentials.length === 0 ? ( + No tokens yet. + ) : ( + connection.credentials.map((credential) => ( + + )) + )} +
+ setCredentialExpiry(next as CredentialExpiry)} + options={[...CREDENTIAL_EXPIRY_OPTIONS]} + /> + = 2} + > + {issueCredential.isPending ? 'Issuing...' : 'Issue token'} + +
+
+
+ + + + + + +
+ +
+ + {reconcile.isPending ? 'Reconciling...' : 'Reconcile now'} + +
+
+
+ + !open && setIssuedSecret(null)} + > + setIssuedSecret(null)}> + Credential issued + + + + + + + setIssuedSecret(null)} + primaryAction={{ label: 'Done', onClick: () => setIssuedSecret(null) }} + /> + + + !open && setPendingRevoke(null)} + title='Revoke token' + text={[ + 'Revoke ', + { text: pendingRevoke?.tokenPrefix ?? '', bold: true }, + '? Your identity provider stops syncing the moment it next uses this token. Issue a new token first if you are rotating.', + ]} + confirm={{ + label: 'Revoke', + onClick: handleConfirmRevoke, + pending: revokeCredential.isPending, + pendingLabel: 'Revoking...', + }} + /> + + ) +} + +/** + * Directory provisioning (SCIM) settings, rendered as a section of the SSO page. + * SSO decides who someone is; provisioning decides who exists and what they can + * reach, so the two are configured together. + */ +export function ScimSection({ organizationId }: ScimSectionProps) { + const { hosted, features } = useDeploymentShape() + /** Hosted ships provisioning with the enterprise plan, which the SSO page already gates; self-hosted follows the flag. */ + const available = hosted || features.scim + const { data, isLoading, isError, error, isFetching, refetch } = useScimConnection( + organizationId, + available + ) + const configure = useConfigureScimConnection() + + if (!available) return null + + if (isError) { + return ( + + void refetch()} + variant='inline' + /> + + ) + } + + const connection = data?.connection ?? null + const enabled = connection?.status === 'active' + + async function handleToggleEnabled(next: boolean) { + try { + await configure.mutateAsync({ organizationId, status: next ? 'active' : 'disabled' }) + toast.success(next ? 'Directory provisioning enabled' : 'Directory provisioning disabled') + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to update directory provisioning')) + } + } + + return ( + +
+ + void handleToggleEnabled(checked)} + disabled={isLoading || configure.isPending} + /> + + + {connection && enabled && ( + + )} +
+
+ ) +} diff --git a/apps/sim/ee/scim/hooks/scim.ts b/apps/sim/ee/scim/hooks/scim.ts new file mode 100644 index 00000000000..2f3ef2a8a8b --- /dev/null +++ b/apps/sim/ee/scim/hooks/scim.ts @@ -0,0 +1,171 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + configureScimConnectionContract, + deleteScimGroupMappingContract, + getScimConnectionContract, + issueScimCredentialContract, + listScimActivityContract, + listScimGroupMappingsContract, + reconcileScimConnectionContract, + revokeScimCredentialContract, + type ScimConnectionSettingsInput, + type ScimGroupMappingBody, + upsertScimGroupMappingContract, +} from '@/lib/api/contracts/organization-scim' + +export const SCIM_CONNECTION_STALE_TIME = 30 * 1000 +export const SCIM_MAPPINGS_STALE_TIME = 30 * 1000 +/** Activity is a debugging surface; it polls while mounted so a sync in progress shows up without a reload. */ +export const SCIM_ACTIVITY_STALE_TIME = 10 * 1000 +export const SCIM_ACTIVITY_REFETCH_INTERVAL = 15 * 1000 + +export const scimKeys = { + all: ['scim'] as const, + connections: () => [...scimKeys.all, 'connection'] as const, + connection: (organizationId?: string) => + [...scimKeys.connections(), organizationId ?? ''] as const, + mappingLists: () => [...scimKeys.all, 'mappings'] as const, + mappings: (organizationId?: string) => + [...scimKeys.mappingLists(), organizationId ?? ''] as const, + activities: () => [...scimKeys.all, 'activity'] as const, + activity: (organizationId?: string) => [...scimKeys.activities(), organizationId ?? ''] as const, +} + +export function useScimConnection(organizationId?: string, enabled = true) { + return useQuery({ + queryKey: scimKeys.connection(organizationId), + queryFn: ({ signal }) => + requestJson(getScimConnectionContract, { params: { id: organizationId as string }, signal }), + enabled: Boolean(organizationId) && enabled, + staleTime: SCIM_CONNECTION_STALE_TIME, + }) +} + +export function useScimGroupMappings(organizationId?: string, enabled = true) { + return useQuery({ + queryKey: scimKeys.mappings(organizationId), + queryFn: async ({ signal }) => { + const data = await requestJson(listScimGroupMappingsContract, { + params: { id: organizationId as string }, + signal, + }) + return data.groups + }, + enabled: Boolean(organizationId) && enabled, + staleTime: SCIM_MAPPINGS_STALE_TIME, + }) +} + +export function useScimActivity(organizationId?: string, enabled = true) { + return useQuery({ + queryKey: scimKeys.activity(organizationId), + queryFn: async ({ signal }) => { + const data = await requestJson(listScimActivityContract, { + params: { id: organizationId as string }, + query: { limit: 50 }, + signal, + }) + return data.entries + }, + enabled: Boolean(organizationId) && enabled, + staleTime: SCIM_ACTIVITY_STALE_TIME, + refetchInterval: SCIM_ACTIVITY_REFETCH_INTERVAL, + }) +} + +interface ConfigureScimConnectionVariables { + organizationId: string + status?: 'active' | 'disabled' + settings?: ScimConnectionSettingsInput +} + +export function useConfigureScimConnection() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ organizationId, ...body }: ConfigureScimConnectionVariables) => + requestJson(configureScimConnectionContract, { params: { id: organizationId }, body }), + onSettled: (_data, _error, { organizationId }) => { + queryClient.invalidateQueries({ queryKey: scimKeys.connection(organizationId) }) + }, + }) +} + +interface IssueScimCredentialVariables { + organizationId: string + expiresInDays?: number +} + +export function useIssueScimCredential() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ organizationId, expiresInDays }: IssueScimCredentialVariables) => + requestJson(issueScimCredentialContract, { + params: { id: organizationId }, + body: expiresInDays ? { expiresInDays } : {}, + }), + onSettled: (_data, _error, { organizationId }) => { + queryClient.invalidateQueries({ queryKey: scimKeys.connection(organizationId) }) + }, + }) +} + +interface RevokeScimCredentialVariables { + organizationId: string + credentialId: string +} + +export function useRevokeScimCredential() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ organizationId, credentialId }: RevokeScimCredentialVariables) => + requestJson(revokeScimCredentialContract, { params: { id: organizationId, credentialId } }), + onSettled: (_data, _error, { organizationId }) => { + queryClient.invalidateQueries({ queryKey: scimKeys.connection(organizationId) }) + }, + }) +} + +interface UpsertScimGroupMappingVariables { + organizationId: string + body: ScimGroupMappingBody +} + +export function useUpsertScimGroupMapping() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ organizationId, body }: UpsertScimGroupMappingVariables) => + requestJson(upsertScimGroupMappingContract, { params: { id: organizationId }, body }), + onSettled: (_data, _error, { organizationId }) => { + queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) + }, + }) +} + +interface DeleteScimGroupMappingVariables { + organizationId: string + mappingId: string +} + +export function useDeleteScimGroupMapping() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ organizationId, mappingId }: DeleteScimGroupMappingVariables) => + requestJson(deleteScimGroupMappingContract, { params: { id: organizationId, mappingId } }), + onSettled: (_data, _error, { organizationId }) => { + queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) + }, + }) +} + +export function useReconcileScimConnection() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (organizationId: string) => + requestJson(reconcileScimConnectionContract, { params: { id: organizationId } }), + onSettled: (_data, _error, organizationId) => { + queryClient.invalidateQueries({ queryKey: scimKeys.connection(organizationId) }) + queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) + }, + }) +} diff --git a/apps/sim/ee/scim/lib/application/admin/connection-view.ts b/apps/sim/ee/scim/lib/application/admin/connection-view.ts new file mode 100644 index 00000000000..c59c373143f --- /dev/null +++ b/apps/sim/ee/scim/lib/application/admin/connection-view.ts @@ -0,0 +1,119 @@ +import { db } from '@sim/db' +import { + type ScimConnectionSettings, + type ScimScope, + scimConnection, + scimCredential, + scimGroup, + scimUser, + workspace, +} from '@sim/db/schema' +import { and, count, desc, eq } from 'drizzle-orm' +import type { ScimConnectionView, ScimCredentialView } from '@/lib/api/contracts/organization-scim' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { scimBaseUrl } from '@/ee/scim/lib/base-url' +import { activeCredentialCondition } from '@/ee/scim/lib/repository/credentials' + +/** Reads shared by the admin use cases: the connection row and its settings view. */ + +interface ConnectionRow { + id: string + organizationId: string + status: string + settings: ScimConnectionSettings +} + +/** The organization's connection, or the not-found refusal every admin write shares. */ +export async function requireConnection(organizationId: string): Promise { + const [row] = await db + .select({ + id: scimConnection.id, + organizationId: scimConnection.organizationId, + status: scimConnection.status, + settings: scimConnection.settings, + }) + .from(scimConnection) + .where(eq(scimConnection.organizationId, organizationId)) + .limit(1) + if (!row) { + throw new OrchestrationError( + 'not_found', + 'Enable directory provisioning for this organization first' + ) + } + return row +} + +/** Refuses a workspace id from outside the organization, on every path that grants access to one. */ +export async function assertWorkspaceInOrganization( + organizationId: string, + workspaceId: string +): Promise { + const [target] = await db + .select({ id: workspace.id }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), eq(workspace.organizationId, organizationId))) + .limit(1) + if (!target) { + throw new OrchestrationError('not_found', 'That workspace does not belong to this organization') + } +} + +export function toCredentialView(row: { + id: string + tokenPrefix: string + scopes: ScimScope[] + expiresAt: Date | null + lastUsedAt: Date | null + createdAt: Date +}): ScimCredentialView { + return { + id: row.id, + tokenPrefix: row.tokenPrefix, + scopes: row.scopes, + expiresAt: row.expiresAt?.toISOString() ?? null, + lastUsedAt: row.lastUsedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + } +} + +export async function loadConnectionView( + organizationId: string +): Promise { + const [row] = await db + .select() + .from(scimConnection) + .where(eq(scimConnection.organizationId, organizationId)) + .limit(1) + if (!row) return null + + const [credentials, [users], [groups]] = await Promise.all([ + db + .select({ + id: scimCredential.id, + tokenPrefix: scimCredential.tokenPrefix, + scopes: scimCredential.scopes, + expiresAt: scimCredential.expiresAt, + lastUsedAt: scimCredential.lastUsedAt, + createdAt: scimCredential.createdAt, + }) + .from(scimCredential) + .where(activeCredentialCondition(row.id)) + .orderBy(desc(scimCredential.createdAt)), + db.select({ value: count() }).from(scimUser).where(eq(scimUser.connectionId, row.id)), + db.select({ value: count() }).from(scimGroup).where(eq(scimGroup.connectionId, row.id)), + ]) + + return { + id: row.id, + status: row.status === 'disabled' ? 'disabled' : 'active', + baseUrl: scimBaseUrl(), + settings: row.settings, + lastRequestAt: row.lastRequestAt?.toISOString() ?? null, + reconciledAt: row.reconciledAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + credentials: credentials.map(toCredentialView), + userCount: users?.value ?? 0, + groupCount: groups?.value ?? 0, + } +} diff --git a/apps/sim/ee/scim/lib/application/admin/connection.ts b/apps/sim/ee/scim/lib/application/admin/connection.ts new file mode 100644 index 00000000000..4507596a9a9 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/admin/connection.ts @@ -0,0 +1,159 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { type ScimConnectionSettings, scimConnection, scimRequestLog } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { desc, eq } from 'drizzle-orm' +import type { ScimConnectionSettingsInput } from '@/lib/api/contracts/organization-scim' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + loadConnectionView, + requireConnection, +} from '@/ee/scim/lib/application/admin/connection-view' +import { + defineAuthorizedScimAdminUseCase, + type ScimAdminUseCaseArgs, +} from '@/ee/scim/lib/application/authorized-scim-admin-use-case' +import { scimAdminOperations } from '@/ee/scim/lib/application/operations' +import { reconcileConnection } from '@/ee/scim/lib/reconcile/job' + +/** The connection itself: reading it, enabling and configuring it, and running a drift pass. */ + +export const getScimConnection = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.read, + async execute({ input }: ScimAdminUseCaseArgs<{ organizationId: string }>) { + return { connection: await loadConnectionView(input.organizationId) } + }, +}) + +export interface ConfigureScimConnectionInput { + organizationId: string + status?: 'active' | 'disabled' + settings?: ScimConnectionSettingsInput +} + +export const configureScimConnection = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.configure, + async execute({ input, context }: ScimAdminUseCaseArgs) { + const { created, previousStatus, status } = await db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, context.organizationId) + const [existing] = await tx + .select({ + id: scimConnection.id, + settings: scimConnection.settings, + status: scimConnection.status, + }) + .from(scimConnection) + .where(eq(scimConnection.organizationId, context.organizationId)) + .limit(1) + + const nextSettings: ScimConnectionSettings = { + /** + * Locking manual membership defaults on for a new connection. Once a + * directory owns membership, a change made only in Sim is reverted by + * the next sync, so a member edited by hand looks like it worked and + * then silently does not. + */ + lockManualMembership: true, + ...(existing?.settings ?? {}), + ...(input.settings ?? {}), + } + const nextStatus = input.status ?? existing?.status ?? 'active' + + if (existing) { + await tx + .update(scimConnection) + .set({ + status: nextStatus, + settings: nextSettings, + updatedAt: new Date(), + }) + .where(eq(scimConnection.id, existing.id)) + } else { + await tx.insert(scimConnection).values({ + id: generateId(), + organizationId: context.organizationId, + status: nextStatus, + settings: nextSettings, + createdBy: context.actorUserId, + }) + } + return { created: !existing, previousStatus: existing?.status ?? null, status: nextStatus } + }) + + const view = await loadConnectionView(context.organizationId) + if (!view || view.status !== status) { + throw new OrchestrationError('internal', 'The connection could not be read back') + } + return { connection: view, created, previousStatus } + }, + /** The action names the transition: enabling (first time or again), disabling, or editing in place. */ + projectAudit: ({ result }) => ({ + action: + result.connection.status !== result.previousStatus + ? result.connection.status === 'active' + ? AuditAction.SCIM_CONNECTION_ENABLED + : AuditAction.SCIM_CONNECTION_DISABLED + : AuditAction.SCIM_CONNECTION_SETTINGS_UPDATED, + resourceType: AuditResourceType.SCIM_CONNECTION, + resourceId: result.connection.id, + metadata: { status: result.connection.status }, + }), +}) + +export const listScimActivity = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.listActivity, + async execute({ input }: ScimAdminUseCaseArgs<{ organizationId: string; limit?: number }>) { + const rows = await db + .select({ + id: scimRequestLog.id, + method: scimRequestLog.method, + path: scimRequestLog.path, + status: scimRequestLog.status, + scimType: scimRequestLog.scimType, + detail: scimRequestLog.detail, + userAgent: scimRequestLog.userAgent, + durationMs: scimRequestLog.durationMs, + createdAt: scimRequestLog.createdAt, + }) + .from(scimRequestLog) + .innerJoin(scimConnection, eq(scimConnection.id, scimRequestLog.connectionId)) + .where(eq(scimConnection.organizationId, input.organizationId)) + .orderBy(desc(scimRequestLog.createdAt)) + .limit(input.limit ?? 50) + + return { + entries: rows.map((row) => ({ ...row, createdAt: row.createdAt.toISOString() })), + } + }, +}) + +export const reconcileScimConnection = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.reconcile, + async execute({ input }: ScimAdminUseCaseArgs<{ organizationId: string }>) { + const connection = await requireConnection(input.organizationId) + if (connection.status !== 'active') { + throw new OrchestrationError( + 'validation', + 'Enable directory provisioning before running a reconciliation' + ) + } + + /** + * The same lease-protected pass the scheduler runs, so an administrator's + * click and the hourly sweep can never reconcile one connection at once. + */ + const report = await reconcileConnection(connection) + if (!report) { + throw new OrchestrationError( + 'conflict', + 'A reconciliation is already running for this organization; try again in a few minutes' + ) + } + return { + reconciledUsers: report.reconciledUsers, + grantsAdded: report.grantsAdded, + grantsRemoved: report.grantsRemoved, + } + }, +}) diff --git a/apps/sim/ee/scim/lib/application/admin/credentials.ts b/apps/sim/ee/scim/lib/application/admin/credentials.ts new file mode 100644 index 00000000000..ee603ab9885 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/admin/credentials.ts @@ -0,0 +1,130 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { SCIM_SCOPES, scimConnection, scimCredential } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, count, eq, isNull, sql } from 'drizzle-orm' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + requireConnection, + toCredentialView, +} from '@/ee/scim/lib/application/admin/connection-view' +import { + defineAuthorizedScimAdminUseCase, + type ScimAdminUseCaseArgs, +} from '@/ee/scim/lib/application/authorized-scim-admin-use-case' +import { scimAdminOperations } from '@/ee/scim/lib/application/operations' +import { generateScimToken } from '@/ee/scim/lib/authenticate' +import { activeCredentialCondition } from '@/ee/scim/lib/repository/credentials' + +/** + * Issuing and revoking the bearer credentials a directory authenticates with. + * + * Two may be active at once. That is the whole point of rotation: an + * administrator issues the replacement, updates the directory, confirms it + * works, and only then revokes the old one — with no window where the directory + * cannot authenticate. + */ +const MAX_ACTIVE_CREDENTIALS = 2 + +const DAY_MS = 24 * 60 * 60 * 1000 + +export interface IssueScimCredentialInput { + organizationId: string + expiresInDays?: number +} + +export const issueScimCredential = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.issueCredential, + async execute({ input, context }: ScimAdminUseCaseArgs) { + const connection = await requireConnection(context.organizationId) + const { secret, hash, prefix } = generateScimToken() + /** Every credential carries every scope today; the scope check stays as the enforcement layer. */ + const scopes = [...SCIM_SCOPES] + const expiresAt = input.expiresInDays + ? new Date(Date.now() + input.expiresInDays * DAY_MS) + : null + + const created = await db.transaction(async (tx) => { + /** Two issue requests serialize on the organization lock, so both cannot see one free slot. */ + await acquireOrganizationMutationLock(tx, context.organizationId) + + const [active] = await tx + .select({ value: count() }) + .from(scimCredential) + .where(activeCredentialCondition(connection.id)) + if ((active?.value ?? 0) >= MAX_ACTIVE_CREDENTIALS) { + throw new OrchestrationError( + 'conflict', + `At most ${MAX_ACTIVE_CREDENTIALS} credentials may be active at once. Revoke one before issuing another.` + ) + } + + const [row] = await tx + .insert(scimCredential) + .values({ + id: generateId(), + connectionId: connection.id, + tokenHash: hash, + tokenPrefix: prefix, + scopes, + expiresAt, + createdBy: context.actorUserId, + }) + .returning({ + id: scimCredential.id, + tokenPrefix: scimCredential.tokenPrefix, + scopes: scimCredential.scopes, + expiresAt: scimCredential.expiresAt, + lastUsedAt: scimCredential.lastUsedAt, + createdAt: scimCredential.createdAt, + }) + return row + }) + + return { secret, credential: toCredentialView(created), connectionId: connection.id } + }, + /** The prefix identifies the credential; the secret is never recorded. */ + projectAudit: ({ result }) => ({ + action: AuditAction.SCIM_CREDENTIAL_ISSUED, + resourceType: AuditResourceType.SCIM_CONNECTION, + resourceId: result.connectionId, + metadata: { tokenPrefix: result.credential.tokenPrefix, scopes: result.credential.scopes }, + }), +}) + +export const revokeScimCredential = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.revokeCredential, + async execute({ + input, + context, + }: ScimAdminUseCaseArgs<{ organizationId: string; credentialId: string }>) { + const [revoked] = await db + .update(scimCredential) + .set({ revokedAt: new Date(), revokedBy: context.actorUserId }) + .where( + and( + eq(scimCredential.id, input.credentialId), + isNull(scimCredential.revokedAt), + sql`${scimCredential.connectionId} in ( + select ${scimConnection.id} from ${scimConnection} + where ${scimConnection.organizationId} = ${context.organizationId} + )` + ) + ) + .returning({ + id: scimCredential.id, + tokenPrefix: scimCredential.tokenPrefix, + connectionId: scimCredential.connectionId, + }) + + if (!revoked) throw new OrchestrationError('not_found', 'Token not found') + return { success: true as const, revoked } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.SCIM_CREDENTIAL_REVOKED, + resourceType: AuditResourceType.SCIM_CONNECTION, + resourceId: result.revoked.connectionId, + metadata: { credentialId: result.revoked.id, tokenPrefix: result.revoked.tokenPrefix }, + }), +}) diff --git a/apps/sim/ee/scim/lib/application/admin/mappings.ts b/apps/sim/ee/scim/lib/application/admin/mappings.ts new file mode 100644 index 00000000000..563afe2c758 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/admin/mappings.ts @@ -0,0 +1,324 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { + permissionGroup, + type ScimConnectionSettings, + scimConnection, + scimGroup, + scimGroupMapping, + scimGroupMember, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, count, eq, sql } from 'drizzle-orm' +import type { ScimGroupMappingView } from '@/lib/api/contracts/organization-scim' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { + assertWorkspaceInOrganization, + requireConnection, +} from '@/ee/scim/lib/application/admin/connection-view' +import { + defineAuthorizedScimAdminUseCase, + type ScimAdminUseCaseArgs, +} from '@/ee/scim/lib/application/authorized-scim-admin-use-case' +import { scimAdminOperations } from '@/ee/scim/lib/application/operations' +import { reconcileUsersProjectionInBatches } from '@/ee/scim/lib/projection/reconcile-user' + +/** + * Mapping directory groups onto Sim access. + * + * A pushed group means nothing until an administrator says what it stands for: + * a permission group, a workspace at a level, or the organization admin role. + * Changing a mapping re-projects every member of the group at once, so the + * change takes effect without waiting for the directory's next cycle. + */ + +const MAPPING_COLUMNS = { + id: scimGroupMapping.id, + groupId: scimGroupMapping.groupId, + targetKind: scimGroupMapping.targetKind, + permissionGroupId: scimGroupMapping.permissionGroupId, + workspaceId: scimGroupMapping.workspaceId, + permissionType: scimGroupMapping.permissionType, + role: scimGroupMapping.role, +} as const + +function toMappingView(row: { + id: string + groupId: string + groupDisplayName: string + targetKind: string + permissionGroupId: string | null + workspaceId: string | null + permissionType: 'admin' | 'write' | 'read' | null + role: string | null +}): ScimGroupMappingView { + return { + id: row.id, + groupId: row.groupId, + groupDisplayName: row.groupDisplayName, + targetKind: row.targetKind as ScimGroupMappingView['targetKind'], + permissionGroupId: row.permissionGroupId, + workspaceId: row.workspaceId, + permissionType: row.permissionType, + role: row.role, + } +} + +export const listScimGroupMappings = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.read, + async execute({ input }: ScimAdminUseCaseArgs<{ organizationId: string }>) { + const [connection] = await db + .select({ id: scimConnection.id }) + .from(scimConnection) + .where(eq(scimConnection.organizationId, input.organizationId)) + .limit(1) + if (!connection) return { groups: [] } + + const [groups, mappings, counts] = await Promise.all([ + db + .select({ id: scimGroup.id, displayName: scimGroup.displayName }) + .from(scimGroup) + .where(eq(scimGroup.connectionId, connection.id)) + .orderBy(scimGroup.displayName), + db + .select({ ...MAPPING_COLUMNS, groupDisplayName: scimGroup.displayName }) + .from(scimGroupMapping) + .innerJoin(scimGroup, eq(scimGroup.id, scimGroupMapping.groupId)) + .where(eq(scimGroup.connectionId, connection.id)), + db + .select({ groupId: scimGroupMember.groupId, value: count() }) + .from(scimGroupMember) + .innerJoin(scimGroup, eq(scimGroup.id, scimGroupMember.groupId)) + .where(eq(scimGroup.connectionId, connection.id)) + .groupBy(scimGroupMember.groupId), + ]) + + const memberCounts = new Map(counts.map((row) => [row.groupId, Number(row.value)])) + const mappingsByGroup = new Map() + for (const row of mappings) { + const list = mappingsByGroup.get(row.groupId) ?? [] + list.push(toMappingView(row)) + mappingsByGroup.set(row.groupId, list) + } + + return { + groups: groups.map((group) => ({ + id: group.id, + displayName: group.displayName, + memberCount: memberCounts.get(group.id) ?? 0, + mappings: mappingsByGroup.get(group.id) ?? [], + })), + } + }, +}) + +/** Re-runs the projection for every member of a group whose mapping changed. */ +async function reconcileGroupMembers(params: { + connectionId: string + organizationId: string + groupId: string + settings: ScimConnectionSettings +}): Promise { + const members = await db + .select({ scimUserId: scimGroupMember.scimUserId }) + .from(scimGroupMember) + .where(eq(scimGroupMember.groupId, params.groupId)) + + await reconcileUsersProjectionInBatches({ + connectionId: params.connectionId, + organizationId: params.organizationId, + scimUserIds: members.map((row) => row.scimUserId), + settings: params.settings, + }) + return members.length +} + +async function requireGroup(connectionId: string, groupId: string) { + const [group] = await db + .select({ id: scimGroup.id, displayName: scimGroup.displayName }) + .from(scimGroup) + .where(and(eq(scimGroup.id, groupId), eq(scimGroup.connectionId, connectionId))) + .limit(1) + if (!group) throw new OrchestrationError('not_found', 'Directory group not found') + return group +} + +async function assertPermissionGroupTarget( + tx: DbOrTx, + organizationId: string, + permissionGroupId: string +) { + const [target] = await tx + .select({ + id: permissionGroup.id, + membershipMode: permissionGroup.membershipMode, + isDefault: permissionGroup.isDefault, + }) + .from(permissionGroup) + .where( + and( + eq(permissionGroup.id, permissionGroupId), + eq(permissionGroup.organizationId, organizationId) + ) + ) + .limit(1) + if (!target) { + throw new OrchestrationError( + 'not_found', + 'That permission group does not belong to this organization' + ) + } + /** The default group governs by not having members; a membership mapping onto it would do nothing. */ + if (target.isDefault) { + throw new OrchestrationError( + 'validation', + 'The organization default permission group cannot be a mapping target' + ) + } + /** + * A directory-managed group must govern exactly its members. Left in + * `inherit` mode, the directory removing the last person would widen it + * from "these people" to "everyone in these workspaces". + */ + if (target.membershipMode !== 'explicit') { + await acquirePermissionGroupOrgLock(tx, organizationId, { lockTimeoutAlreadyBounded: true }) + await tx + .update(permissionGroup) + .set({ membershipMode: 'explicit', updatedAt: new Date() }) + .where(eq(permissionGroup.id, target.id)) + } +} + +export type UpsertScimGroupMappingInput = { organizationId: string } & ( + | { targetKind: 'permission_group'; groupId: string; permissionGroupId: string } + | { + targetKind: 'workspace' + groupId: string + workspaceId: string + permissionType: 'admin' | 'write' | 'read' + } + | { targetKind: 'org_role'; groupId: string; role: 'admin' } +) + +export const upsertScimGroupMapping = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.upsertMapping, + async execute({ input, context }: ScimAdminUseCaseArgs) { + const connection = await requireConnection(context.organizationId) + const group = await requireGroup(connection.id, input.groupId) + if (input.targetKind === 'workspace') { + await assertWorkspaceInOrganization(context.organizationId, input.workspaceId) + } + + const values = { + id: generateId(), + groupId: group.id, + targetKind: input.targetKind, + permissionGroupId: input.targetKind === 'permission_group' ? input.permissionGroupId : null, + workspaceId: input.targetKind === 'workspace' ? input.workspaceId : null, + permissionType: input.targetKind === 'workspace' ? input.permissionType : null, + role: input.targetKind === 'org_role' ? input.role : null, + source: 'manual', + createdBy: context.actorUserId, + } + + /** + * The target check, the insert, and the fallback update commit together, so + * a refused insert cannot leave a permission group switched to explicit + * mode with no mapping. The uniqueness index is on a `coalesce` of the three + * target columns — an expression, not a column list — so it cannot be named + * as a conflict target; a concurrent insert of the same pair loses on the + * index and the row that won is updated instead. + */ + const targetId = values.permissionGroupId ?? values.workspaceId ?? values.role + const mapping = await db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, context.organizationId) + if (input.targetKind === 'permission_group') { + await assertPermissionGroupTarget(tx, context.organizationId, input.permissionGroupId) + } + const [inserted] = await tx + .insert(scimGroupMapping) + .values(values) + .onConflictDoNothing() + .returning(MAPPING_COLUMNS) + if (inserted) return inserted + + /** An administrator re-mapping a pair the directory matched by name takes ownership of it. */ + const [updated] = await tx + .update(scimGroupMapping) + .set({ + permissionType: values.permissionType, + source: 'manual', + createdBy: context.actorUserId, + }) + .where( + and( + eq(scimGroupMapping.groupId, group.id), + eq(scimGroupMapping.targetKind, input.targetKind), + sql`coalesce(${scimGroupMapping.permissionGroupId}, ${scimGroupMapping.workspaceId}, ${scimGroupMapping.role}) = ${targetId}` + ) + ) + .returning(MAPPING_COLUMNS) + if (!updated) throw new OrchestrationError('internal', 'The mapping could not be written') + return updated + }) + + const reconciledUsers = await reconcileGroupMembers({ + connectionId: connection.id, + organizationId: context.organizationId, + groupId: group.id, + settings: connection.settings, + }) + + return { + mapping: toMappingView({ ...mapping, groupDisplayName: group.displayName }), + reconciledUsers, + } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.SCIM_GROUP_MAPPING_UPSERTED, + resourceType: AuditResourceType.SCIM_GROUP, + resourceId: result.mapping.groupId, + resourceName: result.mapping.groupDisplayName, + metadata: { targetKind: result.mapping.targetKind, mappingId: result.mapping.id }, + }), +}) + +export const deleteScimGroupMapping = defineAuthorizedScimAdminUseCase({ + operation: scimAdminOperations.deleteMapping, + async execute({ + input, + context, + }: ScimAdminUseCaseArgs<{ organizationId: string; mappingId: string }>) { + const connection = await requireConnection(context.organizationId) + + const [mapping] = await db + .select({ id: scimGroupMapping.id, groupId: scimGroupMapping.groupId }) + .from(scimGroupMapping) + .innerJoin(scimGroup, eq(scimGroup.id, scimGroupMapping.groupId)) + .where( + and(eq(scimGroupMapping.id, input.mappingId), eq(scimGroup.connectionId, connection.id)) + ) + .limit(1) + if (!mapping) throw new OrchestrationError('not_found', 'Mapping not found') + + await db.delete(scimGroupMapping).where(eq(scimGroupMapping.id, mapping.id)) + + const reconciledUsers = await reconcileGroupMembers({ + connectionId: connection.id, + organizationId: context.organizationId, + groupId: mapping.groupId, + settings: connection.settings, + }) + + return { success: true as const, reconciledUsers, mapping } + }, + projectAudit: ({ result }) => ({ + action: AuditAction.SCIM_GROUP_MAPPING_DELETED, + resourceType: AuditResourceType.SCIM_GROUP, + resourceId: result.mapping.groupId, + metadata: { mappingId: result.mapping.id }, + }), +}) diff --git a/apps/sim/ee/scim/lib/application/audit.ts b/apps/sim/ee/scim/lib/application/audit.ts new file mode 100644 index 00000000000..2ad579a8d5c --- /dev/null +++ b/apps/sim/ee/scim/lib/application/audit.ts @@ -0,0 +1,42 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +/** One semantic audit entry a SCIM or admin use case projects from its result. */ +export interface ScimAuditEntry { + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +/** + * Records the audit entries a use case projected, with one attribution and one + * set of contextual metadata applied to every entry. Both wrappers — the + * directory's and the administrator's — record through here so an audit row + * looks the same whichever surface produced it. + */ +export function recordScimAuditEntries(params: { + /** Null for the directory itself, which has no user account. */ + actorId: string | null + actorName?: string + entries: readonly ScimAuditEntry[] + metadata: Record + request: OrchestrationRequestContext | undefined +}): void { + for (const entry of params.entries) { + recordAudit({ + workspaceId: null, + actorId: params.actorId, + ...(params.actorName ? { actorName: params.actorName } : {}), + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { ...entry.metadata, ...params.metadata }, + request: params.request, + }) + } +} diff --git a/apps/sim/ee/scim/lib/application/authorized-scim-admin-use-case.ts b/apps/sim/ee/scim/lib/application/authorized-scim-admin-use-case.ts new file mode 100644 index 00000000000..28b123a8146 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/authorized-scim-admin-use-case.ts @@ -0,0 +1,116 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { member } from '@sim/db/schema' +import { and, eq } from 'drizzle-orm' +import { ForbiddenOperationError, type OperationUseCase } from '@/lib/core/application' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { recordScimAuditEntries, type ScimAuditEntry } from '@/ee/scim/lib/application/audit' +import type { ScimAdminOperation, ScimAdminPrincipal } from '@/ee/scim/lib/application/operations' +import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' + +/** + * The authorized wrapper for administering a connection from the settings UI. + * + * Gate order is deliberate and each step is its own refusal, so a failure says + * which rule stopped it: principal kind, then organization membership, then the + * admin role, then the entitlement. Mirrors the organization BYOK and usage + * wrappers rather than inventing a fourth shape, and mirrors the directory + * wrapper's audit projection so both halves of the surface record audit the + * same way. + */ + +export interface ScimAdminContext { + organizationId: string + actorUserId: string +} + +export interface ScimAdminUseCaseArgs { + principal: ScimAdminPrincipal + input: I + context: ScimAdminContext + request?: OrchestrationRequestContext +} + +export interface ScimAdminUseCaseResultArgs extends ScimAdminUseCaseArgs { + result: R +} + +interface AuthorizedScimAdminDefinition< + O extends ScimAdminOperation, + I extends { organizationId: string }, + R, +> { + operation: O + execute(args: ScimAdminUseCaseArgs): Promise + /** Audit attributed to the administrator; the organization id is added for every entry. */ + projectAudit?(args: ScimAdminUseCaseResultArgs): ScimAuditEntry | undefined +} + +function requireScimAdminPrincipal( + principal: Principal, + operation: ScimAdminOperation +): asserts principal is ScimAdminPrincipal { + if (principal.kind !== 'session') { + throw new ForbiddenOperationError( + 'PRINCIPAL_KIND_NOT_PERMITTED', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + +export function defineAuthorizedScimAdminUseCase< + const O extends ScimAdminOperation, + I extends { organizationId: string }, + R, +>(definition: AuthorizedScimAdminDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + requireScimAdminPrincipal(principal, definition.operation) + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, input.organizationId), eq(member.userId, principal.userId)) + ) + .limit(1) + + if (!membership) { + throw new ForbiddenOperationError( + 'ORGANIZATION_MEMBERSHIP_REQUIRED', + 'Not a member of the requested organization' + ) + } + if (!definition.operation.organizationRoles.some((role) => role === membership.role)) { + throw new ForbiddenOperationError( + 'ORGANIZATION_ADMIN_REQUIRED', + 'Organization admin or owner role required' + ) + } + if (!(await isScimEntitledForOrganization(input.organizationId))) { + throw new ForbiddenOperationError( + 'ENTERPRISE_PLAN_REQUIRED', + 'Directory provisioning requires an active enterprise subscription' + ) + } + + const context: ScimAdminContext = { + organizationId: input.organizationId, + actorUserId: principal.userId, + } + const result = await definition.execute({ principal, input, context, request }) + + const entry = definition.projectAudit?.({ principal, input, context, request, result }) + if (entry) { + recordScimAuditEntries({ + actorId: context.actorUserId, + entries: [entry], + metadata: { organizationId: context.organizationId }, + request, + }) + } + return result + }, + } +} diff --git a/apps/sim/ee/scim/lib/application/authorized-scim-use-case.ts b/apps/sim/ee/scim/lib/application/authorized-scim-use-case.ts new file mode 100644 index 00000000000..72541a1bb4e --- /dev/null +++ b/apps/sim/ee/scim/lib/application/authorized-scim-use-case.ts @@ -0,0 +1,157 @@ +import type { Principal } from '@sim/auth/principal' +import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { db } from '@sim/db' +import { type ScimConnectionSettings, scimConnection } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type { OperationUseCase } from '@/lib/core/application' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { recordScimAuditEntries, type ScimAuditEntry } from '@/ee/scim/lib/application/audit' +import type { ScimOperation, ScimPrincipal } from '@/ee/scim/lib/application/operations' +import { scimBaseUrl } from '@/ee/scim/lib/base-url' +import { ScimError } from '@/ee/scim/lib/protocol/errors' + +/** + * The authorized wrapper every directory operation runs inside. + * + * It owns the lifecycle the application boundary prescribes — principal check, + * canonical load, authorization, execute, audit projection, post-commit effects + * — for an operation whose scope is an organization rather than a workspace, so + * it cannot use `defineAuthorizedWorkspaceUseCase`. + */ + +export interface ScimUseCaseContext { + connection: { + id: string + organizationId: string + settings: ScimConnectionSettings + } + organizationId: string + /** Absolute base for `meta.location` and `$ref`, e.g. `https://sim.ai/api/scim/v2`. */ + baseUrl: string +} + +export interface ScimUseCaseArgs { + principal: ScimPrincipal + input: I + context: ScimUseCaseContext + request?: OrchestrationRequestContext +} + +export interface ScimUseCaseResultArgs extends ScimUseCaseArgs { + result: R +} + +interface AuthorizedScimUseCaseDefinition { + operation: O + execute(args: ScimUseCaseArgs): Promise + projectAudit?(args: ScimUseCaseResultArgs): ScimAuditEntry | ScimAuditEntry[] | undefined + afterSuccess?(args: ScimUseCaseResultArgs): Promise +} + +function requireScimPrincipal( + principal: Principal, + operation: ScimOperation +): asserts principal is ScimPrincipal { + if (principal.kind !== 'scim_connection') { + throw new Error( + `Operation ${operation.id} reached by principal kind ${principal.kind}, which its policy does not name` + ) + } +} + +/** + * Re-reads the connection on every operation. + * + * Authentication already resolved it, but a long-running provisioning cycle can + * outlive an administrator disabling the connection. Reading it here means the + * next request in that cycle stops, rather than the cycle continuing until its + * credential happens to be checked again. + */ +async function loadActiveConnection( + connectionId: string +): Promise { + const [row] = await db + .select({ + id: scimConnection.id, + organizationId: scimConnection.organizationId, + status: scimConnection.status, + settings: scimConnection.settings, + }) + .from(scimConnection) + .where(eq(scimConnection.id, connectionId)) + .limit(1) + + if (!row || row.status !== 'active') { + throw new ScimError(401, undefined, 'Invalid SCIM token', { + 'WWW-Authenticate': 'Bearer realm="SCIM"', + }) + } + return { id: row.id, organizationId: row.organizationId, settings: row.settings } +} + +export function defineAuthorizedScimUseCase( + definition: AuthorizedScimUseCaseDefinition +): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + requireScimPrincipal(principal, definition.operation) + + if (!principal.scopes.includes(definition.operation.scope)) { + throw new ScimError( + 403, + undefined, + `The credential does not carry the ${definition.operation.scope} scope` + ) + } + + const connection = await loadActiveConnection(principal.connectionId) + if (connection.organizationId !== principal.organizationId) { + /** + * Unreachable through the authenticator, which reads both from the same + * joined row. Asserted anyway because every query below is scoped by the + * connection alone, so a mismatch here would be a cross-tenant read. + */ + throw new Error('SCIM connection organization does not match its principal') + } + + const context: ScimUseCaseContext = { + connection, + organizationId: connection.organizationId, + baseUrl: scimBaseUrl(), + } + + const result = await definition.execute({ principal, input, context, request }) + const resultArgs = { principal, input, context, request, result } + + const projected = definition.projectAudit?.(resultArgs) + const entries = + projected === undefined ? [] : Array.isArray(projected) ? projected : [projected] + if (entries.length > 0) { + /** + * The actor is the connection, never a person: nobody was at a keyboard + * when the directory synchronized, and naming the administrator who + * configured it would attribute months of automated changes to one login. + */ + const attribution = resolvePrincipalAuditAttribution(principal) + recordScimAuditEntries({ + actorId: attribution.actorId, + actorName: attribution.actorName ?? undefined, + entries, + metadata: { + organizationId: context.organizationId, + connectionId: context.connection.id, + credentialId: principal.credentialId, + operation: definition.operation.id, + source: 'scim', + actor: attribution.actor, + }, + request, + }) + } + + await definition.afterSuccess?.(resultArgs) + return result + }, + } +} diff --git a/apps/sim/ee/scim/lib/application/groups/manage-groups.ts b/apps/sim/ee/scim/lib/application/groups/manage-groups.ts new file mode 100644 index 00000000000..7fc9ee761e0 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/groups/manage-groups.ts @@ -0,0 +1,503 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { scimGroup } from '@sim/db/schema' +import { and, eq, ne } from 'drizzle-orm' +import type { ScimPatchOperation } from '@/lib/api/contracts/scim' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' +import type { DbOrTx } from '@/lib/db/types' +import { + defineAuthorizedScimUseCase, + type ScimUseCaseArgs, + type ScimUseCaseContext, +} from '@/ee/scim/lib/application/authorized-scim-use-case' +import { scimOperations } from '@/ee/scim/lib/application/operations' +import { + autoMapPermissionGroupByName, + settleMappedPermissionGroupsExplicit, +} from '@/ee/scim/lib/projection/auto-map' +import { reconcileUsersProjection } from '@/ee/scim/lib/projection/reconcile-user' +import type { CanonicalScimGroup } from '@/ee/scim/lib/protocol/canonical' +import { SCIM_MAX_GROUP_MEMBERS } from '@/ee/scim/lib/protocol/constants' +import { invalidValue, notFound, ScimError, uniqueness } from '@/ee/scim/lib/protocol/errors' +import { parseGroupFilter } from '@/ee/scim/lib/protocol/filter' +import { parseGroupPatch } from '@/ee/scim/lib/protocol/group-patch' +import { + projectionWants, + projectResource, + resolvePage, + type ScimAttributeProjection, + type ScimGroupResource, + toGroupResource, +} from '@/ee/scim/lib/protocol/resources' +import { + addGroupMember, + countGroupMembers, + deleteScimGroupRow, + filterOwnedUsers, + findScimGroupById, + insertScimGroup, + loadGroupMemberIds, + loadGroupMembers, + loadGroupMembersForGroups, + pageScimGroups, + removeGroupMember, + touchScimGroup, + updateScimGroup, +} from '@/ee/scim/lib/repository/groups' + +/** Reads and writes of the Group resource, and the projection each change triggers. */ + +/** + * Every group write runs in one transaction under the organization lock, which + * heads the documented lock order, so two full-membership PATCHes cannot both + * compute from the same stale membership and keep members from both. + */ +function withGroupWrite( + context: ScimUseCaseContext, + work: (tx: DbOrTx) => Promise +): Promise { + return db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, context.organizationId) + return work(tx) + }) +} + +async function assertDisplayNameAvailable( + tx: DbOrTx, + params: { connectionId: string; displayName: string; exceptGroupId?: string } +): Promise { + const [clash] = await tx + .select({ id: scimGroup.id }) + .from(scimGroup) + .where( + and( + eq(scimGroup.connectionId, params.connectionId), + eq(scimGroup.displayNameKey, params.displayName.toLowerCase()), + ...(params.exceptGroupId ? [ne(scimGroup.id, params.exceptGroupId)] : []) + ) + ) + .limit(1) + if (clash) + throw uniqueness(`A group named ${params.displayName} already exists in this directory`) +} + +async function assertMemberCount(tx: DbOrTx, groupId: string): Promise { + const total = await countGroupMembers(tx, groupId) + if (total > SCIM_MAX_GROUP_MEMBERS) { + throw invalidValue(`A Group cannot carry more than ${SCIM_MAX_GROUP_MEMBERS} members`) + } +} + +export interface ListScimGroupsInput { + filter?: string | undefined + startIndex?: number | undefined + count?: number | undefined + projection: ScimAttributeProjection +} + +export const listScimGroups = defineAuthorizedScimUseCase({ + operation: scimOperations.listGroups, + async execute({ input, context }: ScimUseCaseArgs) { + const page = resolvePage({ startIndex: input.startIndex, count: input.count }) + const filters = input.filter ? parseGroupFilter(input.filter) : [] + const { records, totalResults } = await pageScimGroups(db, { + connectionId: context.connection.id, + filters, + offset: page.offset, + limit: page.count, + }) + + /** + * Microsoft Entra lists groups with `excludedAttributes=members` on every + * cycle. Honoring that skips the membership query entirely rather than + * loading thousands of rows only to drop them. + */ + const wantsMembers = projectionWants(input.projection, 'members') + const membersByGroup = wantsMembers + ? await loadGroupMembersForGroups( + db, + records.map((record) => record.id) + ) + : null + const resources: ScimGroupResource[] = records.map((record) => + projectResource( + toGroupResource( + { + ...record, + ...(membersByGroup ? { members: membersByGroup.get(record.id) ?? [] } : {}), + }, + context.baseUrl + ), + input.projection + ) + ) + + return { resources, totalResults, startIndex: page.startIndex } + }, +}) + +export interface GetScimGroupInput { + groupId: string + projection: ScimAttributeProjection +} + +export const getScimGroup = defineAuthorizedScimUseCase({ + operation: scimOperations.readGroup, + async execute({ input, context }: ScimUseCaseArgs) { + const record = await findScimGroupById(db, context.connection.id, input.groupId) + if (!record) throw notFound('SCIM Group not found') + /** + * Okta requires the member list on a bare read, so `members` is included + * unless the request explicitly excluded it. + */ + const members = projectionWants(input.projection, 'members') + ? await loadGroupMembers(db, record.id) + : undefined + return projectResource( + toGroupResource({ ...record, ...(members ? { members } : {}) }, context.baseUrl), + input.projection + ) + }, +}) + +export interface CreateScimGroupInput { + group: CanonicalScimGroup +} + +export interface ScimGroupWriteResult { + groupId: string + displayName: string + resource: ScimGroupResource + touchedUserIds: string[] + /** Whether the name or external id changed; always true for a create. */ + renamed: boolean +} + +export const createScimGroup = defineAuthorizedScimUseCase({ + operation: scimOperations.writeGroup, + async execute({ + input, + context, + }: ScimUseCaseArgs): Promise { + const { group } = input + return withGroupWrite(context, async (tx) => { + await assertDisplayNameAvailable(tx, { + connectionId: context.connection.id, + displayName: group.displayName, + }) + const memberIds = await filterOwnedUsers(tx, context.connection.id, group.memberIds) + + const created = await insertScimGroup(tx, { + connectionId: context.connection.id, + displayName: group.displayName, + externalId: group.externalId, + }) + for (const scimUserId of memberIds) { + await addGroupMember(tx, { groupId: created.id, scimUserId }) + } + await assertMemberCount(tx, created.id) + + const mapped = context.connection.settings.autoMapPermissionGroupsByName + ? await autoMapPermissionGroupByName(tx, { + organizationId: context.organizationId, + scimGroupId: created.id, + displayName: created.displayName, + }) + : 'no-match' + + await reconcileUsersProjection(tx, { + connectionId: context.connection.id, + organizationId: context.organizationId, + scimUserIds: memberIds, + settings: context.connection.settings, + }) + if (mapped === 'mapped') { + await settleMappedPermissionGroupsExplicit(tx, { + organizationId: context.organizationId, + scimGroupId: created.id, + }) + } + + const members = await loadGroupMembers(tx, created.id) + return { + groupId: created.id, + displayName: created.displayName, + resource: toGroupResource({ ...created, members }, context.baseUrl), + touchedUserIds: memberIds, + renamed: true, + } + }) + }, + projectAudit: ({ result }) => ({ + action: AuditAction.SCIM_GROUP_CREATED, + resourceType: AuditResourceType.SCIM_GROUP, + resourceId: result.groupId, + resourceName: result.displayName, + metadata: { memberCount: result.touchedUserIds.length }, + }), +}) + +export interface ReplaceScimGroupInput { + groupId: string + group: CanonicalScimGroup +} + +export const replaceScimGroup = defineAuthorizedScimUseCase({ + operation: scimOperations.writeGroup, + async execute({ + input, + context, + }: ScimUseCaseArgs): Promise { + return withGroupWrite(context, async (tx) => { + const current = await findScimGroupById(tx, context.connection.id, input.groupId) + if (!current) throw notFound('SCIM Group not found') + + await assertDisplayNameAvailable(tx, { + connectionId: context.connection.id, + displayName: input.group.displayName, + exceptGroupId: current.id, + }) + const memberIds = await filterOwnedUsers(tx, context.connection.id, input.group.memberIds) + + const before = await loadGroupMemberIds(tx, current.id) + const desired = new Set(memberIds) + const touched = new Set() + + const renamed = input.group.displayName !== current.displayName + if (renamed || (input.group.externalId ?? null) !== current.externalId) { + await updateScimGroup(tx, { + groupId: current.id, + displayName: input.group.displayName, + externalId: input.group.externalId ?? null, + }) + } + let adopted = false + if (renamed && context.connection.settings.autoMapPermissionGroupsByName) { + const mapped = await autoMapPermissionGroupByName(tx, { + organizationId: context.organizationId, + scimGroupId: current.id, + displayName: input.group.displayName, + }) + adopted = mapped === 'mapped' + /** A mapping gained or lost applies to everyone already in the group, not only to those moving today. */ + if (mapped === 'mapped' || mapped === 'unmapped') + for (const scimUserId of before) touched.add(scimUserId) + } + + for (const scimUserId of before) { + if (desired.has(scimUserId)) continue + await removeGroupMember(tx, { groupId: current.id, scimUserId }) + touched.add(scimUserId) + } + for (const scimUserId of desired) { + if (await addGroupMember(tx, { groupId: current.id, scimUserId })) touched.add(scimUserId) + } + await assertMemberCount(tx, current.id) + + await reconcileUsersProjection(tx, { + connectionId: context.connection.id, + organizationId: context.organizationId, + scimUserIds: [...touched], + settings: context.connection.settings, + }) + if (adopted) { + await settleMappedPermissionGroupsExplicit(tx, { + organizationId: context.organizationId, + scimGroupId: current.id, + }) + } + + const refreshed = await findScimGroupById(tx, context.connection.id, current.id) + if (!refreshed) throw new ScimError(500, undefined, 'The group could not be read back') + const members = await loadGroupMembers(tx, current.id) + return { + groupId: current.id, + displayName: refreshed.displayName, + resource: toGroupResource({ ...refreshed, members }, context.baseUrl), + touchedUserIds: [...touched], + renamed: renamed || (current.externalId ?? null) !== (refreshed.externalId ?? null), + } + }) + }, + /** Okta re-sends the whole group each cycle; an unchanged one records nothing. */ + projectAudit: ({ result }) => + result.touchedUserIds.length === 0 && !result.renamed + ? undefined + : { + action: AuditAction.SCIM_GROUP_UPDATED, + resourceType: AuditResourceType.SCIM_GROUP, + resourceId: result.groupId, + resourceName: result.displayName, + metadata: { membersChanged: result.touchedUserIds.length, renamed: result.renamed }, + }, +}) + +export interface PatchScimGroupInput { + groupId: string + operations: readonly ScimPatchOperation[] +} + +export interface PatchScimGroupResult { + groupId: string + displayName: string + touchedUserIds: string[] + renamed: boolean +} + +export const patchScimGroup = defineAuthorizedScimUseCase({ + operation: scimOperations.writeGroup, + async execute({ + input, + context, + }: ScimUseCaseArgs): Promise { + const patch = parseGroupPatch(input.operations) + + return withGroupWrite(context, async (tx) => { + const current = await findScimGroupById(tx, context.connection.id, input.groupId) + if (!current) throw notFound('SCIM Group not found') + + let adopted = false + const touched = new Set() + let renamed = false + + const applyAdds = async (ids: string[]) => { + for (const scimUserId of await filterOwnedUsers(tx, context.connection.id, ids)) { + if (await addGroupMember(tx, { groupId: current.id, scimUserId })) touched.add(scimUserId) + } + } + const applyRemoves = async (ids: string[]) => { + for (const scimUserId of ids) { + if (await removeGroupMember(tx, { groupId: current.id, scimUserId })) { + touched.add(scimUserId) + } + } + } + + if (patch.kind === 'incremental') { + await applyAdds(patch.add) + await applyRemoves(patch.remove) + } else { + if (patch.displayName !== undefined && patch.displayName !== current.displayName) { + await assertDisplayNameAvailable(tx, { + connectionId: context.connection.id, + displayName: patch.displayName, + exceptGroupId: current.id, + }) + renamed = true + } + const externalIdChanged = + patch.externalId !== undefined && patch.externalId !== current.externalId + if (renamed || externalIdChanged) { + await updateScimGroup(tx, { + groupId: current.id, + ...(renamed && patch.displayName !== undefined + ? { displayName: patch.displayName } + : {}), + ...(externalIdChanged ? { externalId: patch.externalId } : {}), + }) + } + if ( + renamed && + patch.displayName && + context.connection.settings.autoMapPermissionGroupsByName + ) { + const mapped = await autoMapPermissionGroupByName(tx, { + organizationId: context.organizationId, + scimGroupId: current.id, + displayName: patch.displayName, + }) + adopted = adopted || mapped === 'mapped' + if (mapped === 'mapped' || mapped === 'unmapped') { + for (const scimUserId of await loadGroupMemberIds(tx, current.id)) { + touched.add(scimUserId) + } + } + } + renamed = renamed || externalIdChanged + if (patch.members !== undefined) { + const before = await loadGroupMemberIds(tx, current.id) + const desired = new Set(patch.members) + await applyRemoves(before.filter((id) => !desired.has(id))) + await applyAdds([...desired].filter((id) => !before.includes(id))) + } + await applyAdds(patch.addMembers) + await applyRemoves(patch.removeMembers) + } + + await assertMemberCount(tx, current.id) + if (touched.size > 0) await touchScimGroup(tx, current.id) + + await reconcileUsersProjection(tx, { + connectionId: context.connection.id, + organizationId: context.organizationId, + scimUserIds: [...touched], + settings: context.connection.settings, + }) + if (adopted) { + await settleMappedPermissionGroupsExplicit(tx, { + organizationId: context.organizationId, + scimGroupId: current.id, + }) + } + + return { + groupId: current.id, + displayName: + patch.kind === 'full' ? (patch.displayName ?? current.displayName) : current.displayName, + touchedUserIds: [...touched], + renamed, + } + }) + }, + /** A patch that moved nobody and renamed nothing records nothing. */ + projectAudit: ({ result }) => + result.touchedUserIds.length === 0 && !result.renamed + ? undefined + : { + action: AuditAction.SCIM_GROUP_MEMBERSHIP_CHANGED, + resourceType: AuditResourceType.SCIM_GROUP, + resourceId: result.groupId, + resourceName: result.displayName, + metadata: { membersChanged: result.touchedUserIds.length, renamed: result.renamed }, + }, +}) + +export interface DeleteScimGroupInput { + groupId: string +} + +export const deleteScimGroup = defineAuthorizedScimUseCase({ + operation: scimOperations.deleteGroup, + async execute({ input, context }: ScimUseCaseArgs) { + return withGroupWrite(context, async (tx) => { + const current = await findScimGroupById(tx, context.connection.id, input.groupId) + if (!current) throw notFound('SCIM Group not found') + + /** + * Members are read before the delete cascades their rows away, because + * each of them loses whatever access the group's mappings granted and the + * projection has to be re-run for them afterwards. + */ + const memberIds = await loadGroupMemberIds(tx, current.id) + await deleteScimGroupRow(tx, current.id) + await reconcileUsersProjection(tx, { + connectionId: context.connection.id, + organizationId: context.organizationId, + scimUserIds: memberIds, + settings: context.connection.settings, + }) + return { + groupId: current.id, + displayName: current.displayName, + memberCount: memberIds.length, + } + }) + }, + projectAudit: ({ result }) => ({ + action: AuditAction.SCIM_GROUP_DELETED, + resourceType: AuditResourceType.SCIM_GROUP, + resourceId: result.groupId, + resourceName: result.displayName, + metadata: { memberCount: result.memberCount }, + }), +}) diff --git a/apps/sim/ee/scim/lib/application/operations.ts b/apps/sim/ee/scim/lib/application/operations.ts new file mode 100644 index 00000000000..6461f51d596 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/operations.ts @@ -0,0 +1,213 @@ +import type { Principal, ScimCredentialScope } from '@sim/auth/principal' +import type { ApplicationOperation } from '@/lib/core/application' +import { assertOperationCapability } from '@/lib/core/application' + +/** + * The semantic operations of directory provisioning. + * + * SCIM has its own operation type rather than reusing `defineWorkspaceOperation` + * for the same reason organization BYOK does: the caller has no workspace and no + * role in one. Its authority is the credential the organization issued, and the + * only policy left to declare is which credential scope each operation needs. + */ + +export type ScimPrincipal = Extract + +export interface ScimOperation extends ApplicationOperation { + readonly authority: 'scim_connection' + readonly principalKinds: readonly ['scim_connection'] + readonly scope: ScimCredentialScope +} + +function defineScimOperation( + operation: ScimOperation +): ScimOperation { + assertOperationCapability(operation) + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +export const scimOperations = { + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + listUsers: defineScimOperation({ + id: 'scim.users.list', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'users:read', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + readUser: defineScimOperation({ + id: 'scim.users.read', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'users:read', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + provisionUser: defineScimOperation({ + id: 'scim.users.provision', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'users:write', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + updateUser: defineScimOperation({ + id: 'scim.users.update', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'users:write', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + deprovisionUser: defineScimOperation({ + id: 'scim.users.deprovision', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'users:write', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + listGroups: defineScimOperation({ + id: 'scim.groups.list', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'groups:read', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + readGroup: defineScimOperation({ + id: 'scim.groups.read', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'groups:read', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + writeGroup: defineScimOperation({ + id: 'scim.groups.write', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'groups:write', + }), + // permission-group-exempt: the caller is the organization's identity provider, not a member a permission group can govern + deleteGroup: defineScimOperation({ + id: 'scim.groups.delete', + capability: 'none', + authority: 'scim_connection', + principalKinds: ['scim_connection'], + scope: 'groups:write', + }), +} as const + +/** + * Administration of the connection itself, performed by a person in the Sim + * settings UI rather than by the directory. + * + * Separate from the operations above because the authority is different in kind: + * an organization owner or admin holding a session, gated on the enterprise + * entitlement. Mirrors the organization BYOK operation shape. + */ +export interface ScimAdminOperation extends ApplicationOperation { + readonly authority: 'organization_admin' + readonly organizationRoles: readonly ['admin', 'owner'] + readonly principalKinds: readonly ['session'] + readonly workspaceApiKey: 'deny' +} + +export type ScimAdminPrincipal = Extract + +function defineScimAdminOperation( + operation: ScimAdminOperation +): ScimAdminOperation { + assertOperationCapability(operation) + Object.freeze(operation.organizationRoles) + Object.freeze(operation.principalKinds) + return Object.freeze(operation) +} + +const ADMIN_ROLES = ['admin', 'owner'] as const +const ADMIN_PRINCIPALS = ['session'] as const + +/** + * Each operation spells its policy out rather than spreading a shared literal. + * `check:permission-group-enforcement` reads these declarations from source, and + * a spread hides the capability from it — leaving the operation unaudited, which + * is exactly the gap that check exists to catch. + */ +export const scimAdminOperations = { + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + read: defineScimAdminOperation({ + id: 'scim.connection.read', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + configure: defineScimAdminOperation({ + id: 'scim.connection.configure', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + issueCredential: defineScimAdminOperation({ + id: 'scim.credential.issue', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + revokeCredential: defineScimAdminOperation({ + id: 'scim.credential.revoke', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + upsertMapping: defineScimAdminOperation({ + id: 'scim.group_mapping.upsert', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + deleteMapping: defineScimAdminOperation({ + id: 'scim.group_mapping.delete', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + listActivity: defineScimAdminOperation({ + id: 'scim.activity.list', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), + // permission-group-exempt: directory configuration is an owner/admin organization setting, like SSO provider registration + reconcile: defineScimAdminOperation({ + id: 'scim.connection.reconcile', + capability: 'none', + authority: 'organization_admin', + organizationRoles: ADMIN_ROLES, + principalKinds: ADMIN_PRINCIPALS, + workspaceApiKey: 'deny', + }), +} as const diff --git a/apps/sim/ee/scim/lib/application/users/deprovision-user.test.ts b/apps/sim/ee/scim/lib/application/users/deprovision-user.test.ts new file mode 100644 index 00000000000..9e5960bb4bf --- /dev/null +++ b/apps/sim/ee/scim/lib/application/users/deprovision-user.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { member, scimConnection } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + removeUser: vi.fn(), + reconcileSeats: vi.fn(), + endDirectoryMembership: vi.fn(), + findScimUserById: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + removeUserFromOrganization: mocks.removeUser, +})) +vi.mock('@/lib/billing/organizations/seats', () => ({ + reconcileOrganizationSeats: mocks.reconcileSeats, +})) +vi.mock('@/ee/scim/lib/identity/end-directory-membership', () => ({ + endDirectoryMembershipTx: mocks.endDirectoryMembership, +})) +vi.mock('@/ee/scim/lib/repository/users', () => ({ + findScimUserById: mocks.findScimUserById, +})) +vi.mock('@/ee/scim/lib/application/audit', () => ({ + recordScimAuditEntries: mocks.recordAudit, +})) +vi.mock('@/ee/scim/lib/base-url', () => ({ scimBaseUrl: () => 'https://sim.test/api/scim/v2' })) + +import type { Principal } from '@sim/auth/principal' +import { deprovisionScimUser } from '@/ee/scim/lib/application/users/deprovision-user' +import { ScimError } from '@/ee/scim/lib/protocol/errors' + +const principal: Principal = { + kind: 'scim_connection', + organizationId: 'org-1', + connectionId: 'conn-1', + credentialId: 'cred-1', + scopes: ['users:write'], +} + +function stage(membership: Array<{ id: string; role: string }>) { + queueTableRows(scimConnection, [ + { id: 'conn-1', organizationId: 'org-1', status: 'active', settings: {} }, + ]) + queueTableRows(member, membership) + mocks.findScimUserById.mockResolvedValue({ id: 'su-1', userId: 'u-1', externalId: 'ext-1' }) +} + +afterAll(resetDbChainMock) + +describe('deprovisionScimUser', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.removeUser.mockResolvedValue({ success: true }) + mocks.endDirectoryMembership.mockResolvedValue({ removed: 1 }) + mocks.reconcileSeats.mockResolvedValue({ changed: false }) + }) + + it('removes a member through the shared primitive and audits the removal', async () => { + stage([{ id: 'm-1', role: 'member' }]) + const result = await deprovisionScimUser.execute({ + principal, + input: { scimUserId: 'su-1' }, + request: undefined, + }) + expect(mocks.removeUser).toHaveBeenCalledWith({ + userId: 'u-1', + organizationId: 'org-1', + memberId: 'm-1', + revokePersonalApiKeys: true, + }) + expect(mocks.endDirectoryMembership).not.toHaveBeenCalled() + expect(result.removedFromOrganization).toBe(true) + const actions = mocks.recordAudit.mock.calls[0][0].entries.map( + (entry: { action: string }) => entry.action + ) + expect(actions).toEqual(['scim_user.deprovisioned', 'org_member.removed']) + expect(mocks.reconcileSeats).toHaveBeenCalledWith({ + organizationId: 'org-1', + reason: 'scim-member-removed', + }) + }) + + it('refuses to deprovision the owner with a conflict that is not a duplicate', async () => { + stage([{ id: 'm-1', role: 'owner' }]) + const error = await deprovisionScimUser + .execute({ principal, input: { scimUserId: 'su-1' }, request: undefined }) + .catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(409) + expect(error.scimType).toBeUndefined() + expect(mocks.removeUser).not.toHaveBeenCalled() + }) + + it('retires only the directory row when the account already left the organization', async () => { + stage([]) + const result = await deprovisionScimUser.execute({ + principal, + input: { scimUserId: 'su-1' }, + request: undefined, + }) + expect(mocks.removeUser).not.toHaveBeenCalled() + expect(mocks.endDirectoryMembership).toHaveBeenCalledWith(db, { + userId: 'u-1', + organizationId: 'org-1', + }) + expect(result.removedFromOrganization).toBe(false) + expect(mocks.reconcileSeats).not.toHaveBeenCalled() + const actions = mocks.recordAudit.mock.calls[0][0].entries.map( + (entry: { action: string }) => entry.action + ) + expect(actions).toEqual(['scim_user.deprovisioned']) + }) + + it('surfaces a refused removal as a conflict the directory can show', async () => { + stage([{ id: 'm-1', role: 'member' }]) + mocks.removeUser.mockResolvedValue({ + success: false, + error: 'Workflows could not be reassigned', + }) + const error = await deprovisionScimUser + .execute({ principal, input: { scimUserId: 'su-1' }, request: undefined }) + .catch((caught) => caught) + expect(error.status).toBe(409) + expect(error.message).toBe('Workflows could not be reassigned') + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('answers 404 for an id this connection does not own', async () => { + queueTableRows(scimConnection, [ + { id: 'conn-1', organizationId: 'org-1', status: 'active', settings: {} }, + ]) + mocks.findScimUserById.mockResolvedValue(null) + const error = await deprovisionScimUser + .execute({ principal, input: { scimUserId: 'other' }, request: undefined }) + .catch((caught) => caught) + expect(error.status).toBe(404) + }) +}) diff --git a/apps/sim/ee/scim/lib/application/users/deprovision-user.ts b/apps/sim/ee/scim/lib/application/users/deprovision-user.ts new file mode 100644 index 00000000000..f0638ea8183 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/users/deprovision-user.ts @@ -0,0 +1,134 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { member } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' +import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' +import { + defineAuthorizedScimUseCase, + type ScimUseCaseArgs, +} from '@/ee/scim/lib/application/authorized-scim-use-case' +import { scimOperations } from '@/ee/scim/lib/application/operations' +import { endDirectoryMembershipTx } from '@/ee/scim/lib/identity/end-directory-membership' +import { notFound, ScimError } from '@/ee/scim/lib/protocol/errors' +import { findScimUserById } from '@/ee/scim/lib/repository/users' + +const logger = createLogger('ScimDeprovisionUser') + +export interface DeprovisionScimUserInput { + scimUserId: string +} + +export interface DeprovisionScimUserResult { + scimUserId: string + userId: string + /** False when the account had already left the organization by other means. */ + removedFromOrganization: boolean +} + +/** + * Removes a user from the organization at the directory's instruction. + * + * Okta never sends this — it deactivates instead — but Microsoft Entra does, 30 + * days after a hard delete, and OneLogin and JumpCloud can be configured to. The + * Sim account itself survives: the person may hold access in another + * organization later, and their audit history must remain attributable. + * + * Removal is the same primitive the settings UI uses. It ends the membership, + * revokes sessions and personal keys, reassigns what the member owned, and + * retires this directory row into its tombstone, all in one commit. + */ +export const deprovisionScimUser = defineAuthorizedScimUseCase({ + operation: scimOperations.deprovisionUser, + async execute({ + input, + context, + }: ScimUseCaseArgs): Promise { + const current = await findScimUserById(db, context.connection.id, input.scimUserId) + if (!current) throw notFound('SCIM User not found') + + const [membership] = await db + .select({ id: member.id, role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, context.organizationId), eq(member.userId, current.userId)) + ) + .limit(1) + + /** + * The owner is refused. Removing them would leave the organization with + * nobody able to administer billing or transfer ownership, and a directory + * cannot know that Sim treats one member differently. + */ + if (membership?.role === 'owner') { + throw new ScimError( + 409, + undefined, + 'The organization owner cannot be deprovisioned through the directory. Transfer ownership in Sim first.' + ) + } + + if (membership) { + const removal = await removeUserFromOrganization({ + userId: current.userId, + organizationId: context.organizationId, + memberId: membership.id, + revokePersonalApiKeys: true, + }) + if (!removal.success) { + throw new ScimError(409, undefined, removal.error ?? 'The member could not be removed') + } + } else { + /** + * The account left through a path that predates this connection's row, or + * was hard-deleted and recreated. Only the directory's own record remains + * to retire; there is no live access left to revoke. + */ + await db.transaction((tx) => + endDirectoryMembershipTx(tx, { + userId: current.userId, + organizationId: context.organizationId, + }) + ) + } + + return { + scimUserId: current.id, + userId: current.userId, + removedFromOrganization: Boolean(membership), + } + }, + + projectAudit: ({ result, context }) => [ + { + action: AuditAction.SCIM_USER_DEPROVISIONED, + resourceType: AuditResourceType.USER, + resourceId: result.userId, + metadata: { scimUserId: result.scimUserId }, + }, + ...(result.removedFromOrganization + ? [ + { + action: AuditAction.ORG_MEMBER_REMOVED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: context.organizationId, + description: 'Removed from the organization through directory deprovisioning', + metadata: { targetUserId: result.userId, scimUserId: result.scimUserId }, + }, + ] + : []), + ], + + afterSuccess: async ({ result, context }) => { + if (!result.removedFromOrganization) return + try { + await reconcileOrganizationSeats({ + organizationId: context.organizationId, + reason: 'scim-member-removed', + }) + } catch (error) { + logger.error('Failed to reconcile seats after directory deprovisioning', { error }) + } + }, +}) diff --git a/apps/sim/ee/scim/lib/application/users/provision-user.test.ts b/apps/sim/ee/scim/lib/application/users/provision-user.test.ts new file mode 100644 index 00000000000..cce4073a03c --- /dev/null +++ b/apps/sim/ee/scim/lib/application/users/provision-user.test.ts @@ -0,0 +1,533 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { type ScimUserAttributes, scimConnection } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { APIError } from 'better-auth/api' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + createUser: vi.fn(), + applySessionPolicy: vi.fn(), + syncUsageLimits: vi.fn(), + ensureMember: vi.fn(), + resolveSeatPolicy: vi.fn(), + reconcileSeats: vi.fn(), + isInstanceMode: vi.fn(), + getInstanceOrganizationId: vi.fn(), + suspend: vi.fn(), + unsuspend: vi.fn(), + invalidate: vi.fn(), + captureEvent: vi.fn(), + deleteAccount: vi.fn(), + syncIdentity: vi.fn(), + assertEmailAvailable: vi.fn(), + consumeTombstone: vi.fn(), + resolveIdentity: vi.fn(), + reconcile: vi.fn(), + assertUserNameAvailable: vi.fn(), + findScimUserById: vi.fn(), + findScimUserByUserId: vi.fn(), + insertScimUser: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + getSession: vi.fn(), + auth: { api: { getSession: vi.fn(), createUser: mocks.createUser } }, +})) +vi.mock('@/lib/auth/session-policy', () => ({ + applySessionPolicyToNewMember: mocks.applySessionPolicy, +})) +vi.mock('@/lib/billing/core/usage', () => ({ + syncUsageLimitsFromSubscription: mocks.syncUsageLimits, +})) +vi.mock('@/lib/billing/organizations/membership', () => ({ + ensureUserInOrganizationTx: mocks.ensureMember, +})) +vi.mock('@/lib/billing/organizations/seat-policy', () => ({ + resolveOrganizationSeatPolicyTx: mocks.resolveSeatPolicy, +})) +vi.mock('@/lib/billing/organizations/seats', () => ({ + reconcileOrganizationSeats: mocks.reconcileSeats, +})) +vi.mock('@/lib/organizations/instance-org', () => ({ + isInstanceOrganizationMode: mocks.isInstanceMode, + getInstanceOrganizationId: mocks.getInstanceOrganizationId, +})) +vi.mock('@/lib/organizations/members/lifecycle', () => ({ + suspendMemberTx: mocks.suspend, + unsuspendMemberTx: mocks.unsuspend, +})) +vi.mock('@/lib/organizations/members/revocation', () => ({ + invalidateAfterSessionRevocation: mocks.invalidate, +})) +vi.mock('@/lib/posthog/server', () => ({ + captureServerEvent: mocks.captureEvent, +})) +vi.mock('@/lib/users/account-deletion', () => ({ + deleteUserAccount: mocks.deleteAccount, +})) +vi.mock('@/ee/scim/lib/identity/account-identity', () => ({ + syncAccountIdentityTx: mocks.syncIdentity, +})) +vi.mock('@/ee/scim/lib/identity/resolve-user', () => ({ + assertEmailAvailable: mocks.assertEmailAvailable, + consumeTombstone: mocks.consumeTombstone, + resolveProvisionedIdentity: mocks.resolveIdentity, +})) +vi.mock('@/ee/scim/lib/projection/reconcile-user', () => ({ + reconcileUserProjection: mocks.reconcile, +})) +vi.mock('@/ee/scim/lib/repository/users', () => ({ + assertUserNameAvailable: mocks.assertUserNameAvailable, + findScimUserById: mocks.findScimUserById, + findScimUserByUserId: mocks.findScimUserByUserId, + insertScimUser: mocks.insertScimUser, + toUserResourceRow: (record: Record) => ({ + id: record.id, + externalId: record.externalId, + userName: record.userName, + active: record.active && record.userSuspendedAt === null, + attributes: record.attributes, + email: record.email, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + groups: [], + }), +})) +vi.mock('@/ee/scim/lib/application/audit', () => ({ + recordScimAuditEntries: mocks.recordAudit, +})) +vi.mock('@/ee/scim/lib/base-url', () => ({ scimBaseUrl: () => 'https://sim.test/api/scim/v2' })) + +import type { Principal } from '@sim/auth/principal' +import { provisionScimUser } from '@/ee/scim/lib/application/users/provision-user' +import { ScimError, uniqueness } from '@/ee/scim/lib/protocol/errors' + +const principal: Principal = { + kind: 'scim_connection', + organizationId: 'org-1', + connectionId: 'conn-1', + credentialId: 'cred-1', + scopes: ['users:write'], +} + +function attributes(overrides: Partial = {}): ScimUserAttributes { + return { + userName: 'ada@acme.test', + externalId: 'ext-1', + active: true, + displayName: 'Ada Lovelace', + name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, + emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], + ...overrides, + } +} + +function stageConnection() { + queueTableRows(scimConnection, [ + { id: 'conn-1', organizationId: 'org-1', status: 'active', settings: { autoMap: true } }, + ]) +} + +function stageReadBack(userId: string, stored: ScimUserAttributes, suspendedAt: Date | null) { + mocks.findScimUserById.mockResolvedValue({ + id: 'su-new', + userId, + externalId: stored.externalId ?? null, + userName: stored.userName, + active: stored.active, + attributes: stored, + email: 'ada@acme.test', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + userSuspendedAt: suspendedAt, + }) +} + +const run = (input: ScimUserAttributes) => + provisionScimUser.execute({ principal, input: { attributes: input }, request: undefined }) + +const auditActions = () => + mocks.recordAudit.mock.calls[0][0].entries.map((entry: { action: string }) => entry.action) + +afterAll(resetDbChainMock) + +describe('provisionScimUser', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.assertUserNameAvailable.mockResolvedValue(undefined) + mocks.assertEmailAvailable.mockResolvedValue(undefined) + mocks.consumeTombstone.mockResolvedValue(undefined) + mocks.syncIdentity.mockResolvedValue(undefined) + mocks.suspend.mockResolvedValue(undefined) + mocks.unsuspend.mockResolvedValue(undefined) + mocks.isInstanceMode.mockReturnValue(false) + mocks.getInstanceOrganizationId.mockResolvedValue(null) + mocks.resolveIdentity.mockResolvedValue({ action: 'create' }) + mocks.createUser.mockResolvedValue({ user: { id: 'u-new' } }) + mocks.findScimUserByUserId.mockResolvedValue(null) + mocks.resolveSeatPolicy.mockResolvedValue({ organizationSubscriptionId: 'sub-1' }) + mocks.ensureMember.mockResolvedValue({ success: true, memberId: 'm-1', alreadyMember: false }) + mocks.insertScimUser.mockResolvedValue({ id: 'su-new' }) + mocks.reconcile.mockResolvedValue({ added: [], removed: [], raised: [] }) + mocks.deleteAccount.mockResolvedValue({}) + mocks.applySessionPolicy.mockResolvedValue(undefined) + mocks.reconcileSeats.mockResolvedValue({ changed: false }) + mocks.syncUsageLimits.mockResolvedValue(undefined) + stageReadBack('u-new', attributes(), null) + }) + + it('creates the account, admits it, links it, and projects its access', async () => { + stageConnection() + const result = await run(attributes()) + + expect(mocks.assertUserNameAvailable).toHaveBeenCalledWith(db, 'conn-1', 'ada@acme.test') + expect(mocks.assertEmailAvailable).toHaveBeenCalledWith(db, 'ada@acme.test') + expect(mocks.createUser).toHaveBeenCalledWith({ + body: { email: 'ada@acme.test', name: 'Ada Lovelace', data: { emailVerified: false } }, + }) + expect(mocks.ensureMember).toHaveBeenCalledWith(db, { + userId: 'u-new', + organizationId: 'org-1', + role: 'member', + organizationSubscriptionId: 'sub-1', + }) + expect(mocks.insertScimUser).toHaveBeenCalledWith(db, { + connectionId: 'conn-1', + userId: 'u-new', + attributes: attributes(), + active: true, + }) + expect(mocks.consumeTombstone).toHaveBeenCalledWith(db, { + connectionId: 'conn-1', + externalId: 'ext-1', + }) + expect(mocks.reconcile).toHaveBeenCalledWith(db, { + connectionId: 'conn-1', + organizationId: 'org-1', + scimUserId: 'su-new', + settings: { autoMap: true }, + }) + expect(mocks.findScimUserById).toHaveBeenCalledWith(db, 'conn-1', 'su-new') + expect(mocks.syncIdentity).not.toHaveBeenCalled() + expect(mocks.unsuspend).not.toHaveBeenCalled() + expect(mocks.suspend).not.toHaveBeenCalled() + expect(mocks.deleteAccount).not.toHaveBeenCalled() + + expect(result).toMatchObject({ + scimUserId: 'su-new', + userId: 'u-new', + createdAccount: true, + joinedOrganization: true, + subscriptionId: 'sub-1', + organizationId: 'org-1', + }) + expect(result.resource.id).toBe('su-new') + expect(result.resource.userName).toBe('ada@acme.test') + expect(result.resource.active).toBe(true) + expect(result.resource.meta.location).toBe('https://sim.test/api/scim/v2/Users/su-new') + }) + + it('audits the provisioning and the organization join as the connection', async () => { + stageConnection() + await run(attributes()) + expect(auditActions()).toEqual(['scim_user.provisioned', 'org_member.added']) + const call = mocks.recordAudit.mock.calls[0][0] + expect(call.entries[0]).toMatchObject({ + resourceId: 'u-new', + metadata: { scimUserId: 'su-new', createdAccount: true }, + }) + expect(call.entries[1]).toMatchObject({ + resourceId: 'org-1', + metadata: { memberRole: 'member', scimUserId: 'su-new' }, + }) + expect(call.metadata).toMatchObject({ + organizationId: 'org-1', + connectionId: 'conn-1', + credentialId: 'cred-1', + source: 'scim', + }) + }) + + it('runs the post-commit effects against the subscription admission validated', async () => { + stageConnection() + await run(attributes()) + expect(mocks.applySessionPolicy).toHaveBeenCalledWith('u-new', 'org-1') + expect(mocks.reconcileSeats).toHaveBeenCalledWith({ + organizationId: 'org-1', + reason: 'scim-member-added', + subscriptionId: 'sub-1', + }) + expect(mocks.syncUsageLimits).toHaveBeenCalledWith('u-new') + expect(mocks.captureEvent).toHaveBeenCalledWith( + 'u-new', + 'scim_user_provisioned', + { organization_id: 'org-1', created_account: true }, + { groups: { organization: 'org-1' } } + ) + expect(mocks.invalidate).not.toHaveBeenCalled() + }) + + it('omits the subscription id from seat reconciliation when admission validated none', async () => { + stageConnection() + mocks.resolveSeatPolicy.mockResolvedValue({ skipSeatValidation: true }) + const result = await run(attributes()) + expect(mocks.ensureMember).toHaveBeenCalledWith(db, { + userId: 'u-new', + organizationId: 'org-1', + role: 'member', + skipSeatValidation: true, + }) + expect(result.subscriptionId).toBeUndefined() + expect(mocks.reconcileSeats).toHaveBeenCalledWith({ + organizationId: 'org-1', + reason: 'scim-member-added', + }) + }) + + it('keeps going through the remaining effects when one of them fails', async () => { + stageConnection() + mocks.applySessionPolicy.mockRejectedValue(new Error('policy unavailable')) + mocks.reconcileSeats.mockRejectedValue(new Error('stripe down')) + const result = await run(attributes()) + expect(result.userId).toBe('u-new') + expect(mocks.syncUsageLimits).toHaveBeenCalledWith('u-new') + expect(mocks.captureEvent).toHaveBeenCalled() + }) + + it('refuses a duplicate userName as a uniqueness conflict before touching Better Auth', async () => { + stageConnection() + mocks.assertUserNameAvailable.mockRejectedValue( + uniqueness('A user with userName ada@acme.test already exists in this directory') + ) + const error = await run(attributes()).catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(409) + expect(error.scimType).toBe('uniqueness') + expect(mocks.createUser).not.toHaveBeenCalled() + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('maps a Better Auth unique-constraint refusal to a uniqueness conflict', async () => { + stageConnection() + mocks.createUser.mockRejectedValue( + new APIError('UNPROCESSABLE_ENTITY', { message: 'User already exists' }) + ) + const error = await run(attributes()).catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(409) + expect(error.scimType).toBe('uniqueness') + expect(error.message).toBe('Another Sim account already uses this email address') + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mocks.deleteAccount).not.toHaveBeenCalled() + }) + + it('rethrows any other Better Auth failure untouched', async () => { + stageConnection() + const failure = new APIError('INTERNAL_SERVER_ERROR', { message: 'db down' }) + mocks.createUser.mockRejectedValue(failure) + const error = await run(attributes()).catch((caught) => caught) + expect(error).toBe(failure) + expect(mocks.deleteAccount).not.toHaveBeenCalled() + }) + + it('reports seat exhaustion as a plain 409 and removes the orphan account', async () => { + stageConnection() + mocks.ensureMember.mockResolvedValue({ + success: false, + alreadyMember: false, + failureCode: 'no-seats-available', + }) + const error = await run(attributes()).catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(409) + expect(error.scimType).toBeUndefined() + expect(error.message).toContain('no available seats') + expect(mocks.insertScimUser).not.toHaveBeenCalled() + expect(mocks.reconcile).not.toHaveBeenCalled() + expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new') + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.reconcileSeats).not.toHaveBeenCalled() + }) + + it('reports an account committed elsewhere as a uniqueness conflict', async () => { + stageConnection() + mocks.ensureMember.mockResolvedValue({ + success: false, + alreadyMember: false, + failureCode: 'already-in-other-organization', + }) + const error = await run(attributes()).catch((caught) => caught) + expect(error.status).toBe(409) + expect(error.scimType).toBe('uniqueness') + }) + + it('still surfaces the original refusal when the orphan cleanup itself fails', async () => { + stageConnection() + mocks.ensureMember.mockResolvedValue({ + success: false, + alreadyMember: false, + failureCode: 'no-seats-available', + }) + mocks.deleteAccount.mockRejectedValue(new Error('deletion blocked')) + const error = await run(attributes()).catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(409) + expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new') + }) + + it('removes the orphan account when the transaction fails after the account was created', async () => { + stageConnection() + mocks.insertScimUser.mockRejectedValue(new Error('lock timeout')) + const error = await run(attributes()).catch((caught) => caught) + expect(error.message).toBe('lock timeout') + expect(mocks.deleteAccount).toHaveBeenCalledWith('u-new') + }) + + it('never deletes a pre-existing account when linking fails', async () => { + stageConnection() + mocks.resolveIdentity.mockResolvedValue({ + action: 'link', + userId: 'u-old', + via: 'verified-domain', + }) + mocks.ensureMember.mockResolvedValue({ + success: false, + alreadyMember: false, + failureCode: 'no-seats-available', + }) + const error = await run(attributes()).catch((caught) => caught) + expect(error.status).toBe(409) + expect(mocks.createUser).not.toHaveBeenCalled() + expect(mocks.deleteAccount).not.toHaveBeenCalled() + }) + + it('refuses provisioning for a non-instance organization in instance mode', async () => { + stageConnection() + mocks.isInstanceMode.mockReturnValue(true) + mocks.getInstanceOrganizationId.mockResolvedValue('org-instance') + const error = await run(attributes()).catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(409) + expect(error.scimType).toBeUndefined() + expect(mocks.resolveIdentity).not.toHaveBeenCalled() + expect(mocks.createUser).not.toHaveBeenCalled() + }) + + it('serves the instance organization itself in instance mode', async () => { + stageConnection() + mocks.isInstanceMode.mockReturnValue(true) + mocks.getInstanceOrganizationId.mockResolvedValue('org-1') + const result = await run(attributes()) + expect(result.userId).toBe('u-new') + }) + + it('lands an inactive create suspended, and signs the member out after commit', async () => { + stageConnection() + const inactive = attributes({ active: false }) + stageReadBack('u-new', inactive, new Date('2026-01-02T00:00:00.000Z')) + const result = await run(inactive) + expect(mocks.insertScimUser).toHaveBeenCalledWith( + db, + expect.objectContaining({ userId: 'u-new', active: false }) + ) + expect(mocks.suspend).toHaveBeenCalledWith(db, { + userId: 'u-new', + organizationId: 'org-1', + source: 'scim', + }) + expect(mocks.suspend.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.insertScimUser.mock.invocationCallOrder[0] + ) + expect(mocks.unsuspend).not.toHaveBeenCalled() + expect(result.resource.active).toBe(false) + expect(mocks.invalidate).toHaveBeenCalledWith({ userId: 'u-new', organizationId: 'org-1' }) + }) + + it('relinks a tombstoned account instead of creating a new one', async () => { + stageConnection() + mocks.resolveIdentity.mockResolvedValue({ action: 'link', userId: 'u-old', via: 'tombstone' }) + mocks.ensureMember.mockResolvedValue({ success: true, memberId: 'm-1', alreadyMember: true }) + stageReadBack('u-old', attributes(), null) + const result = await run(attributes()) + + expect(mocks.createUser).not.toHaveBeenCalled() + expect(mocks.assertEmailAvailable).not.toHaveBeenCalled() + expect(mocks.findScimUserByUserId).toHaveBeenCalledWith(db, 'conn-1', 'u-old') + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { + userId: 'u-old', + email: 'ada@acme.test', + name: 'Ada Lovelace', + }) + expect(mocks.unsuspend).toHaveBeenCalledWith(db, { userId: 'u-old', source: 'scim' }) + expect(mocks.insertScimUser).toHaveBeenCalledWith( + db, + expect.objectContaining({ userId: 'u-old', active: true }) + ) + expect(mocks.consumeTombstone).toHaveBeenCalledWith(db, { + connectionId: 'conn-1', + externalId: 'ext-1', + }) + expect(mocks.consumeTombstone.mock.invocationCallOrder[0]).toBeGreaterThan( + mocks.insertScimUser.mock.invocationCallOrder[0] + ) + expect(result).toMatchObject({ + userId: 'u-old', + createdAccount: false, + joinedOrganization: false, + }) + expect(auditActions()).toEqual(['scim_user.provisioned']) + expect(mocks.captureEvent).toHaveBeenCalledWith( + 'u-old', + 'scim_user_provisioned', + { organization_id: 'org-1', created_account: false }, + { groups: { organization: 'org-1' } } + ) + }) + + it('does not lift a suspension when the relinked user arrives inactive', async () => { + stageConnection() + mocks.resolveIdentity.mockResolvedValue({ action: 'link', userId: 'u-old', via: 'tombstone' }) + const inactive = attributes({ active: false }) + stageReadBack('u-old', inactive, new Date('2026-01-02T00:00:00.000Z')) + await run(inactive) + expect(mocks.unsuspend).not.toHaveBeenCalled() + expect(mocks.suspend).toHaveBeenCalledWith(db, { + userId: 'u-old', + organizationId: 'org-1', + source: 'scim', + }) + }) + + it('refuses to provision an account this connection already links', async () => { + stageConnection() + mocks.resolveIdentity.mockResolvedValue({ + action: 'link', + userId: 'u-old', + via: 'verified-domain', + }) + mocks.findScimUserByUserId.mockResolvedValue({ id: 'su-existing', userId: 'u-old' }) + const error = await run(attributes()).catch((caught) => caught) + expect(error.status).toBe(409) + expect(error.scimType).toBe('uniqueness') + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + }) + + it('refuses a credential without the write scope before any lookup', async () => { + stageConnection() + const error = await provisionScimUser + .execute({ + principal: { ...principal, scopes: ['users:read'] }, + input: { attributes: attributes() }, + request: undefined, + }) + .catch((caught) => caught) + expect(error.status).toBe(403) + expect(mocks.resolveIdentity).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/scim/lib/application/users/provision-user.ts b/apps/sim/ee/scim/lib/application/users/provision-user.ts new file mode 100644 index 00000000000..c5728fedf60 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/users/provision-user.ts @@ -0,0 +1,307 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import type { ScimUserAttributes } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { APIError } from 'better-auth/api' +import { auth } from '@/lib/auth' +import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' +import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' +import { ensureUserInOrganizationTx } from '@/lib/billing/organizations/membership' +import { resolveOrganizationSeatPolicyTx } from '@/lib/billing/organizations/seat-policy' +import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' +import { + getInstanceOrganizationId, + isInstanceOrganizationMode, +} from '@/lib/organizations/instance-org' +import { suspendMemberTx, unsuspendMemberTx } from '@/lib/organizations/members/lifecycle' +import { invalidateAfterSessionRevocation } from '@/lib/organizations/members/revocation' +import { captureServerEvent } from '@/lib/posthog/server' +import { deleteUserAccount } from '@/lib/users/account-deletion' +import { + defineAuthorizedScimUseCase, + type ScimUseCaseArgs, +} from '@/ee/scim/lib/application/authorized-scim-use-case' +import { scimOperations } from '@/ee/scim/lib/application/operations' +import { syncAccountIdentityTx } from '@/ee/scim/lib/identity/account-identity' +import { + assertEmailAvailable, + consumeTombstone, + resolveProvisionedIdentity, +} from '@/ee/scim/lib/identity/resolve-user' +import { reconcileUserProjection } from '@/ee/scim/lib/projection/reconcile-user' +import { primaryEmail } from '@/ee/scim/lib/protocol/canonical' +import { ScimError, uniqueness } from '@/ee/scim/lib/protocol/errors' +import { toUserResource } from '@/ee/scim/lib/protocol/resources' +import { + assertUserNameAvailable, + findScimUserById, + findScimUserByUserId, + insertScimUser, + toUserResourceRow, +} from '@/ee/scim/lib/repository/users' + +const logger = createLogger('ScimProvisionUser') + +export interface ProvisionScimUserInput { + attributes: ScimUserAttributes +} + +export interface ProvisionScimUserResult { + scimUserId: string + userId: string + createdAccount: boolean + /** False when the account was already a member and only the SCIM link was new. */ + joinedOrganization: boolean + /** The subscription seats were validated against, so the post-commit seat sync targets the same one. */ + subscriptionId: string | undefined + organizationId: string + resource: ReturnType +} + +/** Turns a membership refusal into the SCIM error a directory administrator can act on. */ +function membershipFailure(code: string | undefined): ScimError { + if (code === 'no-seats-available') { + return new ScimError( + 409, + undefined, + 'This organization has no available seats. Add seats in Sim, then retry provisioning.' + ) + } + if (code === 'already-in-other-organization') { + return uniqueness('This user already belongs to a different Sim organization') + } + return new ScimError(409, undefined, 'The user could not be added to the organization') +} + +/** + * Creates a user resource, and the Sim account behind it when there is not one + * already. + */ +export const provisionScimUser = defineAuthorizedScimUseCase({ + operation: scimOperations.provisionUser, + async execute({ + input, + context, + }: ScimUseCaseArgs): Promise { + const { attributes } = input + const email = primaryEmail(attributes) + + /** + * In instance-organization mode every account is placed in the instance + * organization at creation, and an account belongs to one organization. A + * connection for any other organization could never admit anyone. + */ + if (isInstanceOrganizationMode()) { + const instanceOrganizationId = await getInstanceOrganizationId() + if (instanceOrganizationId && instanceOrganizationId !== context.organizationId) { + throw new ScimError( + 409, + undefined, + 'This deployment places every account in its instance organization, so directory provisioning is available only for that organization.' + ) + } + } + + const resolution = await resolveProvisionedIdentity(db, { + connectionId: context.connection.id, + organizationId: context.organizationId, + attributes, + }) + + let userId: string + let createdAccount = false + + /** + * `userName` is unique per connection. Checked up front for a message the + * directory administrator can read; a race that slips past this lands on the + * unique index and is rendered as the same 409 by the error mapper. + */ + await assertUserNameAvailable(db, context.connection.id, attributes.userName) + + if (resolution.action === 'create') { + await assertEmailAvailable(db, email) + try { + const created = await auth.api.createUser({ + body: { + email, + name: attributes.name.formatted, + data: { emailVerified: false }, + }, + }) + userId = created.user.id + } catch (error) { + /** + * Two creates for one address can race past the availability check; + * Better Auth's unique constraint is the arbiter, and the loser is a + * duplicate the directory must resolve, not a server fault to retry. + */ + if (error instanceof APIError && error.statusCode === 422) { + throw uniqueness('Another Sim account already uses this email address') + } + throw error + } + createdAccount = true + } else { + userId = resolution.userId + } + + if (await findScimUserByUserId(db, context.connection.id, userId)) { + throw uniqueness('This directory already provisions the user') + } + + let provisioned: { + scimUserId: string + joinedOrganization: boolean + subscriptionId: string | undefined + resource: ReturnType + } + try { + provisioned = await db.transaction(async (tx) => { + const seatPolicy = await resolveOrganizationSeatPolicyTx(tx, context.organizationId) + const membership = await ensureUserInOrganizationTx(tx, { + userId, + organizationId: context.organizationId, + role: 'member', + ...seatPolicy, + }) + if (!membership.success) throw membershipFailure(membership.failureCode) + + /** + * A relinked account takes the directory's current identity. A rename + * that arrives as delete-and-recreate must land the same way as one that + * arrives as a PATCH, or the response would describe an address the + * account does not have. + */ + if (resolution.action === 'link') { + await syncAccountIdentityTx(tx, { userId, email, name: attributes.name.formatted }) + /** A relinked account may still carry the suspension a lost deprovisioning left behind. */ + if (attributes.active) await unsuspendMemberTx(tx, { userId, source: 'scim' }) + } + + const inserted = await insertScimUser(tx, { + connectionId: context.connection.id, + userId, + attributes, + active: attributes.active, + }) + + /** + * Microsoft Entra pre-provisions a disabled account before its start date, + * so a create can arrive already inactive and must land suspended rather + * than briefly usable. + */ + if (!attributes.active) { + await suspendMemberTx(tx, { + userId, + organizationId: context.organizationId, + source: 'scim', + }) + } + + await consumeTombstone(tx, { + connectionId: context.connection.id, + externalId: attributes.externalId, + }) + await reconcileUserProjection(tx, { + connectionId: context.connection.id, + organizationId: context.organizationId, + scimUserId: inserted.id, + settings: context.connection.settings, + }) + const record = await findScimUserById(tx, context.connection.id, inserted.id) + if (!record) { + throw new ScimError(500, undefined, 'The provisioned user could not be read back') + } + return { + scimUserId: inserted.id, + joinedOrganization: !membership.alreadyMember, + subscriptionId: seatPolicy.organizationSubscriptionId, + resource: toUserResource(toUserResourceRow(record, []), context.baseUrl), + } + }) + } catch (error) { + /** + * The account was created through Better Auth ahead of this transaction, + * so a refusal here — no seats, a lock timeout — would otherwise leave an + * orphan with no membership and no directory link. Removing it means the + * directory's retry starts from a clean slate instead of a half-state. + */ + if (createdAccount) { + await deleteUserAccount(userId).catch((cleanupError) => + logger.error('Failed to remove an account after provisioning was refused', { + userId, + cleanupError, + }) + ) + } + throw error + } + return { + ...provisioned, + userId, + createdAccount, + organizationId: context.organizationId, + } + }, + + projectAudit: ({ result }) => [ + { + action: AuditAction.SCIM_USER_PROVISIONED, + resourceType: AuditResourceType.USER, + resourceId: result.userId, + metadata: { scimUserId: result.scimUserId, createdAccount: result.createdAccount }, + }, + ...(result.joinedOrganization + ? [ + { + action: AuditAction.ORG_MEMBER_ADDED, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: result.organizationId, + description: 'Joined the organization through directory provisioning', + metadata: { memberRole: 'member', scimUserId: result.scimUserId }, + }, + ] + : []), + ], + + /** + * Post-commit effects mirror what SSO just-in-time admission runs, each + * guarded on its own: a failure to reconcile seats must not undo a membership + * that is already committed and already correct. + */ + afterSuccess: async ({ result, context }) => { + /** A member provisioned already inactive had their sessions revoked inside the transaction. */ + if (!result.resource.active) { + invalidateAfterSessionRevocation({ + userId: result.userId, + organizationId: context.organizationId, + }) + } + try { + await applySessionPolicyToNewMember(result.userId, context.organizationId) + } catch (error) { + logger.error('Failed to apply session policy to a provisioned member', { error }) + } + try { + await reconcileOrganizationSeats({ + organizationId: context.organizationId, + reason: 'scim-member-added', + /** The subscription admission was validated against, not whichever is newest now. */ + ...(result.subscriptionId ? { subscriptionId: result.subscriptionId } : {}), + }) + } catch (error) { + logger.error('Failed to reconcile seats after directory provisioning', { error }) + } + try { + await syncUsageLimitsFromSubscription(result.userId) + } catch (error) { + logger.error('Failed to sync usage limits after directory provisioning', { error }) + } + captureServerEvent( + result.userId, + 'scim_user_provisioned', + { organization_id: context.organizationId, created_account: result.createdAccount }, + { groups: { organization: context.organizationId } } + ) + }, +}) diff --git a/apps/sim/ee/scim/lib/application/users/read-users.ts b/apps/sim/ee/scim/lib/application/users/read-users.ts new file mode 100644 index 00000000000..9c4e96bb882 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/users/read-users.ts @@ -0,0 +1,95 @@ +import { db } from '@sim/db' +import { + defineAuthorizedScimUseCase, + type ScimUseCaseArgs, +} from '@/ee/scim/lib/application/authorized-scim-use-case' +import { scimOperations } from '@/ee/scim/lib/application/operations' +import { notFound } from '@/ee/scim/lib/protocol/errors' +import { parseUserFilter } from '@/ee/scim/lib/protocol/filter' +import { + projectionWants, + projectResource, + resolvePage, + type ScimAttributeProjection, + toUserResource, +} from '@/ee/scim/lib/protocol/resources' +import { + findScimUserById, + loadGroupsForScimUsers, + pageScimUsers, + toUserResourceRow, +} from '@/ee/scim/lib/repository/users' + +export interface ListScimUsersInput { + filter?: string | undefined + startIndex?: number | undefined + count?: number | undefined + projection: ScimAttributeProjection +} + +export const listScimUsers = defineAuthorizedScimUseCase({ + operation: scimOperations.listUsers, + async execute({ input, context }: ScimUseCaseArgs) { + const page = resolvePage({ startIndex: input.startIndex, count: input.count }) + const filters = input.filter ? parseUserFilter(input.filter) : [] + + const { records, totalResults } = await pageScimUsers(db, { + connectionId: context.connection.id, + filters, + offset: page.offset, + limit: page.count, + }) + + /** + * The group join is skipped when the request excluded `groups`. Microsoft + * Entra pages every user on an initial cycle, so avoiding a per-page join it + * did not ask for is the difference between one query and two at scale. + */ + const wantsGroups = projectionWants(input.projection, 'groups') + const groupsByUser = wantsGroups + ? await loadGroupsForScimUsers( + db, + records.map((record) => record.id) + ) + : new Map>() + + const resources = records.map((record) => + projectResource( + toUserResource( + toUserResourceRow(record, groupsByUser.get(record.id) ?? []), + context.baseUrl + ), + input.projection + ) + ) + + return { resources, totalResults, startIndex: page.startIndex } + }, +}) + +export interface GetScimUserInput { + scimUserId: string + projection: ScimAttributeProjection +} + +export const getScimUser = defineAuthorizedScimUseCase({ + operation: scimOperations.readUser, + async execute({ input, context }: ScimUseCaseArgs) { + const record = await findScimUserById(db, context.connection.id, input.scimUserId) + /** + * A resource belonging to another connection is reported as absent rather + * than forbidden. Distinguishing the two would confirm that an id exists in + * some other tenant. + */ + if (!record) throw notFound('SCIM User not found') + + const groups = projectionWants(input.projection, 'groups') + ? ((await loadGroupsForScimUsers(db, [record.id])).get(record.id) ?? []) + : [] + + return projectResource( + toUserResource(toUserResourceRow(record, groups), context.baseUrl), + input.projection + ) + }, +}) diff --git a/apps/sim/ee/scim/lib/application/users/update-user.test.ts b/apps/sim/ee/scim/lib/application/users/update-user.test.ts new file mode 100644 index 00000000000..4ac5a2e5c5d --- /dev/null +++ b/apps/sim/ee/scim/lib/application/users/update-user.test.ts @@ -0,0 +1,217 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { type ScimUserAttributes, scimConnection } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + acquireLocks: vi.fn(), + suspend: vi.fn(), + unsuspend: vi.fn(), + revokeSessions: vi.fn(), + invalidate: vi.fn(), + syncIdentity: vi.fn(), + assertDomainOwned: vi.fn(), + reconcile: vi.fn(), + findScimUserById: vi.fn(), + assertUserNameAvailable: vi.fn(), + updateScimUser: vi.fn(), + loadGroups: vi.fn(), + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationUserMutationLocks: mocks.acquireLocks, +})) +vi.mock('@/lib/organizations/members/lifecycle', () => ({ + suspendMemberTx: mocks.suspend, + unsuspendMemberTx: mocks.unsuspend, +})) +vi.mock('@/lib/organizations/members/revocation', () => ({ + revokeUserSessionsTx: mocks.revokeSessions, + invalidateAfterSessionRevocation: mocks.invalidate, +})) +vi.mock('@/ee/scim/lib/identity/account-identity', () => ({ + syncAccountIdentityTx: mocks.syncIdentity, +})) +vi.mock('@/ee/scim/lib/identity/resolve-user', () => ({ + assertDomainOwned: mocks.assertDomainOwned, +})) +vi.mock('@/ee/scim/lib/projection/reconcile-user', () => ({ + reconcileUserProjection: mocks.reconcile, +})) +vi.mock('@/ee/scim/lib/repository/users', () => ({ + findScimUserById: mocks.findScimUserById, + assertUserNameAvailable: mocks.assertUserNameAvailable, + updateScimUser: mocks.updateScimUser, + loadGroupsForScimUsers: mocks.loadGroups, + toUserResourceRow: (record: Record) => ({ + id: record.id, + externalId: record.externalId, + userName: record.userName, + active: record.active, + attributes: record.attributes, + email: record.email, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + groups: [], + }), +})) +vi.mock('@/ee/scim/lib/application/audit', () => ({ + recordScimAuditEntries: mocks.recordAudit, +})) +vi.mock('@/ee/scim/lib/base-url', () => ({ scimBaseUrl: () => 'https://sim.test/api/scim/v2' })) + +import type { Principal } from '@sim/auth/principal' +import { patchScimUser, replaceScimUser } from '@/ee/scim/lib/application/users/update-user' + +const principal: Principal = { + kind: 'scim_connection', + organizationId: 'org-1', + connectionId: 'conn-1', + credentialId: 'cred-1', + scopes: ['users:write'], +} + +function attributes(overrides: Partial = {}): ScimUserAttributes { + return { + userName: 'ada@acme.test', + externalId: 'ext-1', + active: true, + displayName: 'Ada Lovelace', + name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, + emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], + ...overrides, + } +} + +function stage( + record: { attributes?: Partial; email?: string; active?: boolean } = {} +) { + queueTableRows(scimConnection, [ + { id: 'conn-1', organizationId: 'org-1', status: 'active', settings: {} }, + ]) + const stored = attributes(record.attributes) + mocks.findScimUserById.mockResolvedValue({ + id: 'su-1', + userId: 'u-1', + externalId: stored.externalId ?? null, + userName: stored.userName, + active: record.active ?? stored.active, + attributes: stored, + email: record.email ?? 'ada@acme.test', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + userSuspendedAt: null, + }) +} + +const run = ( + useCase: typeof replaceScimUser | typeof patchScimUser, + input: Record +) => useCase.execute({ principal, input: input as never, request: undefined }) + +afterAll(resetDbChainMock) + +describe('user updates', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.loadGroups.mockResolvedValue(new Map()) + mocks.reconcile.mockResolvedValue({ added: [], removed: [], raised: [] }) + }) + + it('serializes on the organization and user locks before computing the update', async () => { + stage() + await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op: 'replace', path: 'displayName', value: 'Augusta' }], + }) + expect(mocks.acquireLocks).toHaveBeenCalledWith(db, { + userId: 'u-1', + organizationIds: ['org-1'], + }) + expect(mocks.findScimUserById).toHaveBeenCalledTimes(3) + expect(mocks.acquireLocks.mock.invocationCallOrder[0]).toBeLessThan( + mocks.findScimUserById.mock.invocationCallOrder[1] + ) + }) + + it('writes and audits nothing when Okta re-sends the stored resource', async () => { + stage() + const result = await run(replaceScimUser, { scimUserId: 'su-1', attributes: attributes() }) + expect(result.outcome).toBeNull() + expect(mocks.updateScimUser).not.toHaveBeenCalled() + expect(mocks.reconcile).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(result.resource.id).toBe('su-1') + }) + + it('deactivates by suspending, never by removing, and keeps the projection', async () => { + stage() + const result = await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op: 'replace', value: { active: false } }], + }) + expect(mocks.suspend).toHaveBeenCalledWith(db, { + userId: 'u-1', + organizationId: 'org-1', + source: 'scim', + }) + expect(mocks.revokeSessions).not.toHaveBeenCalled() + expect(mocks.reconcile).toHaveBeenCalledWith( + db, + expect.objectContaining({ scimUserId: 'su-1' }) + ) + expect(result.outcome).toEqual({ emailChanged: false, deactivated: true, reactivated: false }) + const actions = mocks.recordAudit.mock.calls[0][0].entries.map( + (entry: { action: string }) => entry.action + ) + expect(actions).toEqual(['scim_user.updated', 'scim_user.deactivated']) + expect(mocks.invalidate).toHaveBeenCalledWith({ userId: 'u-1', organizationId: 'org-1' }) + }) + + it('proves the organization owns the new domain before moving the address, then signs the user out', async () => { + stage() + await run(patchScimUser, { + scimUserId: 'su-1', + operations: [{ op: 'replace', path: 'emails[type eq "work"].value', value: 'ada@corp.test' }], + }) + expect(mocks.assertDomainOwned).toHaveBeenCalledWith(db, 'org-1', 'ada@corp.test') + expect(mocks.assertDomainOwned.mock.invocationCallOrder[0]).toBeLessThan( + mocks.syncIdentity.mock.invocationCallOrder[0] + ) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { + userId: 'u-1', + email: 'ada@corp.test', + name: 'Ada Lovelace', + }) + expect(mocks.revokeSessions).toHaveBeenCalled() + }) + + it('re-asserts the directory address when the account drifted away from it', async () => { + stage({ email: 'changed-in-sim@acme.test' }) + const result = await run(replaceScimUser, { scimUserId: 'su-1', attributes: attributes() }) + expect(result.outcome?.emailChanged).toBe(true) + expect(mocks.syncIdentity).toHaveBeenCalledWith(db, { + userId: 'u-1', + email: 'ada@acme.test', + name: 'Ada Lovelace', + }) + }) + + it('refuses a credential without the write scope', async () => { + stage() + const error = await replaceScimUser + .execute({ + principal: { ...principal, scopes: ['users:read'] }, + input: { scimUserId: 'su-1', attributes: attributes() }, + request: undefined, + }) + .catch((caught) => caught) + expect(error.status).toBe(403) + expect(mocks.findScimUserById).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/scim/lib/application/users/update-user.ts b/apps/sim/ee/scim/lib/application/users/update-user.ts new file mode 100644 index 00000000000..d2e6984075e --- /dev/null +++ b/apps/sim/ee/scim/lib/application/users/update-user.ts @@ -0,0 +1,302 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import type { ScimUserAttributes } from '@sim/db/schema' +import { normalizeEmail } from '@sim/utils/string' +import type { ScimPatchOperation } from '@/lib/api/contracts/scim' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import type { DbOrTx } from '@/lib/db/types' +import { suspendMemberTx, unsuspendMemberTx } from '@/lib/organizations/members/lifecycle' +import { + invalidateAfterSessionRevocation, + revokeUserSessionsTx, +} from '@/lib/organizations/members/revocation' +import type { ScimAuditEntry } from '@/ee/scim/lib/application/audit' +import { + defineAuthorizedScimUseCase, + type ScimUseCaseArgs, + type ScimUseCaseContext, +} from '@/ee/scim/lib/application/authorized-scim-use-case' +import { scimOperations } from '@/ee/scim/lib/application/operations' +import { syncAccountIdentityTx } from '@/ee/scim/lib/identity/account-identity' +import { assertDomainOwned } from '@/ee/scim/lib/identity/resolve-user' +import { reconcileUserProjection } from '@/ee/scim/lib/projection/reconcile-user' +import { primaryEmail } from '@/ee/scim/lib/protocol/canonical' +import { notFound, ScimError } from '@/ee/scim/lib/protocol/errors' +import { toUserResource } from '@/ee/scim/lib/protocol/resources' +import { applyUserPatch, userAttributesEqual } from '@/ee/scim/lib/protocol/user-patch' +import { + assertUserNameAvailable, + findScimUserById, + loadGroupsForScimUsers, + type ScimUserRecord, + toUserResourceRow, + updateScimUser, +} from '@/ee/scim/lib/repository/users' + +/** + * The write half of the User resource. + * + * `PUT` and `PATCH` differ only in how they arrive at the next resource — one + * carries it whole, the other as operations against the stored copy. They share + * everything after that, so the two cannot drift on what an email change or a + * deactivation actually does. + */ + +export interface UpdateOutcome { + emailChanged: boolean + deactivated: boolean + reactivated: boolean +} + +async function applyUserUpdate( + tx: DbOrTx, + context: ScimUseCaseContext, + current: ScimUserRecord, + next: ScimUserAttributes +): Promise { + const nextEmail = primaryEmail(next) + const emailChanged = accountEmailDiverged(current, next) + const deactivated = current.active && !next.active + const reactivated = !current.active && next.active + + if (next.userName !== current.userName) { + await assertUserNameAvailable(tx, context.connection.id, next.userName, current.id) + } + + if (emailChanged) { + /** + * A directory may only move an account to an address in a domain the + * organization has proven it owns. Without that, a tenant could point + * someone else's Sim account at a mailbox it controls and recover it. + */ + await assertDomainOwned(tx, context.organizationId, nextEmail) + await syncAccountIdentityTx(tx, { + userId: current.userId, + email: nextEmail, + name: next.name.formatted, + }) + + /** + * An address change ends the sessions established under the old one. A + * deactivation in the same request revokes them itself, so this only runs + * when nothing else will. + */ + if (!deactivated) { + await revokeUserSessionsTx(tx, { + userId: current.userId, + organizationId: context.organizationId, + }) + } + } else if (next.name.formatted !== current.attributes.name.formatted) { + await syncAccountIdentityTx(tx, { userId: current.userId, name: next.name.formatted }) + } + + if (deactivated) { + await suspendMemberTx(tx, { + userId: current.userId, + organizationId: context.organizationId, + source: 'scim', + }) + } else if (reactivated) { + await unsuspendMemberTx(tx, { userId: current.userId, source: 'scim' }) + } + + await updateScimUser(tx, { + scimUserId: current.id, + attributes: next, + active: next.active, + }) + + await reconcileUserProjection(tx, { + connectionId: context.connection.id, + organizationId: context.organizationId, + scimUserId: current.id, + settings: context.connection.settings, + }) + + return { emailChanged, deactivated, reactivated } +} + +export interface UpdateScimUserResult { + scimUserId: string + userId: string + outcome: UpdateOutcome | null + resource: ReturnType +} + +/** + * Loads the stored resource under the organization and user advisory locks. + * + * The locks, not a row lock, serialize two concurrent PATCHes on one account: + * the record is read once to learn the user, locked, then read again so the + * patch is computed against the state the lock protects. A `FOR UPDATE` on the + * `scim_user` row would invert the documented order against projection writers, + * which take the advisory locks first and then touch rows referencing this one. + */ +async function loadUserForUpdate( + tx: DbOrTx, + context: ScimUseCaseContext, + scimUserId: string +): Promise { + const found = await findScimUserById(tx, context.connection.id, scimUserId) + if (!found) throw notFound('SCIM User not found') + await acquireOrganizationUserMutationLocks(tx, { + userId: found.userId, + organizationIds: [context.organizationId], + }) + const current = await findScimUserById(tx, context.connection.id, scimUserId) + if (!current) throw notFound('SCIM User not found') + return current +} + +/** + * Whether the account's address no longer matches what the directory asserts. + * + * The directory is the authority on a provisioned member's address. If the + * person changed it in Sim, the next directory write — even one that repeats + * the stored attributes — restores it, so what the directory sees and what the + * account uses cannot stay apart. + */ +function accountEmailDiverged(current: ScimUserRecord, next: ScimUserAttributes): boolean { + return normalizeEmail(primaryEmail(next)) !== normalizeEmail(current.email) +} + +/** Rendered inside the write transaction, so a concurrent delete cannot make a committed update unreadable. */ +async function renderUpdated( + tx: DbOrTx, + connectionId: string, + scimUserId: string, + baseUrl: string +): Promise> { + const record = await findScimUserById(tx, connectionId, scimUserId) + if (!record) throw new ScimError(500, undefined, 'The updated user could not be read back') + const groups = (await loadGroupsForScimUsers(tx, [record.id])).get(record.id) ?? [] + return toUserResource(toUserResourceRow(record, groups), baseUrl) +} + +function auditEntries(result: UpdateScimUserResult): ScimAuditEntry[] | undefined { + if (!result.outcome) return undefined + const entries: ScimAuditEntry[] = [ + { + action: AuditAction.SCIM_USER_UPDATED, + resourceType: AuditResourceType.USER, + resourceId: result.userId, + metadata: { + scimUserId: result.scimUserId, + emailChanged: result.outcome.emailChanged, + }, + }, + ] + if (result.outcome.deactivated) { + entries.push({ + action: AuditAction.SCIM_USER_DEACTIVATED, + resourceType: AuditResourceType.USER, + resourceId: result.userId, + metadata: { scimUserId: result.scimUserId }, + }) + } + if (result.outcome.reactivated) { + entries.push({ + action: AuditAction.SCIM_USER_REACTIVATED, + resourceType: AuditResourceType.USER, + resourceId: result.userId, + metadata: { scimUserId: result.scimUserId }, + }) + } + return entries +} + +function invalidateIfAccessChanged(result: UpdateScimUserResult, organizationId: string): void { + if (!result.outcome) return + if (result.outcome.emailChanged || result.outcome.deactivated || result.outcome.reactivated) { + invalidateAfterSessionRevocation({ userId: result.userId, organizationId }) + } +} + +export interface ReplaceScimUserInput { + scimUserId: string + attributes: ScimUserAttributes +} + +export const replaceScimUser = defineAuthorizedScimUseCase({ + operation: scimOperations.updateUser, + async execute({ + input, + context, + }: ScimUseCaseArgs): Promise { + return db.transaction(async (tx) => { + const current = await loadUserForUpdate(tx, context, input.scimUserId) + + /** + * A replace keeps attributes Sim does not model that the directory sent on + * a previous write but omitted now, so a partial mapping does not erase + * them. + */ + const next: ScimUserAttributes = { + ...input.attributes, + ...(current.attributes.extra || input.attributes.extra + ? { extra: { ...current.attributes.extra, ...input.attributes.extra } } + : {}), + } + + /** + * Okta re-sends the whole resource on every cycle for every user. A PUT that + * changes nothing must not write, audit, or re-project, or a 2,000-user + * organization produces 2,000 spurious audit rows per sync. + */ + const outcome = + userAttributesEqual(current.attributes, next) && !accountEmailDiverged(current, next) + ? null + : await applyUserUpdate(tx, context, current, next) + return { + scimUserId: current.id, + userId: current.userId, + outcome, + resource: await renderUpdated(tx, context.connection.id, current.id, context.baseUrl), + } + }) + }, + projectAudit: ({ result }) => auditEntries(result), + afterSuccess: async ({ result, context }) => + invalidateIfAccessChanged(result, context.organizationId), +}) + +export interface PatchScimUserInput { + scimUserId: string + operations: readonly ScimPatchOperation[] +} + +export const patchScimUser = defineAuthorizedScimUseCase({ + operation: scimOperations.updateUser, + async execute({ + input, + context, + }: ScimUseCaseArgs): Promise { + return db.transaction(async (tx) => { + const current = await loadUserForUpdate(tx, context, input.scimUserId) + + const { next, changed } = applyUserPatch(current.attributes, input.operations) + + /** + * A patch that changes nothing is answered with the resource and no write. + * Directories re-send unchanged attributes constantly on incremental + * cycles, and treating each as a write would produce an audit row, a + * projection pass, and a `lastModified` bump for a request that meant + * nothing. + */ + const outcome = + changed || accountEmailDiverged(current, next) + ? await applyUserUpdate(tx, context, current, next) + : null + return { + scimUserId: current.id, + userId: current.userId, + outcome, + resource: await renderUpdated(tx, context.connection.id, current.id, context.baseUrl), + } + }) + }, + projectAudit: ({ result }) => auditEntries(result), + afterSuccess: async ({ result, context }) => + invalidateIfAccessChanged(result, context.organizationId), +}) diff --git a/apps/sim/ee/scim/lib/authenticate.test.ts b/apps/sim/ee/scim/lib/authenticate.test.ts new file mode 100644 index 00000000000..8570bfd08ce --- /dev/null +++ b/apps/sim/ee/scim/lib/authenticate.test.ts @@ -0,0 +1,133 @@ +/** + * @vitest-environment node + */ +import { createHash } from 'node:crypto' +import { scimCredential } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import type { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockIsEntitled } = vi.hoisted(() => ({ mockIsEntitled: vi.fn() })) + +vi.mock('@/ee/scim/lib/entitlement', () => ({ + isScimEntitledForOrganization: mockIsEntitled, +})) + +import { authenticateScimRequest, generateScimToken } from '@/ee/scim/lib/authenticate' +import { ScimError } from '@/ee/scim/lib/protocol/errors' + +function requestWithToken(token?: string): NextRequest { + const headers = new Headers() + if (token !== undefined) headers.set('authorization', `Bearer ${token}`) + // double-cast-allowed: the authenticator reads only headers from the request + return { headers } as unknown as NextRequest +} + +function credentialRow(overrides: Record = {}) { + return { + credentialId: 'cred-1', + scopes: ['users:read', 'users:write'], + expiresAt: null, + revokedAt: null, + lastUsedAt: new Date(), + connectionId: 'conn-1', + organizationId: 'org-1', + status: 'active', + lastRequestAt: new Date(), + ...overrides, + } +} + +async function expectUnauthorized(request: NextRequest, detail?: string) { + const error = await authenticateScimRequest(request).catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(401) + expect(error.headers).toEqual({ 'WWW-Authenticate': 'Bearer realm="SCIM"' }) + if (detail) expect(error.message).toBe(detail) + else expect(error.message).toBe('Invalid SCIM token') +} + +afterAll(resetDbChainMock) + +describe('generateScimToken', () => { + it('mints an identifiable secret and stores only its digest', () => { + const { secret, hash, prefix } = generateScimToken() + expect(secret.startsWith('sim_scim_')).toBe(true) + expect(secret.length).toBe('sim_scim_'.length + 40) + expect(hash).toBe(createHash('sha256').update(secret).digest('base64url')) + expect(prefix).toBe(secret.slice(0, 'sim_scim_'.length + 6)) + expect(generateScimToken().secret).not.toBe(secret) + }) +}) + +describe('authenticateScimRequest', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockIsEntitled.mockResolvedValue(true) + }) + + it('demands a bearer credential', async () => { + await expectUnauthorized(requestWithToken(), 'A bearer token is required') + }) + + it('looks the credential up by digest, never by the secret', async () => { + queueTableRows(scimCredential, [credentialRow()]) + await authenticateScimRequest(requestWithToken('sim_scim_secret')) + const digest = createHash('sha256').update('sim_scim_secret').digest('base64url') + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ type: 'eq', right: digest }) + ) + }) + + it('resolves an active credential to a connection principal carrying its scopes', async () => { + queueTableRows(scimCredential, [credentialRow()]) + await expect(authenticateScimRequest(requestWithToken('sim_scim_secret'))).resolves.toEqual({ + kind: 'scim_connection', + organizationId: 'org-1', + connectionId: 'conn-1', + credentialId: 'cred-1', + scopes: ['users:read', 'users:write'], + }) + }) + + it.each([ + ['unknown', null], + ['revoked', credentialRow({ revokedAt: new Date() })], + ['expired', credentialRow({ expiresAt: new Date(Date.now() - 1) })], + ['disabled connection', credentialRow({ status: 'disabled' })], + ])( + 'refuses a %s credential with the same message as every other refusal', + async (_label, row) => { + queueTableRows(scimCredential, row ? [row] : []) + await expectUnauthorized(requestWithToken('sim_scim_secret')) + } + ) + + it('refuses a credential whose organization is no longer entitled', async () => { + queueTableRows(scimCredential, [credentialRow()]) + mockIsEntitled.mockResolvedValue(false) + await expectUnauthorized(requestWithToken('sim_scim_secret')) + expect(mockIsEntitled).toHaveBeenCalledWith('org-1') + }) + + it('accepts a credential that expires in the future', async () => { + queueTableRows(scimCredential, [credentialRow({ expiresAt: new Date(Date.now() + 60_000) })]) + await expect( + authenticateScimRequest(requestWithToken('sim_scim_secret')) + ).resolves.toMatchObject({ + credentialId: 'cred-1', + }) + }) + + it('rewrites last-used timestamps only when they are stale', async () => { + queueTableRows(scimCredential, [credentialRow()]) + await authenticateScimRequest(requestWithToken('sim_scim_secret')) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + + const stale = new Date(Date.now() - 10 * 60 * 1000) + queueTableRows(scimCredential, [credentialRow({ lastUsedAt: stale, lastRequestAt: stale })]) + await authenticateScimRequest(requestWithToken('sim_scim_secret')) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/ee/scim/lib/authenticate.ts b/apps/sim/ee/scim/lib/authenticate.ts new file mode 100644 index 00000000000..1ef6072b591 --- /dev/null +++ b/apps/sim/ee/scim/lib/authenticate.ts @@ -0,0 +1,134 @@ +import type { ScimConnectionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { scimConnection, scimCredential } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { sha256Base64Url } from '@sim/security/hash' +import { generateShortId } from '@sim/utils/id' +import { eq } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { parseBearerToken } from '@/lib/auth/oauth-access-token' +import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' +import { ScimError } from '@/ee/scim/lib/protocol/errors' + +const logger = createLogger('ScimAuthenticate') + +export type ScimConnectionAuthenticator = (request: NextRequest) => Promise + +/** + * The prefix every issued credential carries. + * + * Makes a leaked token identifiable in a log or a secret scanner without + * revealing which tenant it belongs to. + */ +const SCIM_TOKEN_PREFIX = 'sim_scim_' + +/** Characters shown in the settings list so an administrator can tell two apart. */ +const DISPLAY_PREFIX_LENGTH = SCIM_TOKEN_PREFIX.length + 6 + +/** + * Mints a credential. + * + * 40 url-safe characters is about 238 bits, far beyond guessing, and the secret + * is returned exactly once — only its digest is stored, so a database read + * cannot recover a live token. + */ +export function generateScimToken(): { secret: string; hash: string; prefix: string } { + const secret = `${SCIM_TOKEN_PREFIX}${generateShortId(40)}` + return { + secret, + hash: sha256Base64Url(secret), + prefix: secret.slice(0, DISPLAY_PREFIX_LENGTH), + } +} + +function unauthorized(detail = 'Invalid SCIM token'): ScimError { + return new ScimError(401, undefined, detail, { + 'WWW-Authenticate': 'Bearer realm="SCIM"', + }) +} + +/** + * How long a credential's `last_used_at` may lag before it is rewritten. + * + * A provisioning cycle makes hundreds of calls a minute; writing the timestamp + * on each one would turn a read path into a write path for a value nobody reads + * more precisely than "today". + */ +const LAST_USED_WRITE_INTERVAL_MS = 5 * 60 * 1000 + +function touchCredentialLastUsed(credentialId: string, lastUsedAt: Date | null): void { + if (lastUsedAt && Date.now() - lastUsedAt.getTime() < LAST_USED_WRITE_INTERVAL_MS) return + void db + .update(scimCredential) + .set({ lastUsedAt: new Date() }) + .where(eq(scimCredential.id, credentialId)) + .catch((error) => logger.warn('Failed to record SCIM credential use', { error })) +} + +function touchConnectionLastRequest(connectionId: string, lastRequestAt: Date | null): void { + if (lastRequestAt && Date.now() - lastRequestAt.getTime() < LAST_USED_WRITE_INTERVAL_MS) return + void db + .update(scimConnection) + .set({ lastRequestAt: new Date() }) + .where(eq(scimConnection.id, connectionId)) + .catch((error) => logger.warn('Failed to record SCIM connection activity', { error })) +} + +/** + * Resolves the bearer credential on a SCIM request into a principal. + * + * The lookup is by the token's digest, so the comparison the database performs + * is between two fixed-length hashes and reveals nothing about the secret + * through its timing. Every refusal renders the same message: a provider cannot + * be helped by knowing whether a token was unknown, revoked, expired, or + * belonged to a disabled connection, while an attacker probing tokens would + * learn which of those it hit. + */ +export async function authenticateScimRequest( + request: NextRequest +): Promise { + const token = parseBearerToken(request.headers) + if (!token) throw unauthorized('A bearer token is required') + + const [row] = await db + .select({ + credentialId: scimCredential.id, + scopes: scimCredential.scopes, + expiresAt: scimCredential.expiresAt, + revokedAt: scimCredential.revokedAt, + lastUsedAt: scimCredential.lastUsedAt, + connectionId: scimConnection.id, + organizationId: scimConnection.organizationId, + status: scimConnection.status, + lastRequestAt: scimConnection.lastRequestAt, + }) + .from(scimCredential) + .innerJoin(scimConnection, eq(scimConnection.id, scimCredential.connectionId)) + .where(eq(scimCredential.tokenHash, sha256Base64Url(token))) + .limit(1) + + if (!row) throw unauthorized() + if (row.revokedAt) throw unauthorized() + if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) throw unauthorized() + if (row.status !== 'active') throw unauthorized() + + /** + * The entitlement is checked on every request, not only when the connection is + * created. An organization that lapses stops accepting directory writes rather + * than continuing to provision members it is no longer paying for. + */ + if (!(await isScimEntitledForOrganization(row.organizationId))) { + throw unauthorized() + } + + touchCredentialLastUsed(row.credentialId, row.lastUsedAt) + touchConnectionLastRequest(row.connectionId, row.lastRequestAt) + + return { + kind: 'scim_connection', + organizationId: row.organizationId, + connectionId: row.connectionId, + credentialId: row.credentialId, + scopes: row.scopes, + } +} diff --git a/apps/sim/ee/scim/lib/base-url.ts b/apps/sim/ee/scim/lib/base-url.ts new file mode 100644 index 00000000000..c865d7a576e --- /dev/null +++ b/apps/sim/ee/scim/lib/base-url.ts @@ -0,0 +1,7 @@ +import { getBaseUrl } from '@/lib/core/utils/urls' +import { SCIM_BASE_PATH } from '@/ee/scim/lib/protocol/constants' + +/** The absolute root of the SCIM surface, e.g. `https://sim.ai/api/scim/v2`; used for `meta.location`, `$ref`, and settings. */ +export function scimBaseUrl(): string { + return `${getBaseUrl()}${SCIM_BASE_PATH}` +} diff --git a/apps/sim/ee/scim/lib/entitlement.test.ts b/apps/sim/ee/scim/lib/entitlement.test.ts new file mode 100644 index 00000000000..c03246659ff --- /dev/null +++ b/apps/sim/ee/scim/lib/entitlement.test.ts @@ -0,0 +1,44 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockEnterprisePlan } = vi.hoisted(() => ({ mockEnterprisePlan: vi.fn() })) + +vi.mock('@/lib/billing/core/subscription', () => ({ + isOrganizationOnEnterprisePlan: mockEnterprisePlan, +})) + +import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' + +afterEach(resetEnvFlagsMock) + +describe('isScimEntitledForOrganization', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEnterprisePlan.mockResolvedValue(true) + }) + + it('is off on a self-hosted deployment that has not turned it on', async () => { + setEnvFlags({ isScimEnabled: false, isHosted: false }) + await expect(isScimEntitledForOrganization('org-1')).resolves.toBe(false) + expect(mockEnterprisePlan).not.toHaveBeenCalled() + }) + + it('is entitled by the flag alone on a self-hosted deployment', async () => { + setEnvFlags({ isScimEnabled: true, isHosted: false }) + mockEnterprisePlan.mockResolvedValue(false) + await expect(isScimEntitledForOrganization('org-1')).resolves.toBe(true) + expect(mockEnterprisePlan).not.toHaveBeenCalled() + }) + + it('ships with the enterprise plan on the hosted product, with nothing to switch on', async () => { + setEnvFlags({ isScimEnabled: false, isHosted: true }) + mockEnterprisePlan.mockResolvedValue(false) + await expect(isScimEntitledForOrganization('org-1')).resolves.toBe(false) + mockEnterprisePlan.mockResolvedValue(true) + await expect(isScimEntitledForOrganization('org-1')).resolves.toBe(true) + expect(mockEnterprisePlan).toHaveBeenCalledWith('org-1') + }) +}) diff --git a/apps/sim/ee/scim/lib/entitlement.ts b/apps/sim/ee/scim/lib/entitlement.ts new file mode 100644 index 00000000000..688eec2f206 --- /dev/null +++ b/apps/sim/ee/scim/lib/entitlement.ts @@ -0,0 +1,25 @@ +import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' +import { isHosted, isScimEnabled } from '@/lib/core/config/env-flags' + +/** + * Whether this deployment serves directory provisioning at all. + * + * The hosted product ships it as part of the enterprise plan, the same way SSO + * ships: nothing to switch on. A self-hosted deployment turns it on with the + * enterprise switch (`ENTERPRISE_ENABLED`) or the feature's own variable + * (`SCIM_ENABLED`), which also lets an operator turn just this feature off. + */ +export function isScimDeploymentEnabled(): boolean { + return isHosted || isScimEnabled +} + +/** + * Whether directory provisioning may run for an organization: the deployment + * serves it, and on the hosted product the organization holds the enterprise + * plan. + */ +export async function isScimEntitledForOrganization(organizationId: string): Promise { + if (!isScimDeploymentEnabled()) return false + if (!isHosted) return true + return isOrganizationOnEnterprisePlan(organizationId) +} diff --git a/apps/sim/ee/scim/lib/identity/account-identity.ts b/apps/sim/ee/scim/lib/identity/account-identity.ts new file mode 100644 index 00000000000..c41afd03fe9 --- /dev/null +++ b/apps/sim/ee/scim/lib/identity/account-identity.ts @@ -0,0 +1,43 @@ +import { user } from '@sim/db/schema' +import { normalizeEmail } from '@sim/utils/string' +import { eq } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { assertEmailAvailable } from '@/ee/scim/lib/identity/resolve-user' + +/** + * Writes the directory's view of who a person is onto their Sim account. + * + * Shared by an attribute update and by relinking a recreated identity, so a + * rename that arrives as delete-and-recreate lands the same way as one that + * arrives as a PATCH. The caller has already proven the organization owns the + * new address's domain; this asserts nobody else holds it and applies it. + */ +export async function syncAccountIdentityTx( + tx: DbOrTx, + params: { userId: string; email?: string; name: string } +): Promise { + let emailChanged = false + if (params.email !== undefined) { + const [current] = await tx + .select({ email: user.email }) + .from(user) + .where(eq(user.id, params.userId)) + .limit(1) + emailChanged = normalizeEmail(current?.email ?? '') !== normalizeEmail(params.email) + if (emailChanged) await assertEmailAvailable(tx, params.email, params.userId) + } + await tx + .update(user) + .set({ + name: params.name, + ...(params.email !== undefined && emailChanged + ? { + email: params.email, + normalizedEmail: normalizeEmail(params.email), + emailVerified: false, + } + : {}), + updatedAt: new Date(), + }) + .where(eq(user.id, params.userId)) +} diff --git a/apps/sim/ee/scim/lib/identity/end-directory-membership.ts b/apps/sim/ee/scim/lib/identity/end-directory-membership.ts new file mode 100644 index 00000000000..7ca29657988 --- /dev/null +++ b/apps/sim/ee/scim/lib/identity/end-directory-membership.ts @@ -0,0 +1,69 @@ +import { scimConnection, scimUser, scimUserTombstone, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' + +/** + * What ending an organization membership means to the directory. + * + * Called from inside the removal transaction, whoever started it — the settings + * UI, an administrator API, or a SCIM DELETE — so a member can never be gone + * while their directory row says otherwise. The row is replaced by a tombstone + * keyed by the directory's external id, which is what lets a later recreate + * relink the same account, and a suspension the directory applied ends with the + * membership it was scoped to. + * + * Imports only schema so the membership primitive can depend on it without a + * module cycle. + */ +export async function endDirectoryMembershipTx( + tx: DbOrTx, + params: { userId: string; organizationId: string } +): Promise<{ removed: number }> { + const rows = await tx + .select({ + id: scimUser.id, + connectionId: scimUser.connectionId, + externalId: scimUser.externalId, + }) + .from(scimUser) + .innerJoin(scimConnection, eq(scimConnection.id, scimUser.connectionId)) + .where( + and( + eq(scimUser.userId, params.userId), + eq(scimConnection.organizationId, params.organizationId) + ) + ) + if (rows.length === 0) return { removed: 0 } + + for (const row of rows) { + if (!row.externalId) continue + await tx + .insert(scimUserTombstone) + .values({ + id: generateId(), + connectionId: row.connectionId, + externalId: row.externalId, + userId: params.userId, + deletedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [scimUserTombstone.connectionId, scimUserTombstone.externalId], + set: { userId: params.userId, deletedAt: new Date() }, + }) + } + + await tx.delete(scimUser).where( + inArray( + scimUser.id, + rows.map((row) => row.id) + ) + ) + + await tx + .update(user) + .set({ suspendedAt: null, suspensionSource: null, updatedAt: new Date() }) + .where(and(eq(user.id, params.userId), eq(user.suspensionSource, 'scim'))) + + return { removed: rows.length } +} diff --git a/apps/sim/ee/scim/lib/identity/resolve-user.test.ts b/apps/sim/ee/scim/lib/identity/resolve-user.test.ts new file mode 100644 index 00000000000..42319867d47 --- /dev/null +++ b/apps/sim/ee/scim/lib/identity/resolve-user.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { member, type ScimUserAttributes, scimUserTombstone, ssoDomain, user } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + assertEmailAvailable, + resolveProvisionedIdentity, +} from '@/ee/scim/lib/identity/resolve-user' +import { ScimError } from '@/ee/scim/lib/protocol/errors' + +function attributes(overrides: Partial = {}): ScimUserAttributes { + return { + userName: 'ada@acme.com', + name: { formatted: 'Ada Lovelace' }, + emails: [{ value: 'ada@acme.com', primary: true }], + active: true, + ...overrides, + } +} + +const params = { connectionId: 'conn-1', organizationId: 'org-1' } + +async function expectScimError(promise: Promise, status: number, scimType: string) { + const error = await promise.catch((caught) => caught) + expect(error).toBeInstanceOf(ScimError) + expect(error.status).toBe(status) + expect(error.scimType).toBe(scimType) + return error as ScimError +} + +afterAll(resetDbChainMock) + +describe('resolveProvisionedIdentity', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('refuses every address when the organization has verified no domain', async () => { + queueTableRows(ssoDomain, []) + queueTableRows(scimUserTombstone, [{ userId: 'user-old' }]) + const error = await expectScimError( + resolveProvisionedIdentity(db, { ...params, attributes: attributes() }), + 400, + 'invalidValue' + ) + expect(error.message).toContain('no verified email domains') + }) + + it('refuses an address outside the verified domains before consulting any tombstone', async () => { + queueTableRows(ssoDomain, [{ domain: 'acme.com' }]) + queueTableRows(scimUserTombstone, [{ userId: 'user-old' }]) + const error = await expectScimError( + resolveProvisionedIdentity(db, { + ...params, + attributes: attributes({ + externalId: 'ext-1', + emails: [{ value: 'ada@evil.example', primary: true }], + }), + }), + 400, + 'invalidValue' + ) + expect(error.message).toContain('evil.example') + /** Only the domain read ran; the tombstone queue is untouched. */ + expect(dbChainMockFns.from).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.from).toHaveBeenCalledWith(ssoDomain) + }) + + it('relinks through a tombstone left by this connection before looking at email', async () => { + queueTableRows(ssoDomain, [{ domain: 'ACME.com' }]) + queueTableRows(scimUserTombstone, [{ userId: 'user-old' }]) + queueTableRows(user, [{ id: 'user-other' }]) + await expect( + resolveProvisionedIdentity(db, { ...params, attributes: attributes({ externalId: 'ext-1' }) }) + ).resolves.toEqual({ action: 'link', userId: 'user-old', via: 'tombstone' }) + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: scimUserTombstone.connectionId, right: 'conn-1' }, + { type: 'eq', left: scimUserTombstone.externalId, right: 'ext-1' }, + ], + }) + }) + + it('creates when nobody holds the address', async () => { + queueTableRows(ssoDomain, [{ domain: 'acme.com' }]) + queueTableRows(user, []) + await expect( + resolveProvisionedIdentity(db, { ...params, attributes: attributes() }) + ).resolves.toEqual({ action: 'create' }) + }) + + it('links an existing account in this organization or in none', async () => { + queueTableRows(ssoDomain, [{ domain: 'acme.com' }]) + queueTableRows(user, [{ id: 'user-1' }]) + queueTableRows(member, []) + await expect( + resolveProvisionedIdentity(db, { ...params, attributes: attributes() }) + ).resolves.toEqual({ action: 'link', userId: 'user-1', via: 'verified-domain' }) + + queueTableRows(ssoDomain, [{ domain: 'acme.com' }]) + queueTableRows(user, [{ id: 'user-1' }]) + queueTableRows(member, [{ organizationId: 'org-1' }]) + await expect( + resolveProvisionedIdentity(db, { ...params, attributes: attributes() }) + ).resolves.toEqual({ action: 'link', userId: 'user-1', via: 'verified-domain' }) + }) + + it('reports an account committed to another organization as a uniqueness conflict', async () => { + queueTableRows(ssoDomain, [{ domain: 'acme.com' }]) + queueTableRows(user, [{ id: 'user-1' }]) + queueTableRows(member, [{ organizationId: 'org-2' }]) + const error = await expectScimError( + resolveProvisionedIdentity(db, { ...params, attributes: attributes() }), + 409, + 'uniqueness' + ) + expect(error.message).toContain('different organization') + }) +}) + +describe('assertEmailAvailable', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('allows the address when it is free or already the same account', async () => { + queueTableRows(user, []) + await expect(assertEmailAvailable(db, 'ada@acme.com')).resolves.toBeUndefined() + queueTableRows(user, [{ id: 'user-1' }]) + await expect(assertEmailAvailable(db, 'ada@acme.com', 'user-1')).resolves.toBeUndefined() + }) + + it('refuses an address another account holds', async () => { + queueTableRows(user, [{ id: 'user-2' }]) + await expectScimError(assertEmailAvailable(db, 'ada@acme.com', 'user-1'), 409, 'uniqueness') + }) +}) diff --git a/apps/sim/ee/scim/lib/identity/resolve-user.ts b/apps/sim/ee/scim/lib/identity/resolve-user.ts new file mode 100644 index 00000000000..ba444d0d6d9 --- /dev/null +++ b/apps/sim/ee/scim/lib/identity/resolve-user.ts @@ -0,0 +1,167 @@ +import { member, type ScimUserAttributes, scimUserTombstone, ssoDomain, user } from '@sim/db/schema' +import { normalizeSSODomain } from '@sim/utils/sso-domain' +import { isValidEmailSyntax, normalizeEmail } from '@sim/utils/string' +import { and, eq, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { primaryEmail } from '@/ee/scim/lib/protocol/canonical' +import { invalidValue, uniqueness } from '@/ee/scim/lib/protocol/errors' + +/** + * Deciding which Sim account an incoming directory identity refers to. + * + * This is the security boundary of the whole surface. A directory that could + * name an arbitrary address and have Sim hand back the matching account would be + * an account-takeover primitive: the tenant controls what it sends, so it would + * control which account it captures. Every branch below either proves the + * identity was provisioned by this same connection, or proves the organization + * owns the email's domain. + */ + +export type IdentityResolution = + | { action: 'create' } + | { action: 'link'; userId: string; via: 'tombstone' | 'verified-domain' } + +/** Domains this organization has proven it owns, through the SSO domain flow. */ +async function listVerifiedDomains(tx: DbOrTx, organizationId: string): Promise> { + const rows = await tx + .select({ domain: ssoDomain.domain }) + .from(ssoDomain) + .where(and(eq(ssoDomain.organizationId, organizationId), eq(ssoDomain.status, 'verified'))) + const domains = new Set() + for (const row of rows) { + const normalized = normalizeSSODomain(row.domain) + if (normalized) domains.add(normalized) + } + return domains +} + +/** + * Refuses an address outside the organization's verified domains. + * + * Applied on every create and every email change, not only when linking. A + * directory that could set a member's address to a domain the organization does + * not own could point a Sim account at a mailbox it controls and then use + * password recovery against it. + */ +export async function assertDomainOwned( + tx: DbOrTx, + organizationId: string, + email: string +): Promise { + const domain = normalizeSSODomain(email) + if (!domain || !isValidEmailSyntax(email)) { + throw invalidValue(`${email} is not a usable email address`) + } + const verified = await listVerifiedDomains(tx, organizationId) + /** + * `invalidValue` rather than `uniqueness`. Nothing is duplicated here; the + * value is one this organization may not use. Labelling it a uniqueness + * conflict would make Okta record the user as "already exists" and hide the + * actual remedy — verify the domain — from the administrator. + */ + if (verified.size === 0) { + throw invalidValue( + 'This organization has no verified email domains. Verify the domain in Sim before provisioning users.' + ) + } + if (!verified.has(domain)) { + throw invalidValue( + `The domain ${domain} is not verified for this organization, so ${email} cannot be provisioned` + ) + } +} + +/** Refuses an address already held by a different Sim account. */ +export async function assertEmailAvailable( + tx: DbOrTx, + email: string, + exceptUserId?: string +): Promise { + const [existing] = await tx + .select({ id: user.id }) + .from(user) + .where(sql`lower(trim(${user.email})) = ${normalizeEmail(email)}`) + .limit(1) + if (existing && existing.id !== exceptUserId) { + throw uniqueness('Another Sim account already uses this email address') + } +} + +/** + * Chooses whether to create an account or attach to an existing one. + * + * Order matters. An exact tombstone match is the strongest signal available — + * this connection provisioned that external id before — and it is what makes a + * directory's delete-and-recreate (an ordinary rename or rehire) reattach the + * original account instead of stranding it behind a duplicate. + * + * Only then is email considered, and only under two conditions together: the + * organization has proven it owns the domain, and the account is not already + * committed to a different organization. + */ +export async function resolveProvisionedIdentity( + tx: DbOrTx, + params: { connectionId: string; organizationId: string; attributes: ScimUserAttributes } +): Promise { + const email = primaryEmail(params.attributes) + /** Checked first so no path — a tombstone relink included — skips it. */ + await assertDomainOwned(tx, params.organizationId, email) + + const externalId = params.attributes.externalId + if (externalId) { + const [tombstone] = await tx + .select({ userId: scimUserTombstone.userId }) + .from(scimUserTombstone) + .where( + and( + eq(scimUserTombstone.connectionId, params.connectionId), + eq(scimUserTombstone.externalId, externalId) + ) + ) + .limit(1) + if (tombstone) return { action: 'link', userId: tombstone.userId, via: 'tombstone' } + } + + const [existing] = await tx + .select({ id: user.id }) + .from(user) + .where(sql`lower(trim(${user.email})) = ${normalizeEmail(email)}`) + .limit(1) + if (!existing) return { action: 'create' } + + /** + * A Sim account belongs to at most one organization, enforced by a unique + * index on `member.userId`. Attaching to someone already committed elsewhere + * cannot succeed, and reporting it as a conflict is what tells the directory + * administrator to resolve it rather than retrying forever. + */ + const [membership] = await tx + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, existing.id)) + .limit(1) + + if (membership && membership.organizationId !== params.organizationId) { + throw uniqueness( + 'A Sim account with this email already belongs to a different organization. Remove it there before provisioning.' + ) + } + + return { action: 'link', userId: existing.id, via: 'verified-domain' } +} + +/** Clears a tombstone once its identity has been provisioned again. */ +export async function consumeTombstone( + tx: DbOrTx, + params: { connectionId: string; externalId?: string | undefined } +): Promise { + if (!params.externalId) return + await tx + .delete(scimUserTombstone) + .where( + and( + eq(scimUserTombstone.connectionId, params.connectionId), + eq(scimUserTombstone.externalId, params.externalId) + ) + ) +} diff --git a/apps/sim/ee/scim/lib/managed-membership.test.ts b/apps/sim/ee/scim/lib/managed-membership.test.ts new file mode 100644 index 00000000000..f0e1bf274d0 --- /dev/null +++ b/apps/sim/ee/scim/lib/managed-membership.test.ts @@ -0,0 +1,89 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { dbChainMockFns, resetDbChainMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ForbiddenOperationError } from '@/lib/core/application' + +const { mockDeploymentEnabled, mockEntitled } = vi.hoisted(() => ({ + mockDeploymentEnabled: vi.fn(), + mockEntitled: vi.fn(), +})) +vi.mock('@/ee/scim/lib/entitlement', () => ({ + isScimDeploymentEnabled: mockDeploymentEnabled, + isScimEntitledForOrganization: mockEntitled, +})) + +import { + assertInviteeNotScimManaged, + assertMembershipNotScimManaged, +} from '@/ee/scim/lib/managed-membership' + +const params = { organizationId: 'org-1', userId: 'user-1', executor: db } + +function queueProbe(managed: boolean) { + dbChainMockFns.from.mockResolvedValueOnce([{ managed }]) +} + +describe('assertMembershipNotScimManaged', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mockDeploymentEnabled.mockReturnValue(true) + mockEntitled.mockResolvedValue(true) + }) + + it('refuses a change to a member the directory manages', async () => { + queueProbe(true) + const failure = await assertMembershipNotScimManaged(params).catch((error) => error) + expect(failure).toBeInstanceOf(ForbiddenOperationError) + expect(failure.detailCode).toBe('SCIM_MANAGED_MEMBERSHIP') + }) + + it('lets the change through once the organization can no longer sync', async () => { + queueProbe(true) + mockEntitled.mockResolvedValue(false) + await expect(assertMembershipNotScimManaged(params)).resolves.toBeUndefined() + expect(mockEntitled).toHaveBeenCalledWith('org-1') + }) + + it('never reads the plan for a member the directory does not manage', async () => { + queueProbe(false) + await expect(assertMembershipNotScimManaged(params)).resolves.toBeUndefined() + expect(mockEntitled).not.toHaveBeenCalled() + }) + + it('does not query at all on a deployment without provisioning', async () => { + mockDeploymentEnabled.mockReturnValue(false) + await expect(assertMembershipNotScimManaged(params)).resolves.toBeUndefined() + expect(dbChainMockFns.select).not.toHaveBeenCalled() + }) +}) + +describe('assertInviteeNotScimManaged', () => { + beforeEach(() => { + vi.clearAllMocks() + mockEntitled.mockResolvedValue(true) + }) + + it('refuses an invitation to someone the directory provisions', async () => { + await expect( + assertInviteeNotScimManaged({ organizationId: 'org-1', managed: true }) + ).rejects.toMatchObject({ detailCode: 'SCIM_MANAGED_MEMBERSHIP' }) + }) + + it('allows the invitation once the directory can no longer sync', async () => { + mockEntitled.mockResolvedValue(false) + await expect( + assertInviteeNotScimManaged({ organizationId: 'org-1', managed: true }) + ).resolves.toBeUndefined() + }) + + it('costs no plan read for an ordinary invitee', async () => { + await expect( + assertInviteeNotScimManaged({ organizationId: 'org-1', managed: false }) + ).resolves.toBeUndefined() + expect(mockEntitled).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/scim/lib/managed-membership.ts b/apps/sim/ee/scim/lib/managed-membership.ts new file mode 100644 index 00000000000..ca39aa5a662 --- /dev/null +++ b/apps/sim/ee/scim/lib/managed-membership.ts @@ -0,0 +1,84 @@ +import { scimConnection, scimUser } from '@sim/db/schema' +import { type AnyColumn, type SQL, sql } from 'drizzle-orm' +import { ForbiddenOperationError } from '@/lib/core/application' +import type { DbOrTx } from '@/lib/db/types' +import { isScimDeploymentEnabled, isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' + +/** + * Refusing membership edits that the organization's directory owns. + * + * When an administrator makes the directory the source of truth, a change made + * only in Sim is undone by the next sync. Accepting it would be worse than + * refusing: the person doing it sees success, the change disappears hours later, + * and nothing explains why. This turns that into an error that names the + * remedy. + * + * Deliberately not applied to deprovisioning: an administrator must always be + * able to remove someone in an emergency, whatever the directory believes. + */ + +/** + * A SQL predicate that is true when the given user is provisioned by THIS + * organization's active directory connection and that connection has locked + * manual membership. + * + * Anchored to the organization on purpose. Without that anchor, a person + * provisioned by one tenant's directory would be reported as managed to every + * other tenant that happens to look them up — a false refusal and a + * cross-tenant disclosure in one. + * + * Exposed as a predicate rather than a query so a caller that is already + * reading the user can fold it into that read; the invitation flow does, since + * paying a second round trip on every invitation to answer a question that is + * almost always "no" is not worth it. + */ +export function scimManagedUserPredicate( + organizationId: string, + userIdColumn: SQL | AnyColumn +): SQL { + return sql`exists ( + select 1 + from ${scimUser} + join ${scimConnection} on ${scimConnection.id} = ${scimUser.connectionId} + where ${scimUser.userId} = ${userIdColumn} + and ${scimConnection.organizationId} = ${organizationId} + and ${scimConnection.status} = 'active' + and coalesce((${scimConnection.settings} ->> 'lockManualMembership')::boolean, false) = true + )` +} + +/** Refuses a change to a member the directory owns. */ +export async function assertMembershipNotScimManaged(params: { + organizationId: string + userId: string + executor: DbOrTx +}): Promise { + if (!isScimDeploymentEnabled()) return + const [row] = await params.executor + .select({ managed: scimManagedUserPredicate(params.organizationId, sql`${params.userId}`) }) + .from(sql`(select 1) as probe`) + if (!row?.managed) return + /** + * A plan that no longer has directory provisioning must not keep refusing + * manual changes on behalf of a directory that can no longer sync. Read only + * once a managed row is found, so the common case costs nothing. + */ + if (!(await isScimEntitledForOrganization(params.organizationId))) return + throw new ForbiddenOperationError( + 'SCIM_MANAGED_MEMBERSHIP', + 'This member is managed by the organization’s identity provider. Make the change there, or turn off managed-membership locking in the organization’s directory settings.' + ) +} + +/** Refuses an invitation to someone the directory already provisions, while the directory can still sync. */ +export async function assertInviteeNotScimManaged(params: { + organizationId: string + managed: boolean | null | undefined +}): Promise { + if (!params.managed) return + if (!(await isScimEntitledForOrganization(params.organizationId))) return + throw new ForbiddenOperationError( + 'SCIM_MANAGED_MEMBERSHIP', + 'This person is provisioned by the organization’s identity provider, so Sim will not grant them access separately. They already have access, or will once the next directory sync runs.' + ) +} diff --git a/apps/sim/ee/scim/lib/projection/auto-map.test.ts b/apps/sim/ee/scim/lib/projection/auto-map.test.ts new file mode 100644 index 00000000000..947144bf9f2 --- /dev/null +++ b/apps/sim/ee/scim/lib/projection/auto-map.test.ts @@ -0,0 +1,102 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { permissionGroup, scimGroupMapping } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLeafLock } = vi.hoisted(() => ({ mockLeafLock: vi.fn() })) +vi.mock('@/lib/permission-groups/locks', () => ({ acquirePermissionGroupOrgLock: mockLeafLock })) + +import { + autoMapPermissionGroupByName, + settleMappedPermissionGroupsExplicit, +} from '@/ee/scim/lib/projection/auto-map' + +const params = { organizationId: 'org-1', scimGroupId: 'g-1', displayName: 'Engineering' } + +afterAll(resetDbChainMock) + +describe('autoMapPermissionGroupByName', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('removes only automatic mappings to other groups before mapping the match', async () => { + queueTableRows(permissionGroup, [{ id: 'pg-1', membershipMode: 'explicit' }]) + queueTableRows(scimGroupMapping, []) + dbChainMockFns.returning.mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'm-1' }]) + + await expect(autoMapPermissionGroupByName(db, params)).resolves.toBe('mapped') + expect(dbChainMockFns.delete).toHaveBeenCalledWith(scimGroupMapping) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'and', + conditions: expect.arrayContaining([ + { type: 'eq', left: scimGroupMapping.source, right: 'automatic' }, + { type: 'ne', left: scimGroupMapping.permissionGroupId, right: 'pg-1' }, + ]), + }) + ) + expect(dbChainMockFns.values).toHaveBeenCalledWith( + expect.objectContaining({ permissionGroupId: 'pg-1', source: 'automatic' }) + ) + }) + + it('reports unmapped when a rename drops the old automatic mapping and matches nothing', async () => { + queueTableRows(permissionGroup, []) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'old' }]) + await expect(autoMapPermissionGroupByName(db, params)).resolves.toBe('unmapped') + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('reports no-match when nothing was mapped and nothing was removed', async () => { + queueTableRows(permissionGroup, []) + dbChainMockFns.returning.mockResolvedValueOnce([]) + await expect(autoMapPermissionGroupByName(db, params)).resolves.toBe('no-match') + }) + + it('never takes the permission-group leaf lock itself, since user locks follow it', async () => { + queueTableRows(permissionGroup, [{ id: 'pg-1' }]) + queueTableRows(scimGroupMapping, []) + dbChainMockFns.returning.mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'm-1' }]) + await expect(autoMapPermissionGroupByName(db, params)).resolves.toBe('mapped') + expect(mockLeafLock).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('yields to a concurrent identical mapping instead of failing', async () => { + queueTableRows(permissionGroup, [{ id: 'pg-1', membershipMode: 'explicit' }]) + queueTableRows(scimGroupMapping, []) + dbChainMockFns.returning.mockResolvedValueOnce([]) + dbChainMockFns.returning.mockResolvedValueOnce([]) + await expect(autoMapPermissionGroupByName(db, params)).resolves.toBe('already-mapped') + }) +}) + +describe('settleMappedPermissionGroupsExplicit', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('takes the leaf lock before moving a still-inheriting mapped group to explicit', async () => { + queueTableRows(scimGroupMapping, [{ id: 'pg-1' }]) + await settleMappedPermissionGroupsExplicit(db, { organizationId: 'org-1', scimGroupId: 'g-1' }) + expect(mockLeafLock).toHaveBeenCalledWith(db, 'org-1', { lockTimeoutAlreadyBounded: true }) + expect(mockLeafLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.update.mock.invocationCallOrder[0] + ) + }) + + it('does nothing, and takes no lock, when every mapped group is already explicit', async () => { + queueTableRows(scimGroupMapping, []) + await settleMappedPermissionGroupsExplicit(db, { organizationId: 'org-1', scimGroupId: 'g-1' }) + expect(mockLeafLock).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/scim/lib/projection/auto-map.ts b/apps/sim/ee/scim/lib/projection/auto-map.ts new file mode 100644 index 00000000000..e1924c760ac --- /dev/null +++ b/apps/sim/ee/scim/lib/projection/auto-map.ts @@ -0,0 +1,125 @@ +import { permissionGroup, scimGroupMapping } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, ne } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' + +/** + * Links a pushed directory group to a permission group of the same name. + * + * Every mature provisioning integration adopts the container that matches the + * pushed group's name rather than waiting for a person to wire it up, because + * the administrator has usually already created both sides to match. Sim adopts + * only an existing permission group here and never creates one: a permission + * group is an access-control decision with an owner, and a directory sync is not + * the place to make it. + * + * The adopted group is moved to explicit membership so the directory removing + * its last member narrows it to nobody instead of widening it to everyone. That + * move is a separate step, `settleMappedPermissionGroupsExplicit`, taken after + * the members' projection: the permission-group lock it needs is a leaf, and + * the projection takes user locks that must precede it. + * + * A rename drops the automatic mapping the old name earned, so members do not + * keep access to a group whose name the directory no longer carries; mappings an + * administrator made by hand are theirs and are left alone. + */ +export async function autoMapPermissionGroupByName( + tx: DbOrTx, + params: { organizationId: string; scimGroupId: string; displayName: string } +): Promise<'mapped' | 'already-mapped' | 'unmapped' | 'no-match'> { + const [target] = await tx + .select({ id: permissionGroup.id }) + .from(permissionGroup) + .where( + and( + eq(permissionGroup.organizationId, params.organizationId), + eq(permissionGroup.name, params.displayName), + eq(permissionGroup.isDefault, false) + ) + ) + .limit(1) + + const removed = await tx + .delete(scimGroupMapping) + .where( + and( + eq(scimGroupMapping.groupId, params.scimGroupId), + eq(scimGroupMapping.targetKind, 'permission_group'), + eq(scimGroupMapping.source, 'automatic'), + ...(target ? [ne(scimGroupMapping.permissionGroupId, target.id)] : []) + ) + ) + .returning({ id: scimGroupMapping.id }) + /** `unmapped` tells the caller access changed even though nothing new was mapped. */ + if (!target) return removed.length > 0 ? 'unmapped' : 'no-match' + + const [existing] = await tx + .select({ id: scimGroupMapping.id }) + .from(scimGroupMapping) + .where( + and( + eq(scimGroupMapping.groupId, params.scimGroupId), + eq(scimGroupMapping.targetKind, 'permission_group'), + eq(scimGroupMapping.permissionGroupId, target.id) + ) + ) + .limit(1) + if (existing) return 'already-mapped' + + /** The unique index is the arbiter when an administrator maps the same pair concurrently. */ + const inserted = await tx + .insert(scimGroupMapping) + .values({ + id: generateId(), + groupId: params.scimGroupId, + targetKind: 'permission_group', + permissionGroupId: target.id, + source: 'automatic', + createdBy: null, + }) + .onConflictDoNothing() + .returning({ id: scimGroupMapping.id }) + return inserted.length > 0 ? 'mapped' : 'already-mapped' +} + +/** + * Moves every permission group this directory group maps to into explicit + * membership, so it governs exactly its members from now on. + * + * Called once the members' projection has run, because the permission-group + * lock is a leaf: the projection takes the organization's user locks, and a + * leaf taken before them would put this transaction on the wrong side of the + * documented order. Nothing between the mapping and this step observes the + * mode, and both commit together. + */ +export async function settleMappedPermissionGroupsExplicit( + tx: DbOrTx, + params: { organizationId: string; scimGroupId: string } +): Promise { + const inheriting = await tx + .select({ id: permissionGroup.id }) + .from(scimGroupMapping) + .innerJoin(permissionGroup, eq(permissionGroup.id, scimGroupMapping.permissionGroupId)) + .where( + and( + eq(scimGroupMapping.groupId, params.scimGroupId), + eq(scimGroupMapping.targetKind, 'permission_group'), + eq(permissionGroup.organizationId, params.organizationId), + ne(permissionGroup.membershipMode, 'explicit') + ) + ) + if (inheriting.length === 0) return + await acquirePermissionGroupOrgLock(tx, params.organizationId, { + lockTimeoutAlreadyBounded: true, + }) + await tx + .update(permissionGroup) + .set({ membershipMode: 'explicit', updatedAt: new Date() }) + .where( + inArray( + permissionGroup.id, + inheriting.map((row) => row.id) + ) + ) +} diff --git a/apps/sim/ee/scim/lib/projection/grants.test.ts b/apps/sim/ee/scim/lib/projection/grants.test.ts new file mode 100644 index 00000000000..2051eebf577 --- /dev/null +++ b/apps/sim/ee/scim/lib/projection/grants.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type MappingRow, + type ProjectionGrant, + planGrantChanges, + resolveDesiredGrants, +} from '@/ee/scim/lib/projection/grants' + +function workspaceRow(workspaceId: string, permissionType: 'admin' | 'write' | 'read'): MappingRow { + return { + targetKind: 'workspace', + workspaceId, + permissionType, + permissionGroupId: null, + role: null, + } +} + +describe('resolveDesiredGrants', () => { + it('keeps the stronger level when two groups grant the same workspace', () => { + const desired = resolveDesiredGrants([ + workspaceRow('ws-1', 'read'), + workspaceRow('ws-1', 'admin'), + ]) + expect(desired).toEqual([ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'admin' }, + ]) + }) + + it('does not lower a level a later row asks for less of', () => { + const desired = resolveDesiredGrants([ + workspaceRow('ws-1', 'write'), + workspaceRow('ws-1', 'read'), + ]) + expect(desired[0].permissionType).toBe('write') + }) + + it('emits one grant per permission group and per role however many groups repeat them', () => { + const rows: MappingRow[] = [ + { + targetKind: 'permission_group', + permissionGroupId: 'pg-1', + workspaceId: null, + permissionType: null, + role: null, + }, + { + targetKind: 'permission_group', + permissionGroupId: 'pg-1', + workspaceId: null, + permissionType: null, + role: null, + }, + { + targetKind: 'org_role', + permissionGroupId: null, + workspaceId: null, + permissionType: null, + role: 'admin', + }, + { + targetKind: 'org_role', + permissionGroupId: null, + workspaceId: null, + permissionType: null, + role: 'admin', + }, + ] + expect(resolveDesiredGrants(rows)).toEqual([ + { targetKind: 'permission_group', targetId: 'pg-1' }, + { targetKind: 'org_role', targetId: 'admin' }, + ]) + }) + + it('drops rows whose target column is missing', () => { + const rows: MappingRow[] = [ + { + targetKind: 'workspace', + workspaceId: 'ws-1', + permissionType: null, + permissionGroupId: null, + role: null, + }, + { + targetKind: 'permission_group', + permissionGroupId: null, + workspaceId: null, + permissionType: null, + role: null, + }, + { + targetKind: 'something_else', + permissionGroupId: 'x', + workspaceId: 'y', + permissionType: 'admin', + role: 'admin', + }, + ] + expect(resolveDesiredGrants(rows)).toEqual([]) + }) +}) + +describe('planGrantChanges', () => { + const workspace = ( + targetId: string, + permissionType: 'admin' | 'write' | 'read' + ): ProjectionGrant => ({ + targetKind: 'workspace', + targetId, + permissionType, + }) + + it('plans nothing when desired and current agree, which is what makes a reconcile idempotent', () => { + const grants = [ + workspace('ws-1', 'write'), + { targetKind: 'permission_group' as const, targetId: 'pg-1' }, + ] + expect(planGrantChanges(grants, [...grants].reverse())).toEqual({ withdraw: [], apply: [] }) + }) + + it('withdraws what is no longer desired and applies what is new', () => { + const plan = planGrantChanges([workspace('ws-2', 'read')], [workspace('ws-1', 'read')]) + expect(plan.withdraw).toEqual([workspace('ws-1', 'read')]) + expect(plan.apply).toEqual([{ grant: workspace('ws-2', 'read') }]) + }) + + it('carries the previous level when a workspace changes level in either direction', () => { + expect( + planGrantChanges([workspace('ws-1', 'admin')], [workspace('ws-1', 'read')]).apply + ).toEqual([{ grant: workspace('ws-1', 'admin'), previousPermission: 'read' }]) + expect( + planGrantChanges([workspace('ws-1', 'read')], [workspace('ws-1', 'admin')]).apply + ).toEqual([{ grant: workspace('ws-1', 'read'), previousPermission: 'admin' }]) + }) + + it('never withdraws a grant that is also desired at a different level', () => { + const plan = planGrantChanges([workspace('ws-1', 'read')], [workspace('ws-1', 'admin')]) + expect(plan.withdraw).toEqual([]) + }) +}) diff --git a/apps/sim/ee/scim/lib/projection/grants.ts b/apps/sim/ee/scim/lib/projection/grants.ts new file mode 100644 index 00000000000..131cf23f555 --- /dev/null +++ b/apps/sim/ee/scim/lib/projection/grants.ts @@ -0,0 +1,127 @@ +import type { PermissionType } from '@sim/platform-authz/workspace' +import { permissionRank } from '@/lib/workspaces/access/workspace-access' + +/** + * The pure half of projection: what a user's mappings entitle them to, and how + * that differs from what the directory granted before. No database here, so the + * rules that decide access can be tested exhaustively without one. + */ + +export type ProjectionTargetKind = 'permission_group' | 'workspace' | 'org_role' + +/** + * How the directory came to hold a grant: it created the access, or it found the + * person already holding it by hand and adopted the record so the mapping is + * satisfied without re-applying it every pass. + */ +export type ProjectionGrantOrigin = 'directory' | 'adopted' + +export interface ProjectionGrant { + targetKind: ProjectionTargetKind + targetId: string + permissionType?: PermissionType + /** Present on grants read back from provenance; a desired grant has no origin yet. */ + origin?: ProjectionGrantOrigin +} + +/** One `scim_group_mapping` row the user reaches through a group they belong to. */ +export interface MappingRow { + targetKind: string + permissionGroupId: string | null + workspaceId: string | null + permissionType: PermissionType | null + role: string | null +} + +function grantKey(grant: ProjectionGrant): string { + return `${grant.targetKind}:${grant.targetId}` +} + +/** + * Collapses mapping rows into one grant per target. + * + * Two groups granting the same workspace resolve to the stronger level. Rows + * whose target column is missing describe nothing and are dropped. + */ +export function resolveDesiredGrants(rows: readonly MappingRow[]): ProjectionGrant[] { + const byKey = new Map() + + const offer = (grant: ProjectionGrant) => { + const key = grantKey(grant) + const existing = byKey.get(key) + if ( + !existing || + !existing.permissionType || + !grant.permissionType || + permissionRank(grant.permissionType) > permissionRank(existing.permissionType) + ) { + byKey.set(key, grant) + } + } + + for (const row of rows) { + if (row.targetKind === 'permission_group' && row.permissionGroupId) { + offer({ targetKind: 'permission_group', targetId: row.permissionGroupId }) + } else if (row.targetKind === 'workspace' && row.workspaceId && row.permissionType) { + offer({ + targetKind: 'workspace', + targetId: row.workspaceId, + permissionType: row.permissionType, + }) + } else if (row.targetKind === 'org_role' && row.role) { + offer({ targetKind: 'org_role', targetId: row.role }) + } + } + + return [...byKey.values()] +} + +export interface GrantApplication { + grant: ProjectionGrant + /** The level a previous pass set on a workspace, present when the level changes. */ + previousPermission?: PermissionType +} + +export interface GrantPlan { + /** Grants the directory made that no mapping asks for any more. */ + withdraw: ProjectionGrant[] + /** Grants to make or re-level, in desired order. */ + apply: GrantApplication[] +} + +/** + * Diffs the desired set against what the directory previously granted. + * + * Only differences are returned, which is what makes a reconcile pass + * idempotent: identical inputs plan nothing. A workspace already granted at a + * different level is planned as an application carrying the previous level, so + * the executor can lower as well as raise. + */ +export function planGrantChanges( + desired: readonly ProjectionGrant[], + current: readonly ProjectionGrant[] +): GrantPlan { + const desiredByKey = new Map(desired.map((grant) => [grantKey(grant), grant])) + const currentByKey = new Map(current.map((grant) => [grantKey(grant), grant])) + + const withdraw: ProjectionGrant[] = [] + for (const [key, grant] of currentByKey) { + if (!desiredByKey.has(key)) withdraw.push(grant) + } + + const apply: GrantApplication[] = [] + for (const [key, grant] of desiredByKey) { + const existing = currentByKey.get(key) + if (!existing) { + apply.push({ grant }) + continue + } + const levelChanged = + grant.permissionType !== undefined && + existing.permissionType !== undefined && + grant.permissionType !== existing.permissionType + if (levelChanged) apply.push({ grant, previousPermission: existing.permissionType }) + } + + return { withdraw, apply } +} diff --git a/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts b/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts new file mode 100644 index 00000000000..e378b10032e --- /dev/null +++ b/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts @@ -0,0 +1,267 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { scimGroupMember, scimProjectionGrant, scimUser, workspace } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + acquireLocks: vi.fn(), + changeMemberRole: vi.fn(), + addMember: vi.fn(), + removeMember: vi.fn(), + grantWorkspace: vi.fn(), + lowerWorkspace: vi.fn(), + readPermission: vi.fn(), + revokeWorkspace: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationUserMutationLocks: mocks.acquireLocks, +})) +vi.mock('@/lib/organizations/members/lifecycle', () => ({ + changeMemberRoleTx: mocks.changeMemberRole, +})) +vi.mock('@/lib/permission-groups/application/group-membership', () => ({ + addPermissionGroupMemberTx: mocks.addMember, + removePermissionGroupMemberTx: mocks.removeMember, + PermissionGroupNotFoundError: class PermissionGroupNotFoundError extends Error {}, + PermissionGroupScopeConflictError: class PermissionGroupScopeConflictError extends Error { + conflicts: unknown[] = [] + }, +})) +vi.mock('@/lib/workspaces/access/workspace-access', () => ({ + grantWorkspaceAccessTx: mocks.grantWorkspace, + lowerWorkspaceAccessTx: mocks.lowerWorkspace, + readWorkspacePermission: mocks.readPermission, + revokeWorkspaceAccessTx: mocks.revokeWorkspace, + permissionRank: (permission: string) => ({ read: 1, write: 2, admin: 3 })[permission] ?? 0, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { reconcileUserProjection } from '@/ee/scim/lib/projection/reconcile-user' + +const params = { + connectionId: 'conn-1', + organizationId: 'org-1', + scimUserId: 'su-1', + settings: {}, +} + +interface Scenario { + current?: Array> + mappings?: Array> + ownedWorkspaces?: string[] +} + +/** Queues the four reads the reconciler makes, in the order it makes them. */ +function stage({ current = [], mappings = [], ownedWorkspaces = ['ws-1'] }: Scenario) { + queueTableRows(scimUser, [{ userId: 'u-1' }]) + queueTableRows(scimProjectionGrant, current) + queueTableRows(scimGroupMember, mappings) + queueTableRows( + workspace, + ownedWorkspaces.map((id) => ({ id })) + ) +} + +const workspaceMapping = (workspaceId: string, permissionType: string) => ({ + targetKind: 'workspace', + workspaceId, + permissionType, + permissionGroupId: null, + role: null, +}) + +const insertedValues = () => + dbChainMockFns.values.mock.calls.map((call) => call[0] as Record) + +afterAll(resetDbChainMock) + +describe('reconcileUserProjection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.grantWorkspace.mockResolvedValue('granted') + mocks.lowerWorkspace.mockResolvedValue('lowered') + mocks.readPermission.mockResolvedValue('write') + mocks.revokeWorkspace.mockResolvedValue({ revoked: true, ownershipTransferred: false }) + mocks.addMember.mockResolvedValue('added') + mocks.removeMember.mockResolvedValue('removed') + mocks.changeMemberRole.mockResolvedValue({ changed: true, from: 'member', to: 'admin' }) + }) + + it('takes the organization and user locks before reading any grant', async () => { + stage({ mappings: [workspaceMapping('ws-1', 'write')] }) + await reconcileUserProjection(db, params) + expect(mocks.acquireLocks).toHaveBeenCalledWith(db, { + userId: 'u-1', + organizationIds: ['org-1'], + }) + expect(mocks.acquireLocks.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.from.mock.invocationCallOrder[1] + ) + }) + + it('records access the directory created as its own', async () => { + stage({ mappings: [workspaceMapping('ws-1', 'write')] }) + const delta = await reconcileUserProjection(db, params) + expect(mocks.grantWorkspace).toHaveBeenCalledWith(db, { + workspaceId: 'ws-1', + userId: 'u-1', + permission: 'write', + }) + expect(insertedValues()[0]).toMatchObject({ targetId: 'ws-1', origin: 'directory' }) + expect(delta.added).toHaveLength(1) + }) + + it('records access the person already held by hand as adopted, and counts no change', async () => { + mocks.grantWorkspace.mockResolvedValue('unchanged') + stage({ mappings: [workspaceMapping('ws-1', 'write')] }) + const delta = await reconcileUserProjection(db, params) + expect(insertedValues()[0]).toMatchObject({ targetId: 'ws-1', origin: 'adopted' }) + expect(delta.added).toHaveLength(0) + }) + + it('plans nothing when the recorded grants already satisfy the mappings', async () => { + stage({ + current: [ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'write', origin: 'directory' }, + ], + mappings: [workspaceMapping('ws-1', 'write')], + }) + await reconcileUserProjection(db, params) + expect(mocks.grantWorkspace).not.toHaveBeenCalled() + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('forgets an adopted grant without touching the access unless the directory is the source of truth', async () => { + stage({ + current: [ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'write', origin: 'adopted' }, + ], + }) + const forgotten = await reconcileUserProjection(db, params) + expect(mocks.revokeWorkspace).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledWith(scimProjectionGrant) + expect(forgotten.removed).toEqual([ + expect.objectContaining({ targetKind: 'workspace', targetId: 'ws-1' }), + ]) + + vi.clearAllMocks() + resetDbChainMock() + mocks.revokeWorkspace.mockResolvedValue({ revoked: true, ownershipTransferred: false }) + mocks.readPermission.mockResolvedValue('write') + stage({ + current: [ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'write', origin: 'adopted' }, + ], + }) + const withdrawn = await reconcileUserProjection(db, { + ...params, + settings: { lockManualMembership: true }, + }) + expect(mocks.revokeWorkspace).toHaveBeenCalledWith( + db, + expect.objectContaining({ workspaceId: 'ws-1', userId: 'u-1' }) + ) + expect(withdrawn.removed).toHaveLength(1) + }) + + it('leaves the provenance row in place when access could not be handed on', async () => { + mocks.revokeWorkspace.mockResolvedValue({ + revoked: false, + reason: 'unresolved-workflows', + unresolvedWorkflows: ['wf-1'], + }) + stage({ + current: [ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'write', origin: 'directory' }, + ], + }) + const delta = await reconcileUserProjection(db, params) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + expect(delta.removed).toHaveLength(0) + }) + + it('leaves a manual raise above the directory level alone when unlocked', async () => { + mocks.readPermission.mockResolvedValue('admin') + stage({ + current: [ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'write', origin: 'directory' }, + ], + }) + await reconcileUserProjection(db, params) + expect(mocks.revokeWorkspace).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledWith(scimProjectionGrant) + }) + + it('never reaches into a workspace that left the organization, in either direction', async () => { + stage({ + current: [ + { + targetKind: 'workspace', + targetId: 'ws-gone', + permissionType: 'write', + origin: 'directory', + }, + ], + mappings: [workspaceMapping('ws-gone', 'write'), workspaceMapping('ws-1', 'read')], + ownedWorkspaces: ['ws-1'], + }) + await reconcileUserProjection(db, params) + expect(mocks.grantWorkspace).toHaveBeenCalledTimes(1) + expect(mocks.grantWorkspace.mock.calls[0][1]).toMatchObject({ workspaceId: 'ws-1' }) + expect(mocks.revokeWorkspace).not.toHaveBeenCalled() + expect(dbChainMockFns.delete).toHaveBeenCalledWith(scimProjectionGrant) + }) + + it('re-establishes the desired level when the row is no longer at the level the directory set', async () => { + mocks.lowerWorkspace.mockResolvedValue('unchanged') + stage({ + current: [ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'admin', origin: 'directory' }, + ], + mappings: [workspaceMapping('ws-1', 'read')], + }) + await reconcileUserProjection(db, params) + expect(mocks.lowerWorkspace).toHaveBeenCalledWith(db, { + workspaceId: 'ws-1', + userId: 'u-1', + from: 'admin', + to: 'read', + }) + expect(mocks.grantWorkspace).toHaveBeenCalledWith(db, { + workspaceId: 'ws-1', + userId: 'u-1', + permission: 'read', + }) + }) + + it('skips the owner on an organization-role mapping instead of failing the sync', async () => { + mocks.changeMemberRole.mockRejectedValue(new OrchestrationError('conflict', 'owner')) + stage({ + mappings: [ + { + targetKind: 'org_role', + role: 'admin', + workspaceId: null, + permissionType: null, + permissionGroupId: null, + }, + ], + }) + const delta = await reconcileUserProjection(db, params) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + expect(delta.added).toHaveLength(0) + }) + + it('does nothing for a directory row that no longer exists', async () => { + queueTableRows(scimUser, []) + const delta = await reconcileUserProjection(db, params) + expect(delta).toEqual({ added: [], removed: [], raised: [] }) + expect(mocks.acquireLocks).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/ee/scim/lib/projection/reconcile-user.ts b/apps/sim/ee/scim/lib/projection/reconcile-user.ts new file mode 100644 index 00000000000..12aab864efc --- /dev/null +++ b/apps/sim/ee/scim/lib/projection/reconcile-user.ts @@ -0,0 +1,524 @@ +import { db } from '@sim/db' +import { + type ScimConnectionSettings, + scimGroupMapping, + scimGroupMember, + scimProjectionGrant, + scimUser, + workspace, +} from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import type { PermissionType } from '@sim/platform-authz/workspace' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { changeMemberRoleTx } from '@/lib/organizations/members/lifecycle' +import { + addPermissionGroupMemberTx, + PermissionGroupAllMembersConflictError, + PermissionGroupNotFoundError, + PermissionGroupScopeConflictError, + removePermissionGroupMemberTx, +} from '@/lib/permission-groups/application/group-membership' +import { + grantWorkspaceAccessTx, + lowerWorkspaceAccessTx, + permissionRank, + readWorkspacePermission, + revokeWorkspaceAccessTx, +} from '@/lib/workspaces/access/workspace-access' +import { + type MappingRow, + type ProjectionGrant, + type ProjectionGrantOrigin, + type ProjectionTargetKind, + planGrantChanges, + resolveDesiredGrants, +} from '@/ee/scim/lib/projection/grants' + +const logger = createLogger('ScimProjection') + +/** + * Turning directory group membership into Sim access. + * + * A SCIM group means nothing on its own; an administrator maps it to something + * Sim understands. This module computes what a user's mappings say they should + * have, compares it to what SCIM previously granted them, and applies only the + * difference. + * + * The comparison is against SCIM's own grants, recorded in + * `scim_projection_grant`, never against the user's total access. That is the + * distinction that keeps a directory sync from revoking access a workspace + * administrator granted by hand. + */ + +export interface ProjectionDelta { + added: ProjectionGrant[] + removed: ProjectionGrant[] + /** Workspace grants whose level changed in either direction. */ + raised: ProjectionGrant[] +} + +const EMPTY_DELTA: ProjectionDelta = { added: [], removed: [], raised: [] } + +/** Users per transaction when projecting outside a request's own transaction; the organization lock is held for the batch. */ +export const PROJECTION_BATCH_SIZE = 25 + +/** + * The mapping rows this user reaches through their groups. + * + * Deliberately independent of whether the user is active. A deactivation blocks + * sign-in and API keys through suspension; it does not withdraw grants, because + * withdrawing a workspace grant reassigns the workflows the person owns there, + * and that cannot be undone by reactivating them. Grants change only when group + * membership or mappings change, or when the user is deprovisioned outright. + */ +async function loadMappingRows(tx: DbOrTx, scimUserId: string): Promise { + return tx + .select({ + targetKind: scimGroupMapping.targetKind, + permissionGroupId: scimGroupMapping.permissionGroupId, + workspaceId: scimGroupMapping.workspaceId, + permissionType: scimGroupMapping.permissionType, + role: scimGroupMapping.role, + }) + .from(scimGroupMember) + .innerJoin(scimGroupMapping, eq(scimGroupMapping.groupId, scimGroupMember.groupId)) + .where(eq(scimGroupMember.scimUserId, scimUserId)) +} + +async function currentGrants(tx: DbOrTx, scimUserId: string): Promise { + const rows = await tx + .select({ + targetKind: scimProjectionGrant.targetKind, + targetId: scimProjectionGrant.targetId, + permissionType: scimProjectionGrant.permissionType, + origin: scimProjectionGrant.origin, + }) + .from(scimProjectionGrant) + .where(eq(scimProjectionGrant.scimUserId, scimUserId)) + return rows.map((row) => ({ + targetKind: row.targetKind as ProjectionTargetKind, + targetId: row.targetId, + origin: row.origin as ProjectionGrantOrigin, + ...(row.permissionType ? { permissionType: row.permissionType } : {}), + })) +} + +/** + * What applying a grant did. `unchanged` means the person already held it by + * some other route — a manual grant — and nothing was written. + */ +type GrantOutcome = 'applied' | 'unchanged' | 'skipped' + +/** + * Mapped workspaces that no longer belong to the organization the directory + * serves. A workspace can be moved to another tenant after it was mapped; the + * mapping row survives, and following it in either direction would reach into + * that tenant — granting members access there, or deleting rows it now owns. + */ +async function findForeignWorkspaces( + tx: DbOrTx, + workspaceIds: string[], + organizationId: string +): Promise> { + if (workspaceIds.length === 0) return new Set() + const rows = await tx + .select({ id: workspace.id }) + .from(workspace) + .where(and(inArray(workspace.id, workspaceIds), eq(workspace.organizationId, organizationId))) + const owned = new Set(rows.map((row) => row.id)) + return new Set(workspaceIds.filter((id) => !owned.has(id))) +} + +/** Applies one grant. `skipped` means the grant describes nothing this server can apply. */ +async function applyGrant( + tx: DbOrTx, + params: { + organizationId: string + userId: string + grant: ProjectionGrant + /** The level a previous pass set on a workspace, when lowering it. */ + previousPermission?: PermissionType + } +): Promise { + const { grant } = params + switch (grant.targetKind) { + case 'workspace': { + if (!grant.permissionType) return 'skipped' + if ( + params.previousPermission && + permissionRank(params.previousPermission) > permissionRank(grant.permissionType) + ) { + const lowered = await lowerWorkspaceAccessTx(tx, { + workspaceId: grant.targetId, + userId: params.userId, + from: params.previousPermission, + to: grant.permissionType, + }) + if (lowered === 'lowered') return 'applied' + /** The row is no longer at the level the directory set; ensure at least the desired level. */ + } + const outcome = await grantWorkspaceAccessTx(tx, { + workspaceId: grant.targetId, + userId: params.userId, + permission: grant.permissionType, + }) + return outcome === 'unchanged' ? 'unchanged' : 'applied' + } + case 'org_role': { + if (grant.targetId !== 'admin') return 'skipped' + return setOrganizationRole(tx, params.organizationId, params.userId, 'admin') + } + case 'permission_group': { + const outcome = await addPermissionGroupMemberTx(tx, { + organizationId: params.organizationId, + groupId: grant.targetId, + userId: params.userId, + }) + return outcome === 'already-member' ? 'unchanged' : 'applied' + } + } +} + +/** + * Sets a member's organization role on the directory's behalf. + * + * The owner is out of the directory's reach: ownership carries billing and the + * last-owner guarantee, and a group that happens to contain the owner must not + * fail every sync over it. Returns false, and records no grant, so the mapping + * is simply inert for that one person. + */ +async function setOrganizationRole( + tx: DbOrTx, + organizationId: string, + userId: string, + role: 'admin' | 'member' +): Promise { + try { + const change = await changeMemberRoleTx(tx, { organizationId, userId, role }) + return change.changed ? 'applied' : 'unchanged' + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'conflict') { + logger.warn('Skipped an organization role mapping for the owner', { organizationId, userId }) + return 'skipped' + } + if (error instanceof OrchestrationError && error.code === 'not_found') { + logger.warn('Skipped an organization role mapping for a user who is no longer a member', { + organizationId, + userId, + }) + return 'skipped' + } + throw error + } +} + +/** + * Withdraws one grant. Returns false when the grant must stay in place — the + * access could not be handed on — so the provenance row survives and the next + * pass retries instead of forgetting. + * + * Adopted access — held by hand before any mapping covered it — is the person's + * own, so the directory only forgets its record of it, unless the organization + * has made the directory the source of truth. + */ +async function withdrawGrant( + tx: DbOrTx, + params: { + organizationId: string + userId: string + grant: ProjectionGrant + lockManualMembership: boolean + foreignWorkspaceIds: Set + } +): Promise { + const { grant } = params + if (grant.origin === 'adopted' && !params.lockManualMembership) return true + switch (grant.targetKind) { + case 'workspace': { + /** The workspace belongs to another tenant now; its access is theirs to manage. Forget the grant. */ + if (params.foreignWorkspaceIds.has(grant.targetId)) return true + /** + * A grant raised by hand above what the directory set is left alone. The + * directory said "at least write"; someone deliberately made it admin, and + * removing the group should not silently undo that decision. When the + * organization has made the directory the source of truth, it does. + */ + const current = await readWorkspacePermission(tx, { + workspaceId: grant.targetId, + userId: params.userId, + }) + if (!current) return true + if ( + !params.lockManualMembership && + grant.permissionType && + permissionRank(current) > permissionRank(grant.permissionType) + ) { + return true + } + const outcome = await revokeWorkspaceAccessTx(tx, { + workspaceId: grant.targetId, + userId: params.userId, + }) + if (!outcome.revoked) { + logger.warn('Left workspace access in place: ownership could not be handed on', { + workspaceId: grant.targetId, + userId: params.userId, + reason: outcome.reason, + }) + return false + } + return true + } + case 'org_role': + if (grant.targetId !== 'admin') return true + await setOrganizationRole(tx, params.organizationId, params.userId, 'member') + return true + case 'permission_group': + try { + await removePermissionGroupMemberTx(tx, { + organizationId: params.organizationId, + groupId: grant.targetId, + userId: params.userId, + }) + } catch (error) { + /** + * The group may already be gone; the grant row outlives it because the + * target column carries no foreign key. Withdrawing from nothing is + * complete, not a failure. + */ + if (error instanceof PermissionGroupNotFoundError) return true + /** + * An administrator moved the group back to governing everyone, and its + * last member cannot leave without emptying it. The grant stays on + * record so a later pass, or the administrator, can settle it; a sync + * must not fail over a rule the directory cannot see. + */ + if (error instanceof PermissionGroupAllMembersConflictError) { + logger.warn('Left a directory-managed permission group membership in place', { + groupId: grant.targetId, + userId: params.userId, + conflict: error.message, + }) + return false + } + throw error + } + return true + } +} + +/** + * Brings a user's Sim access in line with their directory groups. + * + * Idempotent by construction: it reads the desired set, reads what SCIM granted + * before, and acts only on the difference. Running it twice changes nothing the + * second time, which is what lets the reconcile job re-run it over every user + * without a dry-run mode. + */ +export async function reconcileUserProjection( + tx: DbOrTx, + params: { + connectionId: string + organizationId: string + scimUserId: string + settings: ScimConnectionSettings + } +): Promise { + const [record] = await tx + .select({ userId: scimUser.userId }) + .from(scimUser) + .where(and(eq(scimUser.id, params.scimUserId), eq(scimUser.connectionId, params.connectionId))) + .limit(1) + + if (!record) return EMPTY_DELTA + + /** + * Taken before any target is touched. The organization lock is the root of + * every write path that reaches these tables, so holding it first is what + * rules out a deadlock with the settings routes and with concurrent syncs; + * the permission-group leaf lock is only ever taken underneath it. + */ + await acquireOrganizationUserMutationLocks(tx, { + userId: record.userId, + organizationIds: [params.organizationId], + }) + + const current = await currentGrants(tx, params.scimUserId) + const mapped = resolveDesiredGrants(await loadMappingRows(tx, params.scimUserId)) + const foreignWorkspaceIds = await findForeignWorkspaces( + tx, + [...mapped, ...current] + .filter((grant) => grant.targetKind === 'workspace') + .map((grant) => grant.targetId), + params.organizationId + ) + if (foreignWorkspaceIds.size > 0) { + logger.warn('Ignoring SCIM workspace mappings whose workspace left the organization', { + connectionId: params.connectionId, + workspaceIds: [...foreignWorkspaceIds], + }) + } + const desired = mapped.filter( + (grant) => grant.targetKind !== 'workspace' || !foreignWorkspaceIds.has(grant.targetId) + ) + const plan = planGrantChanges(desired, current) + + const delta: ProjectionDelta = { added: [], removed: [], raised: [] } + const lockManualMembership = params.settings.lockManualMembership === true + + /** Withdrawals first, so a move between groups frees its workspace slot. */ + for (const grant of plan.withdraw) { + const withdrawn = await withdrawGrant(tx, { + organizationId: params.organizationId, + userId: record.userId, + grant, + lockManualMembership, + foreignWorkspaceIds, + }) + if (!withdrawn) continue + await tx + .delete(scimProjectionGrant) + .where( + and( + eq(scimProjectionGrant.scimUserId, params.scimUserId), + eq(scimProjectionGrant.targetKind, grant.targetKind), + eq(scimProjectionGrant.targetId, grant.targetId) + ) + ) + delta.removed.push(grant) + } + + for (const { grant, previousPermission } of plan.apply) { + let applied: GrantOutcome + try { + applied = await applyGrant(tx, { + organizationId: params.organizationId, + userId: record.userId, + grant, + ...(previousPermission ? { previousPermission } : {}), + }) + } catch (error) { + /** + * A mapping can outlive its target — an administrator deletes a permission + * group and the row cascades away, or deletes the group between the read + * and the write. And two mapped groups can collide: the same person in + * both, each governing a shared workspace. Both are configuration problems + * for the administrator to see in the activity log, not reasons to fail + * the directory's request and have it retry forever. + */ + if (error instanceof PermissionGroupNotFoundError) { + logger.warn('Skipped a SCIM mapping whose permission group no longer exists', { + connectionId: params.connectionId, + groupId: grant.targetId, + }) + continue + } + if (error instanceof PermissionGroupScopeConflictError) { + logger.warn('Skipped a SCIM mapping that conflicts with another permission group', { + connectionId: params.connectionId, + groupId: grant.targetId, + conflicts: error.conflicts.length, + }) + continue + } + throw error + } + if (applied === 'skipped') continue + /** + * Every satisfied mapping is recorded, so the next pass plans nothing for + * it. Access the person already held by hand is recorded as adopted: the + * directory knows the mapping is met but does not own the access, and a + * later withdrawal leaves it alone. + */ + await tx + .insert(scimProjectionGrant) + .values({ + id: generateId(), + connectionId: params.connectionId, + scimUserId: params.scimUserId, + targetKind: grant.targetKind, + targetId: grant.targetId, + permissionType: grant.permissionType ?? null, + origin: applied === 'applied' ? 'directory' : 'adopted', + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoUpdate({ + target: [ + scimProjectionGrant.scimUserId, + scimProjectionGrant.targetKind, + scimProjectionGrant.targetId, + ], + set: { + permissionType: grant.permissionType ?? null, + ...(applied === 'applied' ? { origin: 'directory' } : {}), + updatedAt: new Date(), + }, + }) + + if (applied !== 'applied') continue + if (previousPermission) delta.raised.push(grant) + else delta.added.push(grant) + } + + return delta +} + +/** Reconciles several users, in a stable order so concurrent syncs cannot deadlock. */ +export async function reconcileUsersProjection( + tx: DbOrTx, + params: { + connectionId: string + organizationId: string + scimUserIds: string[] + settings: ScimConnectionSettings + } +): Promise { + for (const scimUserId of [...new Set(params.scimUserIds)].sort()) { + await reconcileUserProjection(tx, { + connectionId: params.connectionId, + organizationId: params.organizationId, + scimUserId, + settings: params.settings, + }) + } +} + +/** + * Reconciles many users in transactions of a bounded size. + * + * For callers that are not already inside a transaction — the reconcile job, + * and an administrator changing a mapping on a large group. One transaction over + * thousands of users would hold the organization's advisory locks for its whole + * duration, blocking every invitation and role change in the tenant meanwhile. + */ +export async function reconcileUsersProjectionInBatches(params: { + connectionId: string + organizationId: string + scimUserIds: string[] + settings: ScimConnectionSettings +}): Promise { + const total: ProjectionDelta = { added: [], removed: [], raised: [] } + const ids = [...new Set(params.scimUserIds)].sort() + for (let start = 0; start < ids.length; start += PROJECTION_BATCH_SIZE) { + const batch = ids.slice(start, start + PROJECTION_BATCH_SIZE) + await db.transaction(async (tx) => { + for (const scimUserId of batch) { + const delta = await reconcileUserProjection(tx, { + connectionId: params.connectionId, + organizationId: params.organizationId, + scimUserId, + settings: params.settings, + }) + total.added.push(...delta.added) + total.removed.push(...delta.removed) + total.raised.push(...delta.raised) + } + }) + } + return total +} diff --git a/apps/sim/ee/scim/lib/protocol/canonical.test.ts b/apps/sim/ee/scim/lib/protocol/canonical.test.ts new file mode 100644 index 00000000000..5c86c21da36 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/canonical.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { scimGroupWriteSchema, scimUserWriteSchema } from '@/lib/api/contracts/scim' +import { primaryEmail, toCanonicalGroup, toCanonicalUser } from '@/ee/scim/lib/protocol/canonical' +import { + SCIM_ENTERPRISE_USER_SCHEMA, + SCIM_GROUP_SCHEMA, + SCIM_USER_SCHEMA, +} from '@/ee/scim/lib/protocol/constants' +import type { ScimError } from '@/ee/scim/lib/protocol/errors' +import { ENTRA_LEGACY_GROUP_SCHEMA } from '@/ee/scim/lib/protocol/normalize' + +function parseUser(body: Record) { + return toCanonicalUser(scimUserWriteSchema.parse({ schemas: [SCIM_USER_SCHEMA], ...body })) +} + +describe('toCanonicalUser', () => { + it('takes the flagged primary address', () => { + const user = parseUser({ + userName: 'ada', + emails: [ + { value: 'home@acme.test', primary: false }, + { value: 'work@acme.test', primary: true, type: 'work' }, + ], + }) + expect(primaryEmail(user)).toBe('work@acme.test') + }) + + it('falls back to the first address when none is flagged, as OneLogin sends', () => { + const user = parseUser({ + userName: 'ada', + emails: [{ value: 'first@acme.test' }, { value: 'second@acme.test' }], + }) + expect(primaryEmail(user)).toBe('first@acme.test') + }) + + it('falls back to an email-shaped userName, as Entra often sends alone', () => { + const user = parseUser({ userName: 'Ada@Acme.Test' }) + expect(primaryEmail(user)).toBe('ada@acme.test') + expect(user.userName).toBe('ada@acme.test') + }) + + it('refuses a resource with no usable address', () => { + let scimType: string | undefined + try { + parseUser({ userName: 'ada' }) + } catch (error) { + scimType = (error as ScimError).scimType + } + expect(scimType).toBe('invalidValue') + }) + + it('builds a display name from the parts when none is supplied', () => { + const user = parseUser({ + userName: 'ada@acme.test', + name: { givenName: 'Ada', familyName: 'Lovelace' }, + }) + expect(user.name.formatted).toBe('Ada Lovelace') + expect(user.displayName).toBe('Ada Lovelace') + }) + + it('keeps a provider extension’s attributes under its URN', () => { + const user = parseUser({ + userName: 'ada@acme.test', + 'urn:okta:sim:2.0:user:custom': { costCenter: 'R&D' }, + }) + expect(user.extra).toEqual({ 'urn:okta:sim:2.0:user:custom': { costCenter: 'R&D' } }) + }) + + it('keeps attributes Sim does not model so responses round-trip them', () => { + const user = parseUser({ userName: 'ada@acme.test', nickName: 'Countess' }) + expect(user.extra).toEqual({ nickName: 'Countess' }) + }) + + it('never keeps a password, even though Okta always sends one', () => { + const user = parseUser({ userName: 'ada@acme.test', password: 'hunter2' }) + expect(JSON.stringify(user)).not.toContain('hunter2') + }) + + it('accepts Entra’s string boolean for active', () => { + expect(parseUser({ userName: 'ada@acme.test', active: 'False' }).active).toBe(false) + }) + + it('defaults active to true when omitted', () => { + expect(parseUser({ userName: 'ada@acme.test' }).active).toBe(true) + }) +}) + +describe('schemas declaration', () => { + it('accepts the core User schema with the enterprise extension', () => { + expect( + scimUserWriteSchema.safeParse({ + schemas: [SCIM_USER_SCHEMA, SCIM_ENTERPRISE_USER_SCHEMA], + userName: 'ada@acme.test', + }).success + ).toBe(true) + }) + + it('accepts a provider extension, as Okta declares for every custom attribute', () => { + expect( + scimUserWriteSchema.safeParse({ + schemas: [SCIM_USER_SCHEMA, 'urn:okta:sim:2.0:user:custom'], + userName: 'ada@acme.test', + }).success + ).toBe(true) + }) + + it('refuses a User without the core schema', () => { + const result = scimUserWriteSchema.safeParse({ + schemas: ['urn:okta:sim:2.0:user:custom'], + userName: 'ada@acme.test', + }) + expect(result.success).toBe(false) + expect(result.error?.issues[0]?.message).toBe(`schemas must include ${SCIM_USER_SCHEMA}`) + }) + + it('tolerates Microsoft’s legacy Group schema marker', () => { + expect( + scimGroupWriteSchema.safeParse({ + schemas: [SCIM_GROUP_SCHEMA, ENTRA_LEGACY_GROUP_SCHEMA], + displayName: 'Engineering', + }).success + ).toBe(true) + }) +}) + +describe('toCanonicalGroup', () => { + it('deduplicates member ids', () => { + const group = toCanonicalGroup( + scimGroupWriteSchema.parse({ + schemas: [SCIM_GROUP_SCHEMA], + displayName: 'Engineering', + members: [{ value: 'u1' }, { value: 'u1' }, { value: 'u2' }], + }) + ) + expect(group.memberIds).toEqual(['u1', 'u2']) + }) + + it('refuses a nested group member', () => { + let scimType: string | undefined + try { + toCanonicalGroup( + scimGroupWriteSchema.parse({ + schemas: [SCIM_GROUP_SCHEMA], + displayName: 'Engineering', + members: [{ value: 'g2', type: 'Group' }], + }) + ) + } catch (error) { + scimType = (error as ScimError).scimType + } + expect(scimType).toBe('invalidValue') + }) +}) diff --git a/apps/sim/ee/scim/lib/protocol/canonical.ts b/apps/sim/ee/scim/lib/protocol/canonical.ts new file mode 100644 index 00000000000..d5d366e6201 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/canonical.ts @@ -0,0 +1,192 @@ +import type { ScimUserAttributes, ScimUserEmail } from '@sim/db/schema' +import { isValidEmailSyntax } from '@sim/utils/string' +import type { ScimGroupWriteParsed, ScimUserWriteParsed } from '@/lib/api/contracts/scim' +import { SCIM_ENTERPRISE_USER_SCHEMA } from '@/ee/scim/lib/protocol/constants' +import { invalidValue } from '@/ee/scim/lib/protocol/errors' +import { isRecord } from '@/ee/scim/lib/protocol/normalize' + +/** Attributes Sim models itself; everything else is preserved under `extra`. */ +const MODELLED_USER_KEYS = new Set([ + 'schemas', + 'id', + 'meta', + 'username', + 'externalid', + 'active', + 'displayname', + 'name', + 'emails', + 'password', + SCIM_ENTERPRISE_USER_SCHEMA.toLowerCase(), +]) + +function trimmed(value: string | undefined): string | undefined { + const next = value?.trim() + return next ? next : undefined +} + +/** + * Chooses the address Sim will use as the account's email. + * + * Providers disagree about where it lives: Okta always sends `emails`, Entra + * often sends only an email-shaped `userName`, and OneLogin sends both with no + * `primary` flag. Preference order is the flagged primary, then the first + * address, then an email-shaped `userName`. + */ +function normalizeEmails( + emails: ScimUserWriteParsed['emails'], + userName: string +): { emails: ScimUserEmail[]; primary: string } { + const supplied = (emails ?? []) + .map((entry) => ({ + value: entry.value.trim().toLowerCase(), + type: trimmed(entry.type), + primary: entry.primary === true, + })) + .filter((entry) => entry.value.length > 0) + + if (supplied.length === 0) { + if (!isValidEmailSyntax(userName)) { + throw invalidValue( + 'A primary email address is required: send emails[], or a userName that is an email address' + ) + } + return { + emails: [{ value: userName, type: 'work', primary: true }], + primary: userName, + } + } + + const primaryIndex = supplied.findIndex((entry) => entry.primary) + const chosen = primaryIndex >= 0 ? primaryIndex : 0 + const normalized = supplied.map((entry, index) => ({ ...entry, primary: index === chosen })) + return { emails: normalized, primary: normalized[chosen].value } +} + +function formatName( + name: ScimUserWriteParsed['name'], + displayName: string | undefined, + fallback: string +): ScimUserAttributes['name'] { + const givenName = trimmed(name?.givenName) + const familyName = trimmed(name?.familyName) + const joined = [givenName, familyName].filter(Boolean).join(' ') + const formatted = trimmed(name?.formatted) ?? (joined || trimmed(displayName) || fallback) + return { + formatted, + ...(givenName ? { givenName } : {}), + ...(familyName ? { familyName } : {}), + } +} + +function collectExtra(body: ScimUserWriteParsed): Record | undefined { + const extra: Record = {} + for (const [key, value] of Object.entries(body)) { + if (MODELLED_USER_KEYS.has(key.toLowerCase())) continue + extra[key] = value + } + return Object.keys(extra).length > 0 ? extra : undefined +} + +/** + * Turns an inbound User resource into the shape Sim stores. + * + * The whole resource is kept, not only the fields Sim reads, so a `GET` returns + * what the provider wrote and a later `PATCH` applies to the provider's own view + * rather than to a lossy projection of it. + */ +export function toCanonicalUser(body: ScimUserWriteParsed): ScimUserAttributes { + const userName = body.userName.toLowerCase() + + const { emails, primary } = normalizeEmails(body.emails, userName) + const name = formatName(body.name, body.displayName, primary) + const enterprise = body[SCIM_ENTERPRISE_USER_SCHEMA] + const extra = collectExtra(body) + + return { + userName, + ...(trimmed(body.externalId) ? { externalId: trimmed(body.externalId) } : {}), + active: body.active ?? true, + displayName: trimmed(body.displayName) ?? name.formatted, + name, + emails, + ...(isRecord(enterprise) ? { enterprise: normalizeEnterprise(enterprise) } : {}), + ...(extra ? { extra } : {}), + } +} + +type EnterpriseAttributes = NonNullable + +const ENTERPRISE_STRING_FIELDS = [ + 'department', + 'employeeNumber', + 'costCenter', + 'division', + 'organization', +] as const + +/** + * The enterprise extension as stored. The write contract accepts `manager` as + * either an identifier string or an object, so the string form is normalized + * here rather than trusted to match the stored shape. + */ +function normalizeEnterprise(value: Record): EnterpriseAttributes { + const text = (candidate: unknown) => + typeof candidate === 'string' ? trimmed(candidate) : undefined + const enterprise: EnterpriseAttributes = {} + for (const field of ENTERPRISE_STRING_FIELDS) { + const candidate = text(value[field]) + if (candidate) enterprise[field] = candidate + } + const manager = value.manager + if (typeof manager === 'string' && manager.trim()) { + enterprise.manager = { value: manager.trim() } + } else if (isRecord(manager)) { + const managerValue = text(manager.value) + const displayName = text(manager.displayName) + if (managerValue || displayName) { + enterprise.manager = { + ...(managerValue ? { value: managerValue } : {}), + ...(displayName ? { displayName } : {}), + } + } + } + return enterprise +} + +/** The primary address of a canonical resource. */ +export function primaryEmail(attributes: ScimUserAttributes): string { + return (attributes.emails.find((entry) => entry.primary) ?? attributes.emails[0]).value +} + +export interface CanonicalScimGroup { + displayName: string + externalId?: string + memberIds: string[] +} + +/** Turns an inbound Group resource into a display name and a member id list. */ +export function toCanonicalGroup(body: ScimGroupWriteParsed): CanonicalScimGroup { + const displayName = body.displayName + + const memberIds: string[] = [] + for (const member of body.members ?? []) { + if (member.type && member.type.toLowerCase() !== 'user') { + throw invalidValue('Group members must be Users; nested groups are not supported') + } + if (!memberIds.includes(member.value)) memberIds.push(member.value) + } + + return { + displayName, + ...(trimmed(body.externalId) ? { externalId: trimmed(body.externalId) } : {}), + memberIds, + } +} + +/** Reads the member id out of a PATCH value entry, which may be bare or wrapped. */ +export function readMemberValue(entry: unknown): string { + if (typeof entry === 'string') return entry.trim() + if (isRecord(entry) && typeof entry.value === 'string') return entry.value.trim() + throw invalidValue('Group member entries require a value') +} diff --git a/apps/sim/ee/scim/lib/protocol/constants.ts b/apps/sim/ee/scim/lib/protocol/constants.ts new file mode 100644 index 00000000000..fa922fcea03 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/constants.ts @@ -0,0 +1,57 @@ +/** Schema URNs, media types, and limits fixed by RFC 7643 and RFC 7644. */ + +export const SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User' +export const SCIM_ENTERPRISE_USER_SCHEMA = + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' +export const SCIM_GROUP_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Group' +export const SCIM_LIST_RESPONSE_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse' +export const SCIM_PATCH_OP_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:PatchOp' +export const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error' +export const SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA = + 'urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig' +export const SCIM_RESOURCE_TYPE_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:ResourceType' +export const SCIM_SCHEMA_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:Schema' + +/** SCIM's own media type, plus the plain JSON every provider also sends. */ +export const SCIM_MEDIA_TYPE = 'application/scim+json' +export const SCIM_ACCEPTED_MEDIA_TYPES = [SCIM_MEDIA_TYPE, 'application/json'] as const + +/** The mount point every `meta.location` and `$ref` is built from. */ +export const SCIM_BASE_PATH = '/api/scim/v2' + +/** + * Largest page a list response returns, and the default when a provider asks + * for none. Okta imports with `count=100`; advertising a ceiling above what the + * providers use would only invite a request we would rather not serve in one + * transaction. + */ +export const SCIM_MAX_PAGE_SIZE = 100 + +/** Largest membership a single Group may carry. */ +export const SCIM_MAX_GROUP_MEMBERS = 5000 + +/** Largest number of `and`-joined terms accepted in one filter. */ +export const SCIM_MAX_FILTER_TERMS = 10 + +/** Largest number of operations accepted in one PATCH request. */ +export const SCIM_MAX_PATCH_OPERATIONS = 100 + +/** Largest request body accepted, sized for a full-membership Group write. */ +export const SCIM_MAX_BODY_BYTES = 1_000_000 + +/** + * Requests a connection may make per minute, and the burst it may spend at once. + * + * Microsoft requires a SCIM endpoint to sustain at least 25 requests per second + * per tenant, and opens each provisioning cycle with a burst of reads; Okta pages + * imports at 100 users a call. The bucket is sized to those clients, not to + * interactive traffic. + */ +export const SCIM_RATE_LIMIT = { + maxTokens: 3_000, + refillRate: 1_500, + refillIntervalMs: 60_000, +} as const + +/** Request-log rows kept per connection; the reconcile job prunes the rest. */ +export const SCIM_REQUEST_LOG_RETENTION = 500 diff --git a/apps/sim/ee/scim/lib/protocol/discovery.ts b/apps/sim/ee/scim/lib/protocol/discovery.ts new file mode 100644 index 00000000000..d79da904666 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/discovery.ts @@ -0,0 +1,163 @@ +import { + SCIM_ENTERPRISE_USER_SCHEMA, + SCIM_GROUP_SCHEMA, + SCIM_MAX_PAGE_SIZE, + SCIM_RESOURCE_TYPE_SCHEMA, + SCIM_SCHEMA_SCHEMA, + SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA, + SCIM_USER_SCHEMA, +} from '@/ee/scim/lib/protocol/constants' + +/** + * The discovery documents RFC 7644 requires. + * + * They describe exactly what this server implements, so a provider negotiating + * against them never configures something that will fail later. In particular + * `sort`, `etag`, and `bulk` are advertised as unsupported rather than omitted: + * a provider reading an absent capability may assume the default is true. + */ + +export function serviceProviderConfig(baseUrl: string) { + return { + schemas: [SCIM_SERVICE_PROVIDER_CONFIG_SCHEMA], + documentationUri: 'https://docs.sim.ai/platform/enterprise/scim', + patch: { supported: true }, + bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 }, + filter: { supported: true, maxResults: SCIM_MAX_PAGE_SIZE }, + changePassword: { supported: false }, + sort: { supported: false }, + etag: { supported: false }, + authenticationSchemes: [ + { + type: 'oauthbearertoken', + name: 'OAuth Bearer Token', + description: 'Authentication using a bearer token issued in Sim organization settings', + specUri: 'http://www.rfc-editor.org/info/rfc6750', + primary: true, + }, + ], + meta: { + resourceType: 'ServiceProviderConfig', + location: `${baseUrl}/ServiceProviderConfig`, + }, + } +} + +function resourceType( + id: 'User' | 'Group', + schema: string, + baseUrl: string, + extensions: Array<{ schema: string; required: boolean }> = [] +) { + return { + schemas: [SCIM_RESOURCE_TYPE_SCHEMA], + id, + name: id, + endpoint: `/${id}s`, + description: id === 'User' ? 'User Account' : 'Group', + schema, + ...(extensions.length > 0 ? { schemaExtensions: extensions } : {}), + meta: { resourceType: 'ResourceType', location: `${baseUrl}/ResourceTypes/${id}` }, + } +} + +export function resourceTypes(baseUrl: string) { + return [ + resourceType('User', SCIM_USER_SCHEMA, baseUrl, [ + { schema: SCIM_ENTERPRISE_USER_SCHEMA, required: false }, + ]), + resourceType('Group', SCIM_GROUP_SCHEMA, baseUrl), + ] +} + +function attribute(name: string, overrides: Record = {}): Record { + return { + name, + type: 'string', + multiValued: false, + required: false, + caseExact: false, + mutability: 'readWrite', + returned: 'default', + uniqueness: 'none', + ...overrides, + } +} + +export function schemaDefinitions(baseUrl: string) { + return [ + { + schemas: [SCIM_SCHEMA_SCHEMA], + id: SCIM_USER_SCHEMA, + name: 'User', + description: 'User Account', + attributes: [ + attribute('userName', { required: true, uniqueness: 'server' }), + attribute('externalId'), + attribute('displayName', { mutability: 'readWrite' }), + attribute('active', { type: 'boolean' }), + attribute('name', { + type: 'complex', + subAttributes: [attribute('formatted'), attribute('givenName'), attribute('familyName')], + }), + attribute('emails', { + type: 'complex', + multiValued: true, + subAttributes: [ + attribute('value', { uniqueness: 'server' }), + attribute('type'), + attribute('primary', { type: 'boolean' }), + ], + }), + attribute('groups', { + type: 'complex', + multiValued: true, + mutability: 'readOnly', + subAttributes: [ + attribute('value', { mutability: 'readOnly' }), + attribute('display', { mutability: 'readOnly' }), + attribute('$ref', { mutability: 'readOnly' }), + ], + }), + ], + meta: { resourceType: 'Schema', location: `${baseUrl}/Schemas/${SCIM_USER_SCHEMA}` }, + }, + { + schemas: [SCIM_SCHEMA_SCHEMA], + id: SCIM_ENTERPRISE_USER_SCHEMA, + name: 'EnterpriseUser', + description: 'Enterprise User Extension', + attributes: [ + attribute('employeeNumber'), + attribute('costCenter'), + attribute('organization'), + attribute('division'), + attribute('department'), + attribute('manager', { + type: 'complex', + subAttributes: [attribute('value'), attribute('displayName')], + }), + ], + meta: { + resourceType: 'Schema', + location: `${baseUrl}/Schemas/${SCIM_ENTERPRISE_USER_SCHEMA}`, + }, + }, + { + schemas: [SCIM_SCHEMA_SCHEMA], + id: SCIM_GROUP_SCHEMA, + name: 'Group', + description: 'Group', + attributes: [ + attribute('displayName', { required: true, uniqueness: 'server' }), + attribute('externalId'), + attribute('members', { + type: 'complex', + multiValued: true, + subAttributes: [attribute('value'), attribute('display'), attribute('type')], + }), + ], + meta: { resourceType: 'Schema', location: `${baseUrl}/Schemas/${SCIM_GROUP_SCHEMA}` }, + }, + ] +} diff --git a/apps/sim/ee/scim/lib/protocol/errors.ts b/apps/sim/ee/scim/lib/protocol/errors.ts new file mode 100644 index 00000000000..3c1841e3acc --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/errors.ts @@ -0,0 +1,163 @@ +import { getPostgresErrorCode } from '@sim/utils/errors' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { SCIM_ERROR_SCHEMA } from '@/ee/scim/lib/protocol/constants' + +/** + * The `scimType` vocabulary of RFC 7644 section 3.12. + * + * A provider branches on this, not on the message: Okta reports a `uniqueness` + * conflict as an existing user and retries nothing, while it treats an + * unlabelled 409 as a transient failure worth retrying for the rest of the sync. + */ +export type ScimType = + | 'invalidFilter' + | 'tooMany' + | 'uniqueness' + | 'mutability' + | 'invalidSyntax' + | 'invalidPath' + | 'noTarget' + | 'invalidValue' + | 'invalidVers' + | 'sensitive' + +export interface ScimErrorBody { + schemas: [typeof SCIM_ERROR_SCHEMA] + /** RFC 7644 carries the status as a string inside the body as well. */ + status: string + scimType?: ScimType + detail: string +} + +/** A refusal rendered in the envelope RFC 7644 requires. */ +export class ScimError extends Error { + constructor( + readonly status: number, + readonly scimType: ScimType | undefined, + detail: string, + readonly headers?: Record + ) { + super(detail) + this.name = 'ScimError' + } + + get body(): ScimErrorBody { + return { + schemas: [SCIM_ERROR_SCHEMA], + status: String(this.status), + ...(this.scimType ? { scimType: this.scimType } : {}), + detail: this.message, + } + } +} + +export function scimErrorBody( + status: number, + scimType: ScimType | undefined, + detail: string +): ScimErrorBody { + return new ScimError(status, scimType, detail).body +} + +/** A value the provider sent is not one this attribute accepts. */ +export function invalidValue(detail: string): ScimError { + return new ScimError(400, 'invalidValue', detail) +} + +/** The provider addressed an attribute path this server does not implement. */ +export function invalidPath(detail: string): ScimError { + return new ScimError(400, 'invalidPath', detail) +} + +/** The provider tried to write an attribute the server owns. */ +export function mutability(detail: string): ScimError { + return new ScimError(400, 'mutability', detail) +} + +/** A filtered operation matched nothing and the operation cannot create one. */ +export function noTarget(detail: string): ScimError { + return new ScimError(400, 'noTarget', detail) +} + +/** The filter expression is outside the grammar this server supports. */ +export function invalidFilter(detail: string): ScimError { + return new ScimError(400, 'invalidFilter', detail) +} + +/** A uniqueness constraint the provider must resolve on its side. */ +export function uniqueness(detail: string): ScimError { + return new ScimError(409, 'uniqueness', detail) +} + +export function notFound(detail: string): ScimError { + return new ScimError(404, undefined, detail) +} + +/** + * PostgreSQL's lock-not-available code, raised when an advisory lock waiter hits + * `lock_timeout`. It means "try again", not "your request was wrong". + */ +const PG_LOCK_NOT_AVAILABLE = '55P03' + +/** PostgreSQL's unique-violation code: a concurrent write beat this one to a key. */ +const PG_UNIQUE_VIOLATION = '23505' + +/** PostgreSQL's deadlock code: the loser was rolled back and should simply retry. */ +const PG_DEADLOCK_DETECTED = '40P01' + +function isLockContention(error: unknown): boolean { + const code = getPostgresErrorCode(error) + return code === PG_LOCK_NOT_AVAILABLE || code === PG_DEADLOCK_DETECTED +} + +/** + * Two provisioning requests for the same key can race past every pre-check; + * the unique index is the arbiter. RFC 7644 calls that a `uniqueness` conflict, + * and the label matters: Okta stops retrying on `uniqueness` and treats an + * unlabelled failure as transient. + */ +function isUniqueViolation(error: unknown): boolean { + return getPostgresErrorCode(error) === PG_UNIQUE_VIOLATION +} + +/** + * Renders any failure as a SCIM error. + * + * Domain failures arrive as {@link OrchestrationError} from the shared + * membership and permission primitives, which know nothing about SCIM. Mapping + * them here rather than at each throw site keeps those primitives usable by the + * UI, which needs the same failures rendered as ordinary HTTP. + */ +export function toScimError(error: unknown): ScimError { + if (error instanceof ScimError) return error + + if (isLockContention(error)) { + return new ScimError(503, undefined, 'The organization is busy; retry shortly', { + 'Retry-After': '5', + }) + } + if (isUniqueViolation(error)) { + return new ScimError(409, 'uniqueness', 'A resource with the same identifier already exists') + } + + const orchestration = asOrchestrationError(error) + if (orchestration) { + switch (orchestration.code) { + case 'not_found': + return new ScimError(404, undefined, orchestration.message) + /** A conflict that is not a duplicate carries no `scimType`; `uniqueness` is reserved for duplicates. */ + case 'conflict': + return new ScimError(409, undefined, orchestration.message) + case 'forbidden': + return new ScimError(403, undefined, orchestration.message) + case 'validation': + return new ScimError(400, 'invalidValue', orchestration.message) + case 'locked': + return new ScimError(503, undefined, orchestration.message, { 'Retry-After': '5' }) + default: + return new ScimError(500, undefined, 'Internal server error') + } + } + + return new ScimError(500, undefined, 'Internal server error') +} diff --git a/apps/sim/ee/scim/lib/protocol/filter.test.ts b/apps/sim/ee/scim/lib/protocol/filter.test.ts new file mode 100644 index 00000000000..48736770cad --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/filter.test.ts @@ -0,0 +1,101 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import type { ScimError } from '@/ee/scim/lib/protocol/errors' +import { parseGroupFilter, parseUserFilter } from '@/ee/scim/lib/protocol/filter' + +/** + * The grammar is deliberately small, so these tests are as much about what is + * refused as what is accepted. A filter this server silently widened would hand + * a provider a different set of users than it asked for, and the provider would + * reconcile against that set. + */ + +function scimTypeOf(run: () => unknown): string | undefined { + try { + run() + } catch (error) { + return (error as ScimError).scimType + } + return undefined +} + +describe('parseUserFilter', () => { + it('parses the lookup Okta sends before every create', () => { + expect(parseUserFilter('userName eq "ada@acme.test"')).toEqual([ + { field: 'userName', value: 'ada@acme.test' }, + ]) + }) + + it('parses an externalId lookup, which Entra uses when it is the match attribute', () => { + expect(parseUserFilter('externalId eq "00u1"')).toEqual([ + { field: 'externalId', value: '00u1' }, + ]) + }) + + it('parses the work-email filtered path Entra sends', () => { + expect(parseUserFilter('emails[type eq "work"].value eq "ada@acme.test"')).toEqual([ + { field: 'email', value: 'ada@acme.test' }, + ]) + }) + + it('joins expressions with and', () => { + expect(parseUserFilter('userName eq "ada@acme.test" and externalId eq "00u1"')).toEqual([ + { field: 'userName', value: 'ada@acme.test' }, + { field: 'externalId', value: '00u1' }, + ]) + }) + + it('accepts a URN-qualified attribute name', () => { + expect( + parseUserFilter('urn:ietf:params:scim:schemas:core:2.0:User:userName eq "a@b.test"') + ).toEqual([{ field: 'userName', value: 'a@b.test' }]) + }) + + it('treats the operator as case-insensitive', () => { + expect(parseUserFilter('userName Eq "a@b.test"')).toEqual([ + { field: 'userName', value: 'a@b.test' }, + ]) + }) + + it('does not split on the word and inside a quoted value', () => { + expect(parseGroupFilter('displayName eq "Research and Development"')).toEqual([ + { field: 'displayName', value: 'Research and Development' }, + ]) + }) + + it('refuses an operator outside the supported set', () => { + expect(scimTypeOf(() => parseUserFilter('userName co "ada"'))).toBe('invalidFilter') + }) + + it('refuses an attribute this server cannot answer', () => { + expect(scimTypeOf(() => parseUserFilter('nickName eq "Ada"'))).toBe('invalidFilter') + }) + + it('accepts the unquoted booleans RFC 7644 writes for active', () => { + expect(parseUserFilter('active eq true')).toEqual([{ field: 'active', value: 'true' }]) + expect(parseUserFilter('active eq false')).toEqual([{ field: 'active', value: 'false' }]) + }) + + it('refuses an unquoted value', () => { + expect(scimTypeOf(() => parseUserFilter('userName eq ada'))).toBe('invalidFilter') + }) + + it('refuses more than ten joined expressions', () => { + const filter = Array.from({ length: 11 }, (_, index) => `userName eq "u${index}"`).join(' and ') + expect(scimTypeOf(() => parseUserFilter(filter))).toBe('invalidFilter') + }) +}) + +describe('parseGroupFilter', () => { + it('parses the displayName lookup Okta sends before a group push', () => { + expect(parseGroupFilter('displayName eq "Engineering"')).toEqual([ + { field: 'displayName', value: 'Engineering' }, + ]) + }) + + it('refuses a User attribute on the Group endpoint', () => { + expect(scimTypeOf(() => parseGroupFilter('userName eq "a@b.test"'))).toBe('invalidFilter') + }) +}) diff --git a/apps/sim/ee/scim/lib/protocol/filter.ts b/apps/sim/ee/scim/lib/protocol/filter.ts new file mode 100644 index 00000000000..30b3c6bfbc8 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/filter.ts @@ -0,0 +1,210 @@ +import { SCIM_MAX_FILTER_TERMS } from '@/ee/scim/lib/protocol/constants' +import { invalidFilter } from '@/ee/scim/lib/protocol/errors' +import { normalizeAttributePath } from '@/ee/scim/lib/protocol/normalize' + +/** + * The filter grammar this server accepts, which is the subset the provisioning + * clients actually send. + * + * Okta filters `userName eq "x"` before every create and `displayName eq "x"` + * before every group push. Microsoft Entra states plainly that it "only uses + * the following operators: eq, and", and queries by `userName`, `externalId`, + * or `emails[type eq "work"].value` depending on which attribute the tenant + * chose for matching. OneLogin and JumpCloud send the same `userName eq` probe. + * + * Everything outside that is refused with `invalidFilter` rather than + * approximated. A filter this server silently widened would hand a provider a + * different set of users than it asked for, and it would reconcile against that + * set — deleting or deactivating whatever it believes has disappeared. + */ + +/** Attributes a User filter may name, mapped to the field the repository knows. */ +export type ScimUserFilterField = 'id' | 'userName' | 'externalId' | 'email' | 'active' + +/** Attributes a Group filter may name. */ +export type ScimGroupFilterField = 'id' | 'displayName' | 'externalId' + +export interface ScimFilterTerm { + field: Field + value: string +} + +const USER_FILTER_FIELDS: Record = { + id: 'id', + username: 'userName', + externalid: 'externalId', + 'emails.value': 'email', + 'emails[type eq "work"].value': 'email', + 'emails[primary eq true].value': 'email', + active: 'active', +} + +const GROUP_FILTER_FIELDS: Record = { + id: 'id', + displayname: 'displayName', + externalid: 'externalId', +} + +/** + * Splits on the `and` keyword at the top level of the expression. + * + * Quote- and bracket-aware, so an `and` inside a quoted value or inside a + * `[type eq "work"]` value filter is not mistaken for a separator. + */ +function splitConjunction(expression: string): string[] { + const terms: string[] = [] + let depth = 0 + let quoted = false + let escaped = false + let start = 0 + + for (let index = 0; index < expression.length; index += 1) { + const character = expression[index] + + if (escaped) { + escaped = false + continue + } + if (quoted) { + if (character === '\\') escaped = true + else if (character === '"') quoted = false + continue + } + if (character === '"') { + quoted = true + continue + } + if (character === '[' || character === '(') { + depth += 1 + continue + } + if (character === ']' || character === ')') { + depth -= 1 + if (depth < 0) throw invalidFilter('Unbalanced brackets in filter expression') + continue + } + if (depth > 0) continue + + const isBoundary = (position: number) => + position < 0 || position >= expression.length || /\s/.test(expression[position]) + if ( + (character === 'a' || character === 'A') && + expression.slice(index, index + 3).toLowerCase() === 'and' && + isBoundary(index - 1) && + isBoundary(index + 3) + ) { + terms.push(expression.slice(start, index)) + start = index + 3 + index += 2 + } + } + + if (quoted || depth !== 0) throw invalidFilter('Unterminated quote or bracket in filter') + terms.push(expression.slice(start)) + return terms +} + +/** + * Splits `attributePath operator "value"` at the operator. + * + * Scanned rather than matched with one expression, because an attribute path may + * itself contain spaces and an operator: `emails[type eq "work"].value` is a + * single attribute, and a regex that stopped at the first space would read its + * inner `eq` as the comparison and the rest as a malformed value. + */ +function splitAtOperator(term: string): { attribute: string; operator: string; value: string } { + let depth = 0 + let quoted = false + let escaped = false + + for (let index = 0; index < term.length; index += 1) { + const character = term[index] + if (escaped) { + escaped = false + continue + } + if (quoted) { + if (character === '\\') escaped = true + else if (character === '"') quoted = false + continue + } + if (character === '"') { + quoted = true + continue + } + if (character === '[') depth += 1 + else if (character === ']') depth -= 1 + if (depth !== 0 || !/\s/.test(character)) continue + + const rest = term.slice(index).trimStart() + const operatorEnd = rest.search(/\s/) + if (operatorEnd === -1) break + const operator = rest.slice(0, operatorEnd) + if (!/^[a-zA-Z]{2}$/.test(operator)) continue + + return { + attribute: term.slice(0, index).trim(), + operator: operator.toLowerCase(), + value: rest.slice(operatorEnd).trim(), + } + } + + throw invalidFilter(`Unsupported filter expression: ${term.trim()}`) +} + +function parseTerm(term: string): { attribute: string; value: string } { + const { attribute, operator, value: rawValue } = splitAtOperator(term.trim()) + + if (operator !== 'eq') { + throw invalidFilter(`The filter operator ${operator} is not supported; use eq`) + } + + const raw = rawValue.trim() + /** RFC 7644 writes boolean comparisons unquoted, and only `active` is boolean here. */ + if ( + normalizeAttributePath(attribute).toLowerCase() === 'active' && + (raw === 'true' || raw === 'false') + ) { + return { attribute, value: raw } + } + if (!raw.startsWith('"') || !raw.endsWith('"') || raw.length < 2) { + throw invalidFilter('Filter values must be quoted strings') + } + let value: string + try { + value = JSON.parse(raw) as string + } catch { + throw invalidFilter('Filter values must be quoted strings') + } + + return { attribute, value } +} + +function parseTerms( + filter: string, + fields: Record, + resourceName: string +): ScimFilterTerm[] { + const parts = splitConjunction(filter) + if (parts.length > SCIM_MAX_FILTER_TERMS) { + throw invalidFilter(`A filter may join at most ${SCIM_MAX_FILTER_TERMS} expressions with and`) + } + + return parts.map((part) => { + const { attribute, value } = parseTerm(part) + const normalized = normalizeAttributePath(attribute).toLowerCase() + const field = fields[normalized] + if (!field) { + throw invalidFilter(`The filter attribute ${attribute} is not supported for ${resourceName}`) + } + return { field, value } + }) +} + +export function parseUserFilter(filter: string): ScimFilterTerm[] { + return parseTerms(filter, USER_FILTER_FIELDS, 'User') +} + +export function parseGroupFilter(filter: string): ScimFilterTerm[] { + return parseTerms(filter, GROUP_FILTER_FIELDS, 'Group') +} diff --git a/apps/sim/ee/scim/lib/protocol/group-patch.test.ts b/apps/sim/ee/scim/lib/protocol/group-patch.test.ts new file mode 100644 index 00000000000..e48267154e1 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/group-patch.test.ts @@ -0,0 +1,135 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { scimPatchBodySchema } from '@/lib/api/contracts/scim' +import { SCIM_PATCH_OP_SCHEMA } from '@/ee/scim/lib/protocol/constants' +import type { ScimError } from '@/ee/scim/lib/protocol/errors' +import { parseGroupPatch } from '@/ee/scim/lib/protocol/group-patch' + +function parseOperations(operations: unknown[]) { + return scimPatchBodySchema.parse({ schemas: [SCIM_PATCH_OP_SCHEMA], Operations: operations }) + .Operations +} + +describe('parseGroupPatch', () => { + it('lets the last operation naming a member win', () => { + expect( + parseGroupPatch([ + { op: 'add', path: 'members', value: [{ value: 'u1' }] }, + { op: 'remove', path: 'members[value eq "u1"]' }, + ]) + ).toEqual({ kind: 'incremental', add: [], remove: ['u1'] }) + expect( + parseGroupPatch([ + { op: 'remove', path: 'members[value eq "u1"]' }, + { op: 'add', path: 'members', value: [{ value: 'u1' }] }, + ]) + ).toEqual({ kind: 'incremental', add: ['u1'], remove: [] }) + }) + + it('treats a path-less add of members as a delta, not a replacement', () => { + expect(parseGroupPatch([{ op: 'add', value: { members: [{ value: 'u9' }] } }])).toEqual({ + kind: 'full', + addMembers: ['u9'], + removeMembers: [], + }) + }) + + it('refuses an add to members with no value', () => { + expect(() => parseGroupPatch([{ op: 'add', path: 'members' }])).toThrow('requires a value') + }) + + it('refuses a non-string externalId instead of clearing it', () => { + expect(() => parseGroupPatch([{ op: 'replace', path: 'externalId', value: 42 }])).toThrow( + 'externalId must be a string' + ) + }) + + it('reads Okta’s filtered member removal', () => { + const patch = parseGroupPatch( + parseOperations([{ op: 'remove', path: 'members[value eq "u1"]' }]) + ) + expect(patch).toEqual({ kind: 'incremental', add: [], remove: ['u1'] }) + }) + + it('reads Okta’s member addition', () => { + const patch = parseGroupPatch( + parseOperations([{ op: 'add', path: 'members', value: [{ value: 'u1', display: 'Ada' }] }]) + ) + expect(patch).toEqual({ kind: 'incremental', add: ['u1'], remove: [] }) + }) + + it('reads Entra’s legacy removal, which identifies the member by value alone', () => { + const patch = parseGroupPatch( + parseOperations([{ op: 'Remove', path: 'members', value: [{ value: 'u1' }] }]) + ) + expect(patch).toEqual({ kind: 'incremental', add: [], remove: ['u1'] }) + }) + + it('reads Entra’s add form with a null $ref alongside the value', () => { + const patch = parseGroupPatch( + parseOperations([{ op: 'Add', path: 'members', value: [{ $ref: null, value: 'u2' }] }]) + ) + expect(patch).toEqual({ kind: 'incremental', add: ['u2'], remove: [] }) + }) + + it('treats a wholesale member replacement as a full patch', () => { + const patch = parseGroupPatch( + parseOperations([{ op: 'replace', path: 'members', value: [{ value: 'u1' }] }]) + ) + expect(patch).toMatchObject({ kind: 'full', members: ['u1'] }) + }) + + it('treats a valueless member removal as clearing the membership', () => { + const patch = parseGroupPatch(parseOperations([{ op: 'remove', path: 'members' }])) + expect(patch).toMatchObject({ kind: 'full', members: [] }) + }) + + it('reads Okta’s rename, ignoring the id it echoes back', () => { + const patch = parseGroupPatch( + parseOperations([{ op: 'replace', value: { id: 'g1', displayName: 'Platform' } }]) + ) + expect(patch).toMatchObject({ kind: 'full', displayName: 'Platform' }) + }) + + it('carries membership deltas that accompany a rename', () => { + const patch = parseGroupPatch( + parseOperations([ + { op: 'replace', path: 'displayName', value: 'Platform' }, + { op: 'add', path: 'members', value: [{ value: 'u3' }] }, + ]) + ) + expect(patch).toMatchObject({ kind: 'full', displayName: 'Platform', addMembers: ['u3'] }) + }) + + it('refuses a remove with no path', () => { + let scimType: string | undefined + try { + parseGroupPatch(parseOperations([{ op: 'remove', value: { members: [] } }])) + } catch (error) { + scimType = (error as ScimError).scimType + } + expect(scimType).toBe('noTarget') + }) + + it('refuses removing the display name', () => { + let scimType: string | undefined + try { + parseGroupPatch(parseOperations([{ op: 'remove', path: 'displayName' }])) + } catch (error) { + scimType = (error as ScimError).scimType + } + expect(scimType).toBe('mutability') + }) + + it('refuses an unsupported path', () => { + let scimType: string | undefined + try { + parseGroupPatch(parseOperations([{ op: 'replace', path: 'owner', value: 'u1' }])) + } catch (error) { + scimType = (error as ScimError).scimType + } + expect(scimType).toBe('invalidPath') + }) +}) diff --git a/apps/sim/ee/scim/lib/protocol/group-patch.ts b/apps/sim/ee/scim/lib/protocol/group-patch.ts new file mode 100644 index 00000000000..0f86d5e5543 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/group-patch.ts @@ -0,0 +1,186 @@ +import type { ScimPatchOperation } from '@/lib/api/contracts/scim' +import { readMemberValue } from '@/ee/scim/lib/protocol/canonical' +import { invalidPath, invalidValue, mutability, noTarget } from '@/ee/scim/lib/protocol/errors' +import { isRecord, normalizeAttributePath } from '@/ee/scim/lib/protocol/normalize' + +/** + * A parsed Group PATCH. + * + * Membership changes are separated from whole-resource writes because the + * providers send far more of the former: an incremental sync that moves one + * person between groups should touch two membership rows, not rewrite two full + * member lists. + */ +export type GroupPatch = + | { kind: 'incremental'; add: string[]; remove: string[] } + | { + kind: 'full' + displayName?: string + externalId?: string | null + /** Replaces the whole membership when present. */ + members?: string[] + /** Deltas that accompanied a rename in the same request. */ + addMembers: string[] + removeMembers: string[] + } + +/** `members[value eq "id"]`, the removal form Okta and Entra's newer job send. */ +const FILTERED_MEMBER_PATTERN = + /^members\[\s*value\s+eq\s+(?"|')(?[^"']+)\k\s*\]$/i + +function readMemberList(value: unknown): string[] { + const entries = Array.isArray(value) ? value : [value] + const ids: string[] = [] + for (const entry of entries) { + const id = readMemberValue(entry) + if (id && !ids.includes(id)) ids.push(id) + } + return ids +} + +/** + * Reads a Group PATCH, choosing the incremental path when every operation is a + * membership delta. + */ +export function parseGroupPatch(operations: readonly ScimPatchOperation[]): GroupPatch { + const add: string[] = [] + const remove: string[] = [] + let incremental = true + + const full: Extract = { + kind: 'full', + addMembers: [], + removeMembers: [], + } + + /** + * Membership is a set, so the final state of each member is decided by the + * last operation naming them; an earlier delta is dropped when a later one + * contradicts it, and a wholesale replace supersedes every delta before it. + */ + const addMember = (id: string) => { + const removedAt = remove.indexOf(id) + if (removedAt !== -1) remove.splice(removedAt, 1) + if (!add.includes(id)) add.push(id) + } + const removeMember = (id: string) => { + const addedAt = add.indexOf(id) + if (addedAt !== -1) add.splice(addedAt, 1) + if (!remove.includes(id)) remove.push(id) + } + const applyFullMembers = (ids: string[]) => { + incremental = false + add.length = 0 + remove.length = 0 + full.members = ids + } + const readExternalId = (value: unknown): string | null => { + if (typeof value !== 'string') throw invalidValue('externalId must be a string') + return value.trim() || null + } + + for (const operation of operations) { + if (operation.op === 'remove' && !operation.path) { + throw noTarget('A remove operation requires a path') + } + + if (!operation.path) { + const value = operation.value + if (!isRecord(value)) { + throw invalidValue('A PATCH operation without a path requires an object value') + } + incremental = false + for (const [attribute, nested] of Object.entries(value)) { + const key = normalizeAttributePath(attribute).toLowerCase() + if (key === 'displayname') { + if (typeof nested !== 'string' || !nested.trim()) { + throw invalidValue('displayName must be a non-empty string') + } + full.displayName = nested.trim() + } else if (key === 'externalid') { + full.externalId = readExternalId(nested) + } else if (key === 'members') { + if (operation.op === 'add') for (const id of readMemberList(nested)) addMember(id) + else applyFullMembers(readMemberList(nested)) + } else if (key === 'id' || key === 'schemas' || key.startsWith('meta')) { + /** + * Okta echoes the group's `id` inside a path-less rename. Read-only + * attributes sent this way are ignored rather than refused, because + * refusing would fail every Okta group rename. + */ + } else { + throw invalidPath(`Group PATCH path ${attribute} is not supported`) + } + } + continue + } + + const path = normalizeAttributePath(operation.path) + const filtered = path.match(FILTERED_MEMBER_PATTERN) + if (filtered?.groups) { + if (operation.op !== 'remove') { + throw invalidPath('A filtered members path is only supported for remove') + } + const id = filtered.groups.value.trim() + if (id) removeMember(id) + continue + } + + const key = path.toLowerCase() + if (key === 'members') { + if (operation.op === 'replace') { + /** Clearing a group is an explicit `[]` or a value-less remove, never a missing value. */ + if (operation.value === undefined || operation.value === null) { + throw invalidValue('A replace of members requires a value') + } + applyFullMembers(readMemberList(operation.value)) + continue + } + if (operation.op === 'remove' && operation.value === undefined) { + applyFullMembers([]) + continue + } + if (operation.op === 'add' && operation.value === undefined) { + throw invalidValue('An add to members requires a value') + } + for (const id of readMemberList(operation.value ?? [])) { + if (operation.op === 'add') addMember(id) + else removeMember(id) + } + continue + } + + if (key === 'displayname') { + if (operation.op === 'remove') throw mutability('displayName cannot be removed') + if (typeof operation.value !== 'string' || !operation.value.trim()) { + throw invalidValue('displayName must be a non-empty string') + } + incremental = false + full.displayName = operation.value.trim() + continue + } + + if (key === 'externalid') { + incremental = false + full.externalId = operation.op === 'remove' ? null : readExternalId(operation.value) + continue + } + + if (key === 'id' || key === 'schemas' || key.startsWith('meta')) { + throw mutability(`${operation.path} is read-only`) + } + + throw invalidPath(`Group PATCH path ${operation.path} is not supported`) + } + + if (incremental) return { kind: 'incremental', add, remove } + + /** + * A request that mixed a rename with membership deltas still has to apply + * those deltas. They ride along so the caller resolves them against current + * membership after any wholesale replacement in the same request. + */ + full.addMembers = add + full.removeMembers = remove + return full +} diff --git a/apps/sim/ee/scim/lib/protocol/normalize.ts b/apps/sim/ee/scim/lib/protocol/normalize.ts new file mode 100644 index 00000000000..7d22fc0d559 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/normalize.ts @@ -0,0 +1,108 @@ +/** + * Tolerances for what identity providers actually send, as distinct from what + * RFC 7644 describes. + * + * Every rule here is a documented provider behavior, not a guess. Microsoft + * Entra's classic provisioning job sends booleans as the strings `"True"` and + * `"False"`, capitalizes PATCH operation names, and wraps a single-valued + * attribute in a one-element array. Rejecting any of those is a failed sync the + * administrator cannot fix from their side. + */ + +/** + * Reads a SCIM boolean, accepting the string forms Entra sends. + * + * Returns the input unchanged when it is neither, so the caller's schema + * produces the error rather than this function silently coercing nonsense. + */ +export function normalizeScimBoolean(value: unknown): unknown { + if (typeof value === 'boolean') return value + if (typeof value !== 'string') return value + const lowered = value.trim().toLowerCase() + if (lowered === 'true') return true + if (lowered === 'false') return false + return value +} + +/** + * Unwraps `[x]` to `x`. + * + * Entra sends a one-element array where the schema declares a single value. + * Only applied where a scalar is expected, so a genuinely multi-valued + * attribute keeps its array. + */ +export function unwrapSingleElement(value: unknown): unknown { + return Array.isArray(value) && value.length === 1 ? value[0] : value +} + +/** True when the value is a plain object rather than an array or null. */ +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Strips a schema URN prefix from an attribute path and decodes it. + * + * Case is left to the caller, which compares lower-cased: RFC 7643 makes + * attribute names case-insensitive, and providers disagree — Okta sends + * `userName`, Entra sometimes `username` and sometimes the fully qualified + * `urn:...:User:userName`. + */ +export function normalizeAttributePath(path: string): string { + let value = path.trim() + if (value.startsWith('/')) value = value.slice(1) + try { + value = decodeURIComponent(value) + } catch { + /** Invalid percent-encoding is used as written; the closed path table rejects it as `invalidPath`. */ + } + const lowered = value.toLowerCase() + const coreUserPrefix = 'urn:ietf:params:scim:schemas:core:2.0:user:' + const coreGroupPrefix = 'urn:ietf:params:scim:schemas:core:2.0:group:' + const enterprisePrefix = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:' + if (lowered.startsWith(coreUserPrefix)) return value.slice(coreUserPrefix.length) + if (lowered.startsWith(coreGroupPrefix)) return value.slice(coreGroupPrefix.length) + if (lowered.startsWith(enterprisePrefix)) { + return `enterprise.${value.slice(enterprisePrefix.length)}` + } + if (lowered === enterprisePrefix.slice(0, -1)) return 'enterprise' + return value +} + +/** + * Microsoft's classic schema markers, sent by older provisioning jobs alongside + * the core URNs. They carry no attributes and are never stored or returned. + */ +export const ENTRA_LEGACY_GROUP_SCHEMA = + 'http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/Group' +const ENTRA_LEGACY_USER_SCHEMA = + 'http://schemas.microsoft.com/2006/11/ResourceManagement/ADSCIM/2.0/User' + +/** + * Restores canonical casing on top-level attribute names. + * + * RFC 7643 makes attribute names case-insensitive and Entra sends `username` + * where the schema says `userName`. Only the names given are touched; anything + * else passes through so unknown attributes still round-trip as sent. + */ +export function canonicalizeAttributeNames( + body: unknown, + canonicalNames: readonly string[] +): unknown { + if (!isRecord(body)) return body + const byLower = new Map(canonicalNames.map((name) => [name.toLowerCase(), name])) + const result: Record = {} + for (const [key, value] of Object.entries(body)) { + const canonical = byLower.get(key.toLowerCase()) + if (canonical && !(canonical in body) && !(canonical in result)) result[canonical] = value + else result[key] = value + } + return result +} + +/** Drops schema URNs that are provider markers rather than real extensions. */ +export function stripProviderSchemaMarkers(schemas: readonly string[]): string[] { + return schemas.filter( + (schema) => schema !== ENTRA_LEGACY_GROUP_SCHEMA && schema !== ENTRA_LEGACY_USER_SCHEMA + ) +} diff --git a/apps/sim/ee/scim/lib/protocol/resources.test.ts b/apps/sim/ee/scim/lib/protocol/resources.test.ts new file mode 100644 index 00000000000..42a4db8db4b --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/resources.test.ts @@ -0,0 +1,136 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { scimUserResourceSchema } from '@/lib/api/contracts/scim' +import { SCIM_MAX_PAGE_SIZE } from '@/ee/scim/lib/protocol/constants' +import type { ScimError } from '@/ee/scim/lib/protocol/errors' +import { + parseAttributeProjection, + projectionWants, + projectResource, + resolvePage, + toUserResource, +} from '@/ee/scim/lib/protocol/resources' + +const BASE_URL = 'https://sim.test/api/scim/v2' + +function userRow() { + return { + id: 'su1', + externalId: '00u1', + userName: 'ada@acme.test', + active: true, + attributes: { + userName: 'ada@acme.test', + active: true, + displayName: 'Ada Lovelace', + name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, + emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], + }, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-02-01T00:00:00.000Z'), + email: 'ada@acme.test', + groups: [{ id: 'g1', displayName: 'Engineering' }], + } +} + +describe('resolvePage', () => { + it('defaults to the first page at the maximum size', () => { + expect(resolvePage({})).toEqual({ + startIndex: 1, + offset: 0, + count: SCIM_MAX_PAGE_SIZE, + }) + }) + + it('clamps a zero startIndex up, because Okta sends one on its first import page', () => { + expect(resolvePage({ startIndex: 0 })).toMatchObject({ startIndex: 1, offset: 0 }) + }) + + it('caps the page size so one request cannot ask for an unbounded read', () => { + expect(resolvePage({ count: 5000 }).count).toBe(SCIM_MAX_PAGE_SIZE) + }) + + it('allows a zero count, which Entra uses to ask only for the total', () => { + expect(resolvePage({ count: 0 }).count).toBe(0) + }) +}) + +describe('toUserResource', () => { + it('renders the resource a provider expects', () => { + const resource = toUserResource(userRow(), BASE_URL) + expect(resource).toMatchObject({ + id: 'su1', + externalId: '00u1', + userName: 'ada@acme.test', + active: true, + meta: { + resourceType: 'User', + location: `${BASE_URL}/Users/su1`, + lastModified: '2026-02-01T00:00:00.000Z', + }, + }) + expect(resource.groups).toEqual([ + { value: 'g1', display: 'Engineering', $ref: `${BASE_URL}/Groups/g1` }, + ]) + }) + + it('declares a provider extension it stored and returns its attributes', () => { + const base = userRow() + const row = { + ...base, + attributes: { + ...base.attributes, + extra: { 'urn:okta:sim:2.0:user:custom': { costCenter: 'R&D' } }, + }, + } + const resource = toUserResource(row, BASE_URL) + expect(resource.schemas).toContain('urn:okta:sim:2.0:user:custom') + expect(resource['urn:okta:sim:2.0:user:custom']).toEqual({ costCenter: 'R&D' }) + expect(scimUserResourceSchema.safeParse(resource).success).toBe(true) + }) + + it('reports the Sim account address rather than a stale stored copy', () => { + const row = { ...userRow(), email: 'moved@acme.test' } + expect(toUserResource(row, BASE_URL).emails[0]).toMatchObject({ + value: 'moved@acme.test', + primary: true, + }) + }) +}) + +describe('attribute projection', () => { + it('keeps a projected resource valid against the response contract', () => { + const excluded = parseAttributeProjection({ excludedAttributes: 'groups,emails' }) + const projected = projectResource(toUserResource(userRow(), BASE_URL), excluded) + expect(() => scimUserResourceSchema.parse(projected)).not.toThrow() + + const only = parseAttributeProjection({ attributes: 'userName' }) + const narrow = projectResource(toUserResource(userRow(), BASE_URL), only) + expect(() => scimUserResourceSchema.parse(narrow)).not.toThrow() + expect(narrow).not.toHaveProperty('emails') + }) + + it('honours the members exclusion Entra sends on every group list', () => { + const projection = parseAttributeProjection({ excludedAttributes: 'members' }) + expect(projectionWants(projection, 'members')).toBe(false) + expect(projectionWants(projection, 'displayName')).toBe(true) + }) + + it('keeps schemas, id and meta whatever the request asked for', () => { + const projection = parseAttributeProjection({ attributes: 'userName' }) + const projected = projectResource(toUserResource(userRow(), BASE_URL), projection) + expect(Object.keys(projected).sort()).toEqual(['id', 'meta', 'schemas', 'userName']) + }) + + it('refuses combining an include list with an exclude list', () => { + let scimType: string | undefined + try { + parseAttributeProjection({ attributes: 'userName', excludedAttributes: 'groups' }) + } catch (error) { + scimType = (error as ScimError).scimType + } + expect(scimType).toBe('invalidValue') + }) +}) diff --git a/apps/sim/ee/scim/lib/protocol/resources.ts b/apps/sim/ee/scim/lib/protocol/resources.ts new file mode 100644 index 00000000000..70a5e4a5513 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/resources.ts @@ -0,0 +1,266 @@ +import type { ScimUserAttributes } from '@sim/db/schema' +import { + SCIM_ENTERPRISE_USER_SCHEMA, + SCIM_GROUP_SCHEMA, + SCIM_LIST_RESPONSE_SCHEMA, + SCIM_MAX_PAGE_SIZE, + SCIM_USER_SCHEMA, +} from '@/ee/scim/lib/protocol/constants' +import { invalidValue } from '@/ee/scim/lib/protocol/errors' + +export interface ScimResourceMeta { + resourceType: 'User' | 'Group' + created: string + lastModified: string + location: string + version: string +} + +export interface ScimUserResource { + schemas: string[] + id: string + externalId?: string + userName: string + active: boolean + displayName: string + name: { formatted: string; givenName?: string; familyName?: string } + emails: Array<{ value: string; type?: string; primary: boolean }> + groups: Array<{ value: string; display: string; $ref: string }> + meta: ScimResourceMeta + [attribute: string]: unknown +} + +export interface ScimGroupResource { + schemas: string[] + id: string + externalId?: string + displayName: string + members?: Array<{ value: string; display?: string; $ref: string; type: 'User' }> + meta: ScimResourceMeta + [attribute: string]: unknown +} + +export interface ScimListResponse { + schemas: [typeof SCIM_LIST_RESPONSE_SCHEMA] + totalResults: number + startIndex: number + itemsPerPage: number + Resources: Resource[] +} + +/** + * An entity tag derived from the row's last write. + * + * Advertised as unsupported in `ServiceProviderConfig`, so no provider will send + * `If-Match`. It is still returned because Okta's import surfaces `meta.version` + * in its admin UI, where an empty value reads as a broken integration. + */ +function versionOf(updatedAt: Date): string { + return `W/"${updatedAt.getTime()}"` +} + +export interface UserResourceRow { + id: string + externalId: string | null + userName: string + active: boolean + attributes: ScimUserAttributes + createdAt: Date + updatedAt: Date + /** The Sim account's address, which is authoritative over the stored copy. */ + email: string + groups: Array<{ id: string; displayName: string }> +} + +export function toUserResource(row: UserResourceRow, baseUrl: string): ScimUserResource { + const stored = row.attributes + const primaryType = stored.emails.find((entry) => entry.primary)?.type + + /** + * The address comes from the Sim account rather than the stored resource. The + * two only diverge when something outside SCIM changed it, and reporting the + * stale copy would tell the directory its write is still in place while sign-in + * uses a different address. + */ + const emails: ScimUserResource['emails'] = [ + { value: row.email, primary: true, ...(primaryType ? { type: primaryType } : {}) }, + ...stored.emails + .filter((entry) => !entry.primary && entry.value !== row.email) + .map((entry) => ({ + value: entry.value, + primary: false, + ...(entry.type ? { type: entry.type } : {}), + })), + ] + + return { + schemas: [ + SCIM_USER_SCHEMA, + ...(stored.enterprise ? [SCIM_ENTERPRISE_USER_SCHEMA] : []), + ...Object.keys(stored.extra ?? {}).filter(isSchemaUrn), + ], + ...(stored.extra ?? {}), + id: row.id, + ...(row.externalId ? { externalId: row.externalId } : {}), + userName: row.userName, + active: row.active, + displayName: stored.displayName, + name: stored.name, + emails, + groups: row.groups.map((group) => ({ + value: group.id, + display: group.displayName, + $ref: `${baseUrl}/Groups/${group.id}`, + })), + ...(stored.enterprise ? { [SCIM_ENTERPRISE_USER_SCHEMA]: stored.enterprise } : {}), + meta: { + resourceType: 'User', + created: row.createdAt.toISOString(), + lastModified: row.updatedAt.toISOString(), + location: `${baseUrl}/Users/${row.id}`, + version: versionOf(row.updatedAt), + }, + } +} + +export interface GroupResourceRow { + id: string + externalId: string | null + displayName: string + createdAt: Date + updatedAt: Date + members?: Array<{ scimUserId: string; displayName: string }> +} + +export function toGroupResource(row: GroupResourceRow, baseUrl: string): ScimGroupResource { + return { + schemas: [SCIM_GROUP_SCHEMA], + id: row.id, + ...(row.externalId ? { externalId: row.externalId } : {}), + displayName: row.displayName, + ...(row.members + ? { + members: row.members.map((member) => ({ + value: member.scimUserId, + display: member.displayName, + $ref: `${baseUrl}/Users/${member.scimUserId}`, + type: 'User' as const, + })), + } + : {}), + meta: { + resourceType: 'Group', + created: row.createdAt.toISOString(), + lastModified: row.updatedAt.toISOString(), + location: `${baseUrl}/Groups/${row.id}`, + version: versionOf(row.updatedAt), + }, + } +} + +export function toListResponse( + resources: Resource[], + totalResults: number, + startIndex: number +): ScimListResponse { + return { + schemas: [SCIM_LIST_RESPONSE_SCHEMA], + totalResults, + startIndex, + itemsPerPage: resources.length, + Resources: resources, + } +} + +export interface ScimPage { + startIndex: number + offset: number + count: number +} + +/** + * Resolves the page a list request asked for. + * + * `startIndex` is 1-based per RFC 7644 and clamped up rather than rejected, + * because Okta's import sends `startIndex=0` on its first page. `count` is + * capped so one request cannot ask the database for an unbounded page. + */ +export function resolvePage(input: { + startIndex?: number | undefined + count?: number | undefined +}): ScimPage { + const rawStart = input.startIndex + const rawCount = input.count + + const startIndex = Math.max(rawStart ?? 1, 1) + const count = Math.min(Math.max(rawCount ?? SCIM_MAX_PAGE_SIZE, 0), SCIM_MAX_PAGE_SIZE) + return { startIndex, offset: startIndex - 1, count } +} + +/** + * The attribute projection a request asked for. + * + * Only `members` on Groups and `groups` on Users are honored as real query + * shortcuts, because those are the two that cost a join. Entra sends + * `excludedAttributes=members` on every group list, and answering it by loading + * the membership and then discarding it would defeat the point of the request. + */ +export interface ScimAttributeProjection { + include?: Set + exclude?: Set +} + +function parseAttributeList(value: string | undefined): Set | undefined { + if (!value) return undefined + const names = value + .split(',') + .map((name) => name.trim().toLowerCase()) + .filter(Boolean) + return names.length > 0 ? new Set(names) : undefined +} + +export function parseAttributeProjection(query: { + attributes?: string | undefined + excludedAttributes?: string | undefined +}): ScimAttributeProjection { + const include = parseAttributeList(query.attributes) + const exclude = parseAttributeList(query.excludedAttributes) + if (include && exclude) { + throw invalidValue('attributes and excludedAttributes cannot be combined') + } + return { ...(include ? { include } : {}), ...(exclude ? { exclude } : {}) } +} + +/** Whether a projection asks for an attribute that costs a separate query. */ +export function projectionWants(projection: ScimAttributeProjection, attribute: string): boolean { + const name = attribute.toLowerCase() + if (projection.exclude?.has(name)) return false + if (projection.include) return projection.include.has(name) + return true +} + +/** Attributes every resource keeps regardless of the projection requested. */ +/** An `extra` key that is itself a schema URN carries a provider extension the resource must declare. */ +function isSchemaUrn(key: string): boolean { + return key.startsWith('urn:') +} + +const ALWAYS_RETURNED = new Set(['schemas', 'id', 'meta']) + +/** Drops attributes the request did not ask for. */ +export function projectResource( + resource: Resource, + projection: ScimAttributeProjection +): Resource { + if (!projection.include && !projection.exclude) return resource + const projected: Record = {} + for (const [key, value] of Object.entries(resource)) { + const name = key.toLowerCase() + if (ALWAYS_RETURNED.has(name)) { + projected[key] = value + continue + } + if (projectionWants(projection, name)) projected[key] = value + } + return projected as Resource +} diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.test.ts b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts new file mode 100644 index 00000000000..a88095491b7 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + */ +import type { ScimUserAttributes } from '@sim/db/schema' +import { describe, expect, it } from 'vitest' +import { scimPatchBodySchema } from '@/lib/api/contracts/scim' +import { SCIM_PATCH_OP_SCHEMA } from '@/ee/scim/lib/protocol/constants' +import { applyUserPatch } from '@/ee/scim/lib/protocol/user-patch' + +/** + * Every fixture here is a request shape taken from Okta's or Microsoft's own + * provisioning documentation, not an invented one. The point of the test is that + * what those two products actually send is accepted. + */ + +function baseUser(overrides: Partial = {}): ScimUserAttributes { + return { + userName: 'ada@acme.test', + externalId: '00u1', + active: true, + displayName: 'Ada Lovelace', + name: { formatted: 'Ada Lovelace', givenName: 'Ada', familyName: 'Lovelace' }, + emails: [{ value: 'ada@acme.test', type: 'work', primary: true }], + ...overrides, + } +} + +/** Parses through the real contract so the tests exercise the tolerances too. */ +function parseOperations(operations: unknown[]) { + return scimPatchBodySchema.parse({ schemas: [SCIM_PATCH_OP_SCHEMA], Operations: operations }) + .Operations +} + +describe('applyUserPatch', () => { + it('deactivates from Okta’s path-less replace', () => { + const { next, changed } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'replace', value: { active: false } }]) + ) + expect(changed).toBe(true) + expect(next.active).toBe(false) + }) + + it('deactivates from Entra’s capitalized op and string boolean', () => { + const { next, changed } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'Replace', path: 'active', value: 'False' }]) + ) + expect(changed).toBe(true) + expect(next.active).toBe(false) + }) + + it('reactivates', () => { + const { next } = applyUserPatch( + baseUser({ active: false }), + parseOperations([{ op: 'replace', value: { active: true } }]) + ) + expect(next.active).toBe(true) + }) + + it('applies Entra’s path-less replace with dotted attribute keys', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { + op: 'Replace', + value: { + 'name.givenName': 'Augusta', + 'name.familyName': 'King', + displayName: 'Augusta King', + }, + }, + ]) + ) + expect(next.name.givenName).toBe('Augusta') + expect(next.name.familyName).toBe('King') + expect(next.name.formatted).toBe('Augusta King') + expect(next.displayName).toBe('Augusta King') + }) + + it('creates a work email when the filtered path matches nothing', () => { + const { next } = applyUserPatch( + baseUser({ emails: [{ value: 'ada@acme.test', primary: true }] }), + parseOperations([ + { op: 'replace', path: 'emails[type eq "work"].value', value: 'ada.k@acme.test' }, + ]) + ) + expect(next.emails).toContainEqual({ value: 'ada.k@acme.test', type: 'work', primary: false }) + }) + + it('replaces an existing work email in place', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'Replace', path: 'emails[type eq "work"].value', value: 'ADA.K@ACME.TEST' }, + ]) + ) + expect(next.emails).toEqual([{ value: 'ada.k@acme.test', type: 'work', primary: true }]) + }) + + it('replaces the primary email through Entra’s primary filter', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'replace', path: 'emails[primary eq true].value', value: 'new@acme.test' }, + ]) + ) + expect(next.emails[0].value).toBe('new@acme.test') + }) + + it('unwraps a single-element array around a scalar', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'replace', path: 'name.givenName', value: ['Augusta'] }]) + ) + expect(next.name.givenName).toBe('Augusta') + }) + + it('reads enterprise attributes under the URN-qualified path', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { + op: 'Replace', + path: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department', + value: 'Analytical Engines', + }, + ]) + ) + expect(next.enterprise?.department).toBe('Analytical Engines') + }) + + it('clears a manager when Entra sends an empty string', () => { + const { next } = applyUserPatch( + baseUser({ enterprise: { manager: { value: 'mgr-1' } } }), + parseOperations([{ op: 'Replace', path: 'enterprise.manager', value: '' }]) + ) + expect(next.enterprise?.manager).toBeUndefined() + }) + + it('adds a secondary email without stealing the primary', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'add', path: 'emails', value: [{ value: 'ada@home.test', type: 'home' }] }, + ]) + ) + expect(next.emails).toEqual([ + { value: 'ada@acme.test', type: 'work', primary: true }, + { value: 'ada@home.test', type: 'home', primary: false }, + ]) + }) + + it('applies RFC 7644 canonical nesting in a path-less replace', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { + op: 'replace', + value: { + name: { givenName: 'Augusta' }, + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': { department: 'Maths' }, + }, + }, + ]) + ) + expect(next.name.givenName).toBe('Augusta') + expect(next.name.formatted).toBe('Augusta Lovelace') + expect(next.enterprise?.department).toBe('Maths') + }) + + it('reports no change when a patch re-sends what is already stored', () => { + const { changed } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'replace', value: { active: true } }, + { op: 'Replace', path: 'name.givenName', value: 'Ada' }, + ]) + ) + expect(changed).toBe(false) + }) + + it('defaults a missing op to replace, as Okta omits it', () => { + const { next } = applyUserPatch(baseUser(), parseOperations([{ value: { active: false } }])) + expect(next.active).toBe(false) + }) + + it('refuses a remove with no path', () => { + expect(() => + applyUserPatch(baseUser(), parseOperations([{ op: 'remove', value: { active: false } }])) + ).toThrowError(expect.objectContaining({ scimType: 'noTarget' })) + }) + + it('refuses writing a server-owned attribute', () => { + expect(() => + applyUserPatch(baseUser(), parseOperations([{ op: 'replace', path: 'id', value: 'x' }])) + ).toThrowError(expect.objectContaining({ scimType: 'mutability' })) + }) + + it('keeps attributes this server does not model, as a create would', () => { + const { next, changed } = applyUserPatch( + baseUser(), + parseOperations([ + { op: 'replace', path: 'nickName', value: 'Ada' }, + { op: 'Add', path: 'phoneNumbers[type eq "work"].value', value: '+1 555 0100' }, + { op: 'replace', path: 'addresses[type eq "work"]', value: { locality: 'London' } }, + { op: 'replace', value: { title: 'Analyst', preferredLanguage: 'en-GB' } }, + ]) + ) + expect(changed).toBe(true) + expect(next.extra).toEqual({ + nickName: 'Ada', + title: 'Analyst', + preferredLanguage: 'en-GB', + phoneNumbers: [{ type: 'work', value: '+1 555 0100' }], + addresses: [{ type: 'work', locality: 'London' }], + }) + + const removed = applyUserPatch( + next, + parseOperations([ + { op: 'remove', path: 'phoneNumbers[type eq "work"]' }, + { op: 'remove', path: 'nickName' }, + ]) + ).next + expect(removed.extra?.phoneNumbers).toEqual([]) + expect(removed.extra?.nickName).toBeUndefined() + }) + + it('refuses a non-boolean active value', () => { + expect(() => + applyUserPatch(baseUser(), parseOperations([{ op: 'replace', path: 'active', value: 'yes' }])) + ).toThrowError(expect.objectContaining({ scimType: 'invalidValue' })) + }) + + it('leaves the stored resource untouched', () => { + const original = baseUser() + const snapshot = structuredClone(original) + applyUserPatch(original, parseOperations([{ op: 'replace', value: { active: false } }])) + expect(original).toEqual(snapshot) + }) +}) diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.ts b/apps/sim/ee/scim/lib/protocol/user-patch.ts new file mode 100644 index 00000000000..ce97b1459f6 --- /dev/null +++ b/apps/sim/ee/scim/lib/protocol/user-patch.ts @@ -0,0 +1,396 @@ +import type { ScimUserAttributes, ScimUserEmail } from '@sim/db/schema' +import type { ScimPatchOperation } from '@/lib/api/contracts/scim' +import { invalidPath, invalidValue, mutability, noTarget } from '@/ee/scim/lib/protocol/errors' +import { + isRecord, + normalizeAttributePath, + normalizeScimBoolean, + unwrapSingleElement, +} from '@/ee/scim/lib/protocol/normalize' + +/** + * Applies a PATCH operation list to a stored User resource. + * + * Written here rather than taken from a library. The published SCIM patch + * packages assume the RFC's wire shapes, and the two providers that matter do + * not send them: Microsoft Entra capitalizes operation names, sends booleans as + * strings, and identifies a member to remove by value alone where a library + * compares the whole object. A patch engine that silently no-ops on a removal is + * worse than one that refuses, because the directory records a success and stops + * retrying. + * + * Attributes Sim models are applied to the fields it reads; every other + * attribute is kept under `extra`, exactly as a create or replace keeps it, so + * a directory's own attribute mappings round-trip through PATCH as well. Only + * server-owned attributes (`id`, `schemas`, `meta`) are refused. + */ + +export interface UserPatchOutcome { + next: ScimUserAttributes + changed: boolean +} + +function requireString(value: unknown, attribute: string): string { + const unwrapped = unwrapSingleElement(value) + if (typeof unwrapped !== 'string') throw invalidValue(`${attribute} must be a string`) + const trimmed = unwrapped.trim() + if (!trimmed) throw invalidValue(`${attribute} must not be empty`) + return trimmed +} + +function requireBoolean(value: unknown, attribute: string): boolean { + const normalized = normalizeScimBoolean(unwrapSingleElement(value)) + if (typeof normalized !== 'boolean') throw invalidValue(`${attribute} must be a boolean`) + return normalized +} + +/** Recomputes `formatted` and `displayName` after a name part changes. */ +function refreshDerivedNames(user: ScimUserAttributes, fallback: string): void { + const joined = [user.name.givenName, user.name.familyName].filter(Boolean).join(' ') + if (joined) user.name.formatted = joined + else if (!user.name.formatted) user.name.formatted = fallback + if (!user.displayName) user.displayName = user.name.formatted +} + +function setPrimaryEmailValue(user: ScimUserAttributes, value: string): void { + const primary = user.emails.find((entry) => entry.primary) + if (!primary) throw noTarget('The resource has no primary email address to replace') + primary.value = value.toLowerCase() +} + +function upsertTypedEmail(user: ScimUserAttributes, type: string, value: string): void { + const existing = user.emails.find((entry) => entry.type?.toLowerCase() === type.toLowerCase()) + if (existing) { + existing.value = value.toLowerCase() + return + } + /** + * Entra maps a work address to a filtered path and expects the target to be + * created when the resource does not already carry one. RFC 7644 would answer + * `noTarget`, and doing so fails the whole atomic PATCH over an attribute the + * provider is trying to populate for the first time. + */ + user.emails.push({ value: value.toLowerCase(), type, primary: user.emails.length === 0 }) +} + +function removeTypedEmail(user: ScimUserAttributes, type: string): void { + const remaining = user.emails.filter((entry) => entry.type?.toLowerCase() !== type.toLowerCase()) + if (remaining.length === user.emails.length) return + if (remaining.length === 0) throw invalidValue('A user must keep at least one email address') + if (!remaining.some((entry) => entry.primary)) remaining[0].primary = true + user.emails = remaining +} + +function normalizeEmailList( + value: unknown, + attribute: string, + options: { defaultPrimary: boolean } +): ScimUserEmail[] { + const entries = Array.isArray(value) ? value : [value] + const normalized: ScimUserEmail[] = [] + for (const entry of entries) { + if (!isRecord(entry)) throw invalidValue(`${attribute} entries must be objects`) + const address = requireString(entry.value, `${attribute}.value`) + normalized.push({ + value: address.toLowerCase(), + ...(typeof entry.type === 'string' && entry.type.trim() ? { type: entry.type.trim() } : {}), + primary: normalizeScimBoolean(entry.primary) === true, + }) + } + if (normalized.length === 0) throw invalidValue(`${attribute} must not be empty`) + /** A whole list needs a primary; an added address stays secondary unless it says otherwise. */ + if (options.defaultPrimary && !normalized.some((entry) => entry.primary)) { + normalized[0].primary = true + } + return normalized +} + +/** `emails[type eq "work"].value` and the `primary eq true` variant Entra sends. */ +const FILTERED_EMAIL_PATTERN = + /^emails\[\s*(?type|primary)\s+eq\s+(?"|')?(?[^"'\]]+)\k?\s*\]\.value$/i + +function applyOperation( + user: ScimUserAttributes, + op: 'add' | 'replace' | 'remove', + rawPath: string, + value: unknown +): void { + const path = normalizeAttributePath(rawPath) + const key = path.toLowerCase() + + const filtered = path.match(FILTERED_EMAIL_PATTERN) + if (filtered?.groups) { + const { selector, match } = filtered.groups + if (selector.toLowerCase() === 'primary') { + if (normalizeScimBoolean(match) !== true) { + throw invalidPath(`Unsupported User PATCH path ${rawPath}`) + } + if (op === 'remove') throw invalidValue('The primary email address cannot be removed') + setPrimaryEmailValue(user, requireString(value, 'emails.value')) + return + } + if (op === 'remove') removeTypedEmail(user, match) + else upsertTypedEmail(user, match, requireString(value, 'emails.value')) + return + } + + switch (key) { + case 'active': + user.active = op === 'remove' ? true : requireBoolean(value, 'active') + return + + case 'username': + if (op === 'remove') throw mutability('userName cannot be removed') + user.userName = requireString(value, 'userName').toLowerCase() + return + + case 'externalid': + if (op === 'remove') user.externalId = undefined + else user.externalId = requireString(value, 'externalId') + return + + case 'displayname': + user.displayName = op === 'remove' ? user.name.formatted : requireString(value, 'displayName') + return + + case 'name.formatted': + if (op === 'remove') throw invalidValue('name.formatted cannot be removed') + user.name.formatted = requireString(value, 'name.formatted') + return + + case 'name.givenname': + if (op === 'remove') user.name.givenName = undefined + else user.name.givenName = requireString(value, 'name.givenName') + refreshDerivedNames(user, user.userName) + return + + case 'name.familyname': + if (op === 'remove') user.name.familyName = undefined + else user.name.familyName = requireString(value, 'name.familyName') + refreshDerivedNames(user, user.userName) + return + + case 'emails': + if (op === 'remove') throw invalidValue('emails cannot be removed') + if (op === 'replace') { + user.emails = normalizeEmailList(value, 'emails', { defaultPrimary: true }) + return + } + for (const entry of normalizeEmailList(value, 'emails', { defaultPrimary: false })) { + const existing = user.emails.find((candidate) => candidate.value === entry.value) + if (existing) { + if (entry.primary) { + for (const candidate of user.emails) candidate.primary = false + existing.primary = true + } + continue + } + if (entry.primary) for (const candidate of user.emails) candidate.primary = false + user.emails.push(entry) + } + return + + case 'emails.value': + if (op === 'remove') throw invalidValue('emails cannot be removed') + setPrimaryEmailValue(user, requireString(value, 'emails.value')) + return + + case 'enterprise.department': + case 'enterprise.employeenumber': + case 'enterprise.costcenter': + case 'enterprise.division': + case 'enterprise.organization': { + const field = key.slice('enterprise.'.length) + const attribute = ( + { + department: 'department', + employeenumber: 'employeeNumber', + costcenter: 'costCenter', + division: 'division', + organization: 'organization', + } as const + )[field as 'department' | 'employeenumber' | 'costcenter' | 'division' | 'organization'] + user.enterprise ??= {} + if (op === 'remove') user.enterprise[attribute] = undefined + else user.enterprise[attribute] = requireString(value, `enterprise.${attribute}`) + return + } + + case 'enterprise.manager': + case 'enterprise.manager.value': { + user.enterprise ??= {} + const unwrapped = unwrapSingleElement(value) + /** Entra clears a manager by sending an empty string rather than removing. */ + if (op === 'remove' || unwrapped === '' || unwrapped === null) { + user.enterprise.manager = undefined + return + } + if (typeof unwrapped === 'string') { + user.enterprise.manager = { value: unwrapped } + return + } + if (isRecord(unwrapped)) { + user.enterprise.manager = { + ...(typeof unwrapped.value === 'string' ? { value: unwrapped.value } : {}), + ...(typeof unwrapped.displayName === 'string' + ? { displayName: unwrapped.displayName } + : {}), + } + return + } + throw invalidValue('enterprise manager must be an identifier or an object') + } + + case 'id': + case 'schemas': + throw mutability(`${rawPath} is read-only`) + + default: + if (key.startsWith('meta')) throw mutability(`${rawPath} is read-only`) + applyExtraOperation(user, op, path, value) + } +} + +/** `attr`, `attr.sub`, or `attr[type eq "x"].sub` on an attribute Sim does not model. */ +const EXTRA_PATH_PATTERN = + /^(?[A-Za-z][\w-]*)(?:\[\s*type\s+eq\s+(?"|')?(?[^"'\]]+)\k?\s*\])?(?:\.(?[A-Za-z][\w-]*))?$/ + +/** + * Applies an operation to an attribute Sim does not model. + * + * A create or replace keeps every attribute the directory sends under `extra` + * so responses round-trip them; a patch must do the same, or Entra's default + * mappings — `title`, `preferredLanguage`, work phone and address — would fail + * every update as a whole, since a PATCH is atomic. The stored shape is the + * wire shape: a plain value, a nested object, or a typed multi-valued list. + */ +function applyExtraOperation( + user: ScimUserAttributes, + op: 'add' | 'replace' | 'remove', + path: string, + value: unknown +): void { + const match = path.match(EXTRA_PATH_PATTERN) + if (!match?.groups) throw invalidPath(`User PATCH path ${path} is not supported`) + const { attribute, type, sub } = match.groups + user.extra ??= {} + + if (!type && !sub) { + if (op === 'remove') user.extra[attribute] = undefined + else user.extra[attribute] = value + return + } + + if (type) { + const list = Array.isArray(user.extra[attribute]) ? [...user.extra[attribute]] : [] + const index = list.findIndex( + (entry) => isRecord(entry) && String(entry.type).toLowerCase() === type.toLowerCase() + ) + if (op === 'remove') { + if (index !== -1) list.splice(index, 1) + } else if (sub) { + const current = index !== -1 && isRecord(list[index]) ? list[index] : { type } + const next = { ...current, [sub]: value } + if (index === -1) list.push(next) + else list[index] = next + } else if (isRecord(value)) { + if (index === -1) list.push({ type, ...value }) + else list[index] = { ...(list[index] as Record), ...value } + } else { + throw invalidValue(`${path} requires an object value`) + } + user.extra[attribute] = list + return + } + + const current = isRecord(user.extra[attribute]) ? { ...user.extra[attribute] } : {} + if (op === 'remove') current[sub as string] = undefined + else current[sub as string] = value + user.extra[attribute] = current +} + +/** + * Sorts object keys at every depth so serialization is order-independent. + * + * Cleared attributes are set to `undefined` rather than deleted, and + * `JSON.stringify` drops those, so a cleared attribute compares equal to an + * absent one — which is what it means on the wire and in storage. + */ +function sortDeep(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortDeep) + if (!isRecord(value)) return value + const sorted: Record = {} + for (const key of Object.keys(value).sort()) sorted[key] = sortDeep(value[key]) + return sorted +} + +/** + * Canonical form used only to decide whether a patch changed anything. + * + * Key order in the stored JSON is not meaningful, so comparing serialized + * objects directly would report a change whenever a provider reordered its + * attributes — and every such false positive is a write, an audit row, and a + * projection pass that did nothing. + */ +function comparisonKey(attributes: ScimUserAttributes): string { + return JSON.stringify(sortDeep(attributes)) +} + +/** Whether two canonical resources describe the same state, ignoring key order. */ +export function userAttributesEqual(left: ScimUserAttributes, right: ScimUserAttributes): boolean { + return comparisonKey(left) === comparisonKey(right) +} + +/** + * Orders `name.formatted` after the name parts it would otherwise be derived + * from, so an explicit formatted name wins regardless of JSON property order. + */ +function sortFormattedLast(entries: [string, unknown][]): [string, unknown][] { + const isFormatted = ([key]: [string, unknown]) => key.toLowerCase().endsWith('formatted') + return [...entries.filter((entry) => !isFormatted(entry)), ...entries.filter(isFormatted)] +} + +export function applyUserPatch( + current: ScimUserAttributes, + operations: readonly ScimPatchOperation[] +): UserPatchOutcome { + const next = structuredClone(current) + + for (const operation of operations) { + if (operation.op === 'remove' && !operation.path) { + throw noTarget('A remove operation requires a path') + } + + if (!operation.path) { + const value = operation.value + if (!isRecord(value)) { + throw invalidValue('A PATCH operation without a path requires an object value') + } + /** + * Entra's compliant mode sends one path-less replace whose value object is + * keyed by dotted attribute paths, so each key is dispatched as if it had + * arrived as its own operation. + */ + for (const [attribute, nested] of sortFormattedLast(Object.entries(value))) { + /** + * RFC 7644's canonical form nests complex attributes — `{"name": {"givenName": …}}` + * and the enterprise extension keyed by its URN — so each sub-attribute is + * dispatched by its dotted path. + */ + const normalized = normalizeAttributePath(attribute).toLowerCase() + if (isRecord(nested) && (normalized === 'name' || normalized === 'enterprise')) { + for (const [sub, subValue] of sortFormattedLast(Object.entries(nested))) { + applyOperation(next, operation.op, `${normalized}.${sub}`, subValue) + } + continue + } + applyOperation(next, operation.op, attribute, nested) + } + continue + } + + applyOperation(next, operation.op, operation.path, operation.value) + } + + return { next, changed: comparisonKey(current) !== comparisonKey(next) } +} diff --git a/apps/sim/ee/scim/lib/reconcile/job.test.ts b/apps/sim/ee/scim/lib/reconcile/job.test.ts new file mode 100644 index 00000000000..e06c7d24c32 --- /dev/null +++ b/apps/sim/ee/scim/lib/reconcile/job.test.ts @@ -0,0 +1,306 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { scimConnection } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + isEntitled: vi.fn(), + reconcileBatch: vi.fn(), + listScimUserIds: vi.fn(), + prune: vi.fn(), +})) + +vi.mock('@sim/utils/id', () => ({ generateId: () => 'run-1' })) +vi.mock('@/ee/scim/lib/entitlement', () => ({ + isScimEntitledForOrganization: mocks.isEntitled, +})) +vi.mock('@/ee/scim/lib/projection/reconcile-user', () => ({ + PROJECTION_BATCH_SIZE: 25, + reconcileUsersProjectionInBatches: mocks.reconcileBatch, +})) +vi.mock('@/ee/scim/lib/repository/users', () => ({ + listScimUserIds: mocks.listScimUserIds, +})) +vi.mock('@/ee/scim/lib/request-log', () => ({ + pruneScimRequestLog: mocks.prune, +})) + +import { reconcileConnection, runScimReconcileSweep } from '@/ee/scim/lib/reconcile/job' + +const NOW = new Date('2026-03-01T12:00:00.000Z') +const LEASE_TTL_MS = 15 * 60 * 1000 + +const connection = { id: 'conn-1', organizationId: 'org-1', settings: { autoMap: true } } + +const page = (ids: string[]) => ids.map((id) => ({ id, orderKey: `k-${id}` })) + +const delta = (added = 0, raised = 0, removed = 0) => ({ + added: Array.from({ length: added }, (_, i) => ({ id: `a-${i}` })), + raised: Array.from({ length: raised }, (_, i) => ({ id: `r-${i}` })), + removed: Array.from({ length: removed }, (_, i) => ({ id: `x-${i}` })), +}) + +/** Grants the next compare-and-set lease claim. */ +function grantLease() { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'conn-1' }]) +} + +/** Queues the two connection reads one batch makes: the lease check, then the fresh settings. */ +function stageBatch(token: string, settings?: Record) { + queueTableRows(scimConnection, [{ token }]) + queueTableRows(scimConnection, settings ? [{ settings }] : []) +} + +const setCalls = () => + dbChainMockFns.set.mock.calls.map((call) => call[0] as Record) + +/** Flattens the nested and/or condition tree the mock operators build. */ +function conditionNodes(condition: unknown): Array> { + if (!condition || typeof condition !== 'object') return [] + const node = condition as Record + if ((node.type === 'and' || node.type === 'or') && Array.isArray(node.conditions)) { + return node.conditions.flatMap(conditionNodes) + } + return [node] +} + +afterAll(resetDbChainMock) + +describe('reconcileConnection', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.useFakeTimers() + vi.setSystemTime(NOW) + mocks.isEntitled.mockResolvedValue(true) + mocks.prune.mockResolvedValue(undefined) + mocks.reconcileBatch.mockResolvedValue(delta()) + mocks.listScimUserIds.mockResolvedValue([]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does nothing for an organization that is no longer entitled', async () => { + mocks.isEntitled.mockResolvedValue(false) + const report = await reconcileConnection(connection) + expect(report).toBeNull() + expect(mocks.isEntitled).toHaveBeenCalledWith('org-1') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.prune).not.toHaveBeenCalled() + expect(mocks.listScimUserIds).not.toHaveBeenCalled() + }) + + it('returns null without touching users when the lease claim is refused', async () => { + const report = await reconcileConnection(connection) + expect(report).toBeNull() + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + expect(setCalls()[0]).toEqual({ reconcileLockToken: 'run-1', reconcileLeaseAt: NOW }) + expect(mocks.prune).not.toHaveBeenCalled() + expect(mocks.listScimUserIds).not.toHaveBeenCalled() + expect(mocks.reconcileBatch).not.toHaveBeenCalled() + }) + + it('claims a free lease or one older than its TTL in a single conditional update', async () => { + grantLease() + await reconcileConnection(connection) + const nodes = conditionNodes(dbChainMockFns.where.mock.calls[0][0]) + expect(nodes).toContainEqual({ type: 'eq', left: scimConnection.id, right: 'conn-1' }) + expect(nodes).toContainEqual({ type: 'eq', left: scimConnection.status, right: 'active' }) + expect(nodes).toContainEqual({ type: 'isNull', column: scimConnection.reconcileLockToken }) + expect(nodes).toContainEqual({ + type: 'lt', + left: scimConnection.reconcileLeaseAt, + right: new Date(NOW.getTime() - LEASE_TTL_MS), + }) + expect(dbChainMockFns.update).toHaveBeenCalledWith(scimConnection) + }) + + it('stops the pass and keeps the watermark when another run took the lease over', async () => { + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1', 'su-2'])) + stageBatch('run-2') + const report = await reconcileConnection(connection) + expect(report).toBeNull() + expect(mocks.reconcileBatch).not.toHaveBeenCalled() + const release = setCalls()[1] + expect(release).toEqual({ reconcileLockToken: null, reconcileLeaseAt: null }) + expect(release).not.toHaveProperty('reconciledAt') + }) + + it('re-reads the settings for every batch and pages by the last order key', async () => { + grantLease() + const first = page(Array.from({ length: 25 }, (_, i) => `su-${i}`)) + const second = page(['su-25', 'su-26', 'su-27']) + mocks.listScimUserIds + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + .mockResolvedValueOnce([]) + stageBatch('run-1', { autoMap: false }) + stageBatch('run-1', { autoMap: true, defaultRole: 'admin' }) + + await reconcileConnection(connection) + + expect(mocks.listScimUserIds).toHaveBeenNthCalledWith(1, db, { + connectionId: 'conn-1', + limit: 25, + }) + expect(mocks.listScimUserIds).toHaveBeenNthCalledWith(2, db, { + connectionId: 'conn-1', + afterOrderKey: 'k-su-24', + limit: 25, + }) + expect(mocks.listScimUserIds).toHaveBeenNthCalledWith(3, db, { + connectionId: 'conn-1', + afterOrderKey: 'k-su-27', + limit: 25, + }) + expect(mocks.reconcileBatch).toHaveBeenCalledTimes(2) + expect(mocks.reconcileBatch).toHaveBeenNthCalledWith(1, { + connectionId: 'conn-1', + organizationId: 'org-1', + scimUserIds: first.map((row) => row.id), + settings: { autoMap: false }, + }) + expect(mocks.reconcileBatch).toHaveBeenNthCalledWith(2, { + connectionId: 'conn-1', + organizationId: 'org-1', + scimUserIds: ['su-25', 'su-26', 'su-27'], + settings: { autoMap: true, defaultRole: 'admin' }, + }) + }) + + it('falls back to the settings the due query returned when the row cannot be re-read', async () => { + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1'])).mockResolvedValueOnce([]) + stageBatch('run-1') + await reconcileConnection(connection) + expect(mocks.reconcileBatch).toHaveBeenCalledWith( + expect.objectContaining({ settings: { autoMap: true } }) + ) + }) + + it('reports users reconciled and counts raised grants as additions', async () => { + grantLease() + mocks.listScimUserIds + .mockResolvedValueOnce(page(['su-1', 'su-2'])) + .mockResolvedValueOnce(page(['su-3'])) + .mockResolvedValueOnce([]) + stageBatch('run-1', {}) + stageBatch('run-1', {}) + mocks.reconcileBatch.mockResolvedValueOnce(delta(2, 1, 1)).mockResolvedValueOnce(delta(0, 0, 2)) + + const report = await reconcileConnection(connection) + + expect(report).toEqual({ + connectionId: 'conn-1', + reconciledUsers: 3, + grantsAdded: 3, + grantsRemoved: 3, + }) + }) + + it('stamps reconciledAt only after a completed pass', async () => { + grantLease() + const report = await reconcileConnection(connection) + expect(report).toEqual({ + connectionId: 'conn-1', + reconciledUsers: 0, + grantsAdded: 0, + grantsRemoved: 0, + }) + expect(setCalls()[1]).toEqual({ + reconcileLockToken: null, + reconcileLeaseAt: null, + reconciledAt: NOW, + }) + const releaseNodes = conditionNodes(dbChainMockFns.where.mock.calls.at(-1)?.[0]) + expect(releaseNodes).toContainEqual({ + type: 'eq', + left: scimConnection.reconcileLockToken, + right: 'run-1', + }) + }) + + it('prunes the request log before the pass and releases the lease when a batch throws', async () => { + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1'])) + stageBatch('run-1', {}) + mocks.reconcileBatch.mockRejectedValueOnce(new Error('projection failed')) + + await expect(reconcileConnection(connection)).rejects.toThrow('projection failed') + + expect(mocks.prune).toHaveBeenCalledWith('conn-1') + expect(mocks.prune.mock.invocationCallOrder[0]).toBeLessThan( + mocks.listScimUserIds.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(2) + const release = setCalls()[1] + expect(release).toEqual({ reconcileLockToken: null, reconcileLeaseAt: null }) + expect(release).not.toHaveProperty('reconciledAt') + }) + + it('still releases the lease when the prune itself throws', async () => { + grantLease() + mocks.prune.mockRejectedValueOnce(new Error('prune failed')) + await expect(reconcileConnection(connection)).rejects.toThrow('prune failed') + expect(mocks.listScimUserIds).not.toHaveBeenCalled() + expect(setCalls()[1]).toEqual({ reconcileLockToken: null, reconcileLeaseAt: null }) + }) +}) + +describe('runScimReconcileSweep', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + vi.useFakeTimers() + vi.setSystemTime(NOW) + mocks.isEntitled.mockResolvedValue(true) + mocks.prune.mockResolvedValue(undefined) + mocks.reconcileBatch.mockResolvedValue(delta()) + mocks.listScimUserIds.mockResolvedValue([]) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('returns an empty sweep when nothing is due', async () => { + const sweep = await runScimReconcileSweep() + expect(sweep).toEqual({ connections: 0, reconciledUsers: 0, grantsAdded: 0, grantsRemoved: 0 }) + expect(dbChainMockFns.limit).toHaveBeenCalledWith(200) + expect(mocks.isEntitled).not.toHaveBeenCalled() + }) + + it('totals the completed passes and keeps going past a connection that fails', async () => { + queueTableRows(scimConnection, [ + { id: 'conn-1', organizationId: 'org-1', settings: {} }, + { id: 'conn-2', organizationId: 'org-2', settings: {} }, + { id: 'conn-3', organizationId: 'org-3', settings: {} }, + ]) + + grantLease() + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1', 'su-2'])).mockResolvedValueOnce([]) + stageBatch('run-1', {}) + mocks.reconcileBatch.mockResolvedValueOnce(delta(1, 0, 0)) + + grantLease() + mocks.prune.mockRejectedValueOnce(new Error('tenant down')) + + mocks.isEntitled + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + + const sweep = await runScimReconcileSweep(10) + + expect(dbChainMockFns.limit).toHaveBeenCalledWith(10) + expect(sweep).toEqual({ connections: 1, reconciledUsers: 2, grantsAdded: 1, grantsRemoved: 0 }) + expect(mocks.isEntitled.mock.calls.map((call) => call[0])).toEqual(['org-1', 'org-2', 'org-3']) + expect(mocks.prune).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/ee/scim/lib/reconcile/job.ts b/apps/sim/ee/scim/lib/reconcile/job.ts new file mode 100644 index 00000000000..de6973e71ce --- /dev/null +++ b/apps/sim/ee/scim/lib/reconcile/job.ts @@ -0,0 +1,237 @@ +import { db } from '@sim/db' +import { type ScimConnectionSettings, scimConnection } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, eq, isNull, lt, or, sql } from 'drizzle-orm' +import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' +import { + PROJECTION_BATCH_SIZE, + reconcileUsersProjectionInBatches, +} from '@/ee/scim/lib/projection/reconcile-user' +import { listScimUserIds } from '@/ee/scim/lib/repository/users' +import { pruneScimRequestLog } from '@/ee/scim/lib/request-log' + +const logger = createLogger('ScimReconcile') + +/** + * The scheduled drift pass. + * + * Group mappings are applied when membership changes, so in the ordinary case + * this finds nothing. It exists for the cases where the ordinary path could not + * finish: a post-commit effect that failed, a manual change made while + * managed-membership locking was off, or a mapping edited against a target that + * was concurrently deleted. Re-running the projection is idempotent, so a pass + * that finds nothing writes nothing. + */ + +/** How long a claimed lease is honored before another run may take it over. */ +const LEASE_TTL_MS = 15 * 60 * 1000 + +/** + * How often a connection is swept when nothing else triggers it. + * + * The cron fires hourly and stamps `reconciledAt` at the end of a pass, so a + * connection is due on the next tick only if this interval is comfortably + * shorter than the cron period; an interval equal to it would skip every other + * tick. Fifty minutes keeps the once-an-hour guarantee the docs make. + */ +const RECONCILE_INTERVAL_MS = 50 * 60 * 1000 + +/** + * Users reconciled per transaction. The organization lock is held for the whole + * batch, so it is kept small enough that a tenant's own writes never wait long. + */ + +export interface ScimReconcileReport { + connectionId: string + reconciledUsers: number + grantsAdded: number + grantsRemoved: number +} + +/** + * Claims a connection with a single conditional update. + * + * The compare-and-set is the claim: two schedulers racing produce one winner, + * because only one `UPDATE` can match a row whose lease is free or stale. + */ +async function acquireLease(connectionId: string, runId: string): Promise { + const staleBefore = new Date(Date.now() - LEASE_TTL_MS) + const claimed = await db + .update(scimConnection) + .set({ reconcileLockToken: runId, reconcileLeaseAt: new Date() }) + .where( + and( + eq(scimConnection.id, connectionId), + eq(scimConnection.status, 'active'), + or( + isNull(scimConnection.reconcileLockToken), + lt(scimConnection.reconcileLeaseAt, staleBefore) + ) + ) + ) + .returning({ id: scimConnection.id }) + return claimed.length > 0 +} + +/** Whether this run still holds the connection; a run past the TTL may have been superseded. */ +async function holdsLease(connectionId: string, runId: string): Promise { + const [row] = await db + .select({ token: scimConnection.reconcileLockToken }) + .from(scimConnection) + .where(eq(scimConnection.id, connectionId)) + .limit(1) + return row?.token === runId +} + +/** Releases the lease; the watermark advances only when the pass finished, so a failed batch is retried next hour. */ +async function releaseLease( + connectionId: string, + runId: string, + completed: boolean +): Promise { + await db + .update(scimConnection) + .set({ + reconcileLockToken: null, + reconcileLeaseAt: null, + ...(completed ? { reconciledAt: new Date() } : {}), + }) + .where(and(eq(scimConnection.id, connectionId), eq(scimConnection.reconcileLockToken, runId))) +} + +/** Connections whose last sweep is older than the interval, oldest first. */ +async function findConnectionsDueForReconcile(limit: number): Promise< + Array<{ + id: string + organizationId: string + settings: ScimConnectionSettings + }> +> { + const dueBefore = new Date(Date.now() - RECONCILE_INTERVAL_MS) + return db + .select({ + id: scimConnection.id, + organizationId: scimConnection.organizationId, + settings: scimConnection.settings, + }) + .from(scimConnection) + .where( + and( + eq(scimConnection.status, 'active'), + or(isNull(scimConnection.reconciledAt), lt(scimConnection.reconciledAt, dueBefore)) + ) + ) + .orderBy(sql`${scimConnection.reconciledAt} asc nulls first`) + .limit(limit) +} + +export async function reconcileConnection(connection: { + id: string + organizationId: string + settings: ScimConnectionSettings +}): Promise { + /** + * A lapsed organization's credentials are refused at authentication; its + * projection must not keep being re-applied by the scheduler either. + */ + if (!(await isScimEntitledForOrganization(connection.organizationId))) return null + + const runId = generateId() + if (!(await acquireLease(connection.id, runId))) return null + + const report: ScimReconcileReport = { + connectionId: connection.id, + reconciledUsers: 0, + grantsAdded: 0, + grantsRemoved: 0, + } + + let completed = false + try { + /** Pruned before the pass, so a connection whose pass keeps failing still keeps its log bounded. */ + await pruneScimRequestLog(connection.id) + let cursor: string | undefined + for (;;) { + const page = await listScimUserIds(db, { + connectionId: connection.id, + ...(cursor ? { afterOrderKey: cursor } : {}), + limit: PROJECTION_BATCH_SIZE, + }) + if (page.length === 0) break + if (!(await holdsLease(connection.id, runId))) { + logger.warn('Directory reconciliation stopped: the lease was taken over', { + connectionId: connection.id, + }) + return null + } + + /** + * Settings are read per batch rather than from the row the due query + * returned: an administrator may change them while a long pass runs, and + * projecting a later batch with the old settings would then stamp the + * connection as reconciled against a policy it no longer has. + */ + const [fresh] = await db + .select({ settings: scimConnection.settings }) + .from(scimConnection) + .where(eq(scimConnection.id, connection.id)) + .limit(1) + + const delta = await reconcileUsersProjectionInBatches({ + connectionId: connection.id, + organizationId: connection.organizationId, + scimUserIds: page.map((row) => row.id), + settings: fresh?.settings ?? connection.settings, + }) + report.reconciledUsers += page.length + report.grantsAdded += delta.added.length + delta.raised.length + report.grantsRemoved += delta.removed.length + cursor = page[page.length - 1].orderKey + } + + completed = true + if (report.grantsAdded > 0 || report.grantsRemoved > 0) { + logger.warn('Directory reconciliation corrected drift', report) + } + return report + } finally { + await releaseLease(connection.id, runId, completed) + } +} + +export interface ScimReconcileSweep { + connections: number + reconciledUsers: number + grantsAdded: number + grantsRemoved: number +} + +export async function runScimReconcileSweep(maxConnections = 200): Promise { + const due = await findConnectionsDueForReconcile(maxConnections) + const sweep: ScimReconcileSweep = { + connections: 0, + reconciledUsers: 0, + grantsAdded: 0, + grantsRemoved: 0, + } + + for (const connection of due) { + try { + const report = await reconcileConnection(connection) + if (!report) continue + sweep.connections += 1 + sweep.reconciledUsers += report.reconciledUsers + sweep.grantsAdded += report.grantsAdded + sweep.grantsRemoved += report.grantsRemoved + } catch (error) { + /** One tenant's failure must not stop the sweep for the others. */ + logger.error('Directory reconciliation failed for a connection', { + connectionId: connection.id, + error, + }) + } + } + + return sweep +} diff --git a/apps/sim/ee/scim/lib/repository/credentials.ts b/apps/sim/ee/scim/lib/repository/credentials.ts new file mode 100644 index 00000000000..94f6c93d286 --- /dev/null +++ b/apps/sim/ee/scim/lib/repository/credentials.ts @@ -0,0 +1,11 @@ +import { scimCredential } from '@sim/db/schema' +import { and, eq, isNull, or, sql } from 'drizzle-orm' + +/** Credentials that still authenticate: not revoked and not past their expiry. */ +export function activeCredentialCondition(connectionId: string) { + return and( + eq(scimCredential.connectionId, connectionId), + isNull(scimCredential.revokedAt), + or(isNull(scimCredential.expiresAt), sql`${scimCredential.expiresAt} > now()`) + ) +} diff --git a/apps/sim/ee/scim/lib/repository/groups.ts b/apps/sim/ee/scim/lib/repository/groups.ts new file mode 100644 index 00000000000..5f381d071c8 --- /dev/null +++ b/apps/sim/ee/scim/lib/repository/groups.ts @@ -0,0 +1,259 @@ +import { scimGroup, scimGroupMember, scimUser } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { and, asc, count, eq, inArray, type SQL, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import type { ScimFilterTerm, ScimGroupFilterField } from '@/ee/scim/lib/protocol/filter' +import { buildOrderKey } from '@/ee/scim/lib/repository/users' + +const logger = createLogger('ScimGroupRepository') + +/** Reads and writes of the provisioned Group table, always anchored to a connection. */ + +/** What a member is called in a Group response: the same display name the User resource shows. */ +const memberDisplayName = sql`coalesce(${scimUser.attributes} ->> 'displayName', ${scimUser.userName})` + +export interface ScimGroupRecord { + id: string + externalId: string | null + displayName: string + createdAt: Date + updatedAt: Date +} + +const GROUP_SELECTION = { + id: scimGroup.id, + externalId: scimGroup.externalId, + displayName: scimGroup.displayName, + createdAt: scimGroup.createdAt, + updatedAt: scimGroup.updatedAt, +} as const + +function groupFilterCondition(term: ScimFilterTerm): SQL | undefined { + switch (term.field) { + case 'id': + return eq(scimGroup.id, term.value) + case 'displayName': + return eq(scimGroup.displayNameKey, term.value.toLowerCase()) + case 'externalId': + return eq(scimGroup.externalId, term.value) + } +} + +export async function findScimGroupById( + tx: DbOrTx, + connectionId: string, + groupId: string +): Promise { + const [row] = await tx + .select(GROUP_SELECTION) + .from(scimGroup) + .where(and(eq(scimGroup.connectionId, connectionId), eq(scimGroup.id, groupId))) + .limit(1) + return row ?? null +} + +export async function pageScimGroups( + tx: DbOrTx, + params: { + connectionId: string + filters: ScimFilterTerm[] + offset: number + limit: number + } +): Promise<{ records: ScimGroupRecord[]; totalResults: number }> { + const conditions = [ + eq(scimGroup.connectionId, params.connectionId), + ...params.filters + .map(groupFilterCondition) + .filter((value): value is SQL => value !== undefined), + ] + + const [totalRow] = await tx + .select({ value: count() }) + .from(scimGroup) + .where(and(...conditions)) + + const records = + params.limit === 0 + ? [] + : await tx + .select(GROUP_SELECTION) + .from(scimGroup) + .where(and(...conditions)) + .orderBy(asc(scimGroup.orderKey)) + .limit(params.limit) + .offset(params.offset) + + return { records, totalResults: totalRow?.value ?? 0 } +} + +export interface ScimGroupMemberRow { + scimUserId: string + displayName: string +} + +/** Members of one group, ordered so a response is stable between reads. */ +export async function loadGroupMembers(tx: DbOrTx, groupId: string): Promise { + const rows = await tx + .select({ scimUserId: scimGroupMember.scimUserId, displayName: memberDisplayName }) + .from(scimGroupMember) + .innerJoin(scimUser, eq(scimUser.id, scimGroupMember.scimUserId)) + .where(eq(scimGroupMember.groupId, groupId)) + .orderBy(asc(scimGroupMember.createdAt), asc(scimGroupMember.scimUserId)) + return rows +} + +/** Members of many groups in one query, keyed by group, for list responses. */ +export async function loadGroupMembersForGroups( + tx: DbOrTx, + groupIds: string[] +): Promise> { + const byGroup = new Map() + if (groupIds.length === 0) return byGroup + const rows = await tx + .select({ + groupId: scimGroupMember.groupId, + scimUserId: scimGroupMember.scimUserId, + displayName: memberDisplayName, + }) + .from(scimGroupMember) + .innerJoin(scimUser, eq(scimUser.id, scimGroupMember.scimUserId)) + .where(inArray(scimGroupMember.groupId, groupIds)) + .orderBy(asc(scimGroupMember.createdAt), asc(scimGroupMember.scimUserId)) + for (const row of rows) { + const list = byGroup.get(row.groupId) ?? [] + list.push({ scimUserId: row.scimUserId, displayName: row.displayName }) + byGroup.set(row.groupId, list) + } + return byGroup +} + +export async function loadGroupMemberIds(tx: DbOrTx, groupId: string): Promise { + const rows = await tx + .select({ scimUserId: scimGroupMember.scimUserId }) + .from(scimGroupMember) + .where(eq(scimGroupMember.groupId, groupId)) + return rows.map((row) => row.scimUserId) +} + +/** + * Keeps only the member ids this connection provisioned. + * + * An id from another connection can never be pulled into a group this + * directory controls. An id this directory itself no longer has — a member it + * deprovisioned but still lists in the group — is dropped with a warning rather + * than failing the whole group on every cycle; there is nothing to add. + */ +export async function filterOwnedUsers( + tx: DbOrTx, + connectionId: string, + scimUserIds: string[] +): Promise { + if (scimUserIds.length === 0) return [] + const rows = await tx + .select({ id: scimUser.id }) + .from(scimUser) + .where(and(eq(scimUser.connectionId, connectionId), inArray(scimUser.id, scimUserIds))) + const owned = new Set(rows.map((row) => row.id)) + const unknown = scimUserIds.filter((id) => !owned.has(id)) + if (unknown.length > 0) { + logger.warn('Ignored Group members that are not users of this directory', { + connectionId, + unknown: unknown.length, + }) + } + return scimUserIds.filter((id) => owned.has(id)) +} + +export async function insertScimGroup( + tx: DbOrTx, + params: { connectionId: string; displayName: string; externalId?: string | undefined } +): Promise { + const id = generateId() + const createdAt = new Date() + await tx.insert(scimGroup).values({ + id, + connectionId: params.connectionId, + externalId: params.externalId ?? null, + displayName: params.displayName, + displayNameKey: params.displayName.toLowerCase(), + orderKey: buildOrderKey(createdAt, id), + createdAt, + updatedAt: createdAt, + }) + return { + id, + externalId: params.externalId ?? null, + displayName: params.displayName, + createdAt, + updatedAt: createdAt, + } +} + +export async function updateScimGroup( + tx: DbOrTx, + params: { groupId: string; displayName?: string; externalId?: string | null } +): Promise { + await tx + .update(scimGroup) + .set({ + ...(params.displayName !== undefined + ? { displayName: params.displayName, displayNameKey: params.displayName.toLowerCase() } + : {}), + ...(params.externalId !== undefined ? { externalId: params.externalId } : {}), + updatedAt: new Date(), + }) + .where(eq(scimGroup.id, params.groupId)) +} + +export async function touchScimGroup(tx: DbOrTx, groupId: string): Promise { + await tx.update(scimGroup).set({ updatedAt: new Date() }).where(eq(scimGroup.id, groupId)) +} + +export async function deleteScimGroupRow(tx: DbOrTx, groupId: string): Promise { + await tx.delete(scimGroup).where(eq(scimGroup.id, groupId)) +} + +/** Adds a member, tolerating a repeat. Returns whether the row was new. */ +export async function addGroupMember( + tx: DbOrTx, + params: { groupId: string; scimUserId: string } +): Promise { + const inserted = await tx + .insert(scimGroupMember) + .values({ + id: generateId(), + groupId: params.groupId, + scimUserId: params.scimUserId, + createdAt: new Date(), + }) + .onConflictDoNothing() + .returning({ id: scimGroupMember.id }) + return inserted.length > 0 +} + +/** Removes a member, tolerating one who is not in the group. */ +export async function removeGroupMember( + tx: DbOrTx, + params: { groupId: string; scimUserId: string } +): Promise { + const deleted = await tx + .delete(scimGroupMember) + .where( + and( + eq(scimGroupMember.groupId, params.groupId), + eq(scimGroupMember.scimUserId, params.scimUserId) + ) + ) + .returning({ id: scimGroupMember.id }) + return deleted.length > 0 +} + +export async function countGroupMembers(tx: DbOrTx, groupId: string): Promise { + const [row] = await tx + .select({ value: count() }) + .from(scimGroupMember) + .where(eq(scimGroupMember.groupId, groupId)) + return row?.value ?? 0 +} diff --git a/apps/sim/ee/scim/lib/repository/users.ts b/apps/sim/ee/scim/lib/repository/users.ts new file mode 100644 index 00000000000..6f440b0c5ca --- /dev/null +++ b/apps/sim/ee/scim/lib/repository/users.ts @@ -0,0 +1,269 @@ +import { type ScimUserAttributes, scimGroup, scimGroupMember, scimUser, user } from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { normalizeEmail } from '@sim/utils/string' +import { and, asc, count, eq, inArray, type SQL, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { uniqueness } from '@/ee/scim/lib/protocol/errors' +import type { ScimFilterTerm, ScimUserFilterField } from '@/ee/scim/lib/protocol/filter' +import type { UserResourceRow } from '@/ee/scim/lib/protocol/resources' + +/** + * Reads and writes of the provisioned User table. + * + * Every predicate is anchored to a connection id. That is the whole tenant + * boundary for this surface: a request names a resource id and nothing else, so + * a query that forgot the anchor would let one organization's directory address + * another's users. + */ + +/** + * A sortable key that never reorders between pages. + * + * A provider walks a list with `startIndex`, so rows must keep their positions + * across separate requests. Two rows created in the same millisecond would tie + * on a timestamp alone and could swap places between pages, silently hiding one + * from an import; appending the id breaks the tie permanently. + */ +export function buildOrderKey(createdAt: Date, id: string): string { + return `${String(createdAt.getTime()).padStart(15, '0')}:${id}` +} + +function userFilterCondition(term: ScimFilterTerm): SQL | undefined { + switch (term.field) { + case 'id': + return eq(scimUser.id, term.value) + case 'userName': + return eq(scimUser.userName, term.value.toLowerCase()) + case 'externalId': + return eq(scimUser.externalId, term.value) + case 'email': + return sql`lower(trim(${user.email})) = ${normalizeEmail(term.value)}` + case 'active': + return eq(scimUser.active, term.value.toLowerCase() === 'true') + } +} + +const USER_SELECTION = { + id: scimUser.id, + userId: scimUser.userId, + externalId: scimUser.externalId, + userName: scimUser.userName, + active: scimUser.active, + attributes: scimUser.attributes, + createdAt: scimUser.createdAt, + updatedAt: scimUser.updatedAt, + email: user.email, + userSuspendedAt: user.suspendedAt, +} as const + +export interface ScimUserRecord { + id: string + userId: string + externalId: string | null + userName: string + active: boolean + attributes: ScimUserAttributes + createdAt: Date + updatedAt: Date + email: string + userSuspendedAt: Date | null +} + +/** Group memberships for a set of provisioned users, for the `groups` attribute. */ +export async function loadGroupsForScimUsers( + tx: DbOrTx, + scimUserIds: string[] +): Promise>> { + const byUser = new Map>() + if (scimUserIds.length === 0) return byUser + + const rows = await tx + .select({ + scimUserId: scimGroupMember.scimUserId, + groupId: scimGroup.id, + displayName: scimGroup.displayName, + }) + .from(scimGroupMember) + .innerJoin(scimGroup, eq(scimGroup.id, scimGroupMember.groupId)) + .where(inArray(scimGroupMember.scimUserId, scimUserIds)) + .orderBy(asc(scimGroup.displayName)) + + for (const row of rows) { + const existing = byUser.get(row.scimUserId) + const entry = { id: row.groupId, displayName: row.displayName } + if (existing) existing.push(entry) + else byUser.set(row.scimUserId, [entry]) + } + return byUser +} + +export function toUserResourceRow( + record: ScimUserRecord, + groups: Array<{ id: string; displayName: string }> +): UserResourceRow { + return { + id: record.id, + externalId: record.externalId, + userName: record.userName, + /** + * A suspension applied outside the directory — by an administrator during an + * investigation — is reported as inactive. Answering `true` would tell the + * directory the person can sign in when they cannot. + */ + active: record.active && record.userSuspendedAt === null, + attributes: record.attributes, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + email: record.email, + groups, + } +} + +export async function findScimUserById( + tx: DbOrTx, + connectionId: string, + scimUserId: string +): Promise { + const [row] = await tx + .select(USER_SELECTION) + .from(scimUser) + .innerJoin(user, eq(user.id, scimUser.userId)) + .where(and(eq(scimUser.connectionId, connectionId), eq(scimUser.id, scimUserId))) + .limit(1) + return row ?? null +} + +export async function findScimUserByUserId( + tx: DbOrTx, + connectionId: string, + userId: string +): Promise { + const [row] = await tx + .select(USER_SELECTION) + .from(scimUser) + .innerJoin(user, eq(user.id, scimUser.userId)) + .where(and(eq(scimUser.connectionId, connectionId), eq(scimUser.userId, userId))) + .limit(1) + return row ?? null +} + +export interface ScimUserPage { + records: ScimUserRecord[] + totalResults: number +} + +export async function pageScimUsers( + tx: DbOrTx, + params: { + connectionId: string + filters: ScimFilterTerm[] + offset: number + limit: number + } +): Promise { + const conditions = [ + eq(scimUser.connectionId, params.connectionId), + ...params.filters.map(userFilterCondition).filter((value): value is SQL => value !== undefined), + ] + + const [totalRow] = await tx + .select({ value: count() }) + .from(scimUser) + .innerJoin(user, eq(user.id, scimUser.userId)) + .where(and(...conditions)) + + /** + * A page of zero is a real request: Microsoft Entra's connection test asks for + * the total without any resources. Skipping the row query keeps that cheap. + */ + const records = + params.limit === 0 + ? [] + : await tx + .select(USER_SELECTION) + .from(scimUser) + .innerJoin(user, eq(user.id, scimUser.userId)) + .where(and(...conditions)) + .orderBy(asc(scimUser.orderKey)) + .limit(params.limit) + .offset(params.offset) + + return { records, totalResults: totalRow?.value ?? 0 } +} + +/** Refuses a `userName` another resource on this connection already holds. */ +export async function assertUserNameAvailable( + tx: DbOrTx, + connectionId: string, + userName: string, + exceptScimUserId?: string +): Promise { + const [clash] = await tx + .select({ id: scimUser.id }) + .from(scimUser) + .where(and(eq(scimUser.connectionId, connectionId), eq(scimUser.userName, userName))) + .limit(1) + if (clash && clash.id !== exceptScimUserId) { + throw uniqueness(`A user with userName ${userName} already exists in this directory`) + } +} + +export async function insertScimUser( + tx: DbOrTx, + params: { + connectionId: string + userId: string + attributes: ScimUserAttributes + active: boolean + } +): Promise<{ id: string }> { + const id = generateId() + const createdAt = new Date() + await tx.insert(scimUser).values({ + id, + connectionId: params.connectionId, + userId: params.userId, + externalId: params.attributes.externalId ?? null, + userName: params.attributes.userName, + active: params.active, + attributes: params.attributes, + orderKey: buildOrderKey(createdAt, id), + createdAt, + updatedAt: createdAt, + }) + return { id } +} + +export async function updateScimUser( + tx: DbOrTx, + params: { scimUserId: string; attributes: ScimUserAttributes; active: boolean } +): Promise { + await tx + .update(scimUser) + .set({ + externalId: params.attributes.externalId ?? null, + userName: params.attributes.userName, + active: params.active, + attributes: params.attributes, + updatedAt: new Date(), + }) + .where(eq(scimUser.id, params.scimUserId)) +} + +/** All provisioned users on a connection, in pages, for the reconcile job. */ +export async function listScimUserIds( + tx: DbOrTx, + params: { connectionId: string; afterOrderKey?: string; limit: number } +): Promise> { + return tx + .select({ id: scimUser.id, orderKey: scimUser.orderKey }) + .from(scimUser) + .where( + and( + eq(scimUser.connectionId, params.connectionId), + ...(params.afterOrderKey ? [sql`${scimUser.orderKey} > ${params.afterOrderKey}`] : []) + ) + ) + .orderBy(asc(scimUser.orderKey)) + .limit(params.limit) +} diff --git a/apps/sim/ee/scim/lib/request-log.ts b/apps/sim/ee/scim/lib/request-log.ts new file mode 100644 index 00000000000..0225f04e2d0 --- /dev/null +++ b/apps/sim/ee/scim/lib/request-log.ts @@ -0,0 +1,70 @@ +import type { ScimConnectionPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { scimRequestLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' +import { truncate } from '@sim/utils/string' +import { sql } from 'drizzle-orm' +import { SCIM_REQUEST_LOG_RETENTION } from '@/ee/scim/lib/protocol/constants' +import type { ScimType } from '@/ee/scim/lib/protocol/errors' + +const logger = createLogger('ScimRequestLog') + +/** One protocol request as the activity log records it. */ +export interface ScimRequestLogEntry { + principal: ScimConnectionPrincipal + method: string + path: string + status: number + scimType?: ScimType + detail?: string + userAgent: string | null + durationMs: number +} + +/** + * Records one provisioning request for the settings activity view. + * + * An administrator debugging a connection has nothing else to look at: + * Microsoft Entra reports a failed cycle without saying what it sent, and Okta + * surfaces only the status. Fire-and-forget, because a logging failure must not + * turn a successful provisioning call into an error the directory will retry. + */ +export function recordScimRequest(entry: ScimRequestLogEntry): void { + void db + .insert(scimRequestLog) + .values({ + id: generateId(), + connectionId: entry.principal.connectionId, + credentialId: entry.principal.credentialId, + method: entry.method, + path: entry.path, + status: entry.status, + scimType: entry.scimType ?? null, + /** Bounded: a detail is a sentence for a person, not a payload dump. */ + detail: entry.detail ? truncate(entry.detail, 500) : null, + userAgent: entry.userAgent ? truncate(entry.userAgent, 200) : null, + durationMs: entry.durationMs, + createdAt: new Date(), + }) + .catch((error) => logger.warn('Failed to record a SCIM request', { error })) +} + +/** + * Trims a connection's log to its most recent rows. + * + * Run by the reconcile job rather than on write: pruning inline would add a + * delete to every provisioning call, and the bound only has to hold over hours. + */ +export async function pruneScimRequestLog(connectionId: string): Promise { + await db.execute(sql` + delete from ${scimRequestLog} + where ${scimRequestLog.connectionId} = ${connectionId} + and ${scimRequestLog.id} not in ( + select id from ${scimRequestLog} + where ${scimRequestLog.connectionId} = ${connectionId} + order by ${scimRequestLog.createdAt} desc + limit ${SCIM_REQUEST_LOG_RETENTION} + ) + `) +} diff --git a/apps/sim/ee/scim/lib/route.ts b/apps/sim/ee/scim/lib/route.ts new file mode 100644 index 00000000000..7301ecbc0ce --- /dev/null +++ b/apps/sim/ee/scim/lib/route.ts @@ -0,0 +1,15 @@ +import { createScimRouteBuilder } from '@/lib/api/server/routes' +import { authenticateScimRequest } from '@/ee/scim/lib/authenticate' +import { recordScimRequest } from '@/ee/scim/lib/request-log' + +/** + * The route builder wired to this deployment. + * + * The builder takes its authenticator and request recorder as dependencies so + * it stays testable without a database; this module is where the real ones are + * bound, and it is what every route file imports. + */ +export const defineScimRoute = createScimRouteBuilder({ + authenticate: authenticateScimRequest, + recordRequest: recordScimRequest, +}) diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index bf290898ac1..2c96b29c160 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -90,6 +90,11 @@ vi.mock('@/ee/sso/components/verified-domains-section', () => ({ VerifiedDomainsSection: () =>
, })) +/** Directory provisioning has its own React Query hooks and its own tests; here it is a sibling section. */ +vi.mock('@/ee/scim/components/scim-section', () => ({ + ScimSection: () =>
, +})) + // Surface the real Save/Update action so submit paths are reachable from tests. vi.mock('@/components/settings/save-discard-actions', () => ({ saveDiscardActions: ({ saveLabel, onSave }: { saveLabel?: string; onSave?: () => void }) => [ diff --git a/apps/sim/ee/sso/components/sso-settings.tsx b/apps/sim/ee/sso/components/sso-settings.tsx index 630d3edc260..b75f5c2a0eb 100644 --- a/apps/sim/ee/sso/components/sso-settings.tsx +++ b/apps/sim/ee/sso/components/sso-settings.tsx @@ -35,6 +35,7 @@ import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' import { SettingRow } from '@/ee/components/setting-row' +import { ScimSection } from '@/ee/scim/components/scim-section' import { VerifiedDomainsSection } from '@/ee/sso/components/verified-domains-section' import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' import { useConfigureSSO, useSSOProviders } from '@/ee/sso/hooks/sso' @@ -326,9 +327,12 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { } else { if (!isLoadingProviders && isSSOProviderOwner === false && providers.length > 0) { return ( - - Only the user who configured SSO can manage these settings. - + <> + + Only the user who configured SSO can manage these settings. + + + ) } } @@ -642,6 +646,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { return ( +
@@ -756,6 +761,7 @@ function OrganizationSsoSettings({ organizationId }: SSOProps) { ]} > +
diff --git a/apps/sim/hooks/queries/organization.test.tsx b/apps/sim/hooks/queries/organization.test.tsx index 16dfcccad6c..6e5f17644a0 100644 --- a/apps/sim/hooks/queries/organization.test.tsx +++ b/apps/sim/hooks/queries/organization.test.tsx @@ -78,6 +78,7 @@ const ROSTER_A: { success: true; data: OrganizationRoster } = { name: 'Member A', email: 'member-a@example.com', image: null, + suspendedAt: null, workspaces: [], }, ], diff --git a/apps/sim/lib/api-key/service.ts b/apps/sim/lib/api-key/service.ts index 4520b92f22a..edcee7df2f1 100644 --- a/apps/sim/lib/api-key/service.ts +++ b/apps/sim/lib/api-key/service.ts @@ -48,6 +48,7 @@ interface HashCandidate { type: string expiresAt: Date | null userBanned: boolean | null + userSuspendedAt: Date | null } /** @@ -84,6 +85,7 @@ export async function authenticateApiKeyFromHeader( type: apiKeyTable.type, expiresAt: apiKeyTable.expiresAt, userBanned: userTable.banned, + userSuspendedAt: userTable.suspendedAt, }) .from(apiKeyTable) .leftJoin(userTable, eq(apiKeyTable.userId, userTable.id)) @@ -97,6 +99,14 @@ export async function authenticateApiKeyFromHeader( // Defense in depth: banning deletes a user's keys, but reject any survivor too. if (record.userBanned) return INVALID + /** + * A suspension deliberately leaves the account's resources intact, so unlike + * a ban it does not delete the keys. Refusing a personal key here is what + * ends the member's own machine access. A workspace key is shared and + * belongs to the workspace, so one member's suspension does not break it. + */ + if (record.userSuspendedAt && keyType === 'personal') return INVALID + if (options.userId && record.userId !== options.userId) return INVALID if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID if (record.expiresAt && record.expiresAt < new Date()) return INVALID diff --git a/apps/sim/lib/api/contracts/organization-scim.ts b/apps/sim/lib/api/contracts/organization-scim.ts new file mode 100644 index 00000000000..e9eb7d34368 --- /dev/null +++ b/apps/sim/lib/api/contracts/organization-scim.ts @@ -0,0 +1,200 @@ +import { z } from 'zod' +import { organizationIdSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +/** + * The settings surface an organization administrator uses to configure directory + * provisioning. Ordinary session-authenticated internal routes; the SCIM + * protocol surface is separate and lives in `contracts/scim.ts`. + */ + +const organizationParamsSchema = z.object({ id: organizationIdSchema }) + +const scimScopeSchema = z.enum(['users:read', 'users:write', 'groups:read', 'groups:write']) + +export const scimConnectionSettingsSchema = z.object({ + lockManualMembership: z.boolean().optional(), + disableJit: z.boolean().optional(), + autoMapPermissionGroupsByName: z.boolean().optional(), +}) +export type ScimConnectionSettingsInput = z.input + +const scimCredentialSchema = z.object({ + id: z.string(), + tokenPrefix: z.string(), + scopes: z.array(scimScopeSchema), + expiresAt: z.string().nullable(), + lastUsedAt: z.string().nullable(), + createdAt: z.string(), +}) + +const scimConnectionSchema = z.object({ + id: z.string(), + status: z.enum(['active', 'disabled']), + baseUrl: z.string(), + settings: scimConnectionSettingsSchema, + lastRequestAt: z.string().nullable(), + reconciledAt: z.string().nullable(), + createdAt: z.string(), + credentials: z.array(scimCredentialSchema), + userCount: z.number().int(), + groupCount: z.number().int(), +}) + +export const getScimConnectionContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/scim', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: z.object({ connection: scimConnectionSchema.nullable() }), + }, +}) + +export const configureScimConnectionContract = defineRouteContract({ + method: 'PUT', + path: '/api/organizations/[id]/scim', + params: organizationParamsSchema, + body: z.object({ + status: z.enum(['active', 'disabled']).optional(), + settings: scimConnectionSettingsSchema.optional(), + }), + response: { mode: 'json', schema: z.object({ connection: scimConnectionSchema }) }, +}) + +export const issueScimCredentialContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/scim/credentials', + params: organizationParamsSchema, + body: z.object({ + /** Days until the credential stops working. Omitted means it does not expire. */ + expiresInDays: z.number().int().min(1).max(3650).optional(), + }), + response: { + mode: 'json', + /** `secret` appears here and nowhere else; only its digest is stored. */ + schema: z.object({ secret: z.string(), credential: scimCredentialSchema }), + status: 201, + }, +}) + +export const revokeScimCredentialContract = defineRouteContract({ + method: 'DELETE', + path: '/api/organizations/[id]/scim/credentials/[credentialId]', + params: organizationParamsSchema.extend({ credentialId: z.string().min(1).max(128) }), + response: { mode: 'json', schema: z.object({ success: z.literal(true) }) }, +}) + +const groupMappingSchema = z.object({ + id: z.string(), + groupId: z.string(), + groupDisplayName: z.string(), + targetKind: z.enum(['permission_group', 'workspace', 'org_role']), + permissionGroupId: z.string().nullable(), + workspaceId: z.string().nullable(), + permissionType: z.enum(['admin', 'write', 'read']).nullable(), + role: z.string().nullable(), +}) + +export const listScimGroupMappingsContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/scim/mappings', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: z.object({ + groups: z.array( + z.object({ + id: z.string(), + displayName: z.string(), + memberCount: z.number().int(), + mappings: z.array(groupMappingSchema), + }) + ), + }), + }, +}) + +export const scimGroupMappingBodySchema = z.discriminatedUnion('targetKind', [ + z.object({ + groupId: z.string().min(1).max(128), + targetKind: z.literal('permission_group'), + permissionGroupId: z.string().min(1).max(128), + }), + z.object({ + groupId: z.string().min(1).max(128), + targetKind: z.literal('workspace'), + workspaceId: z.string().min(1).max(128), + permissionType: z.enum(['admin', 'write', 'read']), + }), + z.object({ + groupId: z.string().min(1).max(128), + targetKind: z.literal('org_role'), + role: z.literal('admin'), + }), +]) + +export const upsertScimGroupMappingContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/scim/mappings', + params: organizationParamsSchema, + body: scimGroupMappingBodySchema, + response: { + mode: 'json', + schema: z.object({ mapping: groupMappingSchema, reconciledUsers: z.number().int() }), + status: 201, + }, +}) + +export const deleteScimGroupMappingContract = defineRouteContract({ + method: 'DELETE', + path: '/api/organizations/[id]/scim/mappings/[mappingId]', + params: organizationParamsSchema.extend({ mappingId: z.string().min(1).max(128) }), + response: { + mode: 'json', + schema: z.object({ success: z.literal(true), reconciledUsers: z.number().int() }), + }, +}) + +const scimActivityEntrySchema = z.object({ + id: z.string(), + method: z.string(), + path: z.string(), + status: z.number().int(), + scimType: z.string().nullable(), + detail: z.string().nullable(), + userAgent: z.string().nullable(), + durationMs: z.number().int(), + createdAt: z.string(), +}) + +export const listScimActivityContract = defineRouteContract({ + method: 'GET', + path: '/api/organizations/[id]/scim/activity', + params: organizationParamsSchema, + query: z.object({ limit: z.coerce.number().int().min(1).max(200).optional() }), + response: { + mode: 'json', + schema: z.object({ entries: z.array(scimActivityEntrySchema) }), + }, +}) + +export const reconcileScimConnectionContract = defineRouteContract({ + method: 'POST', + path: '/api/organizations/[id]/scim/reconcile', + params: organizationParamsSchema, + response: { + mode: 'json', + schema: z.object({ + reconciledUsers: z.number().int(), + grantsAdded: z.number().int(), + grantsRemoved: z.number().int(), + }), + }, +}) + +export type ScimGroupMappingBody = z.input +export type ScimActivityEntry = z.output +export type ScimConnectionView = z.output +export type ScimCredentialView = z.output +export type ScimGroupMappingView = z.output diff --git a/apps/sim/lib/api/contracts/organization.ts b/apps/sim/lib/api/contracts/organization.ts index f8cf72bed90..c86bdf21da4 100644 --- a/apps/sim/lib/api/contracts/organization.ts +++ b/apps/sim/lib/api/contracts/organization.ts @@ -275,6 +275,8 @@ export const rosterMemberSchema = z.object({ name: z.string(), email: z.string(), image: z.string().nullable(), + /** Set while a directory deactivation blocks the member's sign-in; access is otherwise intact. */ + suspendedAt: z.string().nullable(), workspaces: z.array(rosterWorkspaceAccessSchema), }) diff --git a/apps/sim/lib/api/contracts/scim.ts b/apps/sim/lib/api/contracts/scim.ts new file mode 100644 index 00000000000..f5263422e41 --- /dev/null +++ b/apps/sim/lib/api/contracts/scim.ts @@ -0,0 +1,347 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + SCIM_ENTERPRISE_USER_SCHEMA, + SCIM_GROUP_SCHEMA, + SCIM_LIST_RESPONSE_SCHEMA, + SCIM_MAX_GROUP_MEMBERS, + SCIM_MAX_PATCH_OPERATIONS, + SCIM_PATCH_OP_SCHEMA, + SCIM_USER_SCHEMA, +} from '@/ee/scim/lib/protocol/constants' +import { + canonicalizeAttributeNames, + normalizeScimBoolean, + stripProviderSchemaMarkers, + unwrapSingleElement, +} from '@/ee/scim/lib/protocol/normalize' + +/** + * Wire schemas for the SCIM 2.0 surface. + * + * Inbound shapes are deliberately tolerant. RFC 7643 defines far more than Sim + * models, and a provider that sends an attribute this server does not store must + * still get its write accepted — so object schemas pass unknown keys through + * rather than stripping or rejecting them, and the canonicalizer decides what is + * kept. Outbound shapes are strict and drive the route builder's response + * validation. + */ + +/** Accepts the string booleans Microsoft Entra's classic provisioning job sends. */ +const scimBoolean = z.preprocess( + (value) => normalizeScimBoolean(unwrapSingleElement(value)), + z.boolean() +) + +const scimEmailSchema = z.looseObject({ + value: z.string().trim().min(1, 'An email entry requires a value').max(320), + type: z.string().max(64).optional(), + primary: scimBoolean.optional(), +}) + +const scimNameSchema = z.looseObject({ + formatted: z.string().max(256).optional(), + givenName: z.string().max(128).optional(), + familyName: z.string().max(128).optional(), +}) + +const scimEnterpriseSchema = z.looseObject({ + department: z.string().max(256).optional(), + employeeNumber: z.string().max(128).optional(), + costCenter: z.string().max(128).optional(), + division: z.string().max(128).optional(), + organization: z.string().max(256).optional(), + manager: z + .union([ + z.string().max(256), + z.looseObject({ + value: z.string().max(256).optional(), + displayName: z.string().max(256).optional(), + }), + ]) + .optional(), +}) + +/** + * An inbound User. + * + * `password` is stripped during parsing. Okta sends one on every create even + * when password sync is off, and Sim never stores or uses it; dropping it here + * keeps the credential out of the parsed request object and therefore out of + * every log line and error detail downstream. + */ +const USER_WRITE_ATTRIBUTES = [ + 'schemas', + 'userName', + 'externalId', + 'active', + 'displayName', + 'name', + 'emails', + SCIM_ENTERPRISE_USER_SCHEMA, +] as const + +/** + * A `schemas` list that declares the resource's core schema. Every extension is + * let through: providers declare their own URNs the moment an administrator + * adds a custom attribute (Okta `urn:okta::2.0:user:custom`, Entra + * `urn:ietf:params:scim:schemas:extension::2.0:User`), and refusing them + * would stop the sync at the first write. + */ +function scimSchemasDeclaring(core: string) { + return z + .array(z.string().max(256)) + .min(1, 'schemas must name at least one URN') + .max(10) + .refine( + (schemas) => stripProviderSchemaMarkers(schemas).includes(core), + `schemas must include ${core}` + ) +} + +export const scimUserWriteSchema = z.preprocess( + (body) => canonicalizeAttributeNames(body, USER_WRITE_ATTRIBUTES), + z + .looseObject({ + schemas: scimSchemasDeclaring(SCIM_USER_SCHEMA), + userName: z.string().trim().min(1, 'userName must not be empty').max(320), + externalId: z.string().trim().max(256).optional(), + active: scimBoolean.optional(), + displayName: z.string().max(256).optional(), + name: scimNameSchema.optional(), + emails: z.array(scimEmailSchema).max(20).optional(), + [SCIM_ENTERPRISE_USER_SCHEMA]: scimEnterpriseSchema.optional(), + }) + .transform(({ password: _password, ...rest }) => rest) +) +/** What the route receives after parsing, which is what the canonicalizer reads. */ +export type ScimUserWriteParsed = z.output + +const scimGroupMemberSchema = z.looseObject({ + value: z.string().trim().min(1, 'A group member requires a value').max(256), + display: z.string().max(256).optional(), + type: z.string().max(64).optional(), +}) + +const GROUP_WRITE_ATTRIBUTES = ['schemas', 'displayName', 'externalId', 'members'] as const + +export const scimGroupWriteSchema = z.preprocess( + (body) => canonicalizeAttributeNames(body, GROUP_WRITE_ATTRIBUTES), + z.looseObject({ + schemas: scimSchemasDeclaring(SCIM_GROUP_SCHEMA), + displayName: z.string().trim().min(1, 'displayName must not be empty').max(256), + externalId: z.string().trim().max(256).optional(), + members: z.array(scimGroupMemberSchema).max(SCIM_MAX_GROUP_MEMBERS).optional(), + }) +) +export type ScimGroupWriteParsed = z.output + +/** + * A PATCH request body. + * + * `op` is lower-cased before validation because Entra capitalizes it, and + * defaults to `replace` because Okta omits it on the deactivation call that is + * the single most important request this server handles. + */ +export const scimPatchBodySchema = z.object({ + schemas: z + .array(z.string().max(256)) + .refine( + (schemas) => schemas.includes(SCIM_PATCH_OP_SCHEMA), + `schemas must include ${SCIM_PATCH_OP_SCHEMA}` + ), + Operations: z + .array( + z.object({ + op: z + .string() + .default('replace') + .transform((value) => value.trim().toLowerCase()) + .pipe(z.enum(['add', 'replace', 'remove'])), + path: z.string().max(512).optional(), + value: z.unknown().optional(), + }) + ) + .min(1, 'Operations must contain at least one operation') + .max(SCIM_MAX_PATCH_OPERATIONS), +}) +export type ScimPatchBody = z.output +export type ScimPatchOperation = ScimPatchBody['Operations'][number] + +export const scimAttributesQuerySchema = z.object({ + attributes: z.string().max(1024).optional(), + excludedAttributes: z.string().max(1024).optional(), +}) + +export const scimListQuerySchema = scimAttributesQuerySchema.extend({ + filter: z.string().max(2048).optional(), + startIndex: z.coerce.number().int().optional(), + count: z.coerce.number().int().optional(), +}) + +export const scimResourceParamsSchema = z.object({ id: z.string().min(1).max(256) }) + +const scimMetaSchema = z.object({ + resourceType: z.enum(['User', 'Group']), + created: z.string(), + lastModified: z.string(), + location: z.string(), + version: z.string(), +}) + +/** + * Only `schemas`, `id`, and `meta` are always present. Every other attribute may + * be dropped by `attributes` / `excludedAttributes` (RFC 7644 section 3.9), which + * Entra uses on every listing, so the response schema cannot require them. + */ +export const scimUserResourceSchema = z.looseObject({ + schemas: z.array(z.string()), + id: z.string(), + externalId: z.string().optional(), + userName: z.string().optional(), + active: z.boolean().optional(), + displayName: z.string().optional(), + name: z + .looseObject({ + formatted: z.string(), + givenName: z.string().optional(), + familyName: z.string().optional(), + }) + .optional(), + emails: z + .array(z.object({ value: z.string(), type: z.string().optional(), primary: z.boolean() })) + .optional(), + groups: z + .array(z.object({ value: z.string(), display: z.string(), $ref: z.string() })) + .optional(), + meta: scimMetaSchema, +}) + +export const scimGroupResourceSchema = z.looseObject({ + schemas: z.array(z.string()), + id: z.string(), + externalId: z.string().optional(), + displayName: z.string().optional(), + members: z + .array( + z.object({ + value: z.string(), + display: z.string().optional(), + $ref: z.string(), + type: z.literal('User'), + }) + ) + .optional(), + meta: scimMetaSchema, +}) + +function listResponseSchema(item: Item) { + return z.object({ + schemas: z.tuple([z.literal(SCIM_LIST_RESPONSE_SCHEMA)]), + totalResults: z.number().int(), + startIndex: z.number().int(), + itemsPerPage: z.number().int(), + Resources: z.array(item), + }) +} + +export const listScimUsersContract = defineRouteContract({ + method: 'GET', + path: '/api/scim/v2/Users', + query: scimListQuerySchema, + response: { mode: 'json', schema: listResponseSchema(scimUserResourceSchema) }, +}) + +export const createScimUserContract = defineRouteContract({ + method: 'POST', + path: '/api/scim/v2/Users', + body: scimUserWriteSchema, + response: { mode: 'json', schema: scimUserResourceSchema, status: 201 }, +}) + +export const getScimUserContract = defineRouteContract({ + method: 'GET', + path: '/api/scim/v2/Users/[id]', + params: scimResourceParamsSchema, + query: scimAttributesQuerySchema, + response: { mode: 'json', schema: scimUserResourceSchema }, +}) + +export const replaceScimUserContract = defineRouteContract({ + method: 'PUT', + path: '/api/scim/v2/Users/[id]', + params: scimResourceParamsSchema, + body: scimUserWriteSchema, + response: { mode: 'json', schema: scimUserResourceSchema }, +}) + +/** + * Returns the updated resource rather than `204`. Okta documents both as + * acceptable and Microsoft's own examples show a body, so the shape that + * satisfies both providers is the one that carries the resource. + */ +export const patchScimUserContract = defineRouteContract({ + method: 'PATCH', + path: '/api/scim/v2/Users/[id]', + params: scimResourceParamsSchema, + body: scimPatchBodySchema, + response: { mode: 'json', schema: scimUserResourceSchema }, +}) + +export const deleteScimUserContract = defineRouteContract({ + method: 'DELETE', + path: '/api/scim/v2/Users/[id]', + params: scimResourceParamsSchema, + response: { mode: 'empty', status: 204 }, +}) + +export const listScimGroupsContract = defineRouteContract({ + method: 'GET', + path: '/api/scim/v2/Groups', + query: scimListQuerySchema, + response: { mode: 'json', schema: listResponseSchema(scimGroupResourceSchema) }, +}) + +export const createScimGroupContract = defineRouteContract({ + method: 'POST', + path: '/api/scim/v2/Groups', + body: scimGroupWriteSchema, + response: { mode: 'json', schema: scimGroupResourceSchema, status: 201 }, +}) + +export const getScimGroupContract = defineRouteContract({ + method: 'GET', + path: '/api/scim/v2/Groups/[id]', + params: scimResourceParamsSchema, + query: scimAttributesQuerySchema, + response: { mode: 'json', schema: scimGroupResourceSchema }, +}) + +export const replaceScimGroupContract = defineRouteContract({ + method: 'PUT', + path: '/api/scim/v2/Groups/[id]', + params: scimResourceParamsSchema, + body: scimGroupWriteSchema, + response: { mode: 'json', schema: scimGroupResourceSchema }, +}) + +/** + * Returns `204`. Microsoft states a group PATCH "should yield an HTTP 204 No + * Content" and warns against returning the member list, which for a large + * group is the difference between a small response and a thousand-entry one on + * every incremental sync. + */ +export const patchScimGroupContract = defineRouteContract({ + method: 'PATCH', + path: '/api/scim/v2/Groups/[id]', + params: scimResourceParamsSchema, + body: scimPatchBodySchema, + response: { mode: 'empty', status: 204 }, +}) + +export const deleteScimGroupContract = defineRouteContract({ + method: 'DELETE', + path: '/api/scim/v2/Groups/[id]', + params: scimResourceParamsSchema, + response: { mode: 'empty', status: 204 }, +}) diff --git a/apps/sim/lib/api/contracts/workspaces.ts b/apps/sim/lib/api/contracts/workspaces.ts index 318562ae4bf..242158ab1e9 100644 --- a/apps/sim/lib/api/contracts/workspaces.ts +++ b/apps/sim/lib/api/contracts/workspaces.ts @@ -254,6 +254,7 @@ export const deploymentFeaturesSchema = z.object({ dataRetention: z.boolean(), inbox: z.boolean(), sandboxes: z.boolean(), + scim: z.boolean(), sessionPolicies: z.boolean(), sso: z.boolean(), usageMonitoring: z.boolean(), diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index d19270b906d..e42507d6cc8 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -17,6 +17,10 @@ export { createInternalResourceConcealmentPolicy, createV2ResourceConcealmentPolicy, } from '@/lib/api/server/routes/resource-concealment' +export { + createScimRouteBuilder, + defineScimDiscoveryRoute, +} from '@/lib/api/server/routes/scim-route' export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { defineV2BodyLifecycleRoute } from '@/lib/api/server/routes/v2-body-lifecycle-route' export { diff --git a/apps/sim/lib/api/server/routes/scim-route.test.ts b/apps/sim/lib/api/server/routes/scim-route.test.ts new file mode 100644 index 00000000000..dd2f6611d63 --- /dev/null +++ b/apps/sim/lib/api/server/routes/scim-route.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + checkRateLimitDirect: vi.fn(), + enforceIpRateLimit: vi.fn(), +})) + +vi.mock('@/ee/scim/lib/base-url', () => ({ scimBaseUrl: () => 'https://sim.test/api/scim/v2' })) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.checkRateLimitDirect + }, + enforceIpRateLimit: mocks.enforceIpRateLimit, +})) + +import type { ScimConnectionPrincipal } from '@sim/auth/principal' +import { + createScimUserContract, + deleteScimUserContract, + listScimUsersContract, +} from '@/lib/api/contracts/scim' +import { createScimRouteBuilder } from '@/lib/api/server/routes' +import { scimOperations } from '@/ee/scim/lib/application/operations' +import { SCIM_MEDIA_TYPE } from '@/ee/scim/lib/protocol/constants' +import { ScimError } from '@/ee/scim/lib/protocol/errors' + +const principal: ScimConnectionPrincipal = { + kind: 'scim_connection', + organizationId: 'org-1', + connectionId: 'conn-1', + credentialId: 'cred-1', + scopes: ['users:read', 'users:write'], +} + +const authenticate = vi.fn() +const recordRequest = vi.fn() +const defineScimRoute = createScimRouteBuilder({ + authenticate, + recordRequest, +}) + +const listUsers = defineScimRoute({ + contract: listScimUsersContract, + operation: scimOperations.listUsers, + useCase: { operation: scimOperations.listUsers, execute: vi.fn() }, + mapInput: () => ({}), + present: () => ({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], + totalResults: 0, + startIndex: 1, + itemsPerPage: 0, + Resources: [], + }), +}) + +const createUser = defineScimRoute({ + contract: createScimUserContract, + operation: scimOperations.provisionUser, + useCase: { operation: scimOperations.provisionUser, execute: vi.fn().mockResolvedValue({}) }, + mapInput: () => ({}), + present: () => ({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + id: 'su-1', + meta: { + resourceType: 'User', + created: '2026-01-01T00:00:00.000Z', + lastModified: '2026-01-01T00:00:00.000Z', + location: 'https://sim.test/api/scim/v2/Users/su-1', + version: 'W/"1"', + }, + }), + headers: () => ({ Location: 'https://sim.test/api/scim/v2/Users/su-1' }), +}) + +const deleteUser = defineScimRoute({ + contract: deleteScimUserContract, + operation: scimOperations.deprovisionUser, + useCase: { operation: scimOperations.deprovisionUser, execute: vi.fn().mockResolvedValue({}) }, + mapInput: () => ({}), +}) + +const withParams = { params: Promise.resolve({ id: 'su-1' }) } + +function request(method: string, path: string, init: RequestInit = {}) { + return new NextRequest(`https://sim.test${path}`, { method, ...init }) +} + +afterEach(resetEnvFlagsMock) + +describe('SCIM route builder', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isScimEnabled: true }) + authenticate.mockResolvedValue(principal) + mocks.checkRateLimitDirect.mockResolvedValue({ allowed: true, resetAt: new Date() }) + mocks.enforceIpRateLimit.mockResolvedValue(null) + }) + + it('hides the whole surface, wrong method included, when the feature is off', async () => { + setEnvFlags({ isScimEnabled: false }) + const response = await listUsers(request('POST', '/api/scim/v2/Users'), undefined) + expect(response.status).toBe(404) + expect(authenticate).not.toHaveBeenCalled() + }) + + it('answers in the SCIM media type with the RFC envelope', async () => { + const response = await listUsers(request('GET', '/api/scim/v2/Users'), undefined) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe(`${SCIM_MEDIA_TYPE}; charset=utf-8`) + expect(recordRequest).toHaveBeenCalledWith(expect.objectContaining({ status: 200, principal })) + }) + + it('refuses a body without a SCIM or JSON content type before authenticating', async () => { + const response = await createUser( + request('POST', '/api/scim/v2/Users', { + body: '{}', + headers: { 'content-type': 'text/plain' }, + }), + undefined + ) + expect(response.status).toBe(415) + expect(authenticate).not.toHaveBeenCalled() + }) + + it('lets DELETE through with no body and no content type, answering 204', async () => { + const response = await deleteUser(request('DELETE', '/api/scim/v2/Users/su-1'), withParams) + expect(response.status).toBe(204) + expect(await response.text()).toBe('') + }) + + it('adds the Location header on a create', async () => { + const response = await createUser( + request('POST', '/api/scim/v2/Users', { + body: JSON.stringify({ + schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'], + userName: 'ada@acme.test', + }), + headers: { 'content-type': SCIM_MEDIA_TYPE }, + }), + undefined + ) + expect(response.status).toBe(201) + expect(response.headers.get('location')).toBe('https://sim.test/api/scim/v2/Users/su-1') + }) + + it('renders a parse failure in the SCIM envelope', async () => { + const response = await createUser( + request('POST', '/api/scim/v2/Users', { + body: '{not json', + headers: { 'content-type': 'application/json' }, + }), + undefined + ) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ + schemas: ['urn:ietf:params:scim:api:messages:2.0:Error'], + status: '400', + scimType: 'invalidSyntax', + detail: 'Request body is not valid JSON', + }) + }) + + it('bounds failed authentication per address and renders 401 with the challenge', async () => { + authenticate.mockRejectedValue( + new ScimError(401, undefined, 'Invalid SCIM token', { + 'WWW-Authenticate': 'Bearer realm="SCIM"', + }) + ) + const refused = await listUsers(request('GET', '/api/scim/v2/Users'), undefined) + expect(refused.status).toBe(401) + expect(refused.headers.get('www-authenticate')).toBe('Bearer realm="SCIM"') + expect(mocks.enforceIpRateLimit).toHaveBeenCalledWith('scim-auth', expect.anything()) + expect(recordRequest).not.toHaveBeenCalled() + + mocks.enforceIpRateLimit.mockResolvedValue(new Response(null, { status: 429 })) + const limited = await listUsers(request('GET', '/api/scim/v2/Users'), undefined) + expect(limited.status).toBe(429) + expect(limited.headers.get('retry-after')).toBe('60') + }) + + it('throttles an authenticated connection with Retry-After', async () => { + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: false, + resetAt: new Date(Date.now() + 5000), + }) + const response = await listUsers(request('GET', '/api/scim/v2/Users'), undefined) + expect(response.status).toBe(429) + expect(Number(response.headers.get('retry-after'))).toBeGreaterThanOrEqual(1) + expect(recordRequest).toHaveBeenCalledWith(expect.objectContaining({ status: 429 })) + }) +}) diff --git a/apps/sim/lib/api/server/routes/scim-route.ts b/apps/sim/lib/api/server/routes/scim-route.ts new file mode 100644 index 00000000000..1b965875110 --- /dev/null +++ b/apps/sim/lib/api/server/routes/scim-route.ts @@ -0,0 +1,328 @@ +import type { ScimConnectionPrincipal } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import type { AnyApiRouteContract, ContractJsonResponse } from '@/lib/api/contracts/types' +import { type ParsedRequest, parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application/operation' +import { enforceIpRateLimit, RateLimiter } from '@/lib/core/rate-limiter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { ScimConnectionAuthenticator } from '@/ee/scim/lib/authenticate' +import { scimBaseUrl } from '@/ee/scim/lib/base-url' +import { isScimDeploymentEnabled } from '@/ee/scim/lib/entitlement' +import { + SCIM_ACCEPTED_MEDIA_TYPES, + SCIM_MAX_BODY_BYTES, + SCIM_MEDIA_TYPE, + SCIM_RATE_LIMIT, +} from '@/ee/scim/lib/protocol/constants' +import { + ScimError, + type ScimErrorBody, + type ScimType, + scimErrorBody, + toScimError, +} from '@/ee/scim/lib/protocol/errors' +import type { ScimRequestLogEntry } from '@/ee/scim/lib/request-log' + +const logger = createLogger('ScimRoute') +const rateLimiter = new RateLimiter() + +/** + * The route builder for the SCIM 2.0 surface. + * + * SCIM cannot use `defineInternalJsonRoute`, and the differences are all + * protocol rather than preference: the caller is a bearer credential with no + * user and no workspace, the error envelope is RFC 7644's rather than Sim's + * `{ error }`, responses must carry `application/scim+json`, `DELETE` and group + * `PATCH` answer `204` with no body, and a wrong media type is a `415` before + * anything is parsed. This is the documented protocol exception the API rules + * allow, and it applies `withRouteHandler` exactly as the internal builder does + * so request ids, logging, and abort handling stay identical. + */ + +export interface ScimPresenterContext { + baseUrl: string +} + +interface ScimRouteOptions { + contract: C + operation: O + useCase: OperationUseCase, I, R> + mapInput( + parsed: ParsedRequest, + context: { principal: ScimConnectionPrincipal; request: NextRequest } + ): I + /** Omitted for `204` routes, which have no body to render. */ + present?(result: NoInfer, context: ScimPresenterContext): ContractJsonResponse + /** Extra response headers, such as `Location` on a create. */ + headers?(result: NoInfer, context: ScimPresenterContext): Record +} + +export interface ScimRouteContext { + params?: Promise> +} + +export type ScimNextRouteHandler = ( + request: NextRequest, + context: ScimRouteContext | undefined +) => Promise | NextResponse | Response + +/** Dependencies the builder needs, injected so the module stays testable. */ +interface ScimRouteDependencies { + authenticate: ScimConnectionAuthenticator + recordRequest(entry: ScimRequestLogEntry): void +} + +function scimResponse(body: unknown, status: number, headers?: Record): Response { + return NextResponse.json(body, { + status, + headers: { + 'content-type': `${SCIM_MEDIA_TYPE}; charset=utf-8`, + 'cache-control': 'no-store', + ...headers, + }, + }) +} + +function scimErrorResponse(error: ScimError): Response { + return scimResponse(error.body, error.status, error.headers) +} + +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH']) + +/** + * Requires a SCIM or JSON content type on a body-bearing request. + * + * `DELETE` is excluded because Okta sends it with no body and no content type, + * and Entra sends one with `text/plain`. + */ +function assertAcceptableMediaType(request: NextRequest): void { + if (!MUTATING_METHODS.has(request.method)) return + const header = request.headers.get('content-type') + if (!header) throw new ScimError(415, undefined, 'A content type is required') + const mediaType = header.split(';')[0]?.trim().toLowerCase() + if (!SCIM_ACCEPTED_MEDIA_TYPES.some((accepted) => accepted === mediaType)) { + throw new ScimError( + 415, + undefined, + `SCIM requests must use ${SCIM_MEDIA_TYPE} or application/json` + ) + } +} + +/** + * Consumes one token from the connection's bucket. + * + * Keyed by connection rather than by credential, so rotating a token cannot be + * used to double the allowance, and never by IP: a provisioning service calls + * from a large shared range, where an IP bucket would either be useless or would + * throttle unrelated tenants together. + */ +async function enforceConnectionRateLimit(principal: ScimConnectionPrincipal): Promise { + const { allowed, resetAt } = await rateLimiter.checkRateLimitDirect( + `route:scim:connection:${principal.connectionId}`, + SCIM_RATE_LIMIT + ) + if (allowed) return + const retryAfter = Math.max(1, Math.ceil((resetAt.getTime() - Date.now()) / 1000)) + throw new ScimError(429, undefined, 'Rate limit exceeded', { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Reset': resetAt.toISOString(), + }) +} + +export function createScimRouteBuilder(dependencies: ScimRouteDependencies) { + return function defineScimRoute< + C extends AnyApiRouteContract, + O extends ApplicationOperation, + I, + R, + >(options: ScimRouteOptions): ScimNextRouteHandler { + if (options.operation.id !== options.useCase.operation.id) { + throw new Error( + `Route operation ${options.operation.id} does not match use case ${options.useCase.operation.id}` + ) + } + const isEmptyResponse = options.contract.response.mode === 'empty' + const present = options.present + if (!isEmptyResponse && !present) { + throw new Error(`${options.contract.method} ${options.contract.path} requires a presenter`) + } + const declaredStatus = options.contract.response.status + const successStatus = + declaredStatus === undefined + ? 200 + : Array.isArray(declaredStatus) + ? declaredStatus[0] + : declaredStatus + + return withRouteHandler( + async (request, context) => { + const startedAt = Date.now() + let principal: ScimConnectionPrincipal | undefined + let status = 500 + let scimType: ScimType | undefined + let detail: string | undefined + + try { + if (!isScimDeploymentEnabled()) throw new ScimError(404, undefined, 'Not found') + if (request.method !== options.contract.method) { + throw new ScimError(405, undefined, `${request.method} is not supported here`) + } + assertAcceptableMediaType(request) + + /** + * Scope is enforced by the use-case wrapper from the operation's own + * declaration, so the route cannot disagree with it. The builder only + * authenticates and admits. + */ + principal = await dependencies.authenticate(request) + await enforceConnectionRateLimit(principal) + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + maxBodyBytes: SCIM_MAX_BODY_BYTES, + validationErrorResponse: (error) => + NextResponse.json( + scimErrorBody(400, 'invalidValue', error.issues[0]?.message ?? 'Invalid request'), + { status: 400 } + ), + invalidJsonResponse: () => + NextResponse.json( + scimErrorBody(400, 'invalidSyntax', 'Request body is not valid JSON'), + { status: 400 } + ), + payloadTooLargeResponse: () => + NextResponse.json(scimErrorBody(413, undefined, 'Request body is too large'), { + status: 413, + }), + /** + * Entra sends `excludedAttributes=` with an empty value on some list + * calls; treating that as a client error would fail an ordinary sync. + */ + rejectBlankQueryValues: false, + }) + if (!parsed.success) { + const failure = (await parsed.response.json()) as ScimErrorBody + status = parsed.response.status + scimType = failure.scimType + detail = failure.detail + return scimResponse(failure, status) + } + + const baseUrl = scimBaseUrl() + const input = options.mapInput(parsed.data, { principal, request }) + const result = await options.useCase.execute({ principal, input, request }) + + if (isEmptyResponse) { + status = successStatus + return new Response(null, { + status, + headers: { 'cache-control': 'no-store' }, + }) + } + + if (!present) throw new Error('unreachable: presenter checked at definition') + const presented = present(result, { baseUrl }) + const validated = + options.contract.response.mode === 'json' + ? options.contract.response.schema.parse(presented) + : presented + status = successStatus + return scimResponse(validated, status, options.headers?.(result, { baseUrl })) + } catch (error) { + const scim = toScimError(error) + status = scim.status + scimType = scim.scimType + detail = scim.message + /** + * Authenticated traffic is admitted per connection. A caller that failed + * to authenticate has no connection, so token guessing is bounded per + * source address instead. + */ + if (scim.status === 401 && !principal) { + const limited = await enforceIpRateLimit('scim-auth', request) + if (limited) { + status = 429 + return scimResponse( + scimErrorBody(429, undefined, 'Too many failed authentication attempts'), + 429, + { 'Retry-After': '60' } + ) + } + } + if (scim.status >= 500) { + logger.error('SCIM request failed', { + connectionId: principal?.connectionId, + path: options.contract.path, + error, + }) + } + return scimErrorResponse(scim) + } finally { + if (principal) { + dependencies.recordRequest({ + principal, + method: request.method, + path: options.contract.path, + status, + ...(scimType ? { scimType } : {}), + ...(detail ? { detail } : {}), + userAgent: request.headers.get('user-agent'), + durationMs: Date.now() - startedAt, + }) + } + } + }, + { + /** + * The terminal envelopes have to speak SCIM too. A provider that receives + * Sim's `{ error }` shape on an unhandled fault logs an unparsable + * response, which is materially harder to diagnose than a typed one. + */ + typedErrorResponse: ({ error, status }) => + scimResponse(scimErrorBody(status, undefined, error.message), status), + unhandledErrorResponse: () => + scimResponse(scimErrorBody(500, undefined, 'Internal server error'), 500), + clientAbortResponse: () => new Response(null, { status: 499 }), + } + ) + } +} + +/** + * A discovery route: unauthenticated, static, and identical for every tenant. + * + * RFC 7644 requires these to be reachable so a provider can negotiate before it + * holds a credential, and they disclose only what this server implements. + */ +export function defineScimDiscoveryRoute( + build: (baseUrl: string, params: Record) => unknown +): ScimNextRouteHandler { + return withRouteHandler( + async (request, context) => { + try { + if (!isScimDeploymentEnabled()) throw new ScimError(404, undefined, 'Not found') + if (request.method !== 'GET') { + throw new ScimError(405, undefined, `${request.method} is not supported here`) + } + /** Unauthenticated, so the only admission control available is by address. */ + const limited = await enforceIpRateLimit('scim-discovery', request) + if (limited) { + throw new ScimError(429, undefined, 'Rate limit exceeded', { + 'Retry-After': limited.headers.get('Retry-After') ?? '60', + }) + } + const params = context?.params ? await context.params : {} + return scimResponse(build(scimBaseUrl(), params), 200) + } catch (error) { + return scimErrorResponse(toScimError(error)) + } + }, + { + typedErrorResponse: ({ error, status }) => + scimResponse(scimErrorBody(status, undefined, error.message), status), + unhandledErrorResponse: () => + scimResponse(scimErrorBody(500, undefined, 'Internal server error'), 500), + } + ) +} diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts index b70596830a3..ca111042d92 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -72,6 +72,7 @@ interface ApiKeyRow { type: string expiresAt: Date | null userBanned: boolean | null + userSuspendedAt: Date | null } function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { @@ -83,6 +84,11 @@ function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { throw new Error(`Personal API key ${row.id} is missing its credential owner`) } if (row.userBanned) throw new V2ApiKeyUnauthenticatedError() + /** + * A suspension keeps the account's resources — and therefore its keys — + * intact, so refusing the key here is what ends its machine access. + */ + if (row.userSuspendedAt) throw new V2ApiKeyUnauthenticatedError() return row } if (row.type === 'workspace' && row.workspaceId) return row @@ -103,6 +109,7 @@ async function authenticateApiKey(apiKeyHeader: string): Promise { - // Blocked emails/domains must not establish sessions, regardless of - // provider (email/password, OAuth, SSO). Deliberately outside the - // try below — a thrown APIError must propagate, not be swallowed. + /** + * Blocked emails/domains and suspended accounts must not establish + * sessions, whatever the provider (email/password, OAuth, SSO). + * Deliberately outside the try below: a thrown APIError must + * propagate, not be swallowed. + */ const accessControl = await getAccessControlConfig() + const [sessionUser] = await db + .select({ email: schema.user.email, suspendedAt: schema.user.suspendedAt }) + .from(schema.user) + .where(eq(schema.user.id, session.userId)) + .limit(1) + + /** + * A suspension leaves the account's resources intact, so nothing else + * in the sign-in path refuses it. This is the gate that makes a + * directory deactivation take effect on the next sign-in attempt; + * existing sessions are deleted when the suspension is applied. + */ + if (sessionUser?.suspendedAt) { + logger.warn('Blocking session creation for suspended account', { + userId: session.userId, + }) + throw new APIError('FORBIDDEN', { + message: 'This account is suspended. Please contact your administrator.', + }) + } + if ( accessControl.blockedSignupDomains.length > 0 || accessControl.blockedEmails.length > 0 ) { - const [sessionUser] = await db - .select({ email: schema.user.email }) - .from(schema.user) - .where(eq(schema.user.id, session.userId)) - .limit(1) if (isEmailBlockedByAccessControl(sessionUser?.email, accessControl)) { logger.warn('Blocking session creation for blocked account', { userId: session.userId, diff --git a/apps/sim/lib/auth/ban.ts b/apps/sim/lib/auth/ban.ts index 8ca733d2994..6c0d0379cd9 100644 --- a/apps/sim/lib/auth/ban.ts +++ b/apps/sim/lib/auth/ban.ts @@ -12,26 +12,44 @@ export function isBanActive(row: { banned: boolean | null; banExpires: Date | nu return true } +/** + * True when the account cannot act: an active ban, or a suspension. + * + * A suspension is what a directory deactivation applies. It must stop the + * person's scheduled runs, webhook triggers, and inbox tasks the same way a ban + * does — the identity provider said "this person is gone", and their + * automations continuing to run under their credentials would be exactly the + * outcome deprovisioning exists to prevent. + */ +export function isAccountBlocked(row: { + banned: boolean | null + banExpires: Date | null + suspendedAt?: Date | null +}): boolean { + return isBanActive(row) || Boolean(row.suspendedAt) +} + /** * True when a raw email (e.g. an inbound sender) is blocked: it is in the * appconfig blocked-emails list, its domain is in the blocked-domains list, - * or it belongs to an account with an active ban. Covers senders that don't - * resolve to a known user id. + * or it belongs to an account with an active ban or suspension. Covers senders + * that don't resolve to a known user id. */ export async function isEmailBlocked(email: string | null | undefined): Promise { if (!email) return false const accessControl = await getAccessControlConfig() if (isEmailBlockedByAccessControl(email, accessControl)) return true const rows = await db - .select({ banned: user.banned, banExpires: user.banExpires }) + .select({ banned: user.banned, banExpires: user.banExpires, suspendedAt: user.suspendedAt }) .from(user) .where(sql`lower(${user.email}) = ${email.toLowerCase()}`) - return rows.some(isBanActive) + return rows.some(isAccountBlocked) } /** * Returns the subset of the given user ids that are currently blocked: an - * active account ban, or an email/domain in the appconfig blocked lists. + * active account ban or suspension, or an email/domain in the appconfig + * blocked lists. * One user query plus the cached access-control fetch. Throws on db * failure — callers must fail closed. */ @@ -42,12 +60,20 @@ export async function getActivelyBannedUserIds(userIds: string[]): Promise isBanActive(row) || isEmailBlockedByAccessControl(row.email, accessControl)) + .filter( + (row) => isAccountBlocked(row) || isEmailBlockedByAccessControl(row.email, accessControl) + ) .map((row) => row.id) } diff --git a/apps/sim/lib/auth/oauth-access-token.test.ts b/apps/sim/lib/auth/oauth-access-token.test.ts index 9f51b181be0..24aa9891feb 100644 --- a/apps/sim/lib/auth/oauth-access-token.test.ts +++ b/apps/sim/lib/auth/oauth-access-token.test.ts @@ -23,6 +23,7 @@ function row(overrides: Record = {}) { clientDisabled: false, userBanned: false, userBanExpires: null, + userSuspendedAt: null, userExists: 'user-1', ...overrides, } @@ -107,6 +108,9 @@ describe('verifyOAuthAccessToken', () => { queueTableRows(schemaMock.oauthAccessToken, [row({ userBanned: true })]) expect(await reason('sim_oat_x')).toBe('user_banned') + queueTableRows(schemaMock.oauthAccessToken, [row({ userSuspendedAt: new Date() })]) + expect(await reason('sim_oat_x')).toBe('user_banned') + queueTableRows(schemaMock.oauthAccessToken, [ row({ userBanned: true, userBanExpires: new Date(Date.now() - 1) }), ]) diff --git a/apps/sim/lib/auth/oauth-access-token.ts b/apps/sim/lib/auth/oauth-access-token.ts index f38e3881e6a..8bc7401e863 100644 --- a/apps/sim/lib/auth/oauth-access-token.ts +++ b/apps/sim/lib/auth/oauth-access-token.ts @@ -4,7 +4,7 @@ import { oauthAccessToken, oauthClient, user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { eq } from 'drizzle-orm' -import { isBanActive } from '@/lib/auth/ban' +import { isAccountBlocked } from '@/lib/auth/ban' import { OAUTH_ACCESS_TOKEN_PREFIX } from '@/lib/auth/oauth-provider' const logger = createLogger('OAuthAccessToken') @@ -90,6 +90,7 @@ export async function verifyOAuthAccessToken(token: string): Promise { const [activeUser] = await tx - .select({ id: user.id, banned: user.banned, banExpires: user.banExpires }) + .select({ + id: user.id, + banned: user.banned, + banExpires: user.banExpires, + suspendedAt: user.suspendedAt, + }) .from(user) .where(eq(user.id, provisionalToken.userId)) .for('share') .limit(1) - if (!activeUser || isBanActive(activeUser)) { + if (!activeUser || isAccountBlocked(activeUser)) { return protocolError('invalid_grant', 'Refresh token is invalid.') } diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts index 06194b17a02..7dccdad2ff5 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.test.ts @@ -48,6 +48,13 @@ vi.mock('@/lib/billing/core/usage', () => ({ syncUsageLimitsFromSubscription: mockSyncUsageLimitsFromSubscription, })) +const { mockIsScimEntitledForOrganization } = vi.hoisted(() => ({ + mockIsScimEntitledForOrganization: vi.fn(), +})) +vi.mock('@/ee/scim/lib/entitlement', () => ({ + isScimEntitledForOrganization: mockIsScimEntitledForOrganization, +})) + vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent, })) @@ -84,6 +91,11 @@ function queueIdentity({ queueTableRows(schemaMock.account, accountLinked ? [{ id: 'account-1' }] : []) } +/** The directory-only read runs after the identity reads and before the membership lookup. */ +function queueDirectoryOnly(directoryOnly: boolean) { + queueTableRows(schemaMock.scimConnection, directoryOnly ? [{ id: 'scim-1' }] : []) +} + async function execute() { return admitSsoUser.execute({ principal, input: { providerId: 'acme-sso' } }) } @@ -179,6 +191,29 @@ describe('SSO JIT admission', () => { expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled() }) + it('leaves admission to the directory when a connection has disabled JIT', async () => { + queueIdentity() + queueDirectoryOnly(true) + mockIsScimEntitledForOrganization.mockResolvedValue(true) + + await expect(execute()).resolves.toEqual({ + kind: 'provisioning-disabled', + organizationId: 'org-1', + }) + expect(mockEnsureUserInOrganizationTx).not.toHaveBeenCalled() + }) + + it('admits by JIT again once the directory can no longer sync', async () => { + queueIdentity() + queueDirectoryOnly(true) + mockIsScimEntitledForOrganization.mockResolvedValue(false) + + const outcome = await execute() + + expect(outcome.kind).toBe('provisioned') + expect(mockEnsureUserInOrganizationTx).toHaveBeenCalled() + }) + it('keeps an existing member active when new provisioning is invite-only', async () => { queueIdentity({ jitProvisioningEnabled: false }) queueTableRows(schemaMock.member, [{ id: 'member-existing' }]) diff --git a/apps/sim/lib/auth/sso/application/admit-sso-user.ts b/apps/sim/lib/auth/sso/application/admit-sso-user.ts index 3207660bacc..7d211e5fd5a 100644 --- a/apps/sim/lib/auth/sso/application/admit-sso-user.ts +++ b/apps/sim/lib/auth/sso/application/admit-sso-user.ts @@ -5,15 +5,15 @@ import { invitation, member, permissions, + scimConnection, ssoProvider, - subscription, user, workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { normalizeSSODomain } from '@sim/utils/sso-domain' import { normalizeEmail } from '@sim/utils/string' -import { and, desc, eq, gt, inArray, isNull, sql } from 'drizzle-orm' +import { and, eq, gt, isNull, sql } from 'drizzle-orm' import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' import { ssoJitAdmissionOperation } from '@/lib/auth/sso/application/operations' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' @@ -21,11 +21,11 @@ import { acquireOrganizationUserMutationLocks, ensureUserInOrganizationTx, } from '@/lib/billing/organizations/membership' +import { resolveOrganizationSeatPolicyTx } from '@/lib/billing/organizations/seat-policy' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' -import { isTeam } from '@/lib/billing/plan-helpers' -import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { assertOperationPrincipal, type OperationUseCase } from '@/lib/core/application/operation' import { captureServerEvent } from '@/lib/posthog/server' +import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' const logger = createLogger('SsoJitAdmission') @@ -130,7 +130,28 @@ async function runAdmissionTransaction( organizationIds: [provider.organizationId], }) - if (!provider.jitProvisioningEnabled) { + /** + * An organization whose directory is the only way in has said so on its + * SCIM connection; a first sign-in must not create a membership the directory + * did not ask for and will not know about. + */ + const [directoryOnly] = await tx + .select({ id: scimConnection.id }) + .from(scimConnection) + .where( + and( + eq(scimConnection.organizationId, provider.organizationId), + eq(scimConnection.status, 'active'), + sql`coalesce((${scimConnection.settings} ->> 'disableJit')::boolean, false) = true` + ) + ) + .limit(1) + + /** A directory that can no longer sync (plan lapsed, feature off) no longer owns the door either. */ + const directoryOwnsAdmission = + Boolean(directoryOnly) && (await isScimEntitledForOrganization(provider.organizationId)) + + if (!provider.jitProvisioningEnabled || directoryOwnsAdmission) { const [sameOrganization] = await tx .select({ id: member.id }) .from(member) @@ -230,27 +251,12 @@ async function runAdmissionTransaction( } } - const [organizationSubscription] = await tx - .select({ id: subscription.id, plan: subscription.plan }) - .from(subscription) - .where( - and( - eq(subscription.referenceId, provider.organizationId), - inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) - ) - ) - .orderBy(desc(subscription.periodStart), desc(subscription.id)) - .limit(1) - + const seatPolicy = await resolveOrganizationSeatPolicyTx(tx, provider.organizationId) const membershipResult = await ensureUserInOrganizationTx(tx, { userId, organizationId: provider.organizationId, role: 'member', - /** Team seats grow to the committed member count; Enterprise remains fixed-capacity. */ - ...(isTeam(organizationSubscription?.plan) ? { skipSeatValidation: true } : {}), - ...(organizationSubscription?.id - ? { organizationSubscriptionId: organizationSubscription.id } - : {}), + ...seatPolicy, }) if (!membershipResult.success || !membershipResult.memberId) { @@ -267,8 +273,8 @@ async function runAdmissionTransaction( return { ...attribution, - ...(organizationSubscription?.id - ? { organizationSubscriptionId: organizationSubscription.id } + ...(seatPolicy.organizationSubscriptionId + ? { organizationSubscriptionId: seatPolicy.organizationSubscriptionId } : {}), result: { kind: membershipResult.alreadyMember ? 'already-member' : 'provisioned', diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index b60cca4b149..21da676b383 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -24,7 +24,10 @@ import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { normalizeEmail } from '@sim/utils/string' import { and, count, desc, eq, inArray, isNull, ne, or, sql } from 'drizzle-orm' -import { invalidateMembershipCache } from '@/lib/auth/security-policy' +import { + invalidateMembershipCache, + invalidateSecurityPolicyVersionCache, +} from '@/lib/auth/security-policy' import { applySessionPolicyToNewMember } from '@/lib/auth/session-policy' import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage' import { @@ -47,11 +50,16 @@ import { enqueueOutboxEvent } from '@/lib/core/outbox/service' import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import type { DbOrTx } from '@/lib/db/types' import { acquireInvitationMutationLocks } from '@/lib/invitations/locks' +import { + revokePersonalApiKeysTx, + revokeUserSessionsTx, +} from '@/lib/organizations/members/revocation' import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' import { reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, WorkspaceBillingAccountRemovalError, } from '@/lib/workspaces/utils' +import { endDirectoryMembershipTx } from '@/ee/scim/lib/identity/end-directory-membership' export { acquireUserBillingIdentityLock } from '@/lib/billing/organizations/billing-identity-lock' export { WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR } from '@/lib/workspaces/utils' @@ -462,6 +470,15 @@ export interface RemoveMemberParams { memberId: string /** Skip departed usage capture and Pro restoration (default: false) */ skipBillingLogic?: boolean + /** + * Also delete the member's personal API keys. Off by default: personal keys + * are the person's own and outlive one organization. Directory + * deprovisioning turns it on, because there the person is leaving Sim as far + * as the organization is concerned. + */ + revokePersonalApiKeys?: boolean + /** The caller's own session token, kept alive when a member removes themselves. */ + spareSessionToken?: string /** * Only remove the member when they hold no remaining permission on any of the * org's workspaces, evaluated atomically under the membership lock. Used by @@ -1235,6 +1252,8 @@ export async function removeUserFromOrganization( memberId, skipBillingLogic = false, requireNoOrgWorkspaceAccess = false, + revokePersonalApiKeys = false, + spareSessionToken, } = params const billingActions = { @@ -1330,6 +1349,21 @@ export async function removeUserFromOrganization( ) ) + /** + * Leaving ends live access at once: sessions go with the membership + * rather than lingering until a cookie cache lapses, and any directory + * row that described this membership is replaced by its tombstone in the + * same commit, so the directory and the organization can never disagree + * about who is a member. + */ + await revokeUserSessionsTx(tx, { + userId, + organizationId, + ...(spareSessionToken ? { spareSessionToken } : {}), + }) + if (revokePersonalApiKeys) await revokePersonalApiKeysTx(tx, { userId }) + await endDirectoryMembershipTx(tx, { userId, organizationId }) + if (workspaceIds.length === 0) { return { skipped: false as const, @@ -1403,6 +1437,7 @@ export async function removeUserFromOrganization( // The departed member's cookie-version/hook-clamp fallbacks must stop // resolving to this org immediately, not after the membership-cache TTL. invalidateMembershipCache(userId) + invalidateSecurityPolicyVersionCache(organizationId) logger.info('Removed member from organization', { organizationId, diff --git a/apps/sim/lib/billing/organizations/seat-policy.ts b/apps/sim/lib/billing/organizations/seat-policy.ts new file mode 100644 index 00000000000..4ed76960ff1 --- /dev/null +++ b/apps/sim/lib/billing/organizations/seat-policy.ts @@ -0,0 +1,36 @@ +import { subscription } from '@sim/db/schema' +import { and, desc, eq, inArray } from 'drizzle-orm' +import { isTeam } from '@/lib/billing/plan-helpers' +import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' +import type { DbOrTx } from '@/lib/db/types' + +/** + * How admission into an organization treats seats. + * + * Team plans add a seat when someone joins; Enterprise buys a fixed number in + * advance and must refuse beyond it. SSO just-in-time admission and directory + * provisioning share this rule so a first sign-in and a directory push agree on + * who fits, and both hand the same subscription id to the seat reconciliation + * that follows a successful join. + */ +export async function resolveOrganizationSeatPolicyTx( + tx: DbOrTx, + organizationId: string +): Promise<{ skipSeatValidation?: true; organizationSubscriptionId?: string }> { + const [entitled] = await tx + .select({ id: subscription.id, plan: subscription.plan }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, organizationId), + inArray(subscription.status, ENTITLED_SUBSCRIPTION_STATUSES) + ) + ) + .orderBy(desc(subscription.periodStart), desc(subscription.id)) + .limit(1) + + return { + ...(isTeam(entitled?.plan) ? { skipSeatValidation: true as const } : {}), + ...(entitled?.id ? { organizationSubscriptionId: entitled.id } : {}), + } +} diff --git a/apps/sim/lib/compare/data/sim.ts b/apps/sim/lib/compare/data/sim.ts index 8bf43d4c589..ec777532b99 100644 --- a/apps/sim/lib/compare/data/sim.ts +++ b/apps/sim/lib/compare/data/sim.ts @@ -1008,8 +1008,8 @@ export const simProfile: CompetitorProfile = { }, sso: { value: - 'Yes: SAML 2.0 and OIDC single sign-on, with users routed to SSO by their email domain and automatically provisioned into the organization on first sign-in', - shortValue: 'SAML 2.0 and OIDC SSO with auto-provisioning', + 'Yes: SAML 2.0 and OIDC single sign-on, with users routed to SSO by their email domain, plus SCIM 2.0 directory provisioning for Okta, Microsoft Entra ID, OneLogin, and JumpCloud that creates, updates, deactivates, and removes members and maps pushed groups to permission groups, workspace access, and the organization admin role', + shortValue: 'SAML 2.0 and OIDC SSO with SCIM 2.0 provisioning', confidence: 'verified', sources: [ { @@ -1017,6 +1017,11 @@ export const simProfile: CompetitorProfile = { label: 'Sim Docs: Single Sign-On (SSO)', asOf: '2026-07-02', }, + { + url: 'https://docs.sim.ai/platform/enterprise/scim', + label: 'Sim Docs: Directory provisioning (SCIM)', + asOf: '2026-09-07', + }, ], }, sessionPolicy: { diff --git a/apps/sim/lib/copilot/generated/docs-manifest.ts b/apps/sim/lib/copilot/generated/docs-manifest.ts index 86005762478..e2f9e8f6189 100644 --- a/apps/sim/lib/copilot/generated/docs-manifest.ts +++ b/apps/sim/lib/copilot/generated/docs-manifest.ts @@ -375,6 +375,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'platform/enterprise/data-drains.mdx', 'platform/enterprise/data-retention.mdx', 'platform/enterprise/forks.mdx', + 'platform/enterprise/scim.mdx', 'platform/enterprise/self-hosted.mdx', 'platform/enterprise/session-policies.mdx', 'platform/enterprise/sso.mdx', diff --git a/apps/sim/lib/core/application/forbidden.ts b/apps/sim/lib/core/application/forbidden.ts index 8dae46aff49..996618b1793 100644 --- a/apps/sim/lib/core/application/forbidden.ts +++ b/apps/sim/lib/core/application/forbidden.ts @@ -65,6 +65,8 @@ export const FORBIDDEN_DETAIL_CODES = [ 'INTEGRATION_NOT_ALLOWED', /** The OAuth access token was not granted the scope this operation needs. */ 'INSUFFICIENT_SCOPE', + /** The organization's identity provider owns this membership, so Sim will not change it. */ + 'SCIM_MANAGED_MEMBERSHIP', ] as const export type ForbiddenDetailCode = (typeof FORBIDDEN_DETAIL_CODES)[number] diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 90828c4262d..22a819e90d8 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -88,8 +88,18 @@ export function assertOperationOAuthPolicy( * Every principal kind an operation can name. `credential_group_enrollment` * authenticates one enrollment flow, while `system` is an infrastructure-owned * workflow execution identity; neither performs a semantic resource operation. + * + * `scim_connection` is excluded for a different reason: it is an organization's + * identity provider, which provisions membership and never reads or writes a + * workspace resource. Leaving it out makes that a compile-time fact — a + * workspace operation cannot name it even by accident — and SCIM declares its + * own operation type in `ee/scim/lib/application/operations.ts`, the way + * organization BYOK does. */ -export type PrincipalKind = Exclude +export type PrincipalKind = Exclude< + Principal['kind'], + 'credential_group_enrollment' | 'system' | 'scim_connection' +> /** * A principal kind a non-workspace operation may name. `delegated` is excluded diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index e7cdeb09d63..d8cae5aeb7d 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -50,6 +50,7 @@ export function capabilityGovernedPrincipalUserId(principal: Principal): string case 'workspace_api_key': case 'system': case 'credential_group_enrollment': + case 'scim_connection': return null case 'delegated': { if (principal.serviceId === 'executor') return null diff --git a/apps/sim/lib/core/config/deployment-shape.test.ts b/apps/sim/lib/core/config/deployment-shape.test.ts index a01adc99bd8..9de0eca990f 100644 --- a/apps/sim/lib/core/config/deployment-shape.test.ts +++ b/apps/sim/lib/core/config/deployment-shape.test.ts @@ -36,6 +36,7 @@ describe('resolveDeploymentShape', () => { dataRetention: false, inbox: true, sandboxes: true, + scim: false, sessionPolicies: true, sso: true, usageMonitoring: false, diff --git a/apps/sim/lib/core/config/deployment-shape.ts b/apps/sim/lib/core/config/deployment-shape.ts index a00429c6fce..299610a5ff6 100644 --- a/apps/sim/lib/core/config/deployment-shape.ts +++ b/apps/sim/lib/core/config/deployment-shape.ts @@ -14,6 +14,7 @@ import { isHosted, isInboxEnabled, isSandboxesEnabled, + isScimEnabled, isSessionPoliciesEnabled, isSsoEnabled, isUsageMonitoringEnabled, @@ -94,6 +95,7 @@ export function resolveDeploymentShape(): DeploymentShape { dataRetention: isDataRetentionEnabled, inbox: isInboxEnabled, sandboxes: isSandboxesEnabled, + scim: isScimEnabled, sessionPolicies: isSessionPoliciesEnabled, sso: isSsoEnabled, usageMonitoring: isUsageMonitoringEnabled, diff --git a/apps/sim/lib/core/config/enterprise-entitlements.ts b/apps/sim/lib/core/config/enterprise-entitlements.ts index f48fd8ca9a0..55635084fc7 100644 --- a/apps/sim/lib/core/config/enterprise-entitlements.ts +++ b/apps/sim/lib/core/config/enterprise-entitlements.ts @@ -35,6 +35,7 @@ export type EnterpriseFeature = | 'inbox' | 'organizations' | 'sandboxes' + | 'scim' | 'sessionPolicies' | 'sso' | 'usageMonitoring' @@ -82,6 +83,7 @@ export const ENTERPRISE_FEATURE_LEGACY_DEFAULTS: Readonly target.workspaceId) const existingUser = await db - .select({ id: user.id }) + .select({ + id: user.id, + scimManaged: + organizationId && isScimDeploymentEnabled() + ? scimManagedUserPredicate(organizationId, user.id) + : sql`false`, + }) .from(user) .where(sql`lower(${user.email}) = ${normalizedEmail}`) .then((rows) => rows[0]) + /** + * When the organization has made its identity provider the source of truth for + * membership, a Sim invitation to someone the directory already provisions is + * redundant at best and reverted at worst. Read as part of the lookup above so + * the common case costs no extra query. + */ + if (organizationId) { + await assertInviteeNotScimManaged({ organizationId, managed: existingUser?.scimManaged }) + } + const existingMembership = existingUser ? await getUserOrganization(existingUser.id) : null let existingOrganizationRole = existingMembership?.role let organizationRoleUpdated = false diff --git a/apps/sim/lib/organizations/members/lifecycle.test.ts b/apps/sim/lib/organizations/members/lifecycle.test.ts new file mode 100644 index 00000000000..c813bc9df0e --- /dev/null +++ b/apps/sim/lib/organizations/members/lifecycle.test.ts @@ -0,0 +1,166 @@ +/** + * @vitest-environment node + */ +import { member, session, user } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAcquireLocks, mockInvalidateVersion, mockInvalidateMembership } = vi.hoisted(() => ({ + mockAcquireLocks: vi.fn(), + mockInvalidateVersion: vi.fn(), + mockInvalidateMembership: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationUserMutationLocks: mockAcquireLocks, +})) +vi.mock('@/lib/auth/security-policy', () => ({ + invalidateSecurityPolicyVersionCache: mockInvalidateVersion, + invalidateMembershipCache: mockInvalidateMembership, +})) + +import { db } from '@sim/db' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + changeMemberRoleTx, + suspendMemberTx, + unsuspendMemberTx, +} from '@/lib/organizations/members/lifecycle' +import { + invalidateAfterSessionRevocation, + revokeUserSessionsTx, +} from '@/lib/organizations/members/revocation' + +afterAll(resetDbChainMock) + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() +}) + +describe('changeMemberRoleTx', () => { + it('takes the organization and user locks before reading the membership', async () => { + queueTableRows(member, [{ id: 'm-1', role: 'member' }]) + await changeMemberRoleTx(db, { organizationId: 'org-1', userId: 'u-1', role: 'admin' }) + expect(mockAcquireLocks).toHaveBeenCalledWith(db, { + userId: 'u-1', + organizationIds: ['org-1'], + }) + expect(mockAcquireLocks.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.select.mock.invocationCallOrder[0] + ) + }) + + it('reports the change it made', async () => { + queueTableRows(member, [{ id: 'm-1', role: 'member' }]) + await expect( + changeMemberRoleTx(db, { organizationId: 'org-1', userId: 'u-1', role: 'admin' }) + ).resolves.toEqual({ changed: true, from: 'member', to: 'admin' }) + expect(dbChainMockFns.update).toHaveBeenCalledWith(member) + expect(dbChainMockFns.set).toHaveBeenCalledWith({ role: 'admin' }) + }) + + it('writes nothing when the role already matches', async () => { + queueTableRows(member, [{ id: 'm-1', role: 'admin' }]) + await expect( + changeMemberRoleTx(db, { organizationId: 'org-1', userId: 'u-1', role: 'admin' }) + ).resolves.toEqual({ changed: false, role: 'admin' }) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('refuses to touch the owner and reports a missing member', async () => { + queueTableRows(member, [{ id: 'm-1', role: 'owner' }]) + const owner = await changeMemberRoleTx(db, { + organizationId: 'org-1', + userId: 'u-1', + role: 'member', + }).catch((error) => error) + expect(owner).toBeInstanceOf(OrchestrationError) + expect(owner.code).toBe('conflict') + + const missing = await changeMemberRoleTx(db, { + organizationId: 'org-1', + userId: 'u-2', + role: 'member', + }).catch((error) => error) + expect(missing.code).toBe('not_found') + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) + +describe('revokeUserSessionsTx', () => { + it('deletes the user’s own sessions and bumps the organization security version together', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 's-1' }, { id: 's-2' }]) + await expect( + revokeUserSessionsTx(db, { userId: 'u-1', organizationId: 'org-1' }) + ).resolves.toEqual({ revoked: 2 }) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(session) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'and', + conditions: expect.arrayContaining([ + { type: 'eq', left: session.userId, right: 'u-1' }, + { type: 'isNull', column: session.impersonatedBy }, + ]), + }) + ) + expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) + }) + + it('spares the session token the caller is still using', async () => { + await revokeUserSessionsTx(db, { + userId: 'u-1', + organizationId: 'org-1', + spareSessionToken: 'keep-me', + }) + expect(dbChainMockFns.where).toHaveBeenCalledWith( + expect.objectContaining({ + conditions: expect.arrayContaining([{ type: 'ne', left: session.token, right: 'keep-me' }]), + }) + ) + }) + + it('clears the caches only through the separate post-commit step', () => { + invalidateAfterSessionRevocation({ userId: 'u-1', organizationId: 'org-1' }) + expect(mockInvalidateVersion).toHaveBeenCalledWith('org-1') + expect(mockInvalidateMembership).toHaveBeenCalledWith('u-1') + }) +}) + +describe('suspendMemberTx and unsuspendMemberTx', () => { + it('suspends once, revokes sessions, and never touches API keys', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'u-1' }]) + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 's-1' }]) + await expect( + suspendMemberTx(db, { userId: 'u-1', organizationId: 'org-1', source: 'scim' }) + ).resolves.toEqual({ suspended: true, sessionsRevoked: 1 }) + expect(mockAcquireLocks).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ suspensionSource: 'scim', suspendedAt: expect.any(Date) }) + ) + expect(dbChainMockFns.delete).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(session) + }) + + it('reports an already-suspended account without claiming a second suspension', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([]) + await expect( + suspendMemberTx(db, { userId: 'u-1', organizationId: 'org-1', source: 'scim' }) + ).resolves.toMatchObject({ suspended: false }) + }) + + it('lifts only a suspension raised by the same source', async () => { + dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'u-1' }]) + await expect(unsuspendMemberTx(db, { userId: 'u-1', source: 'scim' })).resolves.toEqual({ + unsuspended: true, + }) + expect(dbChainMockFns.update).toHaveBeenCalledWith(user) + expect(dbChainMockFns.where).toHaveBeenCalledWith({ + type: 'and', + conditions: [ + { type: 'eq', left: user.id, right: 'u-1' }, + { type: 'eq', left: user.suspensionSource, right: 'scim' }, + ], + }) + }) +}) diff --git a/apps/sim/lib/organizations/members/lifecycle.ts b/apps/sim/lib/organizations/members/lifecycle.ts new file mode 100644 index 00000000000..95343294053 --- /dev/null +++ b/apps/sim/lib/organizations/members/lifecycle.ts @@ -0,0 +1,118 @@ +import { member, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { revokeUserSessionsTx } from '@/lib/organizations/members/revocation' + +const logger = createLogger('OrganizationMemberLifecycle') + +/** + * Member lifecycle primitives shared by the settings UI and directory + * provisioning: suspension, and role changes. Removal lives with the billing + * membership primitives; live-access revocation lives in `revocation.ts`. + */ + +/** + * Who applied a suspension. A source only ever lifts its own, so a second source + * added later cannot have its suspensions undone by a directory sync. + */ +export type SuspensionSource = 'scim' + +export interface SuspendMemberResult { + suspended: boolean + sessionsRevoked: number +} + +/** + * Suspends an account: sign-in is refused, API keys stop authenticating, and + * everything the person owns is left exactly as it was. + * + * Deliberately not the platform ban. A ban runs `disableUserResources`, which + * archives every workspace the user owns and deletes their API keys, and there + * is no server-side path to undo it. A directory deactivation is routine and + * reversible — someone on leave, or moved between teams — so it must not destroy + * the work they own. That includes their API keys: the key rows stay, and the + * authentication paths refuse them while `suspendedAt` is set, so reactivation + * restores every automation exactly as it was. + */ +export async function suspendMemberTx( + tx: DbOrTx, + params: { userId: string; organizationId: string; source: SuspensionSource } +): Promise { + await acquireOrganizationUserMutationLocks(tx, { + userId: params.userId, + organizationIds: [params.organizationId], + }) + + const [updated] = await tx + .update(user) + .set({ suspendedAt: new Date(), suspensionSource: params.source, updatedAt: new Date() }) + .where(and(eq(user.id, params.userId), isNull(user.suspendedAt))) + .returning({ id: user.id }) + + const sessions = await revokeUserSessionsTx(tx, { + userId: params.userId, + organizationId: params.organizationId, + }) + + return { suspended: Boolean(updated), sessionsRevoked: sessions.revoked } +} + +/** Lifts a suspension, but only one raised by the same source. */ +export async function unsuspendMemberTx( + tx: DbOrTx, + params: { userId: string; source: SuspensionSource } +): Promise<{ unsuspended: boolean }> { + const [updated] = await tx + .update(user) + .set({ suspendedAt: null, suspensionSource: null, updatedAt: new Date() }) + .where(and(eq(user.id, params.userId), eq(user.suspensionSource, params.source))) + .returning({ id: user.id }) + return { unsuspended: Boolean(updated) } +} + +export type OrganizationMemberRole = 'admin' | 'member' + +export type ChangeMemberRoleResult = + | { changed: true; from: string; to: OrganizationMemberRole } + | { changed: false; role: string } + +/** + * Changes a member's organization role. + * + * Ownership is out of scope in both directions: the owner's role cannot be + * lowered here, and no caller can raise someone to owner. Transferring ownership + * moves billing and the last-owner guarantee with it, which is its own operation. + */ +export async function changeMemberRoleTx( + tx: DbOrTx, + params: { organizationId: string; userId: string; role: OrganizationMemberRole } +): Promise { + await acquireOrganizationUserMutationLocks(tx, { + userId: params.userId, + organizationIds: [params.organizationId], + }) + + const [current] = await tx + .select({ id: member.id, role: member.role }) + .from(member) + .where(and(eq(member.organizationId, params.organizationId), eq(member.userId, params.userId))) + .limit(1) + + if (!current) throw new OrchestrationError('not_found', 'Member not found') + if (current.role === 'owner') { + throw new OrchestrationError('conflict', 'The organization owner’s role cannot be changed') + } + if (current.role === params.role) return { changed: false, role: current.role } + + await tx.update(member).set({ role: params.role }).where(eq(member.id, current.id)) + logger.info('Changed organization member role', { + organizationId: params.organizationId, + userId: params.userId, + from: current.role, + to: params.role, + }) + return { changed: true, from: current.role, to: params.role } +} diff --git a/apps/sim/lib/organizations/members/revocation.ts b/apps/sim/lib/organizations/members/revocation.ts new file mode 100644 index 00000000000..b1ba25881c3 --- /dev/null +++ b/apps/sim/lib/organizations/members/revocation.ts @@ -0,0 +1,85 @@ +import { apiKey, organization, session as sessionTable } from '@sim/db/schema' +import { and, eq, isNull, ne, sql } from 'drizzle-orm' +import { + invalidateMembershipCache, + invalidateSecurityPolicyVersionCache, +} from '@/lib/auth/security-policy' +import type { DbOrTx } from '@/lib/db/types' + +/** + * Ending a person's live access: their sessions and their personal API keys. + * + * Shared by removal from an organization, suspension, and an email change, so + * every path that must sign someone out does it the same way. Kept apart from + * the membership primitives so the removal transaction can call it without a + * module cycle. + */ + +export interface RevokeSessionsResult { + revoked: number +} + +/** + * Deletes a user's sessions and forces cached session cookies in the + * organization to re-read the database. + * + * Both halves commit together. Deleting sessions without bumping the version + * would leave the signed cookie cache authenticating a deleted session for up + * to five minutes, which is precisely the window a revocation exists to close. + * + * Impersonation sessions are spared: they are platform support tooling, not the + * member's own access. + */ +export async function revokeUserSessionsTx( + tx: DbOrTx, + params: { userId: string; organizationId: string; spareSessionToken?: string } +): Promise { + const deleted = await tx + .delete(sessionTable) + .where( + and( + eq(sessionTable.userId, params.userId), + isNull(sessionTable.impersonatedBy), + ...(params.spareSessionToken ? [ne(sessionTable.token, params.spareSessionToken)] : []) + ) + ) + .returning({ id: sessionTable.id }) + + await tx + .update(organization) + .set({ securityPolicyVersion: sql`${organization.securityPolicyVersion} + 1` }) + .where(eq(organization.id, params.organizationId)) + + return { revoked: deleted.length } +} + +/** + * Drops the caches that make a revocation visible to the next request. + * + * Separate from the transaction on purpose: an in-process cache cleared before + * the commit lands would be repopulated with the pre-commit answer. + */ +export function invalidateAfterSessionRevocation(params: { + userId: string + organizationId: string +}): void { + invalidateSecurityPolicyVersionCache(params.organizationId) + invalidateMembershipCache(params.userId) +} + +/** + * Deletes a user's personal API keys. + * + * Workspace keys are left alone: they belong to the workspace and are shared, so + * one person's departure must not break every automation using them. + */ +export async function revokePersonalApiKeysTx( + tx: DbOrTx, + params: { userId: string } +): Promise<{ revoked: number }> { + const deleted = await tx + .delete(apiKey) + .where(and(eq(apiKey.userId, params.userId), eq(apiKey.type, 'personal'))) + .returning({ id: apiKey.id }) + return { revoked: deleted.length } +} diff --git a/apps/sim/lib/permission-groups/application/group-membership.test.ts b/apps/sim/lib/permission-groups/application/group-membership.test.ts new file mode 100644 index 00000000000..5e007b45e80 --- /dev/null +++ b/apps/sim/lib/permission-groups/application/group-membership.test.ts @@ -0,0 +1,368 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { permissionGroup, permissionGroupMember, permissionGroupWorkspace } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + acquireLock: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/locks', () => ({ + acquirePermissionGroupOrgLock: mocks.acquireLock, +})) + +import { + addPermissionGroupMemberTx, + findAllMembersWorkspaceConflict, + findScopeConflicts, + PermissionGroupAllMembersConflictError, + PermissionGroupNotFoundError, + PermissionGroupScopeConflictError, + removePermissionGroupMemberTx, +} from '@/lib/permission-groups/application/group-membership' + +afterAll(resetDbChainMock) + +describe('findScopeConflicts', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + const baseParams = { + organizationId: 'org-1', + excludeGroupId: 'group-1', + workspaceIds: ['ws-1'], + candidateUserIds: ['user-1'], + } + + const conflictRow = (userId: string, otherGroupName = 'Marketing') => ({ + userId, + userName: 'User One', + userEmail: `${userId}@example.com`, + otherGroupId: 'group-2', + otherGroupName, + }) + + it('returns no conflicts when there are no candidate users', async () => { + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) + + const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] }) + + expect(conflicts).toEqual([]) + }) + + it('returns no conflicts when there are no target workspaces', async () => { + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) + + const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] }) + + expect(conflicts).toEqual([]) + }) + + it('flags a candidate already in another group that shares a workspace', async () => { + queueTableRows(permissionGroupMember, [conflictRow('user-1')]) + + const conflicts = await findScopeConflicts(baseParams) + + expect(conflicts.map((c) => c.userId)).toEqual(['user-1']) + expect(conflicts[0].conflictingGroupName).toBe('Marketing') + }) + + it('returns at most one conflict per user', async () => { + queueTableRows(permissionGroupMember, [ + conflictRow('user-1', 'Marketing'), + conflictRow('user-1', 'Sales'), + ]) + + const conflicts = await findScopeConflicts(baseParams) + + expect(conflicts).toHaveLength(1) + expect(conflicts[0].conflictingGroupName).toBe('Marketing') + }) + + it('returns no conflicts when the query finds no overlapping memberships', async () => { + const conflicts = await findScopeConflicts(baseParams) + + expect(conflicts).toEqual([]) + }) +}) + +describe('findAllMembersWorkspaceConflict', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + const baseParams = { + organizationId: 'org-1', + excludeGroupId: 'group-1', + workspaceIds: ['ws-1', 'ws-2'], + } + + it('returns null when there are no target workspaces', async () => { + queueTableRows(permissionGroup, [ + { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, + ]) + + const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] }) + + expect(conflict).toBeNull() + }) + + it('returns the conflicting all-members group sharing a workspace', async () => { + queueTableRows(permissionGroup, [ + { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, + ]) + + const conflict = await findAllMembersWorkspaceConflict(baseParams) + + expect(conflict).toEqual({ + conflictingGroupId: 'group-2', + conflictingGroupName: 'Marketing', + workspaceName: 'Acme', + }) + }) + + it('returns null when no other all-members group targets the workspaces', async () => { + const conflict = await findAllMembersWorkspaceConflict(baseParams) + + expect(conflict).toBeNull() + }) +}) + +const memberParams = { organizationId: 'org-1', groupId: 'group-1', userId: 'user-1' } + +/** Queues the group row and its workspace rows that `loadLockedGroup` reads, in that order. */ +function stageGroup( + group: { isDefault?: boolean; membershipMode?: 'inherit' | 'explicit' } | null, + workspaceIds: string[] = ['ws-1'] +) { + queueTableRows( + permissionGroup, + group + ? [ + { + id: 'group-1', + isDefault: group.isDefault ?? false, + membershipMode: group.membershipMode ?? 'inherit', + }, + ] + : [] + ) + if (group) { + queueTableRows( + permissionGroupWorkspace, + workspaceIds.map((workspaceId) => ({ workspaceId })) + ) + } +} + +describe('addPermissionGroupMemberTx', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.acquireLock.mockResolvedValue(undefined) + }) + + it('takes the leaf lock with the timeout already bounded before reading or writing anything', async () => { + stageGroup({}) + queueTableRows(permissionGroupMember, []) + queueTableRows(permissionGroupMember, []) + + const result = await addPermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('added') + expect(mocks.acquireLock).toHaveBeenCalledWith(db, 'org-1', { + lockTimeoutAlreadyBounded: true, + }) + expect(mocks.acquireLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.from.mock.invocationCallOrder[0] + ) + expect(mocks.acquireLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.insert.mock.invocationCallOrder[0] + ) + }) + + it('inserts a membership with no human author', async () => { + stageGroup({}) + queueTableRows(permissionGroupMember, []) + queueTableRows(permissionGroupMember, []) + + await addPermissionGroupMemberTx(db, memberParams) + + expect(dbChainMockFns.insert).toHaveBeenCalledWith(permissionGroupMember) + expect(dbChainMockFns.values).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.values.mock.calls[0][0]).toMatchObject({ + permissionGroupId: 'group-1', + organizationId: 'org-1', + userId: 'user-1', + assignedBy: null, + }) + expect(dbChainMockFns.values.mock.calls[0][0].assignedAt).toBeInstanceOf(Date) + }) + + it('short-circuits without inserting when the user is already a member', async () => { + stageGroup({}) + queueTableRows(permissionGroupMember, [{ id: 'pgm-1' }]) + + const result = await addPermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('already-member') + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('refuses a user another group already governs on a shared workspace, inserting nothing', async () => { + stageGroup({}) + queueTableRows(permissionGroupMember, []) + queueTableRows(permissionGroupMember, [ + { + userId: 'user-1', + userName: 'User One', + userEmail: 'user-1@example.com', + otherGroupId: 'group-2', + otherGroupName: 'Marketing', + }, + ]) + + const error = await addPermissionGroupMemberTx(db, memberParams).catch((caught) => caught) + + expect(error).toBeInstanceOf(PermissionGroupScopeConflictError) + expect(error.conflicts).toEqual([ + { + userId: 'user-1', + userName: 'User One', + userEmail: 'user-1@example.com', + conflictingGroupId: 'group-2', + conflictingGroupName: 'Marketing', + }, + ]) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + + it('throws not-found for a group outside the organization, after taking the lock', async () => { + stageGroup(null) + + const error = await addPermissionGroupMemberTx(db, memberParams).catch((caught) => caught) + + expect(error).toBeInstanceOf(PermissionGroupNotFoundError) + expect(mocks.acquireLock).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) +}) + +describe('removePermissionGroupMemberTx', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.acquireLock.mockResolvedValue(undefined) + }) + + const allMembersConflict = { + conflictingGroupId: 'group-2', + conflictingGroupName: 'Marketing', + workspaceName: 'Acme', + } + + it('takes the leaf lock before deleting', async () => { + stageGroup({ membershipMode: 'explicit' }) + queueTableRows(permissionGroupMember, [{ id: 'pgm-1' }]) + + const result = await removePermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('removed') + expect(mocks.acquireLock).toHaveBeenCalledWith(db, 'org-1', { + lockTimeoutAlreadyBounded: true, + }) + expect(mocks.acquireLock.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.delete.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(permissionGroupMember) + expect(dbChainMockFns.where.mock.calls.at(-1)?.[0]).toEqual({ + type: 'eq', + left: permissionGroupMember.id, + right: 'pgm-1', + }) + }) + + it('returns not-a-member without deleting when the user is absent', async () => { + stageGroup({}) + queueTableRows(permissionGroupMember, []) + + const result = await removePermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('not-a-member') + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('refuses to empty an inherit-mode group when another all-members group shares a workspace', async () => { + stageGroup({ membershipMode: 'inherit' }) + queueTableRows(permissionGroupMember, [{ id: 'pgm-1' }]) + queueTableRows(permissionGroupMember, [{ value: 1 }]) + queueTableRows(permissionGroup, [allMembersConflict]) + + const error = await removePermissionGroupMemberTx(db, memberParams).catch((caught) => caught) + + expect(error).toBeInstanceOf(PermissionGroupAllMembersConflictError) + expect(error.conflict).toEqual(allMembersConflict) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) + + it('removes the last member of an inherit-mode group when no other group claims its workspaces', async () => { + stageGroup({ membershipMode: 'inherit' }) + queueTableRows(permissionGroupMember, [{ id: 'pgm-1' }]) + queueTableRows(permissionGroupMember, [{ value: 1 }]) + queueTableRows(permissionGroup, []) + + const result = await removePermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('removed') + expect(dbChainMockFns.delete).toHaveBeenCalledWith(permissionGroupMember) + }) + + it('skips the all-members check when other members remain', async () => { + stageGroup({ membershipMode: 'inherit' }) + queueTableRows(permissionGroupMember, [{ id: 'pgm-1' }]) + queueTableRows(permissionGroupMember, [{ value: 3 }]) + + const result = await removePermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('removed') + expect( + dbChainMockFns.from.mock.calls.filter((call) => call[0] === permissionGroup) + ).toHaveLength(1) + }) + + it('removes the last member of an explicit-mode group without checking for a collision', async () => { + stageGroup({ membershipMode: 'explicit' }) + queueTableRows(permissionGroupMember, [{ id: 'pgm-1' }]) + + const result = await removePermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('removed') + expect(dbChainMockFns.from).toHaveBeenCalledTimes(3) + expect(dbChainMockFns.delete).toHaveBeenCalledWith(permissionGroupMember) + }) + + it('removes a member of the default group without the all-members check', async () => { + stageGroup({ isDefault: true, membershipMode: 'inherit' }) + queueTableRows(permissionGroupMember, [{ id: 'pgm-1' }]) + + const result = await removePermissionGroupMemberTx(db, memberParams) + + expect(result).toBe('removed') + expect(dbChainMockFns.from).toHaveBeenCalledTimes(3) + }) + + it('throws not-found for a group outside the organization, deleting nothing', async () => { + stageGroup(null) + + const error = await removePermissionGroupMemberTx(db, memberParams).catch((caught) => caught) + + expect(error).toBeInstanceOf(PermissionGroupNotFoundError) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/permission-groups/application/group-membership.ts b/apps/sim/lib/permission-groups/application/group-membership.ts new file mode 100644 index 00000000000..a46b68ce1c1 --- /dev/null +++ b/apps/sim/lib/permission-groups/application/group-membership.ts @@ -0,0 +1,301 @@ +import { db } from '@sim/db' +import { + permissionGroup, + permissionGroupMember, + permissionGroupWorkspace, + user, + workspace, +} from '@sim/db/schema' +import { generateId } from '@sim/utils/id' +import { and, asc, count, eq, inArray, ne, sql } from 'drizzle-orm' +import type { DbOrTx } from '@/lib/db/types' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' + +/** + * Permission-group membership and the two rules that constrain it. + * + * The settings routes and directory provisioning both write membership, and + * both must agree on when a write would leave a workspace governed by two + * groups. The rules live here once; the routes consume them for their own + * conflict messages, and the primitives below apply them for callers that do + * not need a message. + */ + +export interface ScopeConflict { + userId: string + userName: string | null + userEmail: string | null + /** The group the member already belongs to that causes the conflict. */ + conflictingGroupId: string + conflictingGroupName: string +} + +/** + * Which of `candidateUserIds` would be governed by two groups on the same + * workspace: each is already an explicit member of another non-default group + * that shares one of `workspaceIds`. The candidate group (`excludeGroupId`) and + * the org default group are ignored — the default never governs through + * membership. Returns at most one conflict per user. + */ +export async function findScopeConflicts( + params: { + organizationId: string + excludeGroupId: string + workspaceIds: string[] + candidateUserIds: string[] + }, + executor: DbOrTx = db +): Promise { + const { organizationId, excludeGroupId, workspaceIds, candidateUserIds } = params + if (candidateUserIds.length === 0 || workspaceIds.length === 0) return [] + + const rows = await executor + .select({ + userId: permissionGroupMember.userId, + userName: user.name, + userEmail: user.email, + otherGroupId: permissionGroup.id, + otherGroupName: permissionGroup.name, + }) + .from(permissionGroupMember) + .innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id)) + .innerJoin( + permissionGroupWorkspace, + eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) + ) + .leftJoin(user, eq(permissionGroupMember.userId, user.id)) + .where( + and( + eq(permissionGroupMember.organizationId, organizationId), + inArray(permissionGroupMember.userId, candidateUserIds), + ne(permissionGroupMember.permissionGroupId, excludeGroupId), + eq(permissionGroup.isDefault, false), + inArray(permissionGroupWorkspace.workspaceId, workspaceIds) + ) + ) + + const conflictByUser = new Map() + for (const row of rows) { + if (conflictByUser.has(row.userId)) continue + conflictByUser.set(row.userId, { + userId: row.userId, + userName: row.userName, + userEmail: row.userEmail, + conflictingGroupId: row.otherGroupId, + conflictingGroupName: row.otherGroupName, + }) + } + return Array.from(conflictByUser.values()) +} + +/** An existing all-members group that already governs everyone in a shared workspace. */ +export interface AllMembersConflict { + conflictingGroupId: string + conflictingGroupName: string + workspaceName: string +} + +/** + * For a group that will govern *all members* of `workspaceIds` (a non-default + * group with no explicit members), return the first other non-default + * all-members group already targeting one of those workspaces, or `null`. Two + * all-members groups on one workspace would both claim everyone there, so this + * is rejected at assignment time. The candidate group (`excludeGroupId`) is + * ignored, and so is any group in `explicit` membership mode: empty, it governs + * nobody rather than everyone, so it cannot collide. + */ +export async function findAllMembersWorkspaceConflict( + params: { organizationId: string; excludeGroupId: string; workspaceIds: string[] }, + executor: DbOrTx = db +): Promise { + const { organizationId, excludeGroupId, workspaceIds } = params + if (workspaceIds.length === 0) return null + + const [row] = await executor + .select({ + conflictingGroupId: permissionGroup.id, + conflictingGroupName: permissionGroup.name, + workspaceName: workspace.name, + }) + .from(permissionGroup) + .innerJoin( + permissionGroupWorkspace, + eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) + ) + .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) + .where( + and( + eq(permissionGroup.organizationId, organizationId), + eq(permissionGroup.isDefault, false), + eq(permissionGroup.membershipMode, 'inherit'), + ne(permissionGroup.id, excludeGroupId), + inArray(permissionGroupWorkspace.workspaceId, workspaceIds), + sql`not exists ( + select 1 from ${permissionGroupMember} + where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} + )` + ) + ) + .orderBy(asc(workspace.name)) + .limit(1) + + return row ?? null +} + +export class PermissionGroupScopeConflictError extends Error { + constructor(readonly conflicts: ScopeConflict[]) { + super('The user is already governed by another permission group on a shared workspace') + this.name = 'PermissionGroupScopeConflictError' + } +} + +export class PermissionGroupAllMembersConflictError extends Error { + constructor(readonly conflict: AllMembersConflict) { + super( + 'Removing the last member would make this group govern every member of its workspaces, and another group already does' + ) + this.name = 'PermissionGroupAllMembersConflictError' + } +} + +export class PermissionGroupNotFoundError extends Error { + constructor() { + super('Permission group not found') + this.name = 'PermissionGroupNotFoundError' + } +} + +interface LockedGroup { + id: string + isDefault: boolean + membershipMode: string + workspaceIds: string[] +} + +async function loadLockedGroup( + tx: DbOrTx, + organizationId: string, + groupId: string +): Promise { + const [group] = await tx + .select({ + id: permissionGroup.id, + isDefault: permissionGroup.isDefault, + membershipMode: permissionGroup.membershipMode, + }) + .from(permissionGroup) + .where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId))) + .limit(1) + if (!group) throw new PermissionGroupNotFoundError() + + const workspaces = await tx + .select({ workspaceId: permissionGroupWorkspace.workspaceId }) + .from(permissionGroupWorkspace) + .where(eq(permissionGroupWorkspace.permissionGroupId, groupId)) + + return { ...group, workspaceIds: workspaces.map((row) => row.workspaceId) } +} + +export type AddPermissionGroupMemberResult = 'added' | 'already-member' + +/** + * Adds a user to a permission group on the directory's behalf. + * + * The caller holds the organization lock, which has already bounded + * `lock_timeout`; the permission-group lock taken here is the leaf, so no + * further advisory lock may follow it. The membership has no human author. + */ +export async function addPermissionGroupMemberTx( + tx: DbOrTx, + params: { organizationId: string; groupId: string; userId: string } +): Promise { + await acquirePermissionGroupOrgLock(tx, params.organizationId, { + lockTimeoutAlreadyBounded: true, + }) + const group = await loadLockedGroup(tx, params.organizationId, params.groupId) + + const [existing] = await tx + .select({ id: permissionGroupMember.id }) + .from(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.permissionGroupId, params.groupId), + eq(permissionGroupMember.userId, params.userId) + ) + ) + .limit(1) + if (existing) return 'already-member' + + const conflicts = await findScopeConflicts( + { + organizationId: params.organizationId, + excludeGroupId: params.groupId, + workspaceIds: group.workspaceIds, + candidateUserIds: [params.userId], + }, + tx + ) + if (conflicts.length > 0) throw new PermissionGroupScopeConflictError(conflicts) + + await tx.insert(permissionGroupMember).values({ + id: generateId(), + permissionGroupId: params.groupId, + organizationId: params.organizationId, + userId: params.userId, + assignedBy: null, + assignedAt: new Date(), + }) + return 'added' +} + +export type RemovePermissionGroupMemberResult = 'removed' | 'not-a-member' + +/** Removes a user from a permission group; the caller holds the organization lock. */ +export async function removePermissionGroupMemberTx( + tx: DbOrTx, + params: { organizationId: string; groupId: string; userId: string } +): Promise { + await acquirePermissionGroupOrgLock(tx, params.organizationId, { + lockTimeoutAlreadyBounded: true, + }) + const group = await loadLockedGroup(tx, params.organizationId, params.groupId) + + const [member] = await tx + .select({ id: permissionGroupMember.id }) + .from(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.permissionGroupId, params.groupId), + eq(permissionGroupMember.userId, params.userId) + ) + ) + .limit(1) + if (!member) return 'not-a-member' + + /** + * Emptying a non-default group in `inherit` mode flips it from governing + * these users to governing everyone in its workspaces, and only one group may + * do that per workspace. A group in `explicit` mode governs nobody when empty, + * so it cannot collide and the check does not apply. + */ + if (!group.isDefault && group.membershipMode === 'inherit') { + const [remaining] = await tx + .select({ value: count() }) + .from(permissionGroupMember) + .where(eq(permissionGroupMember.permissionGroupId, params.groupId)) + if ((remaining?.value ?? 0) <= 1) { + const conflict = await findAllMembersWorkspaceConflict( + { + organizationId: params.organizationId, + excludeGroupId: params.groupId, + workspaceIds: group.workspaceIds, + }, + tx + ) + if (conflict) throw new PermissionGroupAllMembersConflictError(conflict) + } + } + + await tx.delete(permissionGroupMember).where(eq(permissionGroupMember.id, member.id)) + return 'removed' +} diff --git a/apps/sim/lib/permission-groups/resolve.server.ts b/apps/sim/lib/permission-groups/resolve.server.ts index cc2bebb91f9..f0511724f07 100644 --- a/apps/sim/lib/permission-groups/resolve.server.ts +++ b/apps/sim/lib/permission-groups/resolve.server.ts @@ -132,8 +132,9 @@ async function resolveDefaultGroup( * `organizationId`). One effective group per workspace, by precedence: * 1. a non-default group targeting this workspace that `userId` is an explicit * member of, else - * 2. a non-default group targeting this workspace that has no explicit members - * — governs all members of the workspace, including external members, else + * 2. a non-default group in `inherit` mode targeting this workspace that has no + * explicit members — governs all members of the workspace, including + * external members, else * 3. the organization's default group (also governs external members), else * 4. `null` (unrestricted). * @@ -142,6 +143,12 @@ async function resolveDefaultGroup( * workspace. If an overlap nonetheless exists, the oldest group wins — rows are * ordered by `created_at` (then `id`). * + * A group in `explicit` membership mode is excluded from step 2: it governs + * exactly its member rows and therefore governs nobody when empty. Directory- + * provisioned groups use that mode, so an identity provider removing the last + * member narrows the group to nobody rather than silently widening it to + * everyone in its workspaces. + * * Callers gate on enterprise entitlement before invoking this and merge the env * allowlist afterwards. */ @@ -164,6 +171,7 @@ export async function resolveWorkspaceGroup( select 1 from ${permissionGroupMember} where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} )`, + membershipMode: permissionGroup.membershipMode, }) .from(permissionGroup) .innerJoin( @@ -179,7 +187,8 @@ export async function resolveWorkspaceGroup( .orderBy(asc(permissionGroup.createdAt), asc(permissionGroup.id)) const explicitMemberGroup = rows.find((row) => row.isMember) - const winner = explicitMemberGroup ?? rows.find((row) => !row.hasMembers) + const winner = + explicitMemberGroup ?? rows.find((row) => !row.hasMembers && row.membershipMode === 'inherit') if (winner) { return { diff --git a/apps/sim/lib/posthog/events.ts b/apps/sim/lib/posthog/events.ts index 982f963fa02..4d05734eebd 100644 --- a/apps/sim/lib/posthog/events.ts +++ b/apps/sim/lib/posthog/events.ts @@ -726,6 +726,12 @@ export interface PostHogEventMap { is_self_removal: boolean } + /** A member the organization's identity provider created rather than a person. */ + scim_user_provisioned: { + organization_id: string + created_account: boolean + } + org_member_role_changed: { organization_id: string new_role: string diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index ad290bce5c4..c93031622d7 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -482,6 +482,11 @@ export function createUploadSessionAuthBinding( 'forbidden', 'Credential Group enrollment principals cannot create uploads' ) + case 'scim_connection': + throw new UploadSessionError( + 'forbidden', + 'Directory provisioning credentials cannot create uploads' + ) case 'system': throw new UploadSessionError('forbidden', 'System principals cannot create uploads') } diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 31ed03811fc..f85d07d62d7 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -230,6 +230,7 @@ async function executeCopilotRun(params: { }): Promise { if ( params.principal.kind === 'credential_group_enrollment' || + params.principal.kind === 'scim_connection' || (params.principal.kind === 'delegated' && params.principal.serviceId === 'executor') ) { throw new Error('The principal cannot start a Copilot workflow execution') diff --git a/apps/sim/lib/workspaces/access/workspace-access.ts b/apps/sim/lib/workspaces/access/workspace-access.ts new file mode 100644 index 00000000000..deffbea554a --- /dev/null +++ b/apps/sim/lib/workspaces/access/workspace-access.ts @@ -0,0 +1,198 @@ +import { permissions } from '@sim/db/schema' +import type { PermissionType } from '@sim/platform-authz/workspace' +import { generateId } from '@sim/utils/id' +import { and, eq } from 'drizzle-orm' +import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' +import type { DbOrTx } from '@/lib/db/types' +import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' +import { + reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, + transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx, + WorkspaceBillingAccountRemovalError, +} from '@/lib/workspaces/utils' + +/** + * Workspace access as a shared domain primitive. + * + * The permission row is only part of the story — removing someone also has to + * reassign what they own and drop the credential and skill memberships that + * dangle otherwise. That sequence lived inline in the members route; directory + * deprovisioning needs the same one, and a second copy is where the two would + * drift. + */ + +/** Ordering used to decide whether an existing grant already suffices. */ +const PERMISSION_RANK: Record = { read: 1, write: 2, admin: 3 } + +export function permissionRank(permission: PermissionType): number { + return PERMISSION_RANK[permission] +} + +export type GrantWorkspaceAccessOutcome = 'granted' | 'raised' | 'unchanged' + +/** + * Grants a user access to a workspace, never lowering an existing grant. + * + * A directory group says what access someone should have at minimum. Someone + * who was deliberately promoted to workspace admin should not be demoted by the + * next sync of a group that grants write. + */ +export async function grantWorkspaceAccessTx( + tx: DbOrTx, + params: { workspaceId: string; userId: string; permission: PermissionType } +): Promise { + /** + * Insert first and let the unique index on (user, entity) decide. A read + * followed by an insert would let two concurrent grants both see "absent" and + * one of them fail on the index; this way the loser falls through to the + * comparison below against the row that won. + */ + const inserted = await tx + .insert(permissions) + .values({ + id: generateId(), + userId: params.userId, + entityType: 'workspace', + entityId: params.workspaceId, + permissionType: params.permission, + createdAt: new Date(), + updatedAt: new Date(), + }) + .onConflictDoNothing() + .returning({ id: permissions.id }) + if (inserted.length > 0) return 'granted' + + const [existing] = await tx + .select({ id: permissions.id, permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, params.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, params.workspaceId) + ) + ) + .limit(1) + .for('update') + if (!existing) return 'unchanged' + + if (permissionRank(existing.permissionType) >= permissionRank(params.permission)) { + return 'unchanged' + } + + await tx + .update(permissions) + .set({ permissionType: params.permission, updatedAt: new Date() }) + .where(eq(permissions.id, existing.id)) + return 'raised' +} + +/** + * Lowers a grant, but only from the level a previous automated grant set. + * + * A directory mapping edited from admin down to write must take effect, yet a + * person a workspace administrator deliberately promoted must not be demoted by + * it. The caller passes the level it last set; if the row no longer matches, + * someone else raised it and the lowering is skipped. + */ +export async function lowerWorkspaceAccessTx( + tx: DbOrTx, + params: { workspaceId: string; userId: string; from: PermissionType; to: PermissionType } +): Promise<'lowered' | 'unchanged'> { + const updated = await tx + .update(permissions) + .set({ permissionType: params.to, updatedAt: new Date() }) + .where( + and( + eq(permissions.userId, params.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, params.workspaceId), + eq(permissions.permissionType, params.from) + ) + ) + .returning({ id: permissions.id }) + return updated.length > 0 ? 'lowered' : 'unchanged' +} + +export type RevokeWorkspaceAccessResult = + /** `ownershipTransferred` is true when the departing user owned the workspace and it moved to the billed account. */ + | { revoked: true; ownershipTransferred: boolean } + /** Workflows whose owner could not be reassigned; the access row is left in place. */ + | { revoked: false; reason: 'unresolved-workflows'; unresolvedWorkflows: string[] } + /** The user owns the workspace and it has no billed account to hand it to. */ + | { revoked: false; reason: 'workspace-owner-without-successor' } + +/** + * Removes a user's access to one workspace and everything that hangs off it. + * + * Ownership moves first, in the same order the members route uses: the workspace + * itself to its billed account when the departing user owns it, then every + * workflow they own to a remaining member. Either can fail, and a failure is a + * refusal rather than a partial removal — deleting the access row would orphan + * what could not be moved. + */ +export async function revokeWorkspaceAccessTx( + tx: DbOrTx, + params: { workspaceId: string; userId: string } +): Promise { + let ownershipTransferred: boolean + try { + ownershipTransferred = await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({ + tx, + workspaceId: params.workspaceId, + departingUserId: params.userId, + }) + } catch (error) { + if (error instanceof WorkspaceBillingAccountRemovalError) { + return { revoked: false, reason: 'workspace-owner-without-successor' } + } + throw error + } + + const reassignment = await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({ + tx, + workspaceIds: [params.workspaceId], + departingUserId: params.userId, + }) + if (reassignment.unresolved.length > 0) { + return { + revoked: false, + reason: 'unresolved-workflows', + unresolvedWorkflows: reassignment.unresolved, + } + } + + await tx + .delete(permissions) + .where( + and( + eq(permissions.userId, params.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, params.workspaceId) + ) + ) + + await revokeWorkspaceCredentialMembershipsTx(tx, params.workspaceId, params.userId) + await removeWorkspaceSkillMembershipsTx(tx, params.workspaceId, params.userId) + + return { revoked: true, ownershipTransferred } +} + +/** The permission a user currently holds on a workspace, if any. */ +export async function readWorkspacePermission( + tx: DbOrTx, + params: { workspaceId: string; userId: string } +): Promise { + const [row] = await tx + .select({ permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, params.userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, params.workspaceId) + ) + ) + .limit(1) + return row?.permissionType ?? null +} diff --git a/docker/crontab b/docker/crontab index 0af7ad45723..46618c3fc32 100644 --- a/docker/crontab +++ b/docker/crontab @@ -43,6 +43,9 @@ SHELL=/bin/sh # Enterprise data drains 0 * * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/run-data-drains" +# Directory provisioning drift sweep (re-applies SCIM group mappings) +17 * * * * curl -fsS -m 300 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/scim-reconcile" + # Deletes table rows whose TTL column has expired */15 * * * * curl -fsS -m 60 -o /dev/null -H "Authorization: Bearer $CRON_SECRET" "$SIM_URL/api/cron/cleanup-table-row-ttl" diff --git a/helm/sim/Chart.yaml b/helm/sim/Chart.yaml index b0fd7c2a7c2..8781e11ba72 100644 --- a/helm/sim/Chart.yaml +++ b/helm/sim/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: sim description: A Helm chart for Sim - the open-source AI workspace where teams build, deploy, and manage AI agents type: application -version: 1.9.6 +version: 1.10.0 appVersion: "v0.8.24" kubeVersion: ">=1.25.0-0" home: https://sim.ai diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml index 1d40bb19e0b..9a1bed30033 100644 --- a/helm/sim/values.yaml +++ b/helm/sim/values.yaml @@ -1492,6 +1492,18 @@ cronjobs: successfulJobsHistoryLimit: 3 failedJobsHistoryLimit: 1 + # Re-applies directory group mappings so drift between what a mapping says a + # member should have and what SCIM granted them cannot persist. Idempotent, + # and a no-op for organizations with no connection. + scimReconcile: + enabled: true + name: scim-reconcile + schedule: "17 * * * *" + path: "/api/cron/scim-reconcile" + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 1 + # Deletes table rows whose TTL column contains an expired Unix timestamp. cleanupTableRowTtl: enabled: true diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 69a545e0dcf..006a2bc7dff 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -239,6 +239,23 @@ export const AuditAction = { WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back', WORKSPACE_FORK_UNLINKED: 'workspace.fork_unlinked', WORKSPACE_EXPORTED: 'workspace.exported', + // SCIM directory provisioning + SCIM_CONNECTION_ENABLED: 'scim_connection.enabled', + SCIM_CONNECTION_DISABLED: 'scim_connection.disabled', + SCIM_CONNECTION_SETTINGS_UPDATED: 'scim_connection.settings_updated', + SCIM_CREDENTIAL_ISSUED: 'scim_credential.issued', + SCIM_CREDENTIAL_REVOKED: 'scim_credential.revoked', + SCIM_USER_PROVISIONED: 'scim_user.provisioned', + SCIM_USER_UPDATED: 'scim_user.updated', + SCIM_USER_DEACTIVATED: 'scim_user.deactivated', + SCIM_USER_REACTIVATED: 'scim_user.reactivated', + SCIM_USER_DEPROVISIONED: 'scim_user.deprovisioned', + SCIM_GROUP_CREATED: 'scim_group.created', + SCIM_GROUP_UPDATED: 'scim_group.updated', + SCIM_GROUP_MEMBERSHIP_CHANGED: 'scim_group.membership_changed', + SCIM_GROUP_DELETED: 'scim_group.deleted', + SCIM_GROUP_MAPPING_UPSERTED: 'scim_group_mapping.upserted', + SCIM_GROUP_MAPPING_DELETED: 'scim_group_mapping.deleted', } as const export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction] @@ -271,11 +288,14 @@ export const AuditResourceType = { PERMISSION_GROUP: 'permission_group', SANDBOX: 'sandbox', SCHEDULE: 'schedule', + SCIM_CONNECTION: 'scim_connection', + SCIM_GROUP: 'scim_group', /** Not a stored resource: the workspace's secrets, as the thing put at risk. */ SECRET_PROVENANCE: 'secret_provenance', SKILL: 'skill', SUBSCRIPTION: 'subscription', TABLE: 'table', + USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 0ad45cef624..9c1eb7b0fe4 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -6,6 +6,7 @@ export type Principal = | DelegatedPrincipal | SystemPrincipal | CredentialGroupEnrollmentPrincipal + | ScimConnectionPrincipal export interface SessionPrincipal { kind: 'session' @@ -41,6 +42,26 @@ export interface WorkspaceApiKeyPrincipal { keyId: string } +/** Operations a SCIM bearer credential may perform. Mirrors `ScimScope` in the schema. */ +export type ScimCredentialScope = 'users:read' | 'users:write' | 'groups:read' | 'groups:write' + +/** + * An organization's identity provider, authenticated by a SCIM bearer credential. + * + * It represents no human: a directory synchronizes on its own schedule, so + * attributing its writes to whoever last configured the connection would put a + * name on the audit trail that did not perform the change. The organization is + * carried by the credential rather than by the request, which is what keeps one + * tenant's directory from addressing another tenant's users. + */ +export interface ScimConnectionPrincipal { + kind: 'scim_connection' + organizationId: string + connectionId: string + credentialId: string + scopes: readonly ScimCredentialScope[] +} + export interface ExternalUserSubject { kind: 'external_user' provider: string @@ -525,6 +546,12 @@ export type PrincipalActor = enrollmentId: string email: string } + | { + kind: 'scim_connection' + organizationId: string + connectionId: string + credentialId: string + } export interface PrincipalAttribution { actor: PrincipalActor @@ -575,6 +602,7 @@ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject : null case 'workspace_api_key': case 'credential_group_enrollment': + case 'scim_connection': return null } } @@ -629,6 +657,13 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { enrollmentId: principal.enrollmentId, email: principal.email, } + case 'scim_connection': + return { + kind: principal.kind, + organizationId: principal.organizationId, + connectionId: principal.connectionId, + credentialId: principal.credentialId, + } } } @@ -650,6 +685,8 @@ export function resolvePrincipalAuditAttribution(principal: Principal): Principa return { actor, actorId: null, actorName: `System: ${actor.serviceId}` } case 'credential_group_enrollment': return { actor, actorId: null, actorName: actor.email } + case 'scim_connection': + return { actor, actorId: null, actorName: 'SCIM provisioning' } } } @@ -686,6 +723,7 @@ export function resolvePrincipalAttribution( return { actor, attributedUserId } } case 'credential_group_enrollment': + case 'scim_connection': throw new PrincipalSubjectUserRequiredError(actor.kind) } } diff --git a/packages/db/migrations/0324_scim_provisioning.sql b/packages/db/migrations/0324_scim_provisioning.sql new file mode 100644 index 00000000000..824fff69c97 --- /dev/null +++ b/packages/db/migrations/0324_scim_provisioning.sql @@ -0,0 +1,156 @@ +CREATE TABLE "scim_connection" ( + "id" text PRIMARY KEY NOT NULL, + "organization_id" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "last_request_at" timestamp, + "reconcile_lock_token" text, + "reconcile_lease_at" timestamp, + "reconciled_at" timestamp, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_credential" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "token_hash" text NOT NULL, + "token_prefix" text NOT NULL, + "scopes" jsonb NOT NULL, + "expires_at" timestamp, + "revoked_at" timestamp, + "revoked_by" text, + "last_used_at" timestamp, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_group" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "external_id" text, + "display_name" text NOT NULL, + "display_name_key" text NOT NULL, + "order_key" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_group_mapping" ( + "id" text PRIMARY KEY NOT NULL, + "group_id" text NOT NULL, + "target_kind" text NOT NULL, + "permission_group_id" text, + "workspace_id" text, + "permission_type" "permission_type", + "role" text, + "source" text DEFAULT 'manual' NOT NULL, + "created_by" text, + "created_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "scim_group_mapping_target_shape" CHECK (( + ("scim_group_mapping"."target_kind" = 'permission_group' AND "scim_group_mapping"."permission_group_id" IS NOT NULL AND "scim_group_mapping"."workspace_id" IS NULL AND "scim_group_mapping"."permission_type" IS NULL AND "scim_group_mapping"."role" IS NULL) + OR ("scim_group_mapping"."target_kind" = 'workspace' AND "scim_group_mapping"."workspace_id" IS NOT NULL AND "scim_group_mapping"."permission_type" IS NOT NULL AND "scim_group_mapping"."permission_group_id" IS NULL AND "scim_group_mapping"."role" IS NULL) + OR ("scim_group_mapping"."target_kind" = 'org_role' AND "scim_group_mapping"."role" IS NOT NULL AND "scim_group_mapping"."permission_group_id" IS NULL AND "scim_group_mapping"."workspace_id" IS NULL AND "scim_group_mapping"."permission_type" IS NULL) + )) +); +--> statement-breakpoint +CREATE TABLE "scim_group_member" ( + "id" text PRIMARY KEY NOT NULL, + "group_id" text NOT NULL, + "scim_user_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_projection_grant" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "scim_user_id" text NOT NULL, + "target_kind" text NOT NULL, + "target_id" text NOT NULL, + "permission_type" "permission_type", + "origin" text DEFAULT 'directory' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_request_log" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "credential_id" text, + "method" text NOT NULL, + "path" text NOT NULL, + "status" integer NOT NULL, + "scim_type" text, + "detail" text, + "user_agent" text, + "duration_ms" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_user" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "user_id" text NOT NULL, + "external_id" text, + "user_name" text NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "attributes" jsonb NOT NULL, + "order_key" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "scim_user_tombstone" ( + "id" text PRIMARY KEY NOT NULL, + "connection_id" text NOT NULL, + "external_id" text NOT NULL, + "user_id" text NOT NULL, + "deleted_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "permission_group" ADD COLUMN "membership_mode" text DEFAULT 'inherit' NOT NULL;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "suspended_at" timestamp;--> statement-breakpoint +ALTER TABLE "user" ADD COLUMN "suspension_source" text;--> statement-breakpoint +ALTER TABLE "scim_connection" ADD CONSTRAINT "scim_connection_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_connection" ADD CONSTRAINT "scim_connection_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_credential" ADD CONSTRAINT "scim_credential_connection_id_scim_connection_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_credential" ADD CONSTRAINT "scim_credential_revoked_by_user_id_fk" FOREIGN KEY ("revoked_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_credential" ADD CONSTRAINT "scim_credential_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group" ADD CONSTRAINT "scim_group_connection_id_scim_connection_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_mapping" ADD CONSTRAINT "scim_group_mapping_group_id_scim_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."scim_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_mapping" ADD CONSTRAINT "scim_group_mapping_permission_group_id_permission_group_id_fk" FOREIGN KEY ("permission_group_id") REFERENCES "public"."permission_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_mapping" ADD CONSTRAINT "scim_group_mapping_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_mapping" ADD CONSTRAINT "scim_group_mapping_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_member" ADD CONSTRAINT "scim_group_member_group_id_scim_group_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."scim_group"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_group_member" ADD CONSTRAINT "scim_group_member_scim_user_id_scim_user_id_fk" FOREIGN KEY ("scim_user_id") REFERENCES "public"."scim_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_projection_grant" ADD CONSTRAINT "scim_projection_grant_connection_id_scim_connection_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_projection_grant" ADD CONSTRAINT "scim_projection_grant_scim_user_id_scim_user_id_fk" FOREIGN KEY ("scim_user_id") REFERENCES "public"."scim_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_request_log" ADD CONSTRAINT "scim_request_log_connection_id_scim_connection_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_user" ADD CONSTRAINT "scim_user_connection_id_scim_connection_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_user" ADD CONSTRAINT "scim_user_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_user_tombstone" ADD CONSTRAINT "scim_user_tombstone_connection_id_scim_connection_id_fk" FOREIGN KEY ("connection_id") REFERENCES "public"."scim_connection"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "scim_user_tombstone" ADD CONSTRAINT "scim_user_tombstone_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "scim_connection_organization_unique" ON "scim_connection" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "scim_connection_reconcile_due_idx" ON "scim_connection" USING btree ("reconciled_at");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_credential_token_hash_unique" ON "scim_credential" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "scim_credential_connection_idx" ON "scim_credential" USING btree ("connection_id");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_group_connection_display_name_unique" ON "scim_group" USING btree ("connection_id","display_name_key");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_group_connection_external_id_unique" ON "scim_group" USING btree ("connection_id","external_id") WHERE external_id is not null;--> statement-breakpoint +CREATE INDEX "scim_group_connection_order_idx" ON "scim_group" USING btree ("connection_id","order_key");--> statement-breakpoint +CREATE INDEX "scim_group_mapping_group_idx" ON "scim_group_mapping" USING btree ("group_id");--> statement-breakpoint +CREATE INDEX "scim_group_mapping_permission_group_idx" ON "scim_group_mapping" USING btree ("permission_group_id");--> statement-breakpoint +CREATE INDEX "scim_group_mapping_workspace_idx" ON "scim_group_mapping" USING btree ("workspace_id");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_group_mapping_group_target_unique" ON "scim_group_mapping" USING btree ("group_id","target_kind",coalesce("permission_group_id", "workspace_id", "role"));--> statement-breakpoint +CREATE UNIQUE INDEX "scim_group_member_group_user_unique" ON "scim_group_member" USING btree ("group_id","scim_user_id");--> statement-breakpoint +CREATE INDEX "scim_group_member_scim_user_idx" ON "scim_group_member" USING btree ("scim_user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_projection_grant_user_target_unique" ON "scim_projection_grant" USING btree ("scim_user_id","target_kind","target_id");--> statement-breakpoint +CREATE INDEX "scim_projection_grant_connection_idx" ON "scim_projection_grant" USING btree ("connection_id");--> statement-breakpoint +CREATE INDEX "scim_request_log_connection_created_idx" ON "scim_request_log" USING btree ("connection_id","created_at");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_user_connection_user_unique" ON "scim_user" USING btree ("connection_id","user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_user_connection_user_name_unique" ON "scim_user" USING btree ("connection_id","user_name");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_user_connection_external_id_unique" ON "scim_user" USING btree ("connection_id","external_id") WHERE external_id is not null;--> statement-breakpoint +CREATE INDEX "scim_user_connection_order_idx" ON "scim_user" USING btree ("connection_id","order_key");--> statement-breakpoint +CREATE INDEX "scim_user_user_idx" ON "scim_user" USING btree ("user_id");--> statement-breakpoint +CREATE UNIQUE INDEX "scim_user_tombstone_connection_external_id_unique" ON "scim_user_tombstone" USING btree ("connection_id","external_id");--> statement-breakpoint +CREATE INDEX "scim_user_tombstone_user_idx" ON "scim_user_tombstone" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/migrations/meta/0324_snapshot.json b/packages/db/migrations/meta/0324_snapshot.json new file mode 100644 index 00000000000..b3f067b8bdf --- /dev/null +++ b/packages/db/migrations/meta/0324_snapshot.json @@ -0,0 +1,23931 @@ +{ + "id": "05bf71c1-fb83-46f4-92a8-4f97266fcdda", + "prevId": "2ae905da-9784-44b5-a65f-b20c916122e9", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": ["certificate_number"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_unreconciled_terminal_idx": { + "name": "async_jobs_schedule_unreconciled_terminal_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" IN ('completed', 'failed', 'cancelled') AND COALESCE(\"async_jobs\".\"metadata\" ->> 'scheduleReconciled', 'false') <> 'true'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "include_tool_calls": { + "name": "include_tool_calls", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_decision": { + "name": "permission_decision", + "type": "copilot_tool_permission_decision", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "permission_decided_at": { + "name": "permission_decided_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "auto_allowed_tools": { + "name": "auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_deleted_partial_idx": { + "name": "copilot_chats_user_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_chats\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": ["run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unredacted": { + "name": "unredacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_app_id": { + "name": "authorization_app_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_enrollment_id": { + "name": "credential_group_enrollment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_scope_version": { + "name": "managed_oauth_scope_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_oauth_status": { + "name": "managed_oauth_status", + "type": "managed_oauth_credential_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "encrypted_oauth_token_set": { + "name": "encrypted_oauth_token_set", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mcp_tools": { + "name": "mcp_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "mcp_tools_refreshed_at": { + "name": "mcp_tools_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_idx": { + "name": "credential_group_enrollment_idx", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_mcp_server_idx": { + "name": "credential_mcp_server_idx", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_option_unique": { + "name": "credential_group_option_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_group_option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_oauth'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_managed_mcp_enrollment_server_unique": { + "name": "credential_managed_mcp_enrollment_server_unique", + "columns": [ + { + "expression": "credential_group_enrollment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credential\".\"type\" = 'managed_mcp'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk": { + "name": "credential_credential_group_enrollment_id_credential_group_enrollment_id_fk", + "tableFrom": "credential", + "tableTo": "credential_group_enrollment", + "columnsFrom": ["credential_group_enrollment_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_mcp_server_id_mcp_servers_id_fk": { + "name": "credential_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "credential", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_managed_oauth_source_check": { + "name": "credential_managed_oauth_source_check", + "value": "(type::text <> 'managed_oauth') OR (\n account_id IS NULL\n AND provider_id IS NOT NULL\n AND authorization_app_id IS NOT NULL\n AND provider_subject_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND granted_scopes IS NOT NULL\n AND cardinality(granted_scopes) > 0\n AND encrypted_oauth_token_set IS NOT NULL\n AND granted_at IS NOT NULL\n )" + }, + "credential_managed_oauth_group_binding_check": { + "name": "credential_managed_oauth_group_binding_check", + "value": "(type::text <> 'managed_oauth') OR (\n credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NOT NULL\n AND managed_oauth_scope_version IS NOT NULL\n AND managed_oauth_scope_version > 0\n )" + }, + "credential_managed_mcp_source_check": { + "name": "credential_managed_mcp_source_check", + "value": "(type::text <> 'managed_mcp') OR (\n id LIKE 'mcp-cg-%'\n AND account_id IS NULL\n AND provider_id IS NULL\n AND authorization_app_id IS NULL\n AND credential_group_enrollment_id IS NOT NULL\n AND credential_group_option_id IS NULL\n AND mcp_server_id IS NOT NULL\n AND managed_oauth_status IS NOT NULL\n AND (managed_oauth_status <> 'active' OR (\n encrypted_oauth_token_set IS NOT NULL\n AND mcp_tools IS NOT NULL\n ))\n AND granted_at IS NOT NULL\n AND managed_oauth_scope_version IS NULL\n AND provider_subject_id IS NULL\n AND provider_tenant_id IS NULL\n AND granted_scopes IS NULL\n AND provider_metadata IS NULL\n AND created_by IS NULL\n AND env_key IS NULL\n AND env_owner_user_id IS NULL\n AND encrypted_service_account_key IS NULL\n AND unredacted = false\n )" + }, + "credential_creator_source_check": { + "name": "credential_creator_source_check", + "value": "(type::text = 'managed_mcp') OR created_by IS NOT NULL" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_group": { + "name": "credential_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_provider_configuration": { + "name": "encrypted_provider_configuration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "credential_group_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_public_id_unique": { + "name": "credential_group_public_id_unique", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_status_idx": { + "name": "credential_group_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_workspace_name_unique": { + "name": "credential_group_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(\"name\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_workspace_id_workspace_id_fk": { + "name": "credential_group_workspace_id_workspace_id_fk", + "tableFrom": "credential_group", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_created_by_user_id_fk": { + "name": "credential_group_created_by_user_id_fk", + "tableFrom": "credential_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential_group_enrollment": { + "name": "credential_group_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "credential_group_enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + }, + "invitation_token_hash": { + "name": "invitation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitation_expires_at": { + "name": "invitation_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_delivery_error": { + "name": "last_delivery_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_group_enrollment_group_email_unique": { + "name": "credential_group_enrollment_group_email_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_invitation_token_hash_unique": { + "name": "credential_group_enrollment_invitation_token_hash_unique", + "columns": [ + { + "expression": "invitation_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_status_idx": { + "name": "credential_group_enrollment_group_status_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_group_enrollment_group_invited_at_id_idx": { + "name": "credential_group_enrollment_group_invited_at_id_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "invited_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_group_enrollment_credential_group_id_credential_group_id_fk": { + "name": "credential_group_enrollment_credential_group_id_credential_group_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_group_enrollment_created_by_user_id_fk": { + "name": "credential_group_enrollment_created_by_user_id_fk", + "tableFrom": "credential_group_enrollment", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_group_enrollment_normalized_email_check": { + "name": "credential_group_enrollment_normalized_email_check", + "value": "\"credential_group_enrollment\".\"email\" = lower(btrim(\"credential_group_enrollment\".\"email\")) AND length(\"credential_group_enrollment\".\"email\") BETWEEN 3 AND 320" + }, + "credential_group_enrollment_invitation_token_hash_length_check": { + "name": "credential_group_enrollment_invitation_token_hash_length_check", + "value": "length(\"credential_group_enrollment\".\"invitation_token_hash\") = 64" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "trace_child_runs": { + "name": "trace_child_runs", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_queued_at": { + "name": "processing_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_queue_token": { + "name": "processing_queue_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_deferred_until": { + "name": "processing_deferred_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acl": { + "name": "acl", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{ws}'::text[]" + }, + "source_modified_at": { + "name": "source_modified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_acl_gin_idx": { + "name": "doc_acl_gin_idx", + "columns": [ + { + "expression": "acl", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "array_ops" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "gin", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_active_kb_token_count_idx": { + "name": "doc_active_kb_token_count_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "token_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"user_excluded\" = false AND \"document\".\"archived_at\" IS NULL AND \"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "doc_acl_token_shape_check": { + "name": "doc_acl_token_shape_check", + "value": "array_position(\"document\".\"acl\", NULL) IS NULL AND (cardinality(\"document\".\"acl\") = 0 OR (cardinality(\"document\".\"acl\") = array_length(string_to_array(array_to_string(\"document\".\"acl\", E'\\n'), E'\\n'), 1) AND array_to_string(\"document\".\"acl\", E'\\n') ~ '^((ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+)(\\n(ws|pub|link|u:[^\\nA-Z]+@[^\\nA-Z]+|[gs]:[^\\n:]+:[^\\n:]+:[^\\n]+))*)$'))" + } + }, + "isRLSEnabled": false + }, + "public.document_secret_provenance": { + "name": "document_secret_provenance", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "document_secret_provenance_document_id_document_id_fk": { + "name": "document_secret_provenance_document_id_document_id_fk", + "tableFrom": "document_secret_provenance", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "document_secret_provenance_status_check": { + "name": "document_secret_provenance_status_check", + "value": "\"document_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_384": { + "name": "embedding_384", + "type": "vector(384)", + "primaryKey": false, + "notNull": false + }, + "embedding_768": { + "name": "embedding_768", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "embedding_1024": { + "name": "embedding_1024", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "embedding_3072": { + "name": "embedding_3072", + "type": "vector(3072)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_384_vector_hnsw_idx": { + "name": "embedding_384_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_384", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_768_vector_hnsw_idx": { + "name": "embedding_768_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_768", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_1024_vector_hnsw_idx": { + "name": "embedding_1024_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding_1024", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "embedding_3072_vector_hnsw_idx": { + "name": "embedding_3072_vector_hnsw_idx", + "columns": [ + { + "expression": "(\"embedding_3072\"::halfvec(3072)) halfvec_cosine_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_width_check": { + "name": "embedding_width_check", + "value": "num_nonnulls(\"embedding\", \"embedding_384\", \"embedding_768\", \"embedding_1024\", \"embedding_3072\") = 1" + } + }, + "isRLSEnabled": false + }, + "public.embedding_secret_provenance": { + "name": "embedding_secret_provenance", + "schema": "", + "columns": { + "embedding_id": { + "name": "embedding_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "embedding_secret_provenance_embedding_id_embedding_id_fk": { + "name": "embedding_secret_provenance_embedding_id_embedding_id_fk", + "tableFrom": "embedding_secret_provenance", + "tableTo": "embedding", + "columnsFrom": ["embedding_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_secret_provenance_status_check": { + "name": "embedding_secret_provenance_status_check", + "value": "\"embedding_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": ["parent_key", "child_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_references_workflow_id_idx": { + "name": "execution_large_value_references_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": ["key", "execution_id", "source"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_workflow_id_idx": { + "name": "execution_large_values_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.folder": { + "name": "folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "folder_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "folder_user_idx": { + "name": "folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_idx": { + "name": "folder_workspace_resource_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_parent_sort_idx": { + "name": "folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_deleted_at_idx": { + "name": "folder_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_deleted_partial_idx": { + "name": "folder_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"folder\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "folder_workspace_resource_parent_name_active_unique": { + "name": "folder_workspace_resource_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"folder\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "folder_user_id_user_id_fk": { + "name": "folder_user_id_user_id_fk", + "tableFrom": "folder", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_workspace_id_workspace_id_fk": { + "name": "folder_workspace_id_workspace_id_fk", + "tableFrom": "folder", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "folder_parent_id_folder_id_fk": { + "name": "folder_parent_id_folder_id_fk", + "tableFrom": "folder", + "tableTo": "folder", + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_folder_id_idx": { + "name": "kb_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_folder_id_folder_id_fk": { + "name": "knowledge_base_folder_id_folder_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_group_option_id": { + "name": "credential_group_option_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_status": { + "name": "member_sync_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "member_sync_lock_token": { + "name": "member_sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_lock_lease_at": { + "name": "member_sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_member_sync_at": { + "name": "next_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_at": { + "name": "last_member_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_member_sync_error": { + "name": "last_member_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_sync_consecutive_failures": { + "name": "member_sync_consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "access_rewrite_pending": { + "name": "access_rewrite_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sync_lock_token": { + "name": "sync_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_lock_lease_at": { + "name": "sync_lock_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_member_sync_due_idx": { + "name": "kc_member_sync_due_idx", + "columns": [ + { + "expression": "member_sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_member_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"access_mode\" = 'members' AND \"knowledge_connector\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_credential_group_id_credential_group_id_fk": { + "name": "knowledge_connector_credential_group_id_credential_group_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kc_access_mode_check": { + "name": "kc_access_mode_check", + "value": "\"knowledge_connector\".\"access_mode\" IN ('workspace', 'members', 'admin')" + }, + "kc_member_sync_status_check": { + "name": "kc_member_sync_status_check", + "value": "\"knowledge_connector\".\"member_sync_status\" IN ('idle', 'pending', 'running', 'error', 'disabled')" + }, + "kc_sync_lock_exclusive_check": { + "name": "kc_sync_lock_exclusive_check", + "value": "NOT (\"knowledge_connector\".\"sync_lock_token\" IS NOT NULL AND \"knowledge_connector\".\"member_sync_lock_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member": { + "name": "knowledge_connector_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_token": { + "name": "subject_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_started_at": { + "name": "last_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_complete_listing_at": { + "name": "last_complete_listing_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_listed_count": { + "name": "last_listed_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "member_synced_through": { + "name": "member_synced_through", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "change_cursor": { + "name": "change_cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kcm_connector_credential_unique": { + "name": "kcm_connector_credential_unique", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_connector_queue_idx": { + "name": "kcm_connector_queue_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "last_started_at", + "isExpression": false, + "asc": true, + "nulls": "first" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcm_credential_idx": { + "name": "kcm_credential_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_workspace_id_workspace_id_fk": { + "name": "knowledge_connector_member_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_connector_member_credential_id_credential_id_fk": { + "name": "knowledge_connector_member_credential_id_credential_id_fk", + "tableFrom": "knowledge_connector_member", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcm_status_check": { + "name": "kcm_status_check", + "value": "\"knowledge_connector_member\".\"status\" IN ('active', 'suspended', 'disabled')" + }, + "kcm_subject_token_shape_check": { + "name": "kcm_subject_token_shape_check", + "value": "\"knowledge_connector_member\".\"subject_token\" ~ '^s:[^:]+:[^:]+:.+$'" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_member_sync_log": { + "name": "knowledge_connector_member_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "members_claimed": { + "name": "members_claimed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_completed": { + "name": "members_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_incomplete": { + "name": "members_incomplete", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "members_failed": { + "name": "members_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_listed": { + "name": "docs_listed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_hydrated_once": { + "name": "docs_hydrated_once", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_added": { + "name": "observations_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "observations_removed": { + "name": "observations_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_tombstoned": { + "name": "docs_tombstoned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_resurrected": { + "name": "docs_resurrected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_purged": { + "name": "docs_purged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credentials_audited": { + "name": "credentials_audited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcmsl_connector_started_at_idx": { + "name": "kcmsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcmsl_started_at_partial_idx": { + "name": "kcmsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_member_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_member_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_member_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kcmsl_status_check": { + "name": "kcmsl_status_check", + "value": "\"knowledge_connector_member_sync_log\".\"status\" IN ('started', 'completed', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_skipped": { + "name": "docs_skipped", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_started_at_idx": { + "name": "kcsl_connector_started_at_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kcsl_started_at_partial_idx": { + "name": "kcsl_started_at_partial_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector_sync_log\".\"status\" = 'started'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_document_observation": { + "name": "knowledge_document_observation", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "kdo_member_idx": { + "name": "kdo_member_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_document_observation_document_id_document_id_fk": { + "name": "knowledge_document_observation_document_id_document_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "document", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_document_observation_member_id_knowledge_connector_member_id_fk": { + "name": "knowledge_document_observation_member_id_knowledge_connector_member_id_fk", + "tableFrom": "knowledge_document_observation", + "tableTo": "knowledge_connector_member", + "columnsFrom": ["member_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "knowledge_document_observation_document_id_member_id_pk": { + "name": "knowledge_document_observation_document_id_member_id_pk", + "columns": ["document_id", "member_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_group_id": { + "name": "credential_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "managed_connector_id": { + "name": "managed_connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_idx": { + "name": "mcp_servers_credential_group_idx", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_credential_group_managed_connector_unique": { + "name": "mcp_servers_credential_group_managed_connector_unique", + "columns": [ + { + "expression": "credential_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "managed_connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_servers\".\"credential_group_id\" IS NOT NULL AND \"mcp_servers\".\"managed_connector_id\" IS NOT NULL AND \"mcp_servers\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_credential_group_id_credential_group_id_fk": { + "name": "mcp_servers_credential_group_id_credential_group_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "credential_group", + "columnsFrom": ["credential_group_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_servers_credential_group_managed_connector_check": { + "name": "mcp_servers_credential_group_managed_connector_check", + "value": "\"mcp_servers\".\"credential_group_id\" IS NULL OR \"mcp_servers\".\"managed_connector_id\" IS NOT NULL" + }, + "mcp_servers_managed_connector_oauth_check": { + "name": "mcp_servers_managed_connector_oauth_check", + "value": "\"mcp_servers\".\"managed_connector_id\" IS NULL OR \"mcp_servers\".\"auth_type\" = 'oauth'" + } + }, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_secret_provenance": { + "name": "memory_secret_provenance", + "schema": "", + "columns": { + "memory_id": { + "name": "memory_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "memory_secret_provenance_memory_id_memory_id_fk": { + "name": "memory_secret_provenance_memory_id_memory_id_fk", + "tableFrom": "memory_secret_provenance", + "tableTo": "memory", + "columnsFrom": ["memory_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "memory_secret_provenance_status_check": { + "name": "memory_secret_provenance_status_check", + "value": "\"memory_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": ["added_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": ["workspace_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_access_token_client_id_idx": { + "name": "oauth_access_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_session_id_idx": { + "name": "oauth_access_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_refresh_id_idx": { + "name": "oauth_access_token_refresh_id_idx", + "columns": [ + { + "expression": "refresh_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_user_client_idx": { + "name": "oauth_access_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_access_token_expires_at_idx": { + "name": "oauth_access_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": ["refresh_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "oauth_client_user_id_idx": { + "name": "oauth_client_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": ["client_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_consent_client_id_idx": { + "name": "oauth_consent_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_consent_user_client_reference_unique": { + "name": "oauth_consent_user_client_reference_unique", + "nullsNotDistinct": true, + "columns": ["user_id", "client_id", "reference_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_refresh_token_client_id_idx": { + "name": "oauth_refresh_token_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_session_id_idx": { + "name": "oauth_refresh_token_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_user_client_idx": { + "name": "oauth_refresh_token_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_refresh_token_expires_at_idx": { + "name": "oauth_refresh_token_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_family_id_oauth_token_family_id_fk": { + "name": "oauth_refresh_token_family_id_oauth_token_family_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_token_family", + "columnsFrom": ["family_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + }, + "oauth_refresh_token_family_generation_unique": { + "name": "oauth_refresh_token_family_generation_unique", + "nullsNotDistinct": false, + "columns": ["family_id", "generation"] + } + }, + "policies": {}, + "checkConstraints": { + "oauth_refresh_token_generation_check": { + "name": "oauth_refresh_token_generation_check", + "value": "\"oauth_refresh_token\".\"generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.oauth_token_family": { + "name": "oauth_token_family", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_id": { + "name": "consent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_generation": { + "name": "current_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_token_family_client_id_idx": { + "name": "oauth_token_family_client_id_idx", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_session_id_idx": { + "name": "oauth_token_family_session_id_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_user_client_idx": { + "name": "oauth_token_family_user_client_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_consent_id_idx": { + "name": "oauth_token_family_consent_id_idx", + "columns": [ + { + "expression": "consent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "oauth_token_family_expires_at_idx": { + "name": "oauth_token_family_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "oauth_token_family_client_id_oauth_client_client_id_fk": { + "name": "oauth_token_family_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_client", + "columnsFrom": ["client_id"], + "columnsTo": ["client_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_session_id_session_id_fk": { + "name": "oauth_token_family_session_id_session_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "session", + "columnsFrom": ["session_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_token_family_user_id_user_id_fk": { + "name": "oauth_token_family_user_id_user_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_token_family_consent_id_oauth_consent_id_fk": { + "name": "oauth_token_family_consent_id_oauth_consent_id_fk", + "tableFrom": "oauth_token_family", + "tableTo": "oauth_consent", + "columnsFrom": ["consent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "oauth_token_family_generation_check": { + "name": "oauth_token_family_generation_check", + "value": "\"oauth_token_family\".\"current_generation\" BETWEEN 0 AND 1000" + } + }, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "session_policy_settings": { + "name": "session_policy_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "security_policy_version": { + "name": "security_policy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_byok_keys": { + "name": "organization_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_byok_organization_provider_idx": { + "name": "organization_byok_organization_provider_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_byok_keys_organization_id_organization_id_fk": { + "name": "organization_byok_keys_organization_id_organization_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_byok_keys_created_by_user_id_fk": { + "name": "organization_byok_keys_created_by_user_id_fk", + "tableFrom": "organization_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": ["set_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_config": { + "name": "oauth_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "membership_mode": { + "name": "membership_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'inherit'" + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pinned_item": { + "name": "pinned_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pinned_item_user_workspace_idx": { + "name": "pinned_item_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_resource_idx": { + "name": "pinned_item_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pinned_item_user_resource_unique": { + "name": "pinned_item_user_resource_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pinned_item_user_id_user_id_fk": { + "name": "pinned_item_user_id_user_id_fk", + "tableFrom": "pinned_item", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pinned_item_workspace_id_workspace_id_fk": { + "name": "pinned_item_workspace_id_workspace_id_fk", + "tableFrom": "pinned_item", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_policy": { + "name": "resource_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "resource_policy_resource_unique": { + "name": "resource_policy_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resource_policy_workspace_id_idx": { + "name": "resource_policy_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_policy_workspace_id_workspace_id_fk": { + "name": "resource_policy_workspace_id_workspace_id_fk", + "tableFrom": "resource_policy", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_policy_created_by_user_id_fk": { + "name": "resource_policy_created_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "resource_policy_updated_by_user_id_fk": { + "name": "resource_policy_updated_by_user_id_fk", + "tableFrom": "resource_policy", + "tableTo": "user", + "columnsFrom": ["updated_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sandbox_image": { + "name": "sandbox_image", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sandbox_image_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "image_ref": { + "name": "image_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_image_id": { + "name": "provider_image_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "materialization_generation": { + "name": "materialization_generation", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_detail": { + "name": "error_detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sandbox_image_provider_spec_unique": { + "name": "sandbox_image_provider_spec_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_status_idx": { + "name": "sandbox_image_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sandbox_image_last_used_idx": { + "name": "sandbox_image_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection": { + "name": "scim_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_request_at": { + "name": "last_request_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconcile_lock_token": { + "name": "reconcile_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reconcile_lease_at": { + "name": "reconcile_lease_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "reconciled_at": { + "name": "reconciled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_connection_organization_unique": { + "name": "scim_connection_organization_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_connection_reconcile_due_idx": { + "name": "scim_connection_reconcile_due_idx", + "columns": [ + { + "expression": "reconciled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_connection_organization_id_organization_id_fk": { + "name": "scim_connection_organization_id_organization_id_fk", + "tableFrom": "scim_connection", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_connection_created_by_user_id_fk": { + "name": "scim_connection_created_by_user_id_fk", + "tableFrom": "scim_connection", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_credential": { + "name": "scim_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_prefix": { + "name": "token_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_credential_token_hash_unique": { + "name": "scim_credential_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_credential_connection_idx": { + "name": "scim_credential_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_credential_connection_id_scim_connection_id_fk": { + "name": "scim_credential_connection_id_scim_connection_id_fk", + "tableFrom": "scim_credential", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_credential_revoked_by_user_id_fk": { + "name": "scim_credential_revoked_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "scim_credential_created_by_user_id_fk": { + "name": "scim_credential_created_by_user_id_fk", + "tableFrom": "scim_credential", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_connection_display_name_unique": { + "name": "scim_group_connection_display_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_external_id_unique": { + "name": "scim_group_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_connection_order_idx": { + "name": "scim_group_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_connection_id_scim_connection_id_fk": { + "name": "scim_group_connection_id_scim_connection_id_fk", + "tableFrom": "scim_group", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_mapping": { + "name": "scim_group_mapping", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_mapping_group_idx": { + "name": "scim_group_mapping_group_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_permission_group_idx": { + "name": "scim_group_mapping_permission_group_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_workspace_idx": { + "name": "scim_group_mapping_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_mapping_group_target_unique": { + "name": "scim_group_mapping_group_target_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"permission_group_id\", \"workspace_id\", \"role\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_mapping_group_id_scim_group_id_fk": { + "name": "scim_group_mapping_group_id_scim_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_permission_group_id_permission_group_id_fk": { + "name": "scim_group_mapping_permission_group_id_permission_group_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "permission_group", + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_workspace_id_workspace_id_fk": { + "name": "scim_group_mapping_workspace_id_workspace_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_mapping_created_by_user_id_fk": { + "name": "scim_group_mapping_created_by_user_id_fk", + "tableFrom": "scim_group_mapping", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "scim_group_mapping_target_shape": { + "name": "scim_group_mapping_target_shape", + "value": "(\n (\"scim_group_mapping\".\"target_kind\" = 'permission_group' AND \"scim_group_mapping\".\"permission_group_id\" IS NOT NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'workspace' AND \"scim_group_mapping\".\"workspace_id\" IS NOT NULL AND \"scim_group_mapping\".\"permission_type\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"role\" IS NULL)\n OR (\"scim_group_mapping\".\"target_kind\" = 'org_role' AND \"scim_group_mapping\".\"role\" IS NOT NULL AND \"scim_group_mapping\".\"permission_group_id\" IS NULL AND \"scim_group_mapping\".\"workspace_id\" IS NULL AND \"scim_group_mapping\".\"permission_type\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_group_member_group_user_unique": { + "name": "scim_group_member_group_user_unique", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_idx": { + "name": "scim_group_member_scim_user_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'directory'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_projection_grant_user_target_unique": { + "name": "scim_projection_grant_user_target_unique", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_connection_idx": { + "name": "scim_projection_grant_connection_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_connection_id_scim_connection_id_fk": { + "name": "scim_projection_grant_connection_id_scim_connection_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_request_log": { + "name": "scim_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scim_type": { + "name": "scim_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_request_log_connection_created_idx": { + "name": "scim_request_log_connection_created_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_request_log_connection_id_scim_connection_id_fk": { + "name": "scim_request_log_connection_id_scim_connection_id_fk", + "tableFrom": "scim_request_log", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "attributes": { + "name": "attributes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_connection_user_unique": { + "name": "scim_user_connection_user_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_user_name_unique": { + "name": "scim_user_connection_user_name_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_external_id_unique": { + "name": "scim_user_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "external_id is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_connection_order_idx": { + "name": "scim_user_connection_order_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_idx": { + "name": "scim_user_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_connection_id_scim_connection_id_fk": { + "name": "scim_user_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user_tombstone": { + "name": "scim_user_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scim_user_tombstone_connection_external_id_unique": { + "name": "scim_user_tombstone_connection_external_id_unique", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_tombstone_user_idx": { + "name": "scim_user_tombstone_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_tombstone_connection_id_scim_connection_id_fk": { + "name": "scim_user_tombstone_connection_id_scim_connection_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "scim_connection", + "columnsFrom": ["connection_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_user_tombstone_user_id_user_id_fk": { + "name": "scim_user_tombstone_user_id_user_id_fk", + "tableFrom": "scim_user_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret_usage": { + "name": "secret_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_name": { + "name": "secret_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_scope": { + "name": "secret_scope", + "type": "secret_usage_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "secret_owner_user_id": { + "name": "secret_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "source": { + "name": "source", + "type": "secret_usage_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_execution_id": { + "name": "last_execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_trigger": { + "name": "last_trigger", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "secret_usage_bucket_unique": { + "name": "secret_usage_bucket_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_usage_secret_recent_idx": { + "name": "secret_usage_secret_recent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_used_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "secret_usage_workspace_id_workspace_id_fk": { + "name": "secret_usage_workspace_id_workspace_id_fk", + "tableFrom": "secret_usage", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auto_focus_on_click": { + "name": "auto_focus_on_click", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": ["workflow_id", "block_id", "scope_key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_member": { + "name": "skill_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_member_user_id_idx": { + "name": "skill_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "skill_member_unique": { + "name": "skill_member_unique", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_member_skill_id_skill_id_fk": { + "name": "skill_member_skill_id_skill_id_fk", + "tableFrom": "skill_member", + "tableTo": "skill", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_user_id_user_id_fk": { + "name": "skill_member_user_id_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_member_invited_by_user_id_fk": { + "name": "skill_member_invited_by_user_id_fk", + "tableFrom": "skill_member", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_domain": { + "name": "sso_domain", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sso_domain_organization_id_idx": { + "name": "sso_domain_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_domain_idx": { + "name": "sso_domain_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_org_domain_unique": { + "name": "sso_domain_org_domain_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_domain_verified_unique": { + "name": "sso_domain_verified_unique", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_domain_organization_id_organization_id_fk": { + "name": "sso_domain_organization_id_organization_id_fk", + "tableFrom": "sso_domain", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_domain_created_by_user_id_fk": { + "name": "sso_domain_created_by_user_id_fk", + "tableFrom": "sso_domain", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "jit_provisioning_enabled": { + "name": "jit_provisioning_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "last_closed_period_start": { + "name": "last_closed_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_cycle_close_lagging_idx": { + "name": "subscription_cycle_close_lagging_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"subscription\".\"status\" in ('active', 'past_due') and \"subscription\".\"period_start\" is not null and (\"subscription\".\"last_closed_period_start\" is null or \"subscription\".\"last_closed_period_start\" < \"subscription\".\"period_start\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_capability_governed_user_id_user_id_fk": { + "name": "table_row_executions_capability_governed_user_id_user_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": ["row_id", "group_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capability_governed_user_id": { + "name": "capability_governed_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_governed_active_idx": { + "name": "table_run_dispatches_governed_active_idx", + "columns": [ + { + "expression": "capability_governed_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_run_dispatches\".\"status\" IN ('pending', 'dispatching')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "table_run_dispatches_capability_governed_user_id_user_id_fk": { + "name": "table_run_dispatches_capability_governed_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": ["capability_governed_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_views": { + "name": "table_views", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_views_table_created_idx": { + "name": "table_views_table_created_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_workspace_created_idx": { + "name": "table_views_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_views_table_default_unique": { + "name": "table_views_table_default_unique", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_views_table_id_user_table_definitions_id_fk": { + "name": "table_views_table_id_user_table_definitions_id_fk", + "tableFrom": "table_views", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_workspace_id_workspace_id_fk": { + "name": "table_views_workspace_id_workspace_id_fk", + "tableFrom": "table_views", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_views_created_by_user_id_fk": { + "name": "table_views_created_by_user_id_fk", + "tableFrom": "table_views", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.upload_session": { + "name": "upload_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "upload_session_purpose", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "upload_session_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_context": { + "name": "storage_context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "final_key": { + "name": "final_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "upload_session_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_upload_id": { + "name": "provider_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_object_version": { + "name": "provider_object_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "part_size": { + "name": "part_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "part_count": { + "name": "part_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "upload_session_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'uploading'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_lease_id": { + "name": "processing_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "processing_lease_expires_at": { + "name": "processing_lease_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_file_id": { + "name": "completed_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "upload_session_token_hash_unique": { + "name": "upload_session_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_final_key_unique": { + "name": "upload_session_final_key_unique", + "columns": [ + { + "expression": "final_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "upload_session_status_expires_at_idx": { + "name": "upload_session_status_expires_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_period_cost_idx": { + "name": "usage_log_billing_period_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_created_at_cost_idx": { + "name": "usage_log_billing_entity_created_at_cost_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "suspension_source": { + "name": "suspension_source", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": ["normalized_email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "schema_locked": { + "name": "schema_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "insert_locked": { + "name": "insert_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "update_locked": { + "name": "update_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "delete_locked": { + "name": "delete_locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_folder_id_idx": { + "name": "user_table_def_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_folder_id_folder_id_fk": { + "name": "user_table_definitions_folder_id_folder_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_row_secret_provenance": { + "name": "user_table_row_secret_provenance", + "schema": "", + "columns": { + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_table_row_secret_provenance_row_id_user_table_rows_id_fk": { + "name": "user_table_row_secret_provenance_row_id_user_table_rows_id_fk", + "tableFrom": "user_table_row_secret_provenance", + "tableTo": "user_table_rows", + "columnsFrom": ["row_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_table_row_secret_provenance_status_check": { + "name": "user_table_row_secret_provenance_status_check", + "value": "\"user_table_row_secret_provenance\".\"status\" IN ('exact', 'unknown')" + } + }, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_created_id_idx": { + "name": "user_table_rows_table_created_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": ["table_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_status": { + "name": "registration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_generation": { + "name": "registration_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config_fingerprint": { + "name": "config_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prepared_at": { + "name": "prepared_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_active_registration_unique": { + "name": "webhook_active_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'active' AND \"webhook\".\"block_id\" IS NOT NULL AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_candidate_registration_unique": { + "name": "webhook_candidate_registration_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"registration_status\" = 'candidate' AND \"webhook\".\"block_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_registration_status_generation_idx": { + "name": "webhook_registration_status_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "registration_generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_registration_status_check": { + "name": "webhook_registration_status_check", + "value": "\"webhook\".\"registration_status\" IS NULL OR \"webhook\".\"registration_status\" IN ('active', 'candidate', 'retired', 'orphaned')" + }, + "webhook_registration_generation_check": { + "name": "webhook_registration_generation_check", + "value": "\"webhook\".\"registration_generation\" IS NULL OR \"webhook\".\"registration_generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.webhook_path_claim": { + "name": "webhook_path_claim", + "schema": "", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_path_claim_workflow_idx": { + "name": "webhook_path_claim_workflow_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_path_claim_workflow_id_workflow_id_fk": { + "name": "webhook_path_claim_workflow_id_workflow_id_fk", + "tableFrom": "webhook_path_claim", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "webhook_path_claim_generation_check": { + "name": "webhook_path_claim_generation_check", + "value": "\"webhook_path_claim\".\"generation\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "fork_sync_excluded": { + "name": "fork_sync_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_active_workspace_sort_idx": { + "name": "workflow_active_workspace_sort_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_folder_id_fk": { + "name": "workflow_folder_id_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error_enabled": { + "name": "error_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "retry": { + "name": "retry", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_operation": { + "name": "workflow_deployment_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_active_version_id": { + "name": "previous_active_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol_version": { + "name": "protocol_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'preparing'" + }, + "component_readiness": { + "name": "component_readiness", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_hash": { + "name": "request_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_deployment_operation_workflow_generation_unique": { + "name": "workflow_deployment_operation_workflow_generation_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_idempotency_unique": { + "name": "workflow_deployment_operation_workflow_idempotency_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_in_flight_unique": { + "name": "workflow_deployment_operation_workflow_in_flight_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_status_idx": { + "name": "workflow_deployment_operation_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_deployment_version_idx": { + "name": "workflow_deployment_operation_deployment_version_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_operation_workflow_version_generation_idx": { + "name": "workflow_deployment_operation_workflow_version_generation_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_operation_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_operation_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_deployment_operation_previous_active_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_deployment_operation", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["previous_active_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workflow_deployment_operation_action_check": { + "name": "workflow_deployment_operation_action_check", + "value": "\"workflow_deployment_operation\".\"action\" IN ('deploy', 'activate')" + }, + "workflow_deployment_operation_status_check": { + "name": "workflow_deployment_operation_status_check", + "value": "\"workflow_deployment_operation\".\"status\" IN ('preparing', 'activating', 'active', 'failed', 'superseded')" + }, + "workflow_deployment_operation_generation_check": { + "name": "workflow_deployment_operation_generation_check", + "value": "\"workflow_deployment_operation\".\"generation\" > 0" + }, + "workflow_deployment_operation_protocol_version_check": { + "name": "workflow_deployment_operation_protocol_version_check", + "value": "\"workflow_deployment_operation\".\"protocol_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "execution_deadline_at": { + "name": "execution_deadline_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_deadline_idx": { + "name": "workflow_execution_logs_running_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'running' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_started_at_idx": { + "name": "workflow_execution_logs_redacting_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'redacting'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_redacting_deadline_idx": { + "name": "workflow_execution_logs_redacting_deadline_idx", + "columns": [ + { + "expression": "execution_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'redacting' AND \"workflow_execution_logs\".\"execution_deadline_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": ["server_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_operation_id": { + "name": "deployment_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret_scope": { + "name": "secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "mounted_secrets": { + "name": "mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk": { + "name": "workflow_schedule_deployment_operation_id_workflow_deployment_operation_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_operation", + "columnsFrom": ["deployment_operation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_secret_scope": { + "name": "inbox_secret_scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'all'" + }, + "inbox_mounted_secrets": { + "name": "inbox_mounted_secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_inbox_provider_id_idx": { + "name": "workspace_inbox_provider_id_idx", + "columns": [ + { + "expression": "inbox_provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace\".\"inbox_provider_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": ["key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_collab_state": { + "name": "workspace_file_collab_state", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "doc_state": { + "name": "doc_state", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_collab_state_file_id_workspace_files_id_fk": { + "name": "workspace_file_collab_state_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_collab_state", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_backfill": { + "name": "workspace_file_search_backfill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "after_workspace_id": { + "name": "after_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "after_file_id": { + "name": "after_file_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_dispatch_queue": { + "name": "workspace_file_search_dispatch_queue", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enqueued_at": { + "name": "enqueued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_dispatched_at": { + "name": "last_dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_dispatch_queue_schedule_idx": { + "name": "workspace_file_search_dispatch_queue_schedule_idx", + "columns": [ + { + "expression": "last_dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "first" + }, + { + "expression": "enqueued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_queue_workspace_fk": { + "name": "workspace_file_search_queue_workspace_fk", + "tableFrom": "workspace_file_search_dispatch_queue", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_index": { + "name": "workspace_file_search_index", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "workspace_file_search_index_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "partial": { + "name": "partial", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_count": { + "name": "line_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "indexed_bytes": { + "name": "indexed_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "dispatched_at": { + "name": "dispatched_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_search_index_workspace_status_idx": { + "name": "workspace_file_search_index_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_pending_dispatch_idx": { + "name": "workspace_file_search_index_pending_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_index_active_dispatch_idx": { + "name": "workspace_file_search_index_active_dispatch_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dispatched_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_search_index\".\"status\" = 'pending' AND \"workspace_file_search_index\".\"dispatched_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_index_file_fk": { + "name": "workspace_file_search_index_file_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_index_workspace_fk": { + "name": "workspace_file_search_index_workspace_fk", + "tableFrom": "workspace_file_search_index", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_index_pk": { + "name": "workspace_file_search_index_pk", + "columns": ["file_id", "source_content_updated_at"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_search_segment": { + "name": "workspace_file_search_segment", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_content_updated_at": { + "name": "source_content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_number": { + "name": "segment_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_start": { + "name": "segment_start", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_length": { + "name": "line_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workspace_file_search_segment_workspace_revision_idx": { + "name": "workspace_file_search_segment_workspace_revision_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_content_updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_search_segment_workspace_content_trgm_idx": { + "name": "workspace_file_search_segment_workspace_content_trgm_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_ops" + }, + { + "expression": "content", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_search_segment_file_fk": { + "name": "workspace_file_search_segment_file_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_search_segment_workspace_fk": { + "name": "workspace_file_search_segment_workspace_fk", + "tableFrom": "workspace_file_search_segment", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "workspace_file_search_segment_pk": { + "name": "workspace_file_search_segment_pk", + "columns": ["file_id", "source_content_updated_at", "line_number", "segment_number"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_secret_provenance": { + "name": "workspace_file_secret_provenance", + "schema": "", + "columns": { + "file_id": { + "name": "file_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_file_secret_provenance_file_id_workspace_files_id_fk": { + "name": "workspace_file_secret_provenance_file_id_workspace_files_id_fk", + "tableFrom": "workspace_file_secret_provenance", + "tableTo": "workspace_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_file_secret_provenance_status_check": { + "name": "workspace_file_secret_provenance_status_check", + "value": "\"workspace_file_secret_provenance\".\"status\" IN ('exact', 'unknown', 'unrecorded')" + } + }, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "content_updated_at": { + "name": "content_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "secret_provenance_version": { + "name": "secret_provenance_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_folder_id_fk": { + "name": "workspace_files_folder_id_folder_id_fk", + "tableFrom": "workspace_files", + "tableTo": "folder", + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_sandbox": { + "name": "workspace_sandbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "sandbox_language", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "dependencies": { + "name": "dependencies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "cli_tools": { + "name": "cli_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "system_packages": { + "name": "system_packages", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "spec_hash": { + "name": "spec_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_sandbox_workspace_name_unique": { + "name": "workspace_sandbox_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_workspace_idx": { + "name": "workspace_sandbox_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_sandbox_spec_hash_idx": { + "name": "workspace_sandbox_spec_hash_idx", + "columns": [ + { + "expression": "spec_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_sandbox_workspace_id_workspace_id_fk": { + "name": "workspace_sandbox_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "workspace", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_sandbox_created_by_user_id_fk": { + "name": "workspace_sandbox_created_by_user_id_fk", + "tableFrom": "workspace_sandbox", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": ["active", "revoked", "expired"] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": ["payment_failed", "dispute"] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": ["user", "organization"] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": ["mothership", "copilot"] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] + }, + "public.copilot_tool_permission_decision": { + "name": "copilot_tool_permission_decision", + "schema": "public", + "values": ["allow", "allow_chat", "always_allow", "skip"] + }, + "public.credential_group_enrollment_status": { + "name": "credential_group_enrollment_status", + "schema": "public", + "values": ["invited", "delivery_failed", "in_progress", "completed", "revoked"] + }, + "public.credential_group_status": { + "name": "credential_group_status", + "schema": "public", + "values": ["active", "disabled"] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": ["admin", "member"] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": ["active", "pending", "revoked"] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "managed_oauth", + "managed_mcp", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": ["hourly", "daily"] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": ["running", "success", "failed"] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": ["cron", "manual"] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": ["execution_log", "paused_snapshot"] + }, + "public.folder_resource_type": { + "name": "folder_resource_type", + "schema": "public", + "values": ["workflow", "file", "knowledge_base", "table"] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": ["organization", "workspace"] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": ["internal", "external"] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] + }, + "public.managed_oauth_credential_status": { + "name": "managed_oauth_credential_status", + "schema": "public", + "values": ["active", "needs_reauth", "revoked"] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": ["admin", "write", "read"] + }, + "public.sandbox_image_status": { + "name": "sandbox_image_status", + "schema": "public", + "values": ["pending", "building", "ready", "failed"] + }, + "public.sandbox_language": { + "name": "sandbox_language", + "schema": "public", + "values": ["javascript", "python"] + }, + "public.secret_usage_scope": { + "name": "secret_usage_scope", + "schema": "public", + "values": ["workspace", "personal"] + }, + "public.secret_usage_source": { + "name": "secret_usage_source", + "schema": "public", + "values": ["workflow", "copilot", "mcp"] + }, + "public.upload_session_method": { + "name": "upload_session_method", + "schema": "public", + "values": ["put", "multipart"] + }, + "public.upload_session_provider": { + "name": "upload_session_provider", + "schema": "public", + "values": ["local", "s3", "blob", "gcs"] + }, + "public.upload_session_purpose": { + "name": "upload_session_purpose", + "schema": "public", + "values": [ + "workspace_file", + "table_import", + "knowledge_document", + "profile_picture", + "workspace_logo", + "mothership_attachment", + "execution_attachment" + ] + }, + "public.upload_session_status": { + "name": "upload_session_status", + "schema": "public", + "values": [ + "uploading", + "completing", + "finalizing", + "completed", + "aborting", + "aborted", + "failed", + "expired" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": ["model", "fixed", "tool", "model_unbilled"] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment", + "voice-output", + "api-tool" + ] + }, + "public.workspace_file_search_index_status": { + "name": "workspace_file_search_index_status", + "schema": "public", + "values": ["pending", "ready", "skipped", "failed"] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": ["push", "pull"] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "file_folder", + "mcp_server", + "workflow_mcp_server", + "custom_block", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": ["personal", "organization", "grandfathered_shared"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 65c7841ca87..9792ca77969 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -2262,6 +2262,13 @@ "when": 1788820403571, "tag": "0323_oauth_provider", "breakpoints": true + }, + { + "idx": 324, + "version": "7", + "when": 1788826234724, + "tag": "0324_scim_provisioning", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 58c431eab58..a04b5769588 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -59,6 +59,24 @@ export const user = pgTable('user', { banned: boolean('banned').default(false), banReason: text('ban_reason'), banExpires: timestamp('ban_expires'), + /** + * When set, the account is suspended: sign-in is refused and API keys stop + * authenticating, while every resource the user owns is left untouched. + * + * Deliberately not `banned`. A ban is a platform-admin action whose + * `user.update.after` hook runs `disableUserResources`, archiving every + * workspace the user owns and deleting their API keys, and Sim has no + * server-side unban to reverse it. SCIM `active: false` is a reversible + * organization-level suspension that must preserve ownership for a later + * reactivation, so it needs a state of its own. + */ + suspendedAt: timestamp('suspended_at'), + /** + * Who suspended the account. Only `scim` exists today; a source only ever + * lifts its own suspension, so a later source cannot have its suspensions + * undone by a directory sync. + */ + suspensionSource: text('suspension_source'), }) export const session = pgTable( @@ -4791,6 +4809,20 @@ export const permissionGroup = pgTable( createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), isDefault: boolean('is_default').notNull().default(false), + /** + * How an empty non-default group behaves. + * + * `inherit` (the default, and every pre-existing group) keeps the member + * invariant above: no member rows means the group governs every member of + * its workspaces. `explicit` means the group governs exactly its member + * rows and therefore governs nobody when empty. + * + * Directory-managed groups must be `explicit`. Under `inherit`, an identity + * provider removing the last member would silently widen the group from + * "these three people" to "everyone in these workspaces" — the opposite of + * what the administrator asked for. + */ + membershipMode: text('membership_mode').notNull().default('inherit'), }, (table) => ({ createdByIdx: index('permission_group_created_by_idx').on(table.createdBy), @@ -5978,3 +6010,402 @@ export const sandboxImage = pgTable( lastUsedIdx: index('sandbox_image_last_used_idx').on(table.lastUsedAt), }) ) + +/** Operations a SCIM bearer credential is allowed to perform. */ +export type ScimScope = 'users:read' | 'users:write' | 'groups:read' | 'groups:write' + +export const SCIM_SCOPES: readonly ScimScope[] = [ + 'users:read', + 'users:write', + 'groups:read', + 'groups:write', +] + +/** Administrator-controlled behavior of one organization's SCIM connection. */ +export interface ScimConnectionSettings { + /** + * Refuse manual invitations, workspace grants, and role edits for users the + * identity provider manages; removals stay possible so an administrator can + * always act in an emergency. Out-of-band edits desync the directory, so this + * defaults to on for a new connection and an owner may turn it off. + */ + lockManualMembership?: boolean + /** + * Turn off SSO just-in-time provisioning while the connection is active, so + * the directory is the only way into the organization. + */ + disableJit?: boolean + /** Map a pushed group to an existing permission group of the same name. Nothing is created. */ + autoMapPermissionGroupsByName?: boolean +} + +/** One email address as the identity provider supplied it. */ +export interface ScimUserEmail { + value: string + type?: string + primary: boolean +} + +/** + * The last User resource a connection sent, canonicalized. Stored whole so a + * `GET` returns what the provider wrote and a `PATCH` applies to the provider's + * own view rather than to a lossy projection of it. + */ +export interface ScimUserAttributes { + userName: string + externalId?: string + active: boolean + displayName: string + name: { + formatted: string + givenName?: string + familyName?: string + } + emails: ScimUserEmail[] + enterprise?: { + department?: string + employeeNumber?: string + costCenter?: string + division?: string + organization?: string + manager?: { value?: string; displayName?: string } + } + /** Attributes Sim does not model, preserved so responses round-trip them. */ + extra?: Record +} + +/** + * One directory-provisioning connection per organization. + * + * The bearer credential an identity provider presents resolves to this row, and + * that row is the entire authorization scope: no SCIM request names an + * organization, so no request can reach another tenant's users. + */ +export const scimConnection = pgTable( + 'scim_connection', + { + id: text('id').primaryKey(), + organizationId: text('organization_id') + .notNull() + .references(() => organization.id, { onDelete: 'cascade' }), + /** `active` or `disabled`. Disabling refuses every credential immediately. */ + status: text('status').notNull().default('active'), + settings: jsonb('settings').$type().notNull().default({}), + lastRequestAt: timestamp('last_request_at'), + /** Reconcile-job lease, in the shape the connector member sync already uses. */ + reconcileLockToken: text('reconcile_lock_token'), + reconcileLeaseAt: timestamp('reconcile_lease_at'), + reconciledAt: timestamp('reconciled_at'), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + organizationUnique: uniqueIndex('scim_connection_organization_unique').on(table.organizationId), + reconcileDueIdx: index('scim_connection_reconcile_due_idx').on(table.reconciledAt), + }) +) + +/** + * A bearer credential for one connection. + * + * Only the SHA-256 digest is stored, so a database read cannot recover a live + * token. Two credentials may be active at once, which is what lets an + * administrator rotate without a window where the directory cannot authenticate. + */ +export const scimCredential = pgTable( + 'scim_credential', + { + id: text('id').primaryKey(), + connectionId: text('connection_id') + .notNull() + .references(() => scimConnection.id, { onDelete: 'cascade' }), + tokenHash: text('token_hash').notNull(), + /** Leading characters of the token, for identifying it in the settings list. */ + tokenPrefix: text('token_prefix').notNull(), + scopes: jsonb('scopes').$type().notNull(), + expiresAt: timestamp('expires_at'), + revokedAt: timestamp('revoked_at'), + revokedBy: text('revoked_by').references(() => user.id, { onDelete: 'set null' }), + lastUsedAt: timestamp('last_used_at'), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + tokenHashUnique: uniqueIndex('scim_credential_token_hash_unique').on(table.tokenHash), + connectionIdx: index('scim_credential_connection_idx').on(table.connectionId), + }) +) + +/** + * The User resource one connection provisioned, and its link to a Sim account. + * + * `id` is the SCIM resource id the provider stores and addresses; it is never a + * Sim user id, so a provider cannot reach an account it did not provision by + * guessing one. + */ +export const scimUser = pgTable( + 'scim_user', + { + id: text('id').primaryKey(), + connectionId: text('connection_id') + .notNull() + .references(() => scimConnection.id, { onDelete: 'cascade' }), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + externalId: text('external_id'), + /** Lower-cased `userName`; the uniqueness and lookup key within a connection. */ + userName: text('user_name').notNull(), + active: boolean('active').notNull().default(true), + attributes: jsonb('attributes').$type().notNull(), + /** + * Stable ascending sort key for pagination. A provider pages with + * `startIndex`, so the order must not shift between pages the way + * `created_at` alone can when rows share a timestamp. + */ + orderKey: text('order_key').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + connectionUserUnique: uniqueIndex('scim_user_connection_user_unique').on( + table.connectionId, + table.userId + ), + connectionUserNameUnique: uniqueIndex('scim_user_connection_user_name_unique').on( + table.connectionId, + table.userName + ), + connectionExternalIdUnique: uniqueIndex('scim_user_connection_external_id_unique') + .on(table.connectionId, table.externalId) + .where(sql`external_id is not null`), + connectionOrderIdx: index('scim_user_connection_order_idx').on( + table.connectionId, + table.orderKey + ), + userIdx: index('scim_user_user_idx').on(table.userId), + }) +) + +/** + * Remembers which Sim account a deleted external identity belonged to. + * + * Directories delete and recreate a person for an ordinary rename or rehire. The + * tombstone makes the recreated resource relink to the same account instead of + * creating a second one, which is what would otherwise strand the original. + */ +export const scimUserTombstone = pgTable( + 'scim_user_tombstone', + { + id: text('id').primaryKey(), + connectionId: text('connection_id') + .notNull() + .references(() => scimConnection.id, { onDelete: 'cascade' }), + externalId: text('external_id').notNull(), + userId: text('user_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + deletedAt: timestamp('deleted_at').notNull().defaultNow(), + }, + (table) => ({ + connectionExternalIdUnique: uniqueIndex('scim_user_tombstone_connection_external_id_unique').on( + table.connectionId, + table.externalId + ), + userIdx: index('scim_user_tombstone_user_idx').on(table.userId), + }) +) + +/** A Group resource one connection provisioned. */ +export const scimGroup = pgTable( + 'scim_group', + { + id: text('id').primaryKey(), + connectionId: text('connection_id') + .notNull() + .references(() => scimConnection.id, { onDelete: 'cascade' }), + externalId: text('external_id'), + displayName: text('display_name').notNull(), + /** + * Lower-cased `displayName`. Uniqueness is case-insensitive because + * Microsoft Entra treats a group name as its match key and will otherwise + * create a duplicate whose only difference is capitalization. + */ + displayNameKey: text('display_name_key').notNull(), + orderKey: text('order_key').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + connectionDisplayNameUnique: uniqueIndex('scim_group_connection_display_name_unique').on( + table.connectionId, + table.displayNameKey + ), + connectionExternalIdUnique: uniqueIndex('scim_group_connection_external_id_unique') + .on(table.connectionId, table.externalId) + .where(sql`external_id is not null`), + connectionOrderIdx: index('scim_group_connection_order_idx').on( + table.connectionId, + table.orderKey + ), + }) +) + +/** Membership of a provisioned group. */ +export const scimGroupMember = pgTable( + 'scim_group_member', + { + id: text('id').primaryKey(), + groupId: text('group_id') + .notNull() + .references(() => scimGroup.id, { onDelete: 'cascade' }), + scimUserId: text('scim_user_id') + .notNull() + .references(() => scimUser.id, { onDelete: 'cascade' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + groupUserUnique: uniqueIndex('scim_group_member_group_user_unique').on( + table.groupId, + table.scimUserId + ), + scimUserIdx: index('scim_group_member_scim_user_idx').on(table.scimUserId), + }) +) + +/** + * What a directory group means inside Sim, as an administrator configured it. + * + * A group may carry several mappings — a permission group, one or more + * workspaces, and the organization admin role are independent targets. + */ +export const scimGroupMapping = pgTable( + 'scim_group_mapping', + { + id: text('id').primaryKey(), + groupId: text('group_id') + .notNull() + .references(() => scimGroup.id, { onDelete: 'cascade' }), + /** `permission_group`, `workspace`, or `org_role`. */ + targetKind: text('target_kind').notNull(), + permissionGroupId: text('permission_group_id').references(() => permissionGroup.id, { + onDelete: 'cascade', + }), + workspaceId: text('workspace_id').references(() => workspace.id, { onDelete: 'cascade' }), + /** Permission granted on `workspaceId`, for workspace targets. */ + permissionType: permissionTypeEnum('permission_type'), + /** Organization role granted, for `org_role` targets. Only `admin` is accepted. */ + role: text('role'), + /** + * `automatic` mappings were made by name matching and are replaced when the + * group is renamed; `manual` ones were made by an administrator and are + * never removed by a sync. Kept apart from `createdBy`, which goes null when + * its author's account is deleted. + */ + source: text('source').notNull().default('manual'), + createdBy: text('created_by').references(() => user.id, { onDelete: 'set null' }), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + groupIdx: index('scim_group_mapping_group_idx').on(table.groupId), + permissionGroupIdx: index('scim_group_mapping_permission_group_idx').on( + table.permissionGroupId + ), + workspaceIdx: index('scim_group_mapping_workspace_idx').on(table.workspaceId), + /** + * One mapping per group and target. `coalesce` collapses the three mutually + * exclusive target columns into the single value that identifies the target, + * so a group cannot carry the same workspace twice at two permissions. + */ + groupTargetUnique: uniqueIndex('scim_group_mapping_group_target_unique').on( + table.groupId, + table.targetKind, + sql`coalesce(${table.permissionGroupId}, ${table.workspaceId}, ${table.role})` + ), + /** Exactly the columns belonging to `target_kind` are populated. */ + targetShape: check( + 'scim_group_mapping_target_shape', + sql`( + (${table.targetKind} = 'permission_group' AND ${table.permissionGroupId} IS NOT NULL AND ${table.workspaceId} IS NULL AND ${table.permissionType} IS NULL AND ${table.role} IS NULL) + OR (${table.targetKind} = 'workspace' AND ${table.workspaceId} IS NOT NULL AND ${table.permissionType} IS NOT NULL AND ${table.permissionGroupId} IS NULL AND ${table.role} IS NULL) + OR (${table.targetKind} = 'org_role' AND ${table.role} IS NOT NULL AND ${table.permissionGroupId} IS NULL AND ${table.workspaceId} IS NULL AND ${table.permissionType} IS NULL) + )` + ), + }) +) + +/** + * Provenance for every access SCIM granted. + * + * Without it, withdrawing a group's access could not tell an access the + * directory granted from one a workspace administrator granted by hand, and a + * routine group change would revoke the administrator's work. + */ +export const scimProjectionGrant = pgTable( + 'scim_projection_grant', + { + id: text('id').primaryKey(), + connectionId: text('connection_id') + .notNull() + .references(() => scimConnection.id, { onDelete: 'cascade' }), + scimUserId: text('scim_user_id') + .notNull() + .references(() => scimUser.id, { onDelete: 'cascade' }), + targetKind: text('target_kind').notNull(), + /** Permission group id, workspace id, or the granted organization role. */ + targetId: text('target_id').notNull(), + /** The permission SCIM set, so a later manual upgrade stays detectable. */ + permissionType: permissionTypeEnum('permission_type'), + /** + * `directory` when the directory created the access; `adopted` when the + * person already held it by hand and a mapping merely covers it. Adopted + * access is left in place when the mapping goes away, unless the directory + * is the organization's source of truth. + */ + origin: text('origin').notNull().default('directory'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => ({ + userTargetUnique: uniqueIndex('scim_projection_grant_user_target_unique').on( + table.scimUserId, + table.targetKind, + table.targetId + ), + connectionIdx: index('scim_projection_grant_connection_idx').on(table.connectionId), + }) +) + +/** + * Recent provisioning requests, for the settings activity view. + * + * Microsoft Entra reports a failed sync without saying what it sent, so an + * administrator debugging a connection has no other way to see the request that + * failed. Pruned by the reconcile job. + */ +export const scimRequestLog = pgTable( + 'scim_request_log', + { + id: text('id').primaryKey(), + connectionId: text('connection_id') + .notNull() + .references(() => scimConnection.id, { onDelete: 'cascade' }), + credentialId: text('credential_id'), + method: text('method').notNull(), + /** Resource path only. Query strings can carry directory attribute values. */ + path: text('path').notNull(), + status: integer('status').notNull(), + scimType: text('scim_type'), + detail: text('detail'), + userAgent: text('user_agent'), + durationMs: integer('duration_ms').notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + }, + (table) => ({ + connectionCreatedIdx: index('scim_request_log_connection_created_idx').on( + table.connectionId, + table.createdAt + ), + }) +) diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 1c7b2994b0f..5c9083e0228 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -179,6 +179,22 @@ export const auditMock = { WORKSPACE_FORK_ROLLED_BACK: 'workspace.fork_rolled_back', WORKSPACE_FORK_UNLINKED: 'workspace.fork_unlinked', WORKSPACE_EXPORTED: 'workspace.exported', + SCIM_CONNECTION_ENABLED: 'scim_connection.enabled', + SCIM_CONNECTION_DISABLED: 'scim_connection.disabled', + SCIM_CONNECTION_SETTINGS_UPDATED: 'scim_connection.settings_updated', + SCIM_CREDENTIAL_ISSUED: 'scim_credential.issued', + SCIM_CREDENTIAL_REVOKED: 'scim_credential.revoked', + SCIM_USER_PROVISIONED: 'scim_user.provisioned', + SCIM_USER_UPDATED: 'scim_user.updated', + SCIM_USER_DEACTIVATED: 'scim_user.deactivated', + SCIM_USER_REACTIVATED: 'scim_user.reactivated', + SCIM_USER_DEPROVISIONED: 'scim_user.deprovisioned', + SCIM_GROUP_CREATED: 'scim_group.created', + SCIM_GROUP_UPDATED: 'scim_group.updated', + SCIM_GROUP_MEMBERSHIP_CHANGED: 'scim_group.membership_changed', + SCIM_GROUP_DELETED: 'scim_group.deleted', + SCIM_GROUP_MAPPING_UPSERTED: 'scim_group_mapping.upserted', + SCIM_GROUP_MAPPING_DELETED: 'scim_group_mapping.deleted', CREDIT_ISSUED: 'credit.issued', INVOICE_PAYMENT_SUCCEEDED: 'invoice.payment_succeeded', INVOICE_PAYMENT_FAILED: 'invoice.payment_failed', @@ -223,10 +239,13 @@ export const auditMock = { PERMISSION_GROUP: 'permission_group', SANDBOX: 'sandbox', SCHEDULE: 'schedule', + SCIM_CONNECTION: 'scim_connection', + SCIM_GROUP: 'scim_group', SECRET_PROVENANCE: 'secret_provenance', SKILL: 'skill', SUBSCRIPTION: 'subscription', TABLE: 'table', + USER: 'user', WEBHOOK: 'webhook', WORKFLOW: 'workflow', WORKSPACE: 'workspace', diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 0eb882a5925..fedfa59dbae 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -35,6 +35,7 @@ export interface EnvFlagsMockState { isInboxEnabled: boolean isSandboxDeploymentEntitled: boolean isSandboxesEnabled: boolean + isScimEnabled: boolean isWhitelabelingEnabled: boolean isAuditLogsEnabled: boolean isCustomBlocksEnabled: boolean @@ -90,6 +91,7 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isInboxEnabled: true, isSandboxDeploymentEntitled: false, isSandboxesEnabled: false, + isScimEnabled: false, isWhitelabelingEnabled: true, isSessionPoliciesEnabled: true, isAuditLogsEnabled: false, diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index bacf1067c13..f15133d084d 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -154,6 +154,8 @@ export const schemaMock = { banned: 'user.banned', banReason: 'user.banReason', banExpires: 'user.banExpires', + suspendedAt: 'user.suspendedAt', + suspensionSource: 'user.suspensionSource', }, session: { id: 'session.id', @@ -1241,6 +1243,103 @@ export const schemaMock = { createdAt: 'ssoDomain.createdAt', updatedAt: 'ssoDomain.updatedAt', }, + scimConnection: { + id: 'scimConnection.id', + organizationId: 'scimConnection.organizationId', + status: 'scimConnection.status', + settings: 'scimConnection.settings', + lastRequestAt: 'scimConnection.lastRequestAt', + reconcileLockToken: 'scimConnection.reconcileLockToken', + reconcileLeaseAt: 'scimConnection.reconcileLeaseAt', + reconciledAt: 'scimConnection.reconciledAt', + createdBy: 'scimConnection.createdBy', + createdAt: 'scimConnection.createdAt', + updatedAt: 'scimConnection.updatedAt', + }, + scimCredential: { + id: 'scimCredential.id', + connectionId: 'scimCredential.connectionId', + tokenHash: 'scimCredential.tokenHash', + tokenPrefix: 'scimCredential.tokenPrefix', + scopes: 'scimCredential.scopes', + expiresAt: 'scimCredential.expiresAt', + revokedAt: 'scimCredential.revokedAt', + revokedBy: 'scimCredential.revokedBy', + lastUsedAt: 'scimCredential.lastUsedAt', + createdBy: 'scimCredential.createdBy', + createdAt: 'scimCredential.createdAt', + }, + scimUser: { + id: 'scimUser.id', + connectionId: 'scimUser.connectionId', + userId: 'scimUser.userId', + externalId: 'scimUser.externalId', + userName: 'scimUser.userName', + active: 'scimUser.active', + attributes: 'scimUser.attributes', + orderKey: 'scimUser.orderKey', + createdAt: 'scimUser.createdAt', + updatedAt: 'scimUser.updatedAt', + }, + scimUserTombstone: { + id: 'scimUserTombstone.id', + connectionId: 'scimUserTombstone.connectionId', + externalId: 'scimUserTombstone.externalId', + userId: 'scimUserTombstone.userId', + deletedAt: 'scimUserTombstone.deletedAt', + }, + scimGroup: { + id: 'scimGroup.id', + connectionId: 'scimGroup.connectionId', + externalId: 'scimGroup.externalId', + displayName: 'scimGroup.displayName', + displayNameKey: 'scimGroup.displayNameKey', + orderKey: 'scimGroup.orderKey', + createdAt: 'scimGroup.createdAt', + updatedAt: 'scimGroup.updatedAt', + }, + scimGroupMember: { + id: 'scimGroupMember.id', + groupId: 'scimGroupMember.groupId', + scimUserId: 'scimGroupMember.scimUserId', + createdAt: 'scimGroupMember.createdAt', + }, + scimGroupMapping: { + id: 'scimGroupMapping.id', + groupId: 'scimGroupMapping.groupId', + targetKind: 'scimGroupMapping.targetKind', + permissionGroupId: 'scimGroupMapping.permissionGroupId', + source: 'scimGroupMapping.source', + workspaceId: 'scimGroupMapping.workspaceId', + permissionType: 'scimGroupMapping.permissionType', + role: 'scimGroupMapping.role', + createdBy: 'scimGroupMapping.createdBy', + createdAt: 'scimGroupMapping.createdAt', + }, + scimProjectionGrant: { + id: 'scimProjectionGrant.id', + connectionId: 'scimProjectionGrant.connectionId', + scimUserId: 'scimProjectionGrant.scimUserId', + targetKind: 'scimProjectionGrant.targetKind', + targetId: 'scimProjectionGrant.targetId', + permissionType: 'scimProjectionGrant.permissionType', + origin: 'scimProjectionGrant.origin', + createdAt: 'scimProjectionGrant.createdAt', + updatedAt: 'scimProjectionGrant.updatedAt', + }, + scimRequestLog: { + id: 'scimRequestLog.id', + connectionId: 'scimRequestLog.connectionId', + credentialId: 'scimRequestLog.credentialId', + method: 'scimRequestLog.method', + path: 'scimRequestLog.path', + status: 'scimRequestLog.status', + scimType: 'scimRequestLog.scimType', + detail: 'scimRequestLog.detail', + userAgent: 'scimRequestLog.userAgent', + durationMs: 'scimRequestLog.durationMs', + createdAt: 'scimRequestLog.createdAt', + }, workflowMcpServer: { id: 'workflowMcpServer.id', workspaceId: 'workflowMcpServer.workspaceId', @@ -1426,6 +1525,7 @@ export const schemaMock = { createdAt: 'permissionGroup.createdAt', updatedAt: 'permissionGroup.updatedAt', isDefault: 'permissionGroup.isDefault', + membershipMode: 'permissionGroup.membershipMode', }, permissionGroupWorkspace: { id: 'permissionGroupWorkspace.id', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index b4ed445bb1d..0bf8b13d82f 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -42,6 +42,16 @@ const BOUNDARY_POLICY_BASELINE = { } as const const INDIRECT_ZOD_ROUTES = new Set([ + // SCIM discovery documents (RFC 7644 section 4). Each serves a fixed document + // describing what this server implements and accepts no params, query, or body, + // so there is no input to validate and no contract to bind. They are deliberately + // unauthenticated: a provider negotiates against them before it holds a + // credential. Wrapped in withRouteHandler by `defineScimDiscoveryRoute`. + 'apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts', + 'apps/sim/app/api/scim/v2/ResourceTypes/route.ts', + 'apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts', + 'apps/sim/app/api/scim/v2/Schemas/route.ts', + 'apps/sim/app/api/scim/v2/Schemas/[id]/route.ts', // Catch-all JSON 404 for unknown /api/v2 paths. It has no contract by // construction: it exists precisely for requests that match no operation, so // there is no input to validate and its only response is the fixed v2 error @@ -89,6 +99,7 @@ const INDIRECT_ZOD_ROUTES = new Set([ 'apps/sim/app/api/settings/allowed-providers/route.ts', 'apps/sim/app/api/settings/allowed-integrations/route.ts', 'apps/sim/app/api/settings/allowed-mcp-domains/route.ts', + 'apps/sim/app/api/cron/scim-reconcile/route.ts', 'apps/sim/app/api/cron/cleanup-tasks/route.ts', 'apps/sim/app/api/cron/cleanup-soft-deletes/route.ts', 'apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts', @@ -175,9 +186,9 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*)?['"]/ const DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN = - /\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b|\bdefineInternalBinaryRoute\b|\bdefineV2BinaryRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/ + /\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b|\bdefineInternalBinaryRoute\b|\bdefineV2BinaryRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]|\bimport\s*\{[^}]*\bdefineScimRoute\b[^}]*\}\s*from\s*['"]@\/ee\/scim\/lib\/route['"]/ const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN = - /\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute)\s*\(/ + /\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute|defineScimRoute)\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ diff --git a/scripts/check-route-verbs.ts b/scripts/check-route-verbs.ts index 277309de866..aa12468fce1 100644 --- a/scripts/check-route-verbs.ts +++ b/scripts/check-route-verbs.ts @@ -62,6 +62,7 @@ const BUILDERS = [ 'defineV2BodyLifecycleRoute', 'defineInternalJsonRoute', 'defineInternalBinaryRoute', + 'defineScimRoute', ] as const const BUILDER_ALT = BUILDERS.join('|')