From 705a0d1314daf9b508aed1fd458a922df1152b71 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 28 Oct 2026 17:12:48 -0700 Subject: [PATCH 1/2] fix(scim): harden directory lifecycle and clarify group mappings --- .../docs/platform/enterprise/scim/entra.mdx | 15 +- .../docs/platform/enterprise/scim/index.mdx | 42 ++- .../docs/platform/enterprise/scim/okta.mdx | 17 +- .../static/enterprise/scim-group-mappings.png | Bin 0 -> 31836 bytes apps/sim/ee/scim/TESTING.md | 23 +- .../sim/ee/scim/components/group-mappings.tsx | 332 ++++++++++++++++++ apps/sim/ee/scim/components/scim-section.tsx | 235 +------------ apps/sim/ee/scim/hooks/scim.ts | 5 + .../ee/scim/lib/application/admin/mappings.ts | 101 +++++- .../lib/application/groups/manage-groups.ts | 4 - .../application/users/provision-user.test.ts | 2 +- .../lib/application/users/provision-user.ts | 13 +- .../lib/application/users/update-user.test.ts | 1 + .../scim/lib/application/users/update-user.ts | 9 +- apps/sim/ee/scim/lib/projection/auto-map.ts | 4 +- .../lib/projection/reconcile-user.test.ts | 14 +- .../ee/scim/lib/projection/reconcile-user.ts | 25 +- apps/sim/ee/scim/lib/reconcile/job.test.ts | 9 +- apps/sim/ee/scim/lib/reconcile/job.ts | 13 - .../lib/api/contracts/organization-scim.ts | 4 + .../lib/api/contracts/permission-groups.ts | 1 + .../lib/billing/organizations/membership.ts | 2 + .../removal-stops-runs.integration.ts | 131 ++++++- .../organizations/members/lifecycle.test.ts | 2 +- .../lib/organizations/members/lifecycle.ts | 11 +- apps/sim/scripts/test-scim-e2e.ts | 276 ++++++++++++++- 26 files changed, 968 insertions(+), 323 deletions(-) create mode 100644 apps/docs/public/static/enterprise/scim-group-mappings.png create mode 100644 apps/sim/ee/scim/components/group-mappings.tsx diff --git a/apps/docs/content/docs/platform/enterprise/scim/entra.mdx b/apps/docs/content/docs/platform/enterprise/scim/entra.mdx index b5dd7863028..e8b1faf06e7 100644 --- a/apps/docs/content/docs/platform/enterprise/scim/entra.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim/entra.mdx @@ -11,7 +11,7 @@ Use a non-gallery enterprise application in Microsoft Entra ID to create, update ## Before you start -- Use an Enterprise organization in Sim and an organization owner or admin account. +- On Sim Cloud, use an Enterprise organization. Use an organization owner or admin account to configure provisioning. - [Verify each provisioned email domain](/platform/enterprise/verified-domains). - Use an Entra administrator who can create enterprise applications and manage provisioning. - Make Sim reachable by Entra over HTTPS. Self-hosted deployments must meet the [SCIM rollout requirements](/platform/enterprise/scim#deployment-and-upgrades). @@ -20,6 +20,8 @@ Automated user provisioning is available with Entra ID Free. Group provisioning Start with dedicated test accounts. Provisioned members use a seat and receive workspace access through mappings or existing grants. +An existing SAML or OIDC sign-in setup can remain in place. Use the same account email for authentication and provisioning, and review app assignments if they use separate enterprise applications. See [Plan membership and access](/platform/enterprise/scim#plan-membership-and-access) for the distinction between sign-in, organization membership, workspace access, and permission policy. + ## Connect Entra to Sim @@ -101,12 +103,13 @@ Sim deactivation suspends access while retaining membership, ownership, and the ## Provision groups and verify access -With P1 or higher, create a security group with **Assigned** membership and add test accounts as direct members. Assign it under **Users and groups**. Review **Attribute mapping** and keep group provisioning enabled. +With P1 or higher, create a security group with **Assigned** membership and add test accounts as direct members. Assign it under **Users and groups**. Review **Attribute mapping** and keep group provisioning enabled. Create the target workspace and a dedicated permission group in Sim, configuring that policy's restrictions and workspace scope before mapping it. 1. Run **Provision on demand** for one group and select up to five test members, or wait for a scheduled cycle. Nested membership is unsupported; see [Microsoft group assignment](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/assign-user-or-group-access-portal). -2. Once the group appears under **Single sign-on → Provisioning → Group mappings** in Sim, map it to a workspace, permission group, or organization admin role. Start with a workspace mapping and confirm access. -3. Remove a test member from the group while retaining a separate direct app assignment. After synchronization, confirm the directory workspace grant is withdrawn while the account remains active. -4. Add the member back and verify access returns. +2. Once the group appears under **Single sign-on → Provisioning → Group mappings** in Sim, add a **Workspace** mapping and a **Permission group** mapping to the policy created above. Confirm both workspace access and policy restrictions. A permission-group mapping alone does not grant workspace access; an organization-admin mapping is a separate privilege. +3. For automatic policy matching, enable **Provisioning rules → Manage rules → Match permission groups by name**. Names must match an existing non-default Sim permission group exactly, including case. Select **Reconcile now** for groups already synced. Sim does not create the permission group. +4. Remove a test member from the group while retaining a separate direct app assignment. After synchronization, confirm the directory workspace grant is withdrawn while the account remains active. Check the [fallback policy](/platform/enterprise/scim#how-access-is-withdrawn) on any workspace the person can still access. +5. Add the member back and verify access and restrictions return. Read [How access is withdrawn](/platform/enterprise/scim#how-access-is-withdrawn) before mapping groups that overlap manual access. @@ -133,6 +136,8 @@ Issue a replacement token in Sim while the current one remains active. In Entra' | Disablement or deletion has not reached Sim | Inspect the scheduled log, `IsSoftDeleted` mapping to `active`, enabled **Update** actions, and accidental deletion protection. Confirm **Skip out of scope deletions** is disabled. A connection test does not verify lifecycle changes. | | Group assignment is unavailable | Check licensing. Free supports individual assignment; group assignment and provisioning require an upgrade. | | Member has no workspace access | Provision the directory group and add a Sim workspace mapping. User provisioning adds organization membership. | +| Member has access but the expected policy does not apply | Check **Group mappings** for unassigned members. Resolve overlapping membership or workspace scope under **Permission groups**, then select **Reconcile now**. For name matching, also check the exact group name. | +| A removed mapping returns | Turn off name matching or use different names before removing a same-name mapping. See [mapping behavior](/platform/enterprise/scim#map-directory-groups). | | A disabled member still uses a seat | Deactivation suspends access; organization removal is separate. | Use Entra's **Provisioning logs** and Sim's **Provisioning → Activity**. Invalid or revoked tokens appear only in Entra because Sim cannot associate them with a connection. diff --git a/apps/docs/content/docs/platform/enterprise/scim/index.mdx b/apps/docs/content/docs/platform/enterprise/scim/index.mdx index a3630aefa7c..7909411b026 100644 --- a/apps/docs/content/docs/platform/enterprise/scim/index.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim/index.mdx @@ -5,6 +5,7 @@ description: Sync organization members and group-based access from your identity import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' import { Tab, Tabs } from '@/components/ui/tabs' Directory provisioning uses SCIM 2.0, a standard protocol for syncing accounts, to create, update, and deactivate organization members from your identity provider. Map directory groups to workspace access, permission groups, or the organization admin role. @@ -17,19 +18,36 @@ Configure [SSO](/platform/enterprise/sso) separately for authentication. SCIM ca - [Verify every email domain](/platform/enterprise/verified-domains) you will provision. Sim refuses account creation or an email change to an unverified domain. - On self-hosted deployments, enable SSO to reach **Single sign-on → Provisioning**, and enable SCIM. The enterprise master pair enables both; individual setup uses `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`, `SCIM_ENABLED`, and `NEXT_PUBLIC_SCIM_ENABLED`. See [Self-hosted SSO setup](/platform/enterprise/sso#self-hosted-setup) and [Deployment and upgrades](#deployment-and-upgrades). +## Plan membership and access + +If your organization already uses SAML or OIDC, keep that sign-in configuration. SCIM adds directory synchronization alongside it. Configure these four controls separately: + +| Control | What it decides | +| --- | --- | +| SSO | How a person authenticates. Use the same account email in SSO and provisioning. | +| SCIM user provisioning | Whether the person is an organization member and whether their account is active. | +| Workspace mapping | Which workspace the person can access, at Read, Write, or Admin level. | +| Permission-group mapping | Which models, integrations, and actions the person can use in the permission group's selected workspaces. It does not grant workspace access. | + +Create the workspaces and [permission groups](/platform/enterprise/access-control#create-and-configure-a-group) in Sim before mapping directory groups. For example, map a pushed `Finance` group to **Finance workspace → Write** and to a dedicated **Finance policy** permission group scoped to that workspace. These can be two mappings on the same directory group. + +Configure a restrictive organization default permission group for people who retain workspace access after leaving a mapped policy group. Sim selects one governing policy per person and workspace; it does not intersect every policy attached to their directory groups. Review [policy selection and conflicts](/platform/enterprise/access-control#general) before rolling out overlapping groups. + ## What it does | Directory request | Sim behavior | | --- | --- | | Provision a person | Creates or links an account and adds new organization members as Members. Workspace access requires a mapping or existing grant. | -| Update name or email | Updates the account. An email change ends its sessions. | +| Update name or email | Updates the account. An email change ends its sessions and OAuth grants. | | Deactivate a person | Suspends sign-in and personal API keys, and blocks scheduled runs or triggers acting as that person. Ownership, credentials, grants, and workspace history are retained. Shared workspace keys keep working. | | Reactivate a person | Lifts the suspension. Current group memberships and mappings govern directory access. | -| Send SCIM `DELETE` | Removes organization membership, ends sessions, deletes personal API keys, and reassigns owned resources. The Sim account survives. | +| Send SCIM `DELETE` | Removes organization membership, ends sessions and OAuth grants, deletes personal API keys, and reassigns owned resources. The Sim account survives. | | Add or remove group membership | Applies or withdraws the group's mapped access. | Deactivation itself does not withdraw grants. Group or mapping changes can still withdraw access and reassign ownership while someone is suspended. A deactivated member keeps their seat until removed from the organization. +Deactivation stops the person's active Chat runs. Reactivation permits new runs; it does not resume runs stopped during suspension. + Deactivation deletes the member's sessions and stops personal API-key authentication when it completes. Organization security-version invalidation reaches cached browser sessions on other application instances within up to 60 seconds. Sim refuses directory suspension or removal of an organization owner. Transfer ownership in Sim first. @@ -124,7 +142,7 @@ Select **Test Connection**, enable group management if you will map groups, then Under **Provisioning → Group mappings**, map each synced group to one or more targets: -- A **Permission group**, which governs models, integrations, and capabilities. +- A **Permission group**, which governs models, integrations, and capabilities in its selected workspaces. Create and configure it in Sim first; this mapping does not grant workspace access. - A **Workspace**, with **Read**, **Write**, or **Admin** access. - The **Organization admin** role. @@ -134,9 +152,15 @@ When multiple groups grant the same workspace at different levels, the strongest Mapping a permission group permanently switches it to explicit membership: it applies only to its members, and an empty mapped group applies to nobody. A group that previously applied to everyone in its workspaces stops doing so when mapped. Use permission groups created for directory membership rather than existing organization-wide groups. -Under **Provisioning rules → Manage rules**, **Match permission groups by name** maps new or renamed directory groups to existing non-default permission groups with an exact, case-sensitive name match. Sim does not create permission groups. A rename updates automatic mappings and preserves manually added ones. +Under **Provisioning rules → Manage rules**, **Match permission groups by name** maps new or renamed directory groups to existing non-default permission groups with an exact, case-sensitive name match. Sim does not create permission groups. These mappings are labeled **Automatic**. A rename updates automatic mappings and preserves manually added ones. For groups already synced before enabling name matching, select **Reconcile now** or wait for scheduled reconciliation. + +Turning off name matching stops future automatic matching; it does not remove existing mappings. Before removing a mapping whose target still has the same name, turn off **Match permission groups by name** or change the names so they no longer match. Otherwise a later group push or reconciliation can recreate it. A manually added mapping keeps its target when the directory group is renamed. + +A person can belong to only one explicit permission group for a given workspace. A conflicting policy assignment can be skipped even when the directory group itself synchronizes successfully. **Group mappings** shows how many directory members are not assigned to each mapped policy. Open **Permission groups**, resolve the overlapping membership or workspace scope, then select **Reconcile now**. Confirm the warning clears and the person's effective restrictions are correct. + +Directory group mappings with an automatic permission-group match, workspace access, and a warning for an unassigned policy membership @@ -144,8 +168,12 @@ For groups already synced before enabling name matching, select **Reconcile now* Sim tracks grants made through directory mappings. Removing a member from a group or removing its mapping withdraws the tracked access. +Other directory mappings and independent access can still keep the person in a workspace. Removing a permission-group membership changes their governing policy; it does not remove workspace access. Sim then uses another applicable workspace policy, the organization default, or no permission-group restrictions if neither exists. An empty mapped group applies to nobody and never turns into an all-members policy. Verify both workspace access and the fallback policy when testing a removal. + **Lock managed membership** is enabled by default on a new connection. It makes the directory authoritative for provisioned members: Sim refuses manual invitations, workspace grants, workspace role changes, and organization role changes for them. Manual access already held when a mapping begins covering it becomes directory-managed and is withdrawn with that mapping. Administrators can still remove access in an emergency. +The lock does not prevent editing permission-group membership directly in Sim. A manual removal can be restored by reconciliation, and a manual addition to an overlapping policy can block a directory assignment. Manage mapped policy membership through the directory and check **Group mappings** for unassigned members after resolving conflicts. + With locking off, prior manual workspace access is preserved. If a member had manual Read access and a directory mapping raises it to Admin, removing the mapping restores Read. A role manually raised above the directory's level also remains. [Older grants have a baseline limitation](#deployment-and-upgrades). You can manually invite people the directory does not manage. For managed members with locking on, grant access through directory groups. @@ -162,12 +190,16 @@ Connection switches, rules, tokens, and mappings save immediately. **Provisionin **Provisioning → Activity** shows recent authenticated requests, their status, and failure details. A wrong, expired, or revoked token cannot be associated with a connection, so its authentication error appears only in the provider's logs. -**Reconcile now** reapplies group mappings and changed connection rules. For scheduled reconciliation, call `GET /api/cron/scim-reconcile` with the configured `CRON_SECRET`. Helm and production Docker Compose include an hourly schedule when their cron service is configured and running. This endpoint runs directly; it does not require Trigger.dev. See [Background schedules](/platform/enterprise/self-hosted#schedule-the-background-jobs). +**Reconcile now** reapplies stored group memberships, mappings, and changed connection rules. It does not fetch changes from Okta or Entra. If a directory change has not reached Sim, inspect the provider's provisioning job and retry or repush there first. + +For scheduled reconciliation, call `GET /api/cron/scim-reconcile` with the configured `CRON_SECRET`. Helm and production Docker Compose include an hourly schedule when their cron service is configured and running. This endpoint runs directly; it does not require Trigger.dev. See [Background schedules](/platform/enterprise/self-hosted#schedule-the-background-jobs). ## Disable provisioning Turning the connection off stops synchronization; it does not reverse suspension or prior access changes. Deactivated members stay deactivated. To restore an account without the directory, remove it from the organization and invite it again. +While the connection is disabled, **Lock managed membership** and **Disable just-in-time provisioning** no longer apply. Manual membership changes and the SSO provider's first-sign-in setting govern those operations again. Review them before disabling the connection; disabling synchronization is not an offboarding action. + ## Deployment and upgrades Apply database migrations before the new application code. The SCIM tables and suspension fields are additive, allowing older code to run against the expanded schema during rollout. diff --git a/apps/docs/content/docs/platform/enterprise/scim/okta.mdx b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx index f5cf8d12c66..f64d84f95eb 100644 --- a/apps/docs/content/docs/platform/enterprise/scim/okta.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx @@ -11,13 +11,15 @@ Connect an Okta SCIM 2.0 integration to create, update, and deactivate Sim organ ## Before you start -- Use an Enterprise organization in Sim and an organization owner or admin account. +- On Sim Cloud, use an Enterprise organization. Use an organization owner or admin account to configure provisioning. - [Verify each provisioned email domain](/platform/enterprise/verified-domains). - Use an Okta administrator with access to application provisioning. - Make the Sim deployment reachable by Okta over HTTPS. Self-hosted deployments must meet the [SCIM rollout requirements](/platform/enterprise/scim#deployment-and-upgrades). Start with a test account and a dedicated group. Provisioned members use a seat and receive workspace access through mappings or existing grants. +Keep an existing SAML or OIDC sign-in integration when adding provisioning. If provisioning uses a separate Okta app, assign the intended users to both apps and map the same account email. App assignment, pushed group membership, workspace access, and permission policy are separate controls; see [Plan membership and access](/platform/enterprise/scim#plan-membership-and-access). + ## Choose an integration SCIM support depends on how the Okta app was created: @@ -31,7 +33,7 @@ SCIM support depends on how the Okta app was created: See [Okta's custom-app SCIM instructions](https://help.okta.com/en-us/Content/Topics/apps/apps_app_integration_wizard_scim.htm). If the provisioning option is missing, confirm that the feature is enabled for your Okta organization. - The Header Auth Test App walkthrough below is for evaluation. [Okta support limits SCIM Test Apps to development and testing](https://support.okta.com/help/s/article/What-are-SCIM-20-Test-Apps?language=en_US), although its developer guide also describes private template integrations. For production, confirm a supported integration route for your Okta organization using the corresponding official instructions. This guide does not establish a published or certified Sim OIN integration. + [Okta limits SCIM Test Apps to development and testing](https://support.okta.com/help/s/article/What-are-SCIM-20-Test-Apps?language=en_US). Use a supported custom app for production; the Header Auth Test App walkthrough and screenshots below are for evaluation. This guide does not establish a published or certified Sim OIN integration. ### Connect a supported custom app @@ -40,7 +42,7 @@ See [Okta's custom-app SCIM instructions](https://help.okta.com/en-us/Content/To 2. In the supported Okta app, enable SCIM. For a Classic SAML or SWA app, open **General → App Settings → Edit**, set **Provisioning** to **SCIM**, and save. 3. Open **Provisioning → Integration** and configure **SCIM connector base URL** with Sim's Base URL. Set **Unique identifier field for users** to `userName`. 4. Enable the supported push actions needed for your deployment: **Push New Users**, **Push Profile Updates**, and **Push Groups** for group-based access. -5. Configure **HTTP Header** authentication to send `Authorization: Bearer `. Use the chosen integration's documented credential fields; they differ from the Test App's **API Token** field below. +5. Select **HTTP Header** authentication. In the Classic custom app's **Authorization** field, enter `Bearer `. This is a Sim-issued token, not an Okta API token. Other creation experiences may label the credential field differently. 6. Test and save the connection. Under **Provisioning → To App**, enable user creation, attribute updates, and deactivation. Leave password synchronization disabled. Use an application username and primary email in a domain verified by Sim. If Okta usernames differ from email addresses, review the attribute mappings before assigning members. Then complete the lifecycle and group checks below. @@ -106,10 +108,13 @@ Okta sends deactivation, not SCIM user `DELETE`. Sim suspends access while retai Use separate groups for app assignment and Group Push; Okta does not support using the same group for both. Assign accounts to the app first. +For example, use `Sim users` to assign the app and `Finance` as the pushed group. Create a dedicated permission group in Sim with the restrictions and workspace scope Finance members need. A pushed directory group does not create that policy or its workspaces. + 1. Under **Push Groups → Find groups by name**, select the group. 2. Leave **Push group memberships immediately** enabled. For a new downstream group, save with **Create Group** selected. -3. In Sim, open **Single sign-on → Provisioning → Group mappings** and map the synced group to a workspace, permission group, or organization admin role. Workspace access requires a workspace mapping. Name matching can map existing permission groups automatically. -4. Confirm a test member gains workspace access. Remove it from the pushed group while keeping its app assignment, and confirm the directory grant is withdrawn. Add it back and verify access returns without another account being created. +3. In Sim, open **Single sign-on → Provisioning → Group mappings**. Add a **Workspace** mapping at the intended level and a **Permission group** mapping to the policy created above. A permission-group mapping alone does not grant workspace access. You can also map the organization admin role when that is intended. +4. To match policies automatically, enable **Provisioning rules → Manage rules → Match permission groups by name**. The pushed name must exactly match an existing non-default Sim permission group, including case. Select **Reconcile now** for groups already synced. Workspace access still needs its own mapping or existing grant. +5. Confirm a test member gains workspace access and the expected policy restrictions. Remove it from the pushed group while keeping its app assignment, then verify both directory-grant withdrawal and the [fallback policy](/platform/enterprise/scim#how-access-is-withdrawn) on any workspace it can still access. Add it back and verify access returns without another account being created. Header Auth Test App Group Push selecting an engineering group and Create Group for the downstream group @@ -128,6 +133,8 @@ Test credentials, save, and confirm a successful provisioning request before rev | Credential test fails | For Header Auth, include `Bearer `. Check the complete HTTPS URL, token expiry, provisioning switch, and network reachability. | | User creation fails | Verify the email domain, available seats, and whether the account belongs to another Sim organization. | | Member has no workspace access | Push the group and add a workspace mapping. App assignment creates organization membership. | +| Member has access but the expected policy does not apply | Check **Group mappings** for unassigned members. Resolve overlapping membership or workspace scope under **Permission groups**, then select **Reconcile now**. For name matching, also check the exact group name. | +| A removed mapping returns | Turn off name matching or use different names before removing a same-name mapping. See [mapping behavior](/platform/enterprise/scim#map-directory-groups). | | Name or membership updates do not arrive | Check app assignment, provisioning actions, attribute mappings, and Group Push status. | | A pushed group is missing members | Confirm those accounts are active and successfully assigned to the app. After activation, repush the group; see [Okta Group Push troubleshooting](https://help.okta.com/en-us/Content/Topics/users-groups-profiles/usgp-group-push-troubleshoot.htm). | | A deactivated member still uses a seat | Deactivation suspends access; remove organization membership to complete that offboarding step. | diff --git a/apps/docs/public/static/enterprise/scim-group-mappings.png b/apps/docs/public/static/enterprise/scim-group-mappings.png new file mode 100644 index 0000000000000000000000000000000000000000..fd5c703944b8b08f8757d7f9c57701be60e6d782 GIT binary patch literal 31836 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 z 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 literal 0 HcmV?d00001 diff --git a/apps/sim/ee/scim/TESTING.md b/apps/sim/ee/scim/TESTING.md index 3abfe6d9d81..dd012245151 100644 --- a/apps/sim/ee/scim/TESTING.md +++ b/apps/sim/ee/scim/TESTING.md @@ -68,9 +68,17 @@ timeout. - Group membership idempotency and membership-only replacement timestamps. - Workspace, organization-role, and permission-group mappings, reconciliation, drift repair, withdrawal, and preservation of pre-existing manual workspace access. +- Automatic group renames through PATCH and PUT, including a destination already + mapped manually; the old policy is withdrawn immediately. +- Mapping status when overlapping workspace policies prevent assignment, followed + by conflict recovery and reconciliation. +- A concurrent membership-lock change while a group removal waits on the + organization lock; withdrawal uses the committed setting. - Managed-membership enforcement through the actual batch invitation and workspace permission APIs, including successful edits to unmanaged members. -- Session revocation on deactivation, membership retention, directory +- Session revocation on deactivation and permanent OAuth grant revocation on + directory deletion, including an actual OAuth API request after deletion. +- Membership retention, directory reactivation, manual suspension protection, owner protection, and account relinking after deletion and rehire. - Credential scopes, overlapping rotation, the active credential limit, @@ -115,6 +123,19 @@ which verifies that SCIM's `disableJit` setting blocks fresh SSO membership, preserves existing membership, and permits JIT when disabled. These checks run the admission operation and Enterprise entitlement reads through PostgreSQL. +The real PostgreSQL/Redis suite +`lib/mothership/request/application/removal-stops-runs.integration.ts` verifies +that deactivation writes durable Stops for admitted Chat runs, reactivation cannot +revive those runs, an existing account first provisioned inactive is stopped, +and a rolled-back suspension emits no durable or delivered Stop. + +## Verification dates and limits + +The October 28, 2026 audit exercised the current app using the local HTTP harness, +real PostgreSQL/Redis integration suites, and the browser UI with synthetic data. +It did not connect to a live provider tenant. The live-provider results below are +from September 2026 and describe that run, not certification of every later change. + ## Live Okta verification The private **SCIM 2.0 Test App (Header Auth)** has also been exercised through diff --git a/apps/sim/ee/scim/components/group-mappings.tsx b/apps/sim/ee/scim/components/group-mappings.tsx new file mode 100644 index 00000000000..af52d2f9519 --- /dev/null +++ b/apps/sim/ee/scim/components/group-mappings.tsx @@ -0,0 +1,332 @@ +'use client' + +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipSelect, ChipTag, toast } from '@sim/emcn' +import { X } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' +import type { + ScimGroupMappingBody, + ScimGroupMappingView, +} from '@/lib/api/contracts/organization-scim' +import type { PermissionGroup } from '@/lib/api/contracts/permission-groups' +import { + SettingsEmptyState, + SettingsQueryErrorState, +} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + useOrganizationWorkspaces, + usePermissionGroups, +} from '@/ee/access-control/hooks/permission-groups' +import { + type MappingTargetKind, + PERMISSION_OPTIONS, + TARGET_KIND_OPTIONS, + type WorkspacePermission, +} from '@/ee/scim/components/options' +import { + useDeleteScimGroupMapping, + useScimGroupMappings, + useUpsertScimGroupMapping, +} from '@/ee/scim/hooks/scim' + +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 AddMappingProps { + organizationId: string + groupId: string + permissionGroups: PermissionGroup[] + 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') + const discardMapping = () => { + setTargetKind('permission_group') + setTargetId('') + setPermission('read') + } + useSettingsUnsavedGuard({ + isDirty: targetKind !== 'permission_group' || Boolean(targetId) || permission !== 'read', + navigationBlocked: upsertMapping.isPending, + onDiscard: discardMapping, + }) + + 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() + const selectedPermissionGroup = + targetKind === 'permission_group' + ? permissionGroups.find((group) => group.id === targetId) + : undefined + const changesAllMembersPolicy = + selectedPermissionGroup?.membershipMode === 'inherit' && + selectedPermissionGroup.memberCount === 0 && + selectedPermissionGroup.workspaces.length > 0 + + async function handleAdd() { + if (!body || upsertMapping.isPending) return + try { + const result = await upsertMapping.mutateAsync({ organizationId, body }) + discardMapping() + if ((result.mapping.unassignedMemberCount ?? 0) > 0) { + toast.info('Mapping saved. Some permission-group memberships need attention below.') + } else { + toast.success( + `Mapping saved. Checked ${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('') + setPermission('read') + }} + 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'} + +
+ {changesAllMembersPolicy && ( +

+ This permission group currently governs everyone in its workspaces. Mapping it switches it + permanently to selected members. Other members will use the remaining permission policy. +

+ )} +
+ ) +} + +interface GroupMappingsProps { + organizationId: string + active: boolean + autoMapByName: boolean + onReconcile: () => Promise + reconciling: boolean +} + +export function GroupMappings({ + organizationId, + active, + autoMapByName, + onReconcile, + reconciling, +}: GroupMappingsProps) { + const { + data: groups, + isLoading, + isError, + error, + isFetching, + refetch, + } = useScimGroupMappings(organizationId, active) + const { data: allPermissionGroups = [] } = usePermissionGroups(organizationId, active) + /** 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, active) + const deleteMapping = useDeleteScimGroupMapping() + const [pendingRemoveId, setPendingRemoveId] = useState(null) + const pendingRemove = groups + ?.flatMap((group) => group.mappings) + .find((mapping) => mapping.id === pendingRemoveId) + + const names = { + permissionGroups: new Map(permissionGroups.map((group) => [group.id, group.name])), + workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace.name])), + } + const automaticMatchRemains = + pendingRemove?.targetKind === 'permission_group' && + autoMapByName && + (pendingRemove.source === 'automatic' || + names.permissionGroups.get(pendingRemove.permissionGroupId ?? '') === + pendingRemove.groupDisplayName) + + async function handleRemove() { + if (!pendingRemove || deleteMapping.isPending) return + try { + await deleteMapping.mutateAsync({ organizationId, mappingId: pendingRemove.id }) + setPendingRemoveId(null) + toast.success('Mapping removed') + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to remove mapping')) + } + } + + if (isLoading) { + return Loading groups... + } + if (groups === undefined && 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) => { + const unassignedMappings = group.mappings.filter( + (mapping) => (mapping.unassignedMemberCount ?? 0) > 0 + ) + return ( +
+ +
+ {group.mappings.length > 0 && ( +
+ {group.mappings.map((mapping) => ( + setPendingRemoveId(mapping.id)} + > + {describeMapping(mapping, names)} + {mapping.source === 'automatic' ? ' · Automatic' : ''} + + ))} +
+ )} + {unassignedMappings.length > 0 && ( +
+ {unassignedMappings.map((mapping) => ( +

+ {describeMapping(mapping, names)}: {mapping.unassignedMemberCount} directory + {mapping.unassignedMemberCount === 1 ? ' member is' : ' members are'} not + assigned. +

+ ))} +

+ A member can belong to only one permission group per workspace. Review + overlapping memberships in Permission groups, then reconcile. +

+
+ + {reconciling ? 'Reconciling...' : 'Reconcile now'} + +
+
+ )} + +
+
+ ) + })} +
+ !open && setPendingRemoveId(null)} + title='Remove mapping' + text={[ + 'Remove the mapping to ', + { text: pendingRemove ? describeMapping(pendingRemove, names) : '', bold: true }, + '? ', + pendingRemove?.targetKind === 'permission_group' + ? 'Members may keep workspace access under a different permission policy.' + : 'Directory-managed access from this mapping will be withdrawn unless another mapping grants it. Existing manual access may remain.', + ...(automaticMatchRemains + ? [ + ' Name matching is enabled and can recreate this mapping. Turn off Match permission groups by name first to keep it removed.', + ] + : []), + ]} + confirm={{ + label: 'Remove', + onClick: handleRemove, + pending: deleteMapping.isPending, + pendingLabel: 'Removing...', + }} + /> + + ) +} diff --git a/apps/sim/ee/scim/components/scim-section.tsx b/apps/sim/ee/scim/components/scim-section.tsx index 0874809a6ae..193aea825f2 100644 --- a/apps/sim/ee/scim/components/scim-section.tsx +++ b/apps/sim/ee/scim/components/scim-section.tsx @@ -18,15 +18,13 @@ import { Switch, toast, } from '@sim/emcn' -import { Key, X } from '@sim/emcn/icons' +import { Key } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' 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' @@ -39,30 +37,20 @@ 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 { GroupMappings } from '@/ee/scim/components/group-mappings' 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' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' @@ -85,23 +73,6 @@ function formatRelative(iso: string | null): string { 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 @@ -125,198 +96,6 @@ function CredentialRow({ credential, onRevoke }: CredentialRowProps) { ) } -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') - const discardMapping = () => { - setTargetKind('permission_group') - setTargetId('') - setPermission('read') - } - useSettingsUnsavedGuard({ - isDirty: targetKind !== 'permission_group' || Boolean(targetId) || permission !== 'read', - navigationBlocked: upsertMapping.isPending, - onDiscard: discardMapping, - }) - - 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 || upsertMapping.isPending) return - try { - const result = await upsertMapping.mutateAsync({ organizationId, body }) - discardMapping() - 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('') - setPermission('read') - }} - 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 - active: boolean -} - -function GroupMappings({ organizationId, active }: GroupMappingsProps) { - const { - data: groups, - isLoading, - isError, - error, - isFetching, - refetch, - } = useScimGroupMappings(organizationId, active) - const { data: allPermissionGroups = [] } = usePermissionGroups(organizationId, active) - /** 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, active) - 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 (groups === undefined && 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 active: boolean @@ -447,7 +226,7 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet const corrections = report.grantsAdded + report.grantsRemoved toast.success( corrections === 0 - ? `Checked ${report.reconciledUsers} members; nothing to correct` + ? `Checked ${report.reconciledUsers} members. Review Group mappings for assignment status.` : `Checked ${report.reconciledUsers} members; corrected ${corrections} grant${corrections === 1 ? '' : 's'}` ) } catch (error) { @@ -564,7 +343,13 @@ function ConnectionDetails({ organizationId, connection, active }: ConnectionDet label='Directory groups' description='Map directory groups to permission groups, workspaces, or the organization admin role.' > - + diff --git a/apps/sim/ee/scim/hooks/scim.ts b/apps/sim/ee/scim/hooks/scim.ts index 2f3ef2a8a8b..249335e6d78 100644 --- a/apps/sim/ee/scim/hooks/scim.ts +++ b/apps/sim/ee/scim/hooks/scim.ts @@ -13,6 +13,7 @@ import { type ScimGroupMappingBody, upsertScimGroupMappingContract, } from '@/lib/api/contracts/organization-scim' +import { permissionGroupKeys } from '@/hooks/queries/utils/permission-group-keys' export const SCIM_CONNECTION_STALE_TIME = 30 * 1000 export const SCIM_MAPPINGS_STALE_TIME = 30 * 1000 @@ -54,6 +55,7 @@ export function useScimGroupMappings(organizationId?: string, enabled = true) { }, enabled: Boolean(organizationId) && enabled, staleTime: SCIM_MAPPINGS_STALE_TIME, + refetchInterval: SCIM_ACTIVITY_REFETCH_INTERVAL, }) } @@ -138,6 +140,7 @@ export function useUpsertScimGroupMapping() { requestJson(upsertScimGroupMappingContract, { params: { id: organizationId }, body }), onSettled: (_data, _error, { organizationId }) => { queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) + queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(organizationId) }) }, }) } @@ -154,6 +157,7 @@ export function useDeleteScimGroupMapping() { requestJson(deleteScimGroupMappingContract, { params: { id: organizationId, mappingId } }), onSettled: (_data, _error, { organizationId }) => { queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) + queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(organizationId) }) }, }) } @@ -166,6 +170,7 @@ export function useReconcileScimConnection() { onSettled: (_data, _error, organizationId) => { queryClient.invalidateQueries({ queryKey: scimKeys.connection(organizationId) }) queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) + queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(organizationId) }) }, }) } diff --git a/apps/sim/ee/scim/lib/application/admin/mappings.ts b/apps/sim/ee/scim/lib/application/admin/mappings.ts index 62b8e5d762b..f4bfe427c5d 100644 --- a/apps/sim/ee/scim/lib/application/admin/mappings.ts +++ b/apps/sim/ee/scim/lib/application/admin/mappings.ts @@ -2,14 +2,15 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' import { permissionGroup, - type ScimConnectionSettings, + permissionGroupMember, scimConnection, scimGroup, scimGroupMapping, scimGroupMember, + scimUser, } from '@sim/db/schema' import { generateId } from '@sim/utils/id' -import { and, count, eq, sql } from 'drizzle-orm' +import { and, count, eq, isNull, 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' @@ -43,18 +44,23 @@ const MAPPING_COLUMNS = { workspaceId: scimGroupMapping.workspaceId, permissionType: scimGroupMapping.permissionType, role: scimGroupMapping.role, + source: scimGroupMapping.source, } 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 { +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 + source: string + }, + unassignedMemberCount: number | null +): ScimGroupMappingView { return { id: row.id, groupId: row.groupId, @@ -64,9 +70,49 @@ function toMappingView(row: { workspaceId: row.workspaceId, permissionType: row.permissionType, role: row.role, + source: row.source as ScimGroupMappingView['source'], + unassignedMemberCount, } } +/** Reads actual membership so skipped or subsequently removed assignments stay visible. */ +async function unassignedPermissionGroupMemberCounts(params: { + connectionId: string + organizationId: string + groupId?: string +}): Promise> { + const rows = await db + .select({ mappingId: scimGroupMapping.id, value: count() }) + .from(scimGroupMapping) + .innerJoin(scimGroup, eq(scimGroup.id, scimGroupMapping.groupId)) + .innerJoin(scimGroupMember, eq(scimGroupMember.groupId, scimGroup.id)) + .innerJoin( + scimUser, + and( + eq(scimUser.id, scimGroupMember.scimUserId), + eq(scimUser.connectionId, scimGroup.connectionId) + ) + ) + .leftJoin( + permissionGroupMember, + and( + eq(permissionGroupMember.permissionGroupId, scimGroupMapping.permissionGroupId), + eq(permissionGroupMember.organizationId, params.organizationId), + eq(permissionGroupMember.userId, scimUser.userId) + ) + ) + .where( + and( + eq(scimGroup.connectionId, params.connectionId), + eq(scimGroupMapping.targetKind, 'permission_group'), + isNull(permissionGroupMember.id), + params.groupId ? eq(scimGroup.id, params.groupId) : undefined + ) + ) + .groupBy(scimGroupMapping.id) + return new Map(rows.map((row) => [row.mappingId, Number(row.value)])) +} + export const listScimGroupMappings = defineAuthorizedScimAdminUseCase({ operation: scimAdminOperations.read, async execute({ input }: ScimAdminUseCaseArgs<{ organizationId: string }>) { @@ -77,7 +123,7 @@ export const listScimGroupMappings = defineAuthorizedScimAdminUseCase({ .limit(1) if (!connection) return { groups: [] } - const [groups, mappings, counts] = await Promise.all([ + const [groups, mappings, counts, unassignedCounts] = await Promise.all([ db .select({ id: scimGroup.id, displayName: scimGroup.displayName }) .from(scimGroup) @@ -94,13 +140,22 @@ export const listScimGroupMappings = defineAuthorizedScimAdminUseCase({ .innerJoin(scimGroup, eq(scimGroup.id, scimGroupMember.groupId)) .where(eq(scimGroup.connectionId, connection.id)) .groupBy(scimGroupMember.groupId), + unassignedPermissionGroupMemberCounts({ + connectionId: connection.id, + organizationId: input.organizationId, + }), ]) 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)) + list.push( + toMappingView( + row, + row.targetKind === 'permission_group' ? (unassignedCounts.get(row.id) ?? 0) : null + ) + ) mappingsByGroup.set(row.groupId, list) } @@ -120,7 +175,6 @@ async function reconcileGroupMembers(params: { connectionId: string organizationId: string groupId: string - settings: ScimConnectionSettings }): Promise { const members = await db .select({ scimUserId: scimGroupMember.scimUserId }) @@ -131,7 +185,6 @@ async function reconcileGroupMembers(params: { connectionId: params.connectionId, organizationId: params.organizationId, scimUserIds: members.map((row) => row.scimUserId), - settings: params.settings, }) return members.length } @@ -269,11 +322,22 @@ export const upsertScimGroupMapping = defineAuthorizedScimAdminUseCase({ connectionId: connection.id, organizationId: context.organizationId, groupId: group.id, - settings: connection.settings, }) + const unassignedCounts = + input.targetKind === 'permission_group' + ? await unassignedPermissionGroupMemberCounts({ + connectionId: connection.id, + organizationId: context.organizationId, + groupId: group.id, + }) + : null + return { - mapping: toMappingView({ ...mapping, groupDisplayName: group.displayName }), + mapping: toMappingView( + { ...mapping, groupDisplayName: group.displayName }, + unassignedCounts ? (unassignedCounts.get(mapping.id) ?? 0) : null + ), reconciledUsers, } }, @@ -310,7 +374,6 @@ export const deleteScimGroupMapping = defineAuthorizedScimAdminUseCase({ connectionId: connection.id, organizationId: context.organizationId, groupId: mapping.groupId, - settings: connection.settings, }) return { success: true as const, reconciledUsers, mapping } diff --git a/apps/sim/ee/scim/lib/application/groups/manage-groups.ts b/apps/sim/ee/scim/lib/application/groups/manage-groups.ts index 2ad3f480e59..fa9ce15e4e8 100644 --- a/apps/sim/ee/scim/lib/application/groups/manage-groups.ts +++ b/apps/sim/ee/scim/lib/application/groups/manage-groups.ts @@ -214,7 +214,6 @@ export const createScimGroup = defineAuthorizedScimUseCase({ connectionId: context.connection.id, organizationId: context.organizationId, scimUserIds: memberIds, - settings: context.connection.settings, }) if (mapped === 'mapped') { await settleMappedPermissionGroupsExplicit(tx, { @@ -305,7 +304,6 @@ export const replaceScimGroup = defineAuthorizedScimUseCase({ connectionId: context.connection.id, organizationId: context.organizationId, scimUserIds: [...touched], - settings: context.connection.settings, }) if (adopted) { await settleMappedPermissionGroupsExplicit(tx, { @@ -439,7 +437,6 @@ export const patchScimGroup = defineAuthorizedScimUseCase({ connectionId: context.connection.id, organizationId: context.organizationId, scimUserIds: [...touched], - settings: context.connection.settings, }) if (adopted) { await settleMappedPermissionGroupsExplicit(tx, { @@ -493,7 +490,6 @@ export const deleteScimGroup = defineAuthorizedScimUseCase({ connectionId: context.connection.id, organizationId: context.organizationId, scimUserIds: memberIds, - settings: context.connection.settings, }) return { groupId: current.id, 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 index eeead8bc2bb..d0d23302239 100644 --- a/apps/sim/ee/scim/lib/application/users/provision-user.test.ts +++ b/apps/sim/ee/scim/lib/application/users/provision-user.test.ts @@ -165,7 +165,7 @@ describe('provisionScimUser', () => { mocks.assertEmailAvailable.mockResolvedValue(undefined) mocks.consumeTombstone.mockResolvedValue(undefined) mocks.syncIdentity.mockResolvedValue(false) - mocks.suspend.mockResolvedValue(undefined) + mocks.suspend.mockResolvedValue({ suspended: true, sessionsRevoked: 0, stoppedRuns: [] }) mocks.unsuspend.mockResolvedValue(undefined) mocks.isInstanceMode.mockReturnValue(false) mocks.getInstanceOrganizationId.mockResolvedValue(null) diff --git a/apps/sim/ee/scim/lib/application/users/provision-user.ts b/apps/sim/ee/scim/lib/application/users/provision-user.ts index a88375ac3a6..d63a401c9ad 100644 --- a/apps/sim/ee/scim/lib/application/users/provision-user.ts +++ b/apps/sim/ee/scim/lib/application/users/provision-user.ts @@ -10,6 +10,8 @@ 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 type { StoppedRun } from '@/lib/mothership/async-runs/repository' +import { deliverRunStops } from '@/lib/mothership/request/application/stop-delivery' import { getInstanceOrganizationId, isInstanceOrganizationMode, @@ -155,6 +157,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ } let provisioned: { + stoppedRuns: StoppedRun[] scimUserId: string joinedOrganization: boolean subscriptionId: string | undefined @@ -205,12 +208,14 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ * so a create can arrive already inactive and must land suspended rather * than briefly usable. */ + let stoppedRuns: StoppedRun[] = [] if (!attributes.active) { - await suspendMemberTx(tx, { + const suspension = await suspendMemberTx(tx, { userId, organizationId: context.organizationId, source: 'scim', }) + stoppedRuns = suspension.stoppedRuns } await consumeTombstone(tx, { @@ -222,13 +227,13 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ 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 { + stoppedRuns, scimUserId: inserted.id, joinedOrganization: !membership.alreadyMember, subscriptionId: seatPolicy.organizationSubscriptionId, @@ -253,8 +258,10 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ } throw error } + const { stoppedRuns, ...result } = provisioned + deliverRunStops(stoppedRuns) return { - ...provisioned, + ...result, userId, createdAccount, organizationId: context.organizationId, 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 index b809b8a8495..5d4061c2906 100644 --- a/apps/sim/ee/scim/lib/application/users/update-user.test.ts +++ b/apps/sim/ee/scim/lib/application/users/update-user.test.ts @@ -133,6 +133,7 @@ describe('user updates', () => { beforeEach(() => { resetDbChainMock() mocks.loadGroups.mockResolvedValue(new Map()) + mocks.suspend.mockResolvedValue({ suspended: true, sessionsRevoked: 0, stoppedRuns: [] }) mocks.reconcile.mockResolvedValue({ added: [], removed: [], raised: [], stoppedRuns: [] }) }) diff --git a/apps/sim/ee/scim/lib/application/users/update-user.ts b/apps/sim/ee/scim/lib/application/users/update-user.ts index 7361ec169c2..7b6518b5430 100644 --- a/apps/sim/ee/scim/lib/application/users/update-user.ts +++ b/apps/sim/ee/scim/lib/application/users/update-user.ts @@ -100,12 +100,14 @@ async function applyUserUpdate( await syncAccountIdentityTx(tx, { userId: current.userId, name: nextName }) } + let stoppedRuns: StoppedRun[] = [] if (deactivated) { - await suspendMemberTx(tx, { + const suspension = await suspendMemberTx(tx, { userId: current.userId, organizationId: context.organizationId, source: 'scim', }) + stoppedRuns = suspension.stoppedRuns } else if (reactivated) { await unsuspendMemberTx(tx, { userId: current.userId, source: 'scim' }) } @@ -120,12 +122,13 @@ async function applyUserUpdate( connectionId: context.connection.id, organizationId: context.organizationId, scimUserId: current.id, - settings: context.connection.settings, }) return { outcome: { emailChanged, deactivated, reactivated }, - stoppedRuns: projection.stoppedRuns, + stoppedRuns: [ + ...new Map([...stoppedRuns, ...projection.stoppedRuns].map((run) => [run.id, run])).values(), + ], } } diff --git a/apps/sim/ee/scim/lib/projection/auto-map.ts b/apps/sim/ee/scim/lib/projection/auto-map.ts index 629940e52a0..0d61a469e0e 100644 --- a/apps/sim/ee/scim/lib/projection/auto-map.ts +++ b/apps/sim/ee/scim/lib/projection/auto-map.ts @@ -65,7 +65,7 @@ export async function autoMapPermissionGroupByName( ) ) .limit(1) - if (existing) return 'already-mapped' + if (existing) return removed.length > 0 ? 'mapped' : 'already-mapped' /** The unique index is the arbiter when an administrator maps the same pair concurrently. */ const inserted = await tx @@ -80,7 +80,7 @@ export async function autoMapPermissionGroupByName( }) .onConflictDoNothing() .returning({ id: scimGroupMapping.id }) - return inserted.length > 0 ? 'mapped' : 'already-mapped' + return inserted.length > 0 || removed.length > 0 ? 'mapped' : 'already-mapped' } /** diff --git a/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts b/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts index 2a03b1d107e..35cd37aaaae 100644 --- a/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts +++ b/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts @@ -59,10 +59,10 @@ const params = { connectionId: 'conn-1', organizationId: 'org-1', scimUserId: 'su-1', - settings: {}, } interface Scenario { + lockManualMembership?: boolean current?: Array> mappings?: Array> ownedWorkspaces?: string[] @@ -72,6 +72,7 @@ interface Scenario { } function stage({ + lockManualMembership = false, current = [], mappings = [], ownedWorkspaces = ['ws-1'], @@ -79,7 +80,7 @@ function stage({ actualGroups = [], actualRole = 'member', }: Scenario) { - queueTableRows(scimUser, [{ userId: 'u-1' }]) + queueTableRows(scimUser, [{ userId: 'u-1', settings: { lockManualMembership } }]) queueTableRows(scimProjectionGrant, current) queueTableRows(scimGroupMember, mappings) queueTableRows( @@ -283,6 +284,7 @@ describe('reconcileUserProjection', () => { it('withdraws the entire covered access when managed membership is locked', async () => { stage({ + lockManualMembership: true, current: [ { targetKind: 'workspace', @@ -294,7 +296,7 @@ describe('reconcileUserProjection', () => { ], }) mocks.readPermission.mockResolvedValue('admin') - await reconcileUserProjection(db, { ...params, settings: { lockManualMembership: true } }) + await reconcileUserProjection(db, params) expect(mocks.revokeWorkspace).toHaveBeenCalled() expect(mocks.lowerWorkspace).not.toHaveBeenCalled() }) @@ -321,14 +323,12 @@ describe('reconcileUserProjection', () => { }) mocks.readPermission.mockResolvedValue('write') stage({ + lockManualMembership: true, current: [ { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'write', origin: 'adopted' }, ], }) - const withdrawn = await reconcileUserProjection(db, { - ...params, - settings: { lockManualMembership: true }, - }) + const withdrawn = await reconcileUserProjection(db, params) expect(mocks.revokeWorkspace).toHaveBeenCalledWith( db, expect.objectContaining({ workspaceId: 'ws-1', userId: 'u-1' }) diff --git a/apps/sim/ee/scim/lib/projection/reconcile-user.ts b/apps/sim/ee/scim/lib/projection/reconcile-user.ts index 15be39369c5..cfbc77b3549 100644 --- a/apps/sim/ee/scim/lib/projection/reconcile-user.ts +++ b/apps/sim/ee/scim/lib/projection/reconcile-user.ts @@ -3,7 +3,7 @@ import { member, permissionGroupMember, permissions, - type ScimConnectionSettings, + scimConnection, scimGroupMapping, scimGroupMember, scimProjectionGrant, @@ -14,7 +14,10 @@ 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 { + acquireOrganizationMutationLock, + acquireOrganizationUserMutationLocks, +} from '@/lib/billing/organizations/membership' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' import type { StoppedRun } from '@/lib/mothership/async-runs/repository' @@ -435,13 +438,20 @@ export async function reconcileUserProjection( connectionId: string organizationId: string scimUserId: string - settings: ScimConnectionSettings } ): Promise { + await acquireOrganizationMutationLock(tx, params.organizationId) const [record] = await tx - .select({ userId: scimUser.userId }) + .select({ userId: scimUser.userId, settings: scimConnection.settings }) .from(scimUser) - .where(and(eq(scimUser.id, params.scimUserId), eq(scimUser.connectionId, params.connectionId))) + .innerJoin(scimConnection, eq(scimConnection.id, scimUser.connectionId)) + .where( + and( + eq(scimUser.id, params.scimUserId), + eq(scimUser.connectionId, params.connectionId), + eq(scimConnection.organizationId, params.organizationId) + ) + ) .limit(1) if (!record) return emptyDelta() @@ -484,7 +494,7 @@ export async function reconcileUserProjection( const delta = emptyDelta() const accessEndedIn = new Set() - const lockManualMembership = params.settings.lockManualMembership === true + const lockManualMembership = record.settings.lockManualMembership === true /** Withdrawals first, so a move between groups frees its workspace slot. */ for (const grant of plan.withdraw) { @@ -621,7 +631,6 @@ export async function reconcileUsersProjection( connectionId: string organizationId: string scimUserIds: string[] - settings: ScimConnectionSettings } ): Promise { const total = emptyDelta() @@ -630,7 +639,6 @@ export async function reconcileUsersProjection( connectionId: params.connectionId, organizationId: params.organizationId, scimUserId, - settings: params.settings, }) addChanges(total, delta) total.stoppedRuns.push(...delta.stoppedRuns) @@ -653,7 +661,6 @@ export async function reconcileUsersProjectionInBatches(params: { connectionId: string organizationId: string scimUserIds: string[] - settings: ScimConnectionSettings }): Promise { const changes: ProjectionChanges = { added: [], removed: [], raised: [] } const ids = [...new Set(params.scimUserIds)].sort() diff --git a/apps/sim/ee/scim/lib/reconcile/job.test.ts b/apps/sim/ee/scim/lib/reconcile/job.test.ts index f25a9ad6775..9b910ea80a4 100644 --- a/apps/sim/ee/scim/lib/reconcile/job.test.ts +++ b/apps/sim/ee/scim/lib/reconcile/job.test.ts @@ -67,10 +67,9 @@ 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) { +/** Queues the lease check before a batch. */ +function stageBatch(token: string) { queueTableRows(scimConnection, [{ token }]) - queueTableRows(scimConnection, settings ? [{ settings }] : []) } const setCalls = () => @@ -188,7 +187,7 @@ describe('reconcileConnection', () => { 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', {}) + stageBatch('run-1') mocks.reconcileBatch.mockRejectedValueOnce(new Error('projection failed')) await expect(reconcileConnection(connection)).rejects.toThrow('projection failed') @@ -228,7 +227,7 @@ describe('runScimReconcileSweep', () => { grantLease() mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1', 'su-2'])).mockResolvedValueOnce([]) - stageBatch('run-1', {}) + stageBatch('run-1') mocks.reconcileBatch.mockResolvedValueOnce(delta(1, 0, 0)) grantLease() diff --git a/apps/sim/ee/scim/lib/reconcile/job.ts b/apps/sim/ee/scim/lib/reconcile/job.ts index 18e44d5ba1d..61324842b82 100644 --- a/apps/sim/ee/scim/lib/reconcile/job.ts +++ b/apps/sim/ee/scim/lib/reconcile/job.ts @@ -220,23 +220,10 @@ export async function reconcileConnection(connection: { 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 diff --git a/apps/sim/lib/api/contracts/organization-scim.ts b/apps/sim/lib/api/contracts/organization-scim.ts index e9eb7d34368..b2b05bf9cbc 100644 --- a/apps/sim/lib/api/contracts/organization-scim.ts +++ b/apps/sim/lib/api/contracts/organization-scim.ts @@ -94,6 +94,10 @@ const groupMappingSchema = z.object({ workspaceId: z.string().nullable(), permissionType: z.enum(['admin', 'write', 'read']).nullable(), role: z.string().nullable(), + /** Optional while older application instances still serve the settings API. */ + source: z.enum(['automatic', 'manual']).optional(), + /** Current directory members missing the mapped permission-group membership. */ + unassignedMemberCount: z.number().int().min(0).max(5000).nullable().optional(), }) export const listScimGroupMappingsContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/permission-groups.ts b/apps/sim/lib/api/contracts/permission-groups.ts index fbc0f2a25c6..4f90be85298 100644 --- a/apps/sim/lib/api/contracts/permission-groups.ts +++ b/apps/sim/lib/api/contracts/permission-groups.ts @@ -52,6 +52,7 @@ export const permissionGroupSchema = z.object({ creatorEmail: z.string().nullable(), memberCount: z.number(), isDefault: z.boolean(), + membershipMode: z.string().optional(), /** * Workspaces this group targets. Empty for the default group (which governs * every workspace) and for a non-default group scoped to nothing. diff --git a/apps/sim/lib/billing/organizations/membership.ts b/apps/sim/lib/billing/organizations/membership.ts index 0fb26bb379d..127a9b26e0c 100644 --- a/apps/sim/lib/billing/organizations/membership.ts +++ b/apps/sim/lib/billing/organizations/membership.ts @@ -24,6 +24,7 @@ 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 { revokeUserOAuthGrants } from '@/lib/auth/oauth-access-token' import { invalidateMembershipCache, invalidateSecurityPolicyVersionCache, @@ -1487,6 +1488,7 @@ export async function removeUserFromOrganization( ...(spareSessionToken ? { spareSessionToken } : {}), ...(spareSessionId ? { spareSessionId } : {}), }) + if (revokePersonalApiKeys) await revokeUserOAuthGrants(userId, tx) if (workspaceIds.length === 0) { return { diff --git a/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts b/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts index bdf0338e3ce..1fb7736773c 100644 --- a/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts +++ b/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts @@ -64,6 +64,7 @@ import { db } from '@sim/db' import { copilotAsyncToolCalls, copilotChats, + copilotOrganizationRequestStops, copilotRequestStops, copilotRuns, idempotencyKey, @@ -73,6 +74,7 @@ import { scimConnection, scimGroupMapping, scimGroupMember, + ssoDomain, user, workspace, } from '@sim/db/schema' @@ -107,6 +109,8 @@ import { revokeWorkspaceAccessTx } from '@/lib/workspaces/access/workspace-acces import { removeWorkspaceMemberRecord } from '@/lib/workspaces/permissions/member-removal-store' import { DELETE as deleteWorkspace } from '@/app/api/workspaces/[id]/route' import { replaceScimGroup } from '@/ee/scim/lib/application/groups/manage-groups' +import { provisionScimUser } from '@/ee/scim/lib/application/users/provision-user' +import { patchScimUser } from '@/ee/scim/lib/application/users/update-user' import { PROJECTION_BATCH_SIZE, reconcileUsersProjectionInBatches, @@ -278,15 +282,11 @@ async function runControl(run: SeededRun) { } async function hasStop(run: SeededRun) { + const table = run.organizationId ? copilotOrganizationRequestStops : copilotRequestStops const rows = await db - .select({ streamId: copilotRequestStops.streamId }) - .from(copilotRequestStops) - .where( - and( - eq(copilotRequestStops.userId, run.userId), - eq(copilotRequestStops.streamId, run.streamId) - ) - ) + .select({ streamId: table.streamId }) + .from(table) + .where(and(eq(table.userId, run.userId), eq(table.streamId, run.streamId))) return rows.length > 0 } @@ -381,7 +381,7 @@ async function seedDirectory(organizationId: string, target: DirectoryTarget) { organizationId, connectionId, credentialId: generateId(), - scopes: ['groups:write'], + scopes: ['groups:write', 'users:write'], } /** Sets the group's members, as a directory's full-group PUT does. */ const setGroupMembers = (memberIds: string[]) => @@ -391,9 +391,10 @@ async function seedDirectory(organizationId: string, target: DirectoryTarget) { }) /** Re-projects these users in bounded transactions, as the scheduled pass and mapping edits do. */ const reconcile = (scimUserIds: string[]) => - reconcileUsersProjectionInBatches({ connectionId, organizationId, scimUserIds, settings: {} }) + reconcileUsersProjectionInBatches({ connectionId, organizationId, scimUserIds }) return { connectionId, + principal, groupId: group.id, setGroupMembers, reconcile, @@ -768,6 +769,116 @@ describe.runIf(Boolean(redisUrl))('removing a run’s chat, owner or access stop expect(await runControl(ownerRun)).toEqual(LIVE) }) + it('keeps directory-deactivated runs stopped after reactivation without withdrawing grants', async () => { + const ownerId = await seedUser() + const { organizationId, workspaceId } = await seedOrganization(ownerId) + const directory = await seedDirectory(organizationId, { workspaceId }) + const suspended = await seedDirectoryMember(directory.connectionId, organizationId) + await directory.setGroupMembers([suspended.scimUserId]) + const workspaceRun = await seedRun({ userId: suspended.userId, workspaceId }) + const assistantRun = await seedRun({ userId: suspended.userId, organizationId }) + const ownerRun = await seedRun({ userId: ownerId, workspaceId }) + + for (const active of [false, true]) { + await patchScimUser.execute({ + principal: directory.principal, + input: { + scimUserId: suspended.scimUserId, + operations: [{ op: 'replace', path: 'active', value: active }], + }, + }) + } + + expect(await hasStop(workspaceRun)).toBe(true) + expect(await hasStop(assistantRun)).toBe(true) + expect(await runControl(workspaceRun)).toEqual(STOPPED) + expect(await runControl(assistantRun)).toEqual(STOPPED) + await expect.poll(pushedStops).toEqual([workspaceRun.streamId, assistantRun.streamId].sort()) + expect(await runControl(ownerRun)).toEqual(LIVE) + expect( + await db + .select({ permission: permissions.permissionType }) + .from(permissions) + .where(and(eq(permissions.userId, suspended.userId), eq(permissions.entityId, workspaceId))) + ).toEqual([{ permission: 'write' }]) + const freshRun = await seedRun({ userId: suspended.userId, workspaceId }) + expect(await runControl(freshRun)).toEqual(LIVE) + }) + + it('stops an existing member’s runs when first provisioned inactive', async () => { + const ownerId = await seedUser() + const { organizationId, workspaceId } = await seedOrganization(ownerId) + const directory = await seedDirectory(organizationId, { workspaceId }) + const userId = await seedUser() + await db.insert(member).values({ id: generateId(), userId, organizationId, role: 'member' }) + await grantWrite(userId, workspaceId) + await db.insert(ssoDomain).values({ + id: generateId(), + organizationId, + domain: 'run-removal.test', + status: 'verified', + verificationToken: generateId(), + }) + const run = await seedRun({ userId, workspaceId }) + + const provisioned = await provisionScimUser.execute({ + principal: directory.principal, + input: { + attributes: { + userName: `${userId}@run-removal.test`, + active: false, + name: { formatted: 'Run removal fixture' }, + emails: [{ value: `${userId}@run-removal.test`, primary: true }], + }, + }, + }) + + expect(provisioned.createdAccount).toBe(false) + expect(provisioned.resource.active).toBe(false) + expect(await hasStop(run)).toBe(true) + await expect.poll(pushedStops).toEqual([run.streamId]) + await patchScimUser.execute({ + principal: directory.principal, + input: { + scimUserId: provisioned.scimUserId, + operations: [{ op: 'replace', path: 'active', value: true }], + }, + }) + expect(await runControl(run)).toEqual(STOPPED) + }) + + it('does not stop runs when a directory deactivation rolls back', async () => { + const ownerId = await seedUser() + const { organizationId, workspaceId } = await seedOrganization(ownerId) + const directory = await seedDirectory(organizationId, { workspaceId }) + const suspended = await seedDirectoryMember(directory.connectionId, organizationId) + await grantWrite(suspended.userId, workspaceId) + const run = await seedRun({ userId: suspended.userId, workspaceId }) + await db.insert(scimGroupMember).values({ + id: generateId(), + groupId: directory.groupId, + scimUserId: suspended.scimUserId, + }) + const allowGrants = await refuseGrantsFor(suspended.scimUserId) + try { + await expect( + patchScimUser.execute({ + principal: directory.principal, + input: { + scimUserId: suspended.scimUserId, + operations: [{ op: 'replace', path: 'active', value: false }], + }, + }) + ).rejects.toMatchObject(REFUSED) + } finally { + await allowGrants() + } + + expect(requestedStops()).toEqual([]) + expect(await hasStop(run)).toBe(false) + expect(await runControl(run)).toEqual(LIVE) + }) + it('pushes Stop for a directory demotion that ends an admin’s derived access', async () => { const ownerId = await seedUser() const { organizationId, workspaceId } = await seedOrganization(ownerId) diff --git a/apps/sim/lib/organizations/members/lifecycle.test.ts b/apps/sim/lib/organizations/members/lifecycle.test.ts index 2880b027e43..25824f1468a 100644 --- a/apps/sim/lib/organizations/members/lifecycle.test.ts +++ b/apps/sim/lib/organizations/members/lifecycle.test.ts @@ -125,7 +125,7 @@ describe('suspendMemberTx and unsuspendMemberTx', () => { dbChainMockFns.returning.mockResolvedValueOnce([{ id: 's-1' }]) await expect( suspendMemberTx(db, { userId: 'u-1', organizationId: 'org-1', source: 'scim' }) - ).resolves.toEqual({ suspended: true, sessionsRevoked: 1 }) + ).resolves.toEqual({ suspended: true, sessionsRevoked: 1, stoppedRuns: [] }) expect(mockAcquireLocks).toHaveBeenCalledTimes(1) expect(dbChainMockFns.set).toHaveBeenCalledWith( expect.objectContaining({ suspensionSource: 'scim', suspendedAt: expect.any(Date) }) diff --git a/apps/sim/lib/organizations/members/lifecycle.ts b/apps/sim/lib/organizations/members/lifecycle.ts index af7fe445c35..f2f5d235711 100644 --- a/apps/sim/lib/organizations/members/lifecycle.ts +++ b/apps/sim/lib/organizations/members/lifecycle.ts @@ -5,7 +5,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { acquireOrganizationUserMutationLocks } from '@/lib/billing/organizations/membership' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx, DbTransaction } from '@/lib/db/types' -import type { StoppedRun } from '@/lib/mothership/async-runs/repository' +import { type StoppedRun, stopLiveRunsTx } from '@/lib/mothership/async-runs/repository' import { revokeUserSessionsTx } from '@/lib/organizations/members/revocation' import { stopRunsWithoutWorkspaceAccessTx } from '@/lib/workspaces/access/workspace-access' @@ -26,6 +26,8 @@ export type SuspensionSource = 'scim' export interface SuspendMemberResult { suspended: boolean sessionsRevoked: number + /** Deliver these durable Stops only after the suspension commits. */ + stoppedRuns: StoppedRun[] } /** @@ -37,8 +39,8 @@ export interface SuspendMemberResult { * 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. + * authentication paths refuse them while `suspendedAt` is set. Reactivation + * permits new runs; already admitted Chat runs are durably stopped. */ export async function suspendMemberTx( tx: DbTransaction, @@ -73,7 +75,8 @@ export async function suspendMemberTx( organizationId: params.organizationId, }) - return { suspended: Boolean(updated), sessionsRevoked: sessions.revoked } + const stoppedRuns = await stopLiveRunsTx(tx, { userId: params.userId }) + return { suspended: Boolean(updated), sessionsRevoked: sessions.revoked, stoppedRuns } } /** Lifts a suspension, but only one raised by the same source. */ diff --git a/apps/sim/scripts/test-scim-e2e.ts b/apps/sim/scripts/test-scim-e2e.ts index a9e3d592f56..1d631b86533 100644 --- a/apps/sim/scripts/test-scim-e2e.ts +++ b/apps/sim/scripts/test-scim-e2e.ts @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto' import { writeFile } from 'node:fs/promises' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' import { generateId, generateShortId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' import { makeSignature } from 'better-auth/crypto' @@ -562,7 +563,7 @@ async function run() { ) await check( - 'An address change ends sessions and OAuth grants issued under the old one', + 'Address changes and directory removal retire OAuth grants instead of restoring them after suspension', async () => { const carolEmail = `carol@${primary.domain}` const created = await scim(token, '/Users', { @@ -621,6 +622,28 @@ async function run() { (await sql`select id from oauth_access_token where user_id = ${userId}`).length, 0 ) + + const rawToken = generateShortId(32) + const bearer = `sim_oat_${rawToken}` + await sql`insert into oauth_token_family (id, client_id, user_id, created_at, expires_at) + values (${generateId()}, ${clientId}, ${userId}, now(), now() + interval '1 day')` + await sql`insert into oauth_access_token (id, token, client_id, user_id, scopes, created_at, expires_at) + values (${generateId()}, ${createHash('sha256').update(rawToken).digest('hex')}, + ${clientId}, ${userId}, ${['api:read']}, now(), now() + interval '1 hour')` + await request('/api/v2/workspaces', { token: bearer }) + await patch(token, `/Users/${carolId}`, [{ op: 'replace', path: 'active', value: false }]) + await request('/api/v2/workspaces', { token: bearer, expected: 401 }) + await scim(token, `/Users/${carolId}`, { method: 'DELETE', expected: 204 }) + await request('/api/v2/workspaces', { token: bearer, expected: 401 }) + assert.equal( + (await sql`select id from oauth_token_family where user_id = ${userId}`).length, + 0, + 'Directory removal must retire refresh families as well as sessions' + ) + assert.equal( + (await sql`select id from oauth_access_token where user_id = ${userId}`).length, + 0 + ) } finally { await sql`delete from oauth_client where client_id = ${clientId}` await sql`delete from "user" where id = ${userId}` @@ -715,6 +738,257 @@ async function run() { } ) + await check( + 'Renaming an automatically matched group withdraws its old policy even when the new target is already mapped', + async () => { + await admin(primary, '', { + method: 'PUT', + body: { settings: { autoMapPermissionGroupsByName: true } }, + }) + try { + for (const method of ['PATCH', 'PUT'] as const) { + const oldPolicyId = generateId() + const newPolicyId = generateId() + const oldName = `SCIM E2E ${method} Old policy` + const newName = `SCIM E2E ${method} New policy` + await sql`insert into permission_group (id, organization_id, name, created_by) + values (${oldPolicyId}, ${primary.orgId}, ${oldName}, ${primary.ownerId}), + (${newPolicyId}, ${primary.orgId}, ${newName}, ${primary.ownerId})` + const created = await scim(token, '/Groups', { + method: 'POST', + expected: 201, + body: { + schemas: [GROUP_SCHEMA], + displayName: oldName, + members: [{ value: aliceId }], + }, + }) + const renamedGroupId = string(created.body.id) + try { + await admin(primary, '/mappings', { + method: 'POST', + expected: 201, + body: { + groupId: renamedGroupId, + targetKind: 'permission_group', + permissionGroupId: newPolicyId, + }, + }) + assert.equal( + ( + await sql`select id from permission_group_member where user_id = ${aliceUserId} + and permission_group_id in (${oldPolicyId}, ${newPolicyId})` + ).length, + 2 + ) + if (method === 'PATCH') { + await patch( + token, + `/Groups/${renamedGroupId}`, + [{ op: 'replace', path: 'displayName', value: newName }], + 204 + ) + } else { + await scim(token, `/Groups/${renamedGroupId}`, { + method, + body: { + schemas: [GROUP_SCHEMA], + displayName: newName, + members: [{ value: aliceId }], + }, + }) + } + assert.equal( + ( + await sql`select id from permission_group_member where permission_group_id = ${oldPolicyId}` + ).length, + 0, + `${method} rename must immediately withdraw the old automatically matched policy` + ) + assert.equal( + ( + await sql`select id from scim_projection_grant where scim_user_id = ${aliceId} + and target_kind = 'permission_group' and target_id = ${oldPolicyId}` + ).length, + 0 + ) + assert.equal( + ( + await sql`select id from permission_group_member where permission_group_id = ${newPolicyId}` + ).length, + 1 + ) + } finally { + await scim(token, `/Groups/${renamedGroupId}`, { method: 'DELETE', expected: 204 }) + await sql`delete from permission_group where id in (${oldPolicyId}, ${newPolicyId})` + } + } + } finally { + await admin(primary, '', { + method: 'PUT', + body: { settings: { autoMapPermissionGroupsByName: false } }, + }) + } + } + ) + + await check( + 'Mapping status exposes unapplied policy memberships and clears after conflict recovery', + async () => { + const firstPolicyId = generateId() + const secondPolicyId = generateId() + const conflictGroupIds: string[] = [] + await sql`insert into permission_group (id, organization_id, name, membership_mode, created_by) + values (${firstPolicyId}, ${primary.orgId}, 'SCIM E2E first policy', 'explicit', ${primary.ownerId}), + (${secondPolicyId}, ${primary.orgId}, 'SCIM E2E second policy', 'explicit', ${primary.ownerId})` + for (const policyId of [firstPolicyId, secondPolicyId]) { + await sql`insert into permission_group_workspace (id, permission_group_id, workspace_id, organization_id) + values (${generateId()}, ${policyId}, ${primary.workspaceId}, ${primary.orgId})` + } + try { + for (const [index, policyId] of [firstPolicyId, secondPolicyId].entries()) { + const created = await scim(token, '/Groups', { + method: 'POST', + expected: 201, + body: { + schemas: [GROUP_SCHEMA], + displayName: `SCIM E2E conflict ${index}`, + members: [{ value: aliceId }], + }, + }) + const directoryGroupId = string(created.body.id) + conflictGroupIds.push(directoryGroupId) + await admin(primary, '/mappings', { + method: 'POST', + expected: 201, + body: { + groupId: directoryGroupId, + targetKind: 'permission_group', + permissionGroupId: policyId, + }, + }) + } + const mappings = (await admin(primary, '/mappings')).body.groups + assert(Array.isArray(mappings)) + const conflicted = record( + mappings.find((value) => record(value).id === conflictGroupIds[1]) + ) + assert(Array.isArray(conflicted.mappings)) + const pending = record(conflicted.mappings[0]) + assert.equal(pending.source, 'manual') + assert.equal( + pending.unassignedMemberCount, + 1, + 'A saved mapping must expose the skipped member' + ) + assert.equal( + ( + await sql`select id from permission_group_member where permission_group_id = ${secondPolicyId}` + ).length, + 0 + ) + await patch( + token, + `/Groups/${conflictGroupIds[0]}`, + [{ op: 'remove', path: `members[value eq "${aliceId}"]` }], + 204 + ) + await admin(primary, '/reconcile', { method: 'POST' }) + const recovered = (await admin(primary, '/mappings')).body.groups + assert(Array.isArray(recovered)) + const recoveredGroup = record( + recovered.find((value) => record(value).id === conflictGroupIds[1]) + ) + assert(Array.isArray(recoveredGroup.mappings)) + assert.equal(record(recoveredGroup.mappings[0]).unassignedMemberCount, 0) + assert.equal( + ( + await sql`select id from permission_group_member where permission_group_id = ${secondPolicyId}` + ).length, + 1 + ) + } finally { + for (const directoryGroupId of conflictGroupIds) { + await scim(token, `/Groups/${directoryGroupId}`, { method: 'DELETE', expected: 204 }) + } + await sql`delete from permission_group where id in (${firstPolicyId}, ${secondPolicyId})` + } + } + ) + + await check( + 'Group removal honors membership locking enabled while the request waits for the organization lock', + async () => { + await sql`insert into permissions (id, user_id, entity_type, entity_id, permission_type) + values (${generateId()}, ${aliceUserId}, 'workspace', ${primary.workspaceId}, 'read')` + const created = await scim(token, '/Groups', { + method: 'POST', + expected: 201, + body: { + schemas: [GROUP_SCHEMA], + displayName: 'SCIM E2E concurrent lock', + members: [{ value: aliceId }], + }, + }) + const lockedGroupId = string(created.body.id) + try { + await admin(primary, '/mappings', { + method: 'POST', + expected: 201, + body: { + groupId: lockedGroupId, + targetKind: 'workspace', + workspaceId: primary.workspaceId, + permissionType: 'read', + }, + }) + let removal: Promise | undefined + try { + await sql.begin(async (tx) => { + const [holder] = await tx`select pg_backend_pid() as pid, + pg_advisory_xact_lock(hashtextextended(${`organization-mutation:${primary.orgId}`}, 0))` + removal = patch( + token, + `/Groups/${lockedGroupId}`, + [{ op: 'remove', path: `members[value eq "${aliceId}"]` }], + 204 + ) + void removal.catch(() => {}) + const deadline = performance.now() + 4000 + for (;;) { + const waiting = await sql`select pid from pg_stat_activity + where wait_event = 'advisory' and ${holder.pid} = any(pg_blocking_pids(pid))` + if (waiting.length > 0) break + assert( + performance.now() < deadline, + 'The group request must reach the organization lock' + ) + await sleep(10) + } + await tx`update scim_connection + set settings = settings || '{"lockManualMembership":true}'::jsonb + where organization_id = ${primary.orgId}` + }) + } finally { + await removal + } + assert.equal( + await permission(aliceUserId, primary.workspaceId), + undefined, + 'A committed membership lock must govern the subsequent directory withdrawal' + ) + } finally { + await scim(token, `/Groups/${lockedGroupId}`, { method: 'DELETE', expected: 204 }) + await admin(primary, '', { + method: 'PUT', + body: { settings: { lockManualMembership: false } }, + }) + await sql`delete from permissions where user_id = ${aliceUserId} + and entity_type = 'workspace' and entity_id = ${primary.workspaceId}` + } + } + ) + await check( 'Workspace, organization-role, and permission-group mappings project and withdraw access', async () => { From f492eef2e08269f4a2367104d6660003b4808fb2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 28 Oct 2026 18:38:41 -0700 Subject: [PATCH 2/2] fix(scim): close provisioning races and refresh policy state --- .../components/group-detail.tsx | 4 +- apps/sim/ee/scim/TESTING.md | 9 +- apps/sim/ee/scim/hooks/scim.ts | 6 +- .../application/groups/manage-groups.test.ts | 1 + .../lib/application/groups/manage-groups.ts | 37 ++++-- .../mothership/async-runs/repository.test.ts | 40 +++--- .../lib/mothership/async-runs/repository.ts | 12 +- .../removal-stops-runs.integration.ts | 122 ++++++++++++++++++ apps/sim/scripts/test-scim-e2e.ts | 92 +++++++++++++ 9 files changed, 283 insertions(+), 40 deletions(-) diff --git a/apps/sim/ee/access-control/components/group-detail.tsx b/apps/sim/ee/access-control/components/group-detail.tsx index d0ebf019166..a061a333d9e 100644 --- a/apps/sim/ee/access-control/components/group-detail.tsx +++ b/apps/sim/ee/access-control/components/group-detail.tsx @@ -1588,7 +1588,9 @@ export function GroupDetail({
{members.length === 0 - ? 'Applies to all members of its workspaces. Add members to restrict it to specific people.' + ? group.membershipMode === 'inherit' + ? 'Applies to all members of its workspaces. Add members to restrict it to specific people.' + : 'No members assigned.' : `Restricted to ${members.length} member${members.length === 1 ? '' : 's'}`} { queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) - queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(organizationId) }) + queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all }) }, }) } @@ -157,7 +157,7 @@ export function useDeleteScimGroupMapping() { requestJson(deleteScimGroupMappingContract, { params: { id: organizationId, mappingId } }), onSettled: (_data, _error, { organizationId }) => { queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) - queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(organizationId) }) + queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all }) }, }) } @@ -170,7 +170,7 @@ export function useReconcileScimConnection() { onSettled: (_data, _error, organizationId) => { queryClient.invalidateQueries({ queryKey: scimKeys.connection(organizationId) }) queryClient.invalidateQueries({ queryKey: scimKeys.mappings(organizationId) }) - queryClient.invalidateQueries({ queryKey: permissionGroupKeys.list(organizationId) }) + queryClient.invalidateQueries({ queryKey: permissionGroupKeys.all }) }, }) } diff --git a/apps/sim/ee/scim/lib/application/groups/manage-groups.test.ts b/apps/sim/ee/scim/lib/application/groups/manage-groups.test.ts index 7b469c7a426..ec66b05d1af 100644 --- a/apps/sim/ee/scim/lib/application/groups/manage-groups.test.ts +++ b/apps/sim/ee/scim/lib/application/groups/manage-groups.test.ts @@ -77,6 +77,7 @@ describe('replaceScimGroup', () => { queueTableRows(scimConnection, [ { id: 'conn-1', organizationId: 'org-1', status: 'active', settings: {} }, ]) + queueTableRows(scimConnection, [{ status: 'active', settings: {} }]) let group = { ...initialGroup } const memberIds = new Set(['user-1']) mocks.findGroup.mockImplementation(async () => group) diff --git a/apps/sim/ee/scim/lib/application/groups/manage-groups.ts b/apps/sim/ee/scim/lib/application/groups/manage-groups.ts index fa9ce15e4e8..ba1d03fb197 100644 --- a/apps/sim/ee/scim/lib/application/groups/manage-groups.ts +++ b/apps/sim/ee/scim/lib/application/groups/manage-groups.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' -import { scimGroup } from '@sim/db/schema' +import { type ScimConnectionSettings, scimConnection, 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' @@ -57,11 +57,26 @@ import { */ async function withGroupWrite( context: ScimUseCaseContext, - work: (tx: DbTransaction) => Promise + work: (tx: DbTransaction, settings: ScimConnectionSettings) => Promise ): Promise> { const { stoppedRuns, ...result } = await db.transaction(async (tx) => { await acquireOrganizationMutationLock(tx, context.organizationId) - return work(tx) + const [connection] = await tx + .select({ status: scimConnection.status, settings: scimConnection.settings }) + .from(scimConnection) + .where( + and( + eq(scimConnection.id, context.connection.id), + eq(scimConnection.organizationId, context.organizationId) + ) + ) + .limit(1) + if (!connection || connection.status !== 'active') { + throw new ScimError(401, undefined, 'Invalid SCIM token', { + 'WWW-Authenticate': 'Bearer realm="SCIM"', + }) + } + return work(tx, connection.settings) }) deliverRunStops(stoppedRuns) return result @@ -185,7 +200,7 @@ export const createScimGroup = defineAuthorizedScimUseCase({ context, }: ScimUseCaseArgs): Promise { const { group } = input - return withGroupWrite(context, async (tx) => { + return withGroupWrite(context, async (tx, settings) => { await assertDisplayNameAvailable(tx, { connectionId: context.connection.id, displayName: group.displayName, @@ -202,7 +217,7 @@ export const createScimGroup = defineAuthorizedScimUseCase({ } await assertMemberCount(tx, created.id) - const mapped = context.connection.settings.autoMapPermissionGroupsByName + const mapped = settings.autoMapPermissionGroupsByName ? await autoMapPermissionGroupByName(tx, { organizationId: context.organizationId, scimGroupId: created.id, @@ -253,7 +268,7 @@ export const replaceScimGroup = defineAuthorizedScimUseCase({ input, context, }: ScimUseCaseArgs): Promise { - return withGroupWrite(context, async (tx) => { + return withGroupWrite(context, async (tx, settings) => { const current = await findScimGroupById(tx, context.connection.id, input.groupId) if (!current) throw notFound('SCIM Group not found') @@ -277,7 +292,7 @@ export const replaceScimGroup = defineAuthorizedScimUseCase({ }) } let adopted = false - if (renamed && context.connection.settings.autoMapPermissionGroupsByName) { + if (renamed && settings.autoMapPermissionGroupsByName) { const mapped = await autoMapPermissionGroupByName(tx, { organizationId: context.organizationId, scimGroupId: current.id, @@ -358,7 +373,7 @@ export const patchScimGroup = defineAuthorizedScimUseCase({ }: ScimUseCaseArgs): Promise { const patch = parseGroupPatch(input.operations) - return withGroupWrite(context, async (tx) => { + return withGroupWrite(context, async (tx, settings) => { const current = await findScimGroupById(tx, context.connection.id, input.groupId) if (!current) throw notFound('SCIM Group not found') @@ -402,11 +417,7 @@ export const patchScimGroup = defineAuthorizedScimUseCase({ ...(externalIdChanged ? { externalId: patch.externalId } : {}), }) } - if ( - renamed && - patch.displayName && - context.connection.settings.autoMapPermissionGroupsByName - ) { + if (renamed && patch.displayName && settings.autoMapPermissionGroupsByName) { const mapped = await autoMapPermissionGroupByName(tx, { organizationId: context.organizationId, scimGroupId: current.id, diff --git a/apps/sim/lib/mothership/async-runs/repository.test.ts b/apps/sim/lib/mothership/async-runs/repository.test.ts index dbbae17f179..372ee97b6ac 100644 --- a/apps/sim/lib/mothership/async-runs/repository.test.ts +++ b/apps/sim/lib/mothership/async-runs/repository.test.ts @@ -42,14 +42,15 @@ describe('run admission and early Stop', () => { expect(await requestRunStop(scope)).toBeNull() expect(dbChainMockFns.values).toHaveBeenCalledWith(scope) expect(dbChainMockFns.onConflictDoNothing).toHaveBeenCalled() - expect(dbChainMockFns.execute).toHaveBeenCalledTimes(2) expect( - hasMockCondition( - dbChainMockFns.where.mock.calls[0]?.[0], - (condition) => - condition.type === 'eq' && - condition.left === copilotRuns.userId && - condition.right === scope.userId + dbChainMockFns.where.mock.calls.some(([predicate]) => + hasMockCondition( + predicate, + (condition) => + condition.type === 'eq' && + condition.left === copilotRuns.userId && + condition.right === scope.userId + ) ) ).toBe(true) }) @@ -148,12 +149,13 @@ describe('run admission and early Stop', () => { [copilotRequestStops.workspaceId, scope.workspaceId], [copilotRequestStops.streamId, scope.streamId], ]) { - /** The chat row is read first; the Stop lookup follows it. */ expect( - hasMockCondition( - dbChainMockFns.where.mock.calls[1]?.[0], - (condition) => - condition.type === 'eq' && condition.left === column && condition.right === value + dbChainMockFns.where.mock.calls.some(([predicate]) => + hasMockCondition( + predicate, + (condition) => + condition.type === 'eq' && condition.left === column && condition.right === value + ) ) ).toBe(true) } @@ -167,12 +169,14 @@ describe('run admission and early Stop', () => { expect.objectContaining({ workspaceId: scope.workspaceId, status: 'active' }) ) expect( - hasMockCondition( - dbChainMockFns.where.mock.calls[0]?.[0], - (condition) => - condition.type === 'eq' && - condition.left === copilotChats.userId && - condition.right === scope.userId + dbChainMockFns.where.mock.calls.some(([predicate]) => + hasMockCondition( + predicate, + (condition) => + condition.type === 'eq' && + condition.left === copilotChats.userId && + condition.right === scope.userId + ) ) ).toBe(true) }) diff --git a/apps/sim/lib/mothership/async-runs/repository.ts b/apps/sim/lib/mothership/async-runs/repository.ts index e0d69b87c8a..ef9adbed548 100644 --- a/apps/sim/lib/mothership/async-runs/repository.ts +++ b/apps/sim/lib/mothership/async-runs/repository.ts @@ -9,6 +9,7 @@ import { copilotOrganizationRequestStops, copilotRequestStops, copilotRuns, + user, } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { filterUndefined, toRecordOrNull } from '@sim/utils/object' @@ -154,7 +155,7 @@ export interface CreateRunSegmentInput { type RunAdmissionTransaction = Parameters[0]>[0] -/** Serializes Stop and admission, including when no run row exists to lock yet. */ +/** Serializes account suspension, Stop and admission, even before a run row exists. */ export async function withRunAdmissionLock( userId: string, streamId: string, @@ -162,6 +163,8 @@ export async function withRunAdmissionLock( ): Promise { return db.transaction(async (tx) => { await tx.execute(sql`SET LOCAL statement_timeout = '10s'`) + /** Lock the account before any chat/run writes; suspension updates it before scanning runs. */ + await tx.select({ id: user.id }).from(user).where(eq(user.id, userId)).for('share') const key = JSON.stringify(['copilot-run-admission', userId, streamId]) await acquireAdvisoryXactLock(tx, 'copilot_run_admission', key) return action(tx) @@ -462,8 +465,10 @@ export async function insertRunSegment(tx: RunAdmissionTransaction, input: Creat workspaceId: copilotChats.workspaceId, organizationId: copilotChats.organizationId, deletedAt: copilotChats.deletedAt, + suspendedAt: user.suspendedAt, }) .from(copilotChats) + .innerJoin(user, eq(user.id, copilotChats.userId)) .where(and(eq(copilotChats.id, input.chatId), eq(copilotChats.userId, input.userId))) .for('share') const inheritsOwner = !input.workspaceId && !input.organizationId @@ -475,7 +480,10 @@ export async function insertRunSegment(tx: RunAdmissionTransaction, input: Creat .from(table) .where(predicate) .limit(1) - const closedAt = stop?.stoppedAt ?? chat?.deletedAt + const closedAt = stop?.stoppedAt ?? chat?.deletedAt ?? chat?.suspendedAt + if (chat?.suspendedAt && !stop) { + await recordRunStopTx(tx, { ...input, workspaceId, organizationId }, false) + } const [run] = await tx .insert(copilotRuns) .values({ diff --git a/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts b/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts index 1fb7736773c..63cce9648e7 100644 --- a/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts +++ b/apps/sim/lib/mothership/request/application/removal-stops-runs.integration.ts @@ -805,6 +805,128 @@ describe.runIf(Boolean(redisUrl))('removing a run’s chat, owner or access stop expect(await runControl(freshRun)).toEqual(LIVE) }) + it.each(['interactive', 'headless'] as const)( + 'stops a concurrent %s admission when directory suspension races its insert', + async (kind) => { + const ownerId = await seedUser() + const { organizationId, workspaceId } = await seedOrganization(ownerId) + const directory = await seedDirectory(organizationId, { workspaceId }) + const suspended = await seedDirectoryMember(directory.connectionId, organizationId) + await directory.setGroupMembers([suspended.scimUserId]) + const chatId = await seedChat(suspended.userId, workspaceId) + const setActive = (active: boolean) => + patchScimUser.execute({ + principal: directory.principal, + input: { + scimUserId: suspended.scimUserId, + operations: [{ op: 'replace', path: 'active', value: active }], + }, + }) + const admit = async () => { + if (kind === 'headless') { + return (await admitHeadlessRun(suspended.userId, chatId, workspaceId)).seeded + } + const streamId = generateId() + const runId = generateId() + testStreams.add(streamId) + const sendClaim = { normalizedKey: `run-removal:${generateId()}`, claimToken: generateId() } + await db + .insert(idempotencyKey) + .values({ key: sendClaim.normalizedKey, result: { claimToken: sendClaim.claimToken } }) + expect(await acquirePendingChatStream(chatId, streamId, 0)).toBe(true) + const lease = getLocalChatStreamLease(chatId, streamId) + if (!lease) throw new Error('The admission needs its chat lease') + const content = 'Summarize the quarter' + try { + await admitChatTurn.execute({ + principal: createSessionPrincipal({ + userId: suspended.userId, + sessionId: generateId(), + }), + input: { + chatId, + runId, + executionId: generateId(), + requestId: generateId(), + message: { id: streamId, content }, + recovery: { + kind: 'interactive_stream', + goRoute: '/api/mothership', + clientToolPickupExpected: false, + request: { + userId: suspended.userId, + chatId, + messageId: streamId, + workspaceId, + message: content, + }, + }, + lease, + sendClaim, + notifyWorkspaceStatus: false, + }, + }) + } finally { + await releasePendingChatStream(chatId, streamId, lease) + } + return { userId: suspended.userId, chatId, streamId, runId, workspaceId } + } + + const pending = await whileHolding(chatId, async (holder) => { + const admission = admit() + const admitting = await lockWaiterBehind(holder) + let settled = false + const suspension = setActive(false) + .then(() => setActive(true)) + .finally(() => { + settled = true + }) + void suspension.catch(() => {}) + await expect + .poll(async () => { + const waiting = await db.execute(sql` + SELECT pid FROM pg_stat_activity WHERE datname = current_database() + AND ${admitting}::int = ANY(pg_blocking_pids(pid)) + `) + return settled || waiting.length > 0 + }) + .toBe(true) + return { admission, suspension } + }) + const [run] = await Promise.all([pending.admission, pending.suspension]) + + expect(await hasStop(run)).toBe(true) + expect(await runControl(run)).toEqual(STOPPED) + } + ) + + it('keeps a headless run admitted during suspension stopped after reactivation', async () => { + const ownerId = await seedUser() + const { organizationId, workspaceId } = await seedOrganization(ownerId) + const directory = await seedDirectory(organizationId, { workspaceId }) + const suspended = await seedDirectoryMember(directory.connectionId, organizationId) + await directory.setGroupMembers([suspended.scimUserId]) + const setActive = (active: boolean) => + patchScimUser.execute({ + principal: directory.principal, + input: { + scimUserId: suspended.scimUserId, + operations: [{ op: 'replace', path: 'active', value: active }], + }, + }) + await setActive(false) + const { run, seeded } = await admitHeadlessRun( + suspended.userId, + await seedChat(suspended.userId, workspaceId), + workspaceId + ) + await setActive(true) + + expect(run.status).toBe('cancelled') + expect(await toolAdmissionClosedAt(seeded)).not.toBeNull() + expect(await runControl(seeded)).toEqual(STOPPED) + }) + it('stops an existing member’s runs when first provisioned inactive', async () => { const ownerId = await seedUser() const { organizationId, workspaceId } = await seedOrganization(ownerId) diff --git a/apps/sim/scripts/test-scim-e2e.ts b/apps/sim/scripts/test-scim-e2e.ts index 1d631b86533..747ce437e00 100644 --- a/apps/sim/scripts/test-scim-e2e.ts +++ b/apps/sim/scripts/test-scim-e2e.ts @@ -916,6 +916,98 @@ async function run() { } ) + for (const method of ['POST', 'PUT', 'PATCH'] as const) { + for (const enabled of [true, false]) { + await check( + `Group ${method} honors name matching changed to ${enabled} while waiting for the organization lock`, + async () => { + const policyId = generateId() + const name = `SCIM E2E ${method} concurrent ${enabled}` + let directoryGroupId: string | undefined + await sql`insert into permission_group (id, organization_id, name, created_by) + values (${policyId}, ${primary.orgId}, ${name}, ${primary.ownerId})` + try { + await admin(primary, '', { + method: 'PUT', + body: { settings: { autoMapPermissionGroupsByName: !enabled } }, + }) + if (method !== 'POST') { + const created = await scim(token, '/Groups', { + method: 'POST', + expected: 201, + body: { + schemas: [GROUP_SCHEMA], + displayName: `${name} before`, + members: [{ value: aliceId }], + }, + }) + directoryGroupId = string(created.body.id) + } + let writing: Promise | undefined + try { + await sql.begin(async (tx) => { + const [holder] = await tx`select pg_backend_pid() as pid, + pg_advisory_xact_lock(hashtextextended(${`organization-mutation:${primary.orgId}`}, 0))` + writing = + method === 'PATCH' + ? patch( + token, + `/Groups/${directoryGroupId}`, + [{ op: 'replace', path: 'displayName', value: name }], + 204 + ) + : scim(token, method === 'POST' ? '/Groups' : `/Groups/${directoryGroupId}`, { + method, + expected: method === 'POST' ? 201 : 200, + body: { + schemas: [GROUP_SCHEMA], + displayName: name, + members: [{ value: aliceId }], + }, + }).then((result) => { + directoryGroupId = string(result.body.id) + }) + void writing.catch(() => {}) + const deadline = performance.now() + 4000 + for (;;) { + const waiting = await sql`select pid from pg_stat_activity + where wait_event = 'advisory' and ${holder.pid} = any(pg_blocking_pids(pid))` + if (waiting.length > 0) break + assert( + performance.now() < deadline, + 'The group write must reach the organization lock' + ) + await sleep(10) + } + await tx`update scim_connection + set settings = settings || ${tx.json({ autoMapPermissionGroupsByName: enabled })}::jsonb + where organization_id = ${primary.orgId}` + }) + } finally { + await writing + } + assert(directoryGroupId, 'The group write must return its identifier') + const mappings = await sql`select id from scim_group_mapping + where group_id = ${directoryGroupId} and permission_group_id = ${policyId}` + assert.equal( + mappings.length, + enabled ? 1 : 0, + 'The committed name-matching setting must govern the group write' + ) + } finally { + if (directoryGroupId) + await scim(token, `/Groups/${directoryGroupId}`, { method: 'DELETE', expected: 204 }) + await sql`delete from permission_group where id = ${policyId}` + await admin(primary, '', { + method: 'PUT', + body: { settings: { autoMapPermissionGroupsByName: false } }, + }) + } + } + ) + } + } + await check( 'Group removal honors membership locking enabled while the request waits for the organization lock', async () => {