diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 83017381cd2..ca01e27127f 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -9,7 +9,7 @@ permissions: jobs: oauth-postgres: - name: OAuth PostgreSQL (${{ matrix.provision }}) + name: OAuth and SCIM PostgreSQL (${{ matrix.provision }}) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} timeout-minutes: 15 strategy: @@ -22,17 +22,17 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres - POSTGRES_DB: sim_oauth + POSTGRES_DB: sim_auth_scim ports: - 5432:5432 options: >- - --health-cmd "pg_isready -U postgres -d sim_oauth" + --health-cmd "pg_isready -U postgres -d sim_auth_scim" --health-interval 5s --health-timeout 5s --health-retries 10 env: - DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_oauth - OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_oauth + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim BETTER_AUTH_SECRET: oauth-postgres-ci-secret-at-least-32-characters OAUTH_PROVIDER_ENABLED: 'true' NEXT_PUBLIC_APP_URL: https://test.sim.ai @@ -73,7 +73,7 @@ jobs: working-directory: packages/db run: bun run db:migrate - - name: Verify provider issuance and token lifecycle in PostgreSQL + - name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL working-directory: apps/sim run: >- bunx vitest run @@ -81,6 +81,68 @@ jobs: lib/auth/oauth-provider-lifecycle.postgres.test.ts app/api/auth/oauth2/token/route.postgres.test.ts lib/auth/sim-auth-adapter.test.ts + ee/scim/lib/managed-membership.postgres.test.ts + lib/auth/sso/application/admit-sso-user.postgres.test.ts + + - name: Verify SCIM and administration over real HTTP + working-directory: apps/sim + env: + NEXT_PUBLIC_APP_URL: http://127.0.0.1:3017 + BETTER_AUTH_URL: http://127.0.0.1:3017 + NEXT_PUBLIC_FORCE_HOSTED: 'true' + BILLING_ENABLED: 'true' + NEXT_PUBLIC_BILLING_ENABLED: 'true' + ENTERPRISE_ENABLED: 'true' + NEXT_PUBLIC_ENTERPRISE_ENABLED: 'true' + SCIM_ENABLED: 'true' + NEXT_PUBLIC_SCIM_ENABLED: 'true' + SSO_ENABLED: 'true' + NEXT_PUBLIC_SSO_ENABLED: 'true' + ORGANIZATIONS_ENABLED: 'true' + NEXT_PUBLIC_ORGANIZATIONS_ENABLED: 'true' + INTERNAL_API_SECRET: scim-http-ci-local-secret-at-least-32-characters + DB_TX_TRIPWIRE: throw + DISABLE_TELEMETRY: 'true' + NEXT_TELEMETRY_DISABLED: '1' + NEXT_PUBLIC_CHAT_DISABLED: 'true' + run: | + server_log="$RUNNER_TEMP/scim-next.log" + node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3017 > "$server_log" 2>&1 & + server_pid=$! + finish() { + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$RUNNER_TEMP/scim-http-status.log" + } + trap finish EXIT + deadline=$((SECONDS + 120)) + until curl --fail --silent --max-time 3 http://127.0.0.1:3017/api/health > /dev/null; do + if ! kill -0 "$server_pid" 2>/dev/null; then + echo 'Local SCIM app exited during startup.' + exit 1 + fi + if [ "$SECONDS" -ge "$deadline" ]; then + echo 'Local SCIM app did not become ready within 120 seconds.' + exit 1 + fi + sleep 2 + done + SCIM_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ + SCIM_E2E_DATABASE_URL="$DATABASE_URL" \ + SCIM_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ + SCIM_E2E_REPORT_PATH="$RUNNER_TEMP/scim-e2e-report.json" \ + bun run test:scim:e2e + + - name: Upload SCIM failure report and HTTP status log + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: scim-failure-${{ matrix.provision }} + path: | + ${{ runner.temp }}/scim-e2e-report.json + ${{ runner.temp }}/scim-http-status.log + if-no-files-found: ignore + retention-days: 7 test-build: name: Lint and Test diff --git a/apps/docs/content/docs/platform/enterprise/scim.mdx b/apps/docs/content/docs/platform/enterprise/scim.mdx index c64ee14d26c..fbab49fadcf 100644 --- a/apps/docs/content/docs/platform/enterprise/scim.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim.mdx @@ -6,28 +6,29 @@ description: Create, update, and deactivate Sim members automatically from your import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Image } from '@/components/ui/image' import { FAQ } from '@/components/ui/faq' -Directory provisioning connects your identity provider to Sim over SCIM 2.0. Your provider creates members when someone joins, updates them when their details change, and deactivates them the moment they leave — without anyone touching Sim. +Directory provisioning connects your identity provider to Sim over SCIM 2.0. Your provider creates members when someone joins, updates them when their details change, and deactivates them when it sends a deactivation request — without anyone touching Sim. It pairs with [SSO](/platform/enterprise/sso). SSO proves who someone is when they sign in. Directory provisioning decides who exists and what they can reach, before and after that. - Included with Enterprise plans. Requires [SSO](/platform/enterprise/sso) to be enabled, because provisioning is configured from the SSO settings page, and at least one [verified domain](/platform/enterprise/verified-domains) for your organization. Self-hosted deployments get it with the other enterprise features through `ENTERPRISE_ENABLED=true`, or turn just this feature on or off with `SCIM_ENABLED` and `NEXT_PUBLIC_SCIM_ENABLED`, alongside the [SSO variables](/platform/enterprise/sso#self-hosted-setup). + Included with Enterprise plans. The settings are under **Single sign-on → Provisioning**. On self-hosted deployments, enable the SSO settings page to reach this tab. A saved SSO provider is not required for SCIM; [verify each email domain](/platform/enterprise/verified-domains) before provisioning users at that domain. Self-hosted deployments get it with the other enterprise features through `ENTERPRISE_ENABLED=true` and `NEXT_PUBLIC_ENTERPRISE_ENABLED=true`, or turn just this feature on or off with `SCIM_ENABLED` and `NEXT_PUBLIC_SCIM_ENABLED`, alongside the [SSO variables](/platform/enterprise/sso#self-hosted-setup). ## What it does | Your provider does this | Sim does this | | --- | --- | -| Assigns a person to the Sim app | Creates their account and adds them to your organization as a Member | +| Assigns a person to the Sim app | Creates or links their account; new organization members join as Members | | Updates their name or email | Updates the Sim account, and ends their sessions if the address changed | | Deactivates them | Blocks sign-in and stops their personal API keys. Everything they own, and every grant they hold, is left untouched; shared workspace keys keep working | -| Reactivates them | Restores access exactly as it was | -| Removes them from the app | Removes their organization membership, ends their sessions, deletes their personal API keys, and reassigns what they owned | +| Reactivates them | Lifts the suspension; current group memberships and mappings determine access | +| Sends a SCIM DELETE request | Removes their organization membership, ends their sessions, deletes their personal API keys, and reassigns what they owned | | Adds them to a group | Grants whatever that group maps to | -Deactivation is reversible and never destructive. Someone on leave keeps their workflows, their credentials, and their workspace history; they simply cannot sign in. +Deactivation preserves ownership, credentials, and workspace history. It also blocks scheduled runs and triggers acting as that person. Changes to group membership or mappings can still withdraw access while someone is suspended. ## Turn it on @@ -44,7 +45,7 @@ This is what stops another tenant's directory from claiming an address it does n ### Enable directory provisioning -In **Settings → SSO → Directory provisioning**, turn it on. Sim shows your SCIM base URL: +Open **Settings → Organization → Single sign-on → Provisioning** and turn on **Enable directory provisioning**. Sim shows your SCIM base URL: ``` https:///api/scim/v2 @@ -54,7 +55,7 @@ https:///api/scim/v2 ### Issue a token -Choose whether the token expires (never, 90 days, or a year) and select **Issue token**. It appears once — copy it straight into your provider. +In **Tokens**, choose whether the token expires (never, 90 days, or a year) and select **Issue token**. It appears once — copy it straight into your provider. Two tokens can be active at a time, so you can rotate without downtime: issue the new one, update your provider, confirm a sync succeeds, then revoke the old one. @@ -65,14 +66,16 @@ Two tokens can be active at a time, so you can rotate without downtime: issue th -In your Okta app, open **Provisioning → Integration** and select **Configure API Integration**. +If you use OIDC for sign-in, create a separate provisioning integration: Okta cannot add SCIM to a custom OIDC app. In the Okta Integration Network catalog, add **SCIM 2.0 Test App (Header Auth)** for a private integration. See [Okta's setup guide](https://developer.okta.com/docs/guides/scim-provisioning-integration-connect/main/). -- **SCIM connector base URL**: `https:///api/scim/v2` -- **Unique identifier field for users**: `userName` -- **Supported provisioning actions**: Push New Users, Push Profile Updates, Push Groups -- **Authentication Mode**: HTTP Header, with your Sim token +In that app, open **Provisioning → Integration → Configure API Integration**, enable API integration, and enter: -Select **Test API Credentials**, then save. Under **Provisioning → To App**, enable Create Users, Update User Attributes, and Deactivate Users. +- **SCIM 2.0 Base Url**: `https:///api/scim/v2` +- **API Token**: your Sim token + +Select **Test API Credentials**, then save. Under **Provisioning → To App**, enable Create Users, Update User Attributes, and Deactivate Users. Assign a test user first, then use **Push Groups** for groups you want to map in Sim. Keep assignment groups separate from groups you push, as required by Okta. + +For an existing custom SAML or SWA app instead, follow [Okta's custom-app SCIM guide](https://help.okta.com/en-us/Content/Topics/apps/apps_app_integration_wizard_scim.htm). Its SCIM connection fields differ: set the unique identifier to `userName`, choose HTTP Header authentication, and enable the relevant provisioning actions. Okta never deletes users over SCIM. Unassigning someone, or deactivating them in Okta, sends a deactivation — which Sim applies as a suspension. @@ -86,27 +89,30 @@ In your enterprise application, open **Provisioning** and set Provisioning Mode Select **Test Connection**, then save and start provisioning. -Entra runs an initial cycle over everyone in scope, then incremental cycles roughly every 40 minutes. Removing someone from the app sends a deactivation; a permanent delete in Entra sends a removal about 30 days later. +Entra runs an initial cycle over everyone in scope, then incremental cycles roughly every 40 minutes. Unassignment normally sends a deactivation. Soft-deleted directory users are retained for 30 days; hard deletion can then send a SCIM DELETE during a provisioning cycle. An administrator can hard-delete earlier, and users already unassigned may no longer be managed. See [Microsoft’s provisioning lifecycle](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/how-provisioning-works). -Add a **SCIM Provisioner with SAML** app. +Add **SCIM Provisioner with SAML (SCIM v2 Core)**. Use the SCIM 2.0 connector, since Sim does not support SCIM 1.1. - **SCIM Base URL**: `https:///api/scim/v2` - **SCIM Bearer Token**: your Sim token -Enable provisioning and choose what happens when a user is removed. Suspend maps to a Sim suspension; Delete removes their membership. +In **Parameters**, map `scimusername` to the user's email. Check that **SCIM JSON Template** uses `urn:ietf:params:scim:schemas:core:2.0:User` and sends that email as `userName` or in `emails`. Save, enable the API connection, then enable provisioning. See [OneLogin's custom connector guide](https://onelogin.service-now.com/kb?id=kb_article_view&sysparm_article=KB0013904). + +Choose what happens when a user is deleted in OneLogin. **Suspend** maps to a Sim suspension; **Delete** removes their membership. If administrative approval is enabled, approve the pending provisioning actions in OneLogin before expecting a sync. See [OneLogin's provisioning test guide](https://developers.onelogin.com/docs/scim/test-your-scim/). -Add a **Custom SCIM** identity management integration. +Open an application's **Provisioning** tab and configure a **Custom SCIM** integration. You can use an existing application, a custom SAML application, or a URL Bookmark for provisioning without SAML. - **Base URL**: `https:///api/scim/v2` -- **Token Key**: your Sim token +- **Token** (also called **Token Key**): your Sim token +- **Test User Email**: an unused address in a domain verified by your Sim organization -Enable group sync if you plan to map groups. +Select **Test Connection**, enable group management if you plan to map groups, then select **Activate**. Activation creates and deletes a test user and, with group management enabled, a test group. See [JumpCloud's custom SCIM guide](https://jumpcloud.com/support/provision-and-manage-users-and-groups-in-apps-using-custom-scim-identity-management-integration). @@ -115,7 +121,7 @@ Enable group sync if you plan to map groups. ### Map your groups -Groups mean nothing to Sim until you say what they stand for. In **Settings → SSO → Directory provisioning → Group mappings**, point each pushed group at one or more of: +Groups mean nothing to Sim until you say what they stand for. In **Single sign-on → Provisioning → Group mappings**, point each pushed group at one or more of: - a **permission group**, which governs models, integrations, and capabilities - a **workspace**, at Read, Write, or Admin @@ -123,7 +129,7 @@ Groups mean nothing to Sim until you say what they stand for. In **Settings → A group can carry several mappings. When two groups grant the same workspace at different levels, the stronger one wins. The organization's default permission group cannot be a target: it governs by having no members. -Turning on **Match permission groups by name** maps a pushed group to an existing permission group of the same name automatically, and remaps it when the group is renamed. Nothing is created. +Under **Provisioning rules**, select **Manage rules**. **Match permission groups by name** maps new or renamed directory groups to existing permission groups with the exact same name, including capitalization. After enabling it for groups already synced, select **Reconcile now** or wait for the next scheduled reconciliation. Sim creates no permission groups; renaming a directory group updates its automatic mapping and preserves mappings added manually. Mapping a permission group to a directory group switches that permission group to explicit membership permanently: it governs exactly the people in it, and an empty group governs nobody. A permission group that governed everyone in its workspaces stops doing so the moment it is mapped, so map groups you created for the directory rather than your organization-wide ones. @@ -131,25 +137,39 @@ Mapping a permission group to a directory group switches that permission group t +Provisioning tab with an active SCIM connection, tokens, provisioning rules, group mappings, and recent activity + ## How access is withdrawn Sim records every grant it makes on your behalf. When someone leaves a group, what the directory granted is taken back. -**Managed membership locking**, on by default, makes the directory the source of truth for provisioned members: Sim refuses invitations, workspace grants, workspace role changes, and organization role changes for them, because the next sync would revert them anyway. Access a member already held by hand when a mapping started covering it counts as directory access from then on, so it is withdrawn with the mapping. Removals stay possible so an administrator can always act in an emergency. +**Lock managed membership**, on by default for a new connection, makes the directory the source of truth for provisioned members: Sim refuses invitations, workspace grants, workspace role changes, and organization role changes for them, because the next sync would revert them anyway. Access a member already held by hand when a mapping started covering it counts as directory access from then on, so it is withdrawn with the mapping. Removals stay possible so an administrator can always act in an emergency. -With locking off, manual access layers on top of directory access: access granted by hand stays when a group is left, and a workspace role raised by hand above what the directory set is left alone. +With locking off, access held before the directory mapping is preserved. If someone had manual Read access and the directory raises it to Admin, removing the mapping restores Read. A workspace role raised by hand above the directory's level is also left alone. The upgrade limitation below applies to grants recorded by older versions. ## Provisioning and SSO together -A member the directory created can sign in with SSO immediately; the two resolve to the same account through your verified domain. +An active provisioned member can sign in once SSO is configured for their verified domain. Provisioning and SSO resolve to the same account. -If you want the directory to be the only way in, enable **Disable just-in-time provisioning** in the connection settings. Sim then refuses to create membership for someone signing in who was never provisioned. +To stop SSO from creating organization memberships, open **Provisioning rules → Manage rules** and enable **Disable just-in-time provisioning**. While the SCIM connection is active and entitled, this overrides **Sign-in → First sign-in → Automatic**. Existing organization members can still sign in, including members added by invitation; the setting does not require every existing member to have been provisioned by SCIM. ## Watching a sync -**Settings → SSO → Directory provisioning → Activity** lists recent authenticated requests with their status and, for a failure, what was wrong. Providers report a failed cycle without saying what they sent, so this is usually the fastest way to see the cause. A request that fails to authenticate has no connection to log against, so a wrong or revoked token shows up only as your provider's own authentication error. +Changes to the enable switch, provisioning rules, tokens, and mappings save immediately. There is no page-level Save button on this tab. + +**Single sign-on → Provisioning → Activity** lists recent authenticated requests with their status and, for a failure, what was wrong. Providers report a failed cycle without saying what they sent, so this is usually the fastest way to see the cause. A request that fails to authenticate has no connection to log against, so a wrong or revoked token shows up only as your provider's own authentication error. + +The scheduled reconciliation task re-applies group mappings hourly when the background task runner is configured and running. You can run it on demand with **Reconcile now**, which is also how a change to the connection settings reaches members before the next sync. + +## Deployment and upgrades + +Apply the database migrations before deploying the new application code. The SCIM tables and suspension fields are additive, so the older application can continue running against the expanded schema during rollout. + +Keep `SCIM_ENABLED=false` and `NEXT_PUBLIC_SCIM_ENABLED=false` on the new deployment until all older application instances and workers have been drained. Older versions do not enforce SCIM suspensions or explicit permission-group membership. Enable provisioning only after every instance runs the new code. These explicit flags also override the hosted default. + +Once SCIM has suspended users or established managed access, rolling back to code that predates SCIM is not safe: it cannot enforce those restrictions. Disabling the SCIM connection stops synchronization but does not undo suspensions or restore old membership semantics. -Sim also re-applies every group mapping once an hour, so drift cannot persist. You can run it on demand with **Reconcile now**, which is also how a change to the connection settings reaches members before the next sync. +Migration `0325_scim_manual_workspace_baseline` adds tracking of prior manual workspace access. It cannot reconstruct levels overwritten by older SCIM code. Review those existing directory-owned grants before withdrawing mappings with membership locking off, and restore prior manual access explicitly where needed. ## Reference @@ -157,15 +177,15 @@ Sim also re-applies every group mapping once an hour, so drift cannot persist. Y - Authentication: `Authorization: Bearer ` - Resources: `/Users`, `/Groups`, plus `/ServiceProviderConfig`, `/ResourceTypes`, and `/Schemas` - Filters: `eq` only, up to ten terms joined with `and`. Users: `id`, `userName`, `externalId`, `emails.value` (also `emails[type eq "work"].value`), `active`. Groups: `id`, `displayName`, `externalId` -- Limits: 1,500 requests per minute per connection, 1 MB per request, 5,000 members per group -- `userName` is stored and returned lower-cased; top-level attributes and schema extensions Sim does not model (custom attributes included) are kept and returned as sent, and a PUT preserves ones it omits +- Limits: sustained 1,500 requests per minute per connection with a burst capacity of 3,000, 1 MB per request, 5,000 members per group +- `userName` is stored and returned lower-cased. Unmodeled top-level attributes and custom schema extensions are retained, and a PUT preserves ones it omits. Passwords are neither stored nor returned; Sim controls `id`, `meta`, and group membership - Group display names are unique within a connection, ignoring case - Page size: up to 100 per request diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index a89267d677d..8921a0ad2a2 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -28,7 +28,15 @@ Decide your **Provider ID** before configuring your identity provider. It become ### 1. Open SSO settings -Go to **Settings → Organization → Single sign-on** in your organization settings. +Go to **Settings → Organization → Single sign-on**. The page has three tabs: + +| Tab | Manage | +| --- | --- | +| **Sign-in** | OIDC or SAML configuration, callback URLs, and first-sign-in membership | +| **Domains** | DNS verification shared by SSO and SCIM | +| **Provisioning** | SCIM connection, tokens, rules, group mappings, and activity | + +Use **Domains** to verify ownership, then return to **Sign-in** to configure your provider. Switching tabs preserves an unsaved sign-in draft while you stay on this page; use **Save** or **Update** to commit it. The selected tab is included in the URL, so it can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled. ### 2. Choose a protocol @@ -39,24 +47,24 @@ Go to **Settings → Organization → Single sign-on** in your organization sett ### 3. Fill in the form -Single Sign-On configuration form showing Provider Type (OIDC), Provider ID, Issuer URL, Domain, Client ID, Client Secret, Scopes, and Callback URL fields +Sign-in tab showing the OIDC configuration form with advanced options collapsed **Fields required for both protocols:** | Field | What to enter | |-------|--------------| -| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you — `azure-ad-acme`, not `azure-ad`. If the ID is taken, Sim tells you and suggests a free one. | +| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you, such as `azure-ad-acme`. If the ID is taken, Sim asks you to choose another. | | **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. | | **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. | -| **Member provisioning** | **Automatic** adds a user authenticated through this verified SSO connection to the organization as a Member and consumes a billed seat. Team seat counts grow with membership; fixed-seat plans require available capacity. **Invite only** authenticates the user without creating organization membership. Neither mode grants workspace access automatically. | +| **First sign-in → On first SSO sign-in** | **Automatic** adds a user authenticated through this verified SSO connection to the organization as a Member and consumes a billed seat. Team seat counts grow with membership; fixed-seat plans require available capacity. **Invite only** authenticates the user without creating organization membership. Neither mode grants workspace access automatically. | **OIDC additional fields:** | Field | What to enter | |-------|--------------| | **Client ID** | The application client ID from your IdP. | -| **Client Secret** | The client secret from your IdP. | -| **Scopes** | Comma-separated OIDC scopes. Default: `openid,profile,email`. | +| **Client secret** | The client secret from your IdP. | +| **Scopes** | Under **Advanced options**. Comma-separated OIDC scopes; default: `openid,profile,email`. | For OIDC, Sim automatically fetches endpoints (`authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`) from your issuer's `/.well-known/openid-configuration` discovery document. You only need to provide the issuer URL. @@ -66,12 +74,12 @@ Go to **Settings → Organization → Single sign-on** in your organization sett | Field | What to enter | |-------|--------------| -| **Entry Point URL** | The IdP's SSO service URL where Sim sends authentication requests. | -| **Identity Provider Certificate** | The Base-64 encoded X.509 certificate from your IdP for verifying assertions. | +| **Entry point URL** | The IdP's SSO service URL where Sim sends authentication requests. | +| **Identity provider certificate** | The Base-64 encoded X.509 certificate from your IdP for verifying assertions. | -### 4. Copy the Callback URL +### 4. Copy the callback URL -The **Callback URL** shown in the form is the endpoint your identity provider must redirect users back to after authentication. Copy it and register it in your IdP before saving. +Copy **Callback URL** for OIDC or **ACS URL (Reply URL)** for SAML. This is the endpoint that receives your identity provider's authentication response. Register it in your IdP before saving. If you set a SAML **Callback URL override** under Advanced options, the copyable ACS URL uses that override. **OIDC providers** (Okta, Microsoft Entra ID, Google Workspace, Auth0): ``` @@ -89,6 +97,14 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the --- +## Editing and advanced configuration + +For a saved connection, open **Sign-in** and select **Edit**. The Provider ID remains fixed. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes. + +**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default. + +SCIM settings save immediately in the **Provisioning** tab. Its **Disable just-in-time provisioning** rule overrides Automatic first-sign-in membership while the connection is active and entitled. Existing members can still sign in. See [directory provisioning](/platform/enterprise/scim#provisioning-and-sso-together). + ## Provider Guides @@ -107,7 +123,7 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the ``` 4. Under **Assignments**, grant access to the relevant users or groups 5. Copy the **Client ID** and **Client Secret** from the app's **General** tab -6. Your Okta domain is the hostname of your admin console, e.g. `dev-1234567.okta.com` +6. Copy your Okta organization domain from the account menu in the Admin Console, e.g. `dev-1234567.okta.com`. The Admin Console's `-admin` hostname is a different URL. See [Find your Okta domain](https://developer.okta.com/docs/guides/find-your-domain/main/). **In Sim:** @@ -115,12 +131,12 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the |-------|-------| | Provider Type | OIDC | | Provider ID | `okta` | -| Issuer URL | `https://dev-1234567.okta.com/oauth2/default` | +| Issuer URL | `https://dev-1234567.okta.com` | | Domain | `company.com` | | Client ID | From Okta app | | Client Secret | From Okta app | -The issuer URL uses Okta's default authorization server, which is pre-configured on every Okta org. If you created a custom authorization server, replace `default` with your server name. +For ordinary OIDC sign-in, use your Okta organization issuer as shown. A custom authorization server requires API Access Management; its issuer is `https:///oauth2/`. The `default` custom server is included in Okta's Integrator Free Plan but is not available in every production organization. See [Okta's authorization server guide](https://developer.okta.com/docs/concepts/auth-servers/). @@ -137,7 +153,7 @@ The issuer URL uses Okta's default authorization server, which is pre-configured ``` 3. After registration, go to **Certificates & secrets → New client secret** and copy the value immediately — it won't be shown again 4. Go to **Overview** and copy the **Application (client) ID** and **Directory (tenant) ID** -5. Go to **Token configuration → Add optional claim**, choose **ID**, and add **email**. Entra omits the email address for managed users without this claim, and sign-in then fails with a missing-user-info error +5. Keep `email` in Sim's OIDC scopes. On Entra's v2.0 endpoint, this scope requests the email claim; alternatively, add **email** under **Token configuration → Add optional claim → ID**. Confirm the account supplies an email in your verified domain: a user principal name is not necessarily that email, and the claim is not guaranteed for every account. See [Microsoft's ID token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference) 6. If **Enterprise applications → Sim → Properties → Assignment required** is **Yes**, assign the users or groups who should sign in. Microsoft rejects unassigned users before they reach Sim **In Sim:** @@ -165,9 +181,9 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp 2. Open **Single sign-on** and select **SAML** 3. Edit **Basic SAML Configuration** and set both values from Sim's SSO settings page: - **Identifier (Entity ID)** — the **SP Entity ID** field - - **Reply URL (Assertion Consumer Service URL)** — the **ACS URL** field + - **Reply URL (Assertion Consumer Service URL)** — the **ACS URL (Reply URL)** field 4. Under **Attributes & Claims**, confirm the default claims are present. Sim reads the standard schema claim URIs for email, name, and name identifier -5. Under **SAML Certificates**, download **Certificate (Base64)**. Its contents go in Sim's **Certificate** field, which is required. You can optionally also download **Federation Metadata XML** and paste it into Sim's **IDP Metadata XML** field under **Advanced Options** — it does not replace the certificate +5. Under **SAML Certificates**, download **Certificate (Base64)**. Its contents go in Sim's required **Identity provider certificate** field. You can optionally also download **Federation Metadata XML** and paste it into Sim's **IdP metadata XML** field under **Advanced options** — it does not replace the certificate 6. From the **Set up** panel for your application, copy the **Login URL** and the **Microsoft Entra Identifier** 7. Under **Users and groups**, assign the people who should be able to sign in — Microsoft rejects unassigned users before they reach Sim @@ -179,8 +195,8 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp | Provider ID | `azure-ad-acme` (must be globally unique) | | Issuer URL | **Microsoft Entra Identifier**, e.g. `https://sts.windows.net/{tenant-id}/` | | Domain | `company.com` | -| Entry Point URL | **Login URL** from Entra | -| Certificate | Contents of the Base64 certificate | +| Entry point URL | **Login URL** from Entra | +| Identity provider certificate | Contents of the Base64 certificate | The **Identifier (Entity ID)** you set in Entra is what Sim validates the assertion's audience against. If it does not match the **SP Entity ID** shown in Sim exactly, sign-in fails with an audience mismatch. @@ -235,7 +251,7 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp ``` https:///api/auth/sso/saml2/callback/adfs ``` -5. Export the **Token-signing certificate** from **Certificates**: right-click → **View Certificate → Details → Copy to File**, choose **Base-64 encoded X.509 (.CER)**. The `.cer` file is PEM-encoded — rename it to `.pem` before pasting its contents into Sim. +5. Export the **Token-signing certificate** from **Certificates**: right-click → **View Certificate → Details → Copy to File**, choose **Base-64 encoded X.509 (.CER)**. Paste the file's text into **Identity provider certificate**, including the certificate header and footer. 6. Note the **ADFS Federation Service endpoint URL** (e.g. `https://adfs.company.com/adfs/ls`) **In Sim:** @@ -246,8 +262,8 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp | Provider ID | `adfs` | | Issuer URL | `https://adfs.company.com/adfs/services/trust` (the ADFS Federation Service identifier) | | Domain | `company.com` | -| Entry Point URL | `https://adfs.company.com/adfs/ls` | -| Certificate | Contents of the `.pem` file | +| Entry point URL | `https://adfs.company.com/adfs/ls` | +| Identity provider certificate | Contents of the exported Base64 certificate | The **Issuer URL** is the identity provider's own identifier, found in ADFS under **Service → Federation Service Properties → Federation Service identifier**. It is not Sim's URL — Sim's identifier is the **SP Entity ID** shown in the SSO settings, which you register in ADFS as the relying party identifier. @@ -269,13 +285,13 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu 2. They enter their work email (e.g. `alice@company.com`) 3. Sim redirects them to your identity provider 4. After authenticating, they are returned to Sim -5. If **Member provisioning** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity +5. If **First sign-in** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity 6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access With **Automatic** provisioning, no invitation is required for organization membership. The join follows the organization's seat policy and does not infer a role from IdP claims: every newly provisioned user starts as a Member. Team subscriptions grow their billed seat count with membership; fixed-seat plans reject the join when capacity is full. With **Invite only**, SSO proves identity but does not create new membership or workspace access; new access must be granted separately, while existing organization membership and workspace access remain available. - Sign-in must start from Sim. Launching from your identity provider's app portal (Microsoft's **My Apps**, Okta's dashboard tile) sends an unsolicited assertion, which Sim rejects. This is deliberate — accepting them would let anyone replay an assertion into your tenant — but it means an IdP-initiated test fails even when the configuration is correct. + Start SAML sign-in from Sim's **Sign in with SSO** flow. Sim rejects unsolicited SAML assertions, so an IdP-initiated SAML test from an app portal can fail even when the configuration is correct. SSO provisioning creates internal organization members but does not grant workspace access. To grant workspace access from your identity provider, use [directory provisioning](/platform/enterprise/scim) and map a pushed group to a workspace. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved. @@ -301,11 +317,11 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "What happens when a user signs in with SSO for the first time?", - answer: "Sim creates or links their account. If Member provisioning is Automatic and a seat is available, Sim adds them to your organization as a Member; no manual organization invite is needed. Workspace access is always granted separately. If provisioning is Invite only, or the user already has a pending invitation or external workspace access, Sim preserves that flow instead of creating membership automatically." + answer: "Sim creates or links their account. If first-sign-in provisioning is Automatic and a seat is available, Sim adds them to your organization as a Member; no manual organization invite is needed. Workspace access is always granted separately. If provisioning is Invite only, or the user already has a pending invitation or external workspace access, Sim preserves that flow instead of creating membership automatically." }, { question: "Does disabling someone in the identity provider remove their Sim access?", - answer: "With [directory provisioning](/platform/enterprise/scim) connected, yes: your identity provider sends the deactivation, and Sim blocks sign-in and stops their API keys while leaving everything they own intact. With SSO alone, disabling the IdP account only blocks future SSO authentication — remove or suspend the user in Sim as part of offboarding." + answer: "When [directory provisioning](/platform/enterprise/scim) sends a deactivation, Sim blocks sign-in and personal API keys while preserving ownership. Shared workspace keys keep working. With SSO alone, disabling the IdP account only blocks future SSO authentication; remove the member in Sim as part of offboarding." }, { question: "Can I still use email/password login after enabling SSO?", @@ -313,7 +329,7 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "A user already has an account with the same email — what happens when they sign in with SSO?", - answer: "Sim links the SSO identity to that account automatically. Linking is authorized by your verified domain: because you proved ownership of the domain before configuring SSO, Sim treats your identity provider as authoritative for email addresses on it. This works the same for OIDC and SAML, and does not depend on your IdP sending an email_verified claim — Microsoft Entra, for example, never sends one. Matching is by email address, so the address your IdP asserts must be identical to the one on the existing account. If it differs — a privileged or admin variant such as p-alice@company.com, or a different alias — Sim treats it as a new person and creates a separate account rather than linking." + answer: "Sim links the SSO identity to that account automatically. Linking is authorized by your verified domain: because you proved ownership of the domain before configuring SSO, Sim treats your identity provider as authoritative for email addresses on it. This works for OIDC and SAML without requiring an email_verified claim. Matching uses the address sent as email; Sim does not resolve different aliases or user principal names to an existing account. If the asserted address differs from the existing account, Sim treats it as a separate account." }, { question: "Who can configure SSO on Sim Cloud?", @@ -325,7 +341,7 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "How do I update or replace an existing SSO configuration?", - answer: "Open Settings → Organization → Single sign-on and click Edit. Update the fields and save. The existing provider configuration is replaced." + answer: "Open Settings → Organization → Single sign-on → Sign-in and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it requires deleting the provider and creating a new one." } ]} /> @@ -342,7 +358,7 @@ Self-hosted deployments use environment variables instead of the billing/plan ch SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true -# Optional: directory provisioning (SCIM), configured from the SSO settings page +# Optional: directory provisioning (SCIM), configured from Single sign-on → Provisioning SCIM_ENABLED=true NEXT_PUBLIC_SCIM_ENABLED=true @@ -376,7 +392,7 @@ SSO_ENABLED=true \ NEXT_PUBLIC_APP_URL=https://your-instance.com \ SSO_PROVIDER_TYPE=oidc \ SSO_PROVIDER_ID=okta \ -SSO_ISSUER=https://dev-1234567.okta.com/oauth2/default \ +SSO_ISSUER=https://dev-1234567.okta.com \ SSO_DOMAIN=company.com \ SSO_USER_EMAIL=admin@company.com \ SSO_OIDC_CLIENT_ID=your-client-id \ diff --git a/apps/docs/content/docs/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/platform/enterprise/verified-domains.mdx index 4a4dedcd684..68df8d9618c 100644 --- a/apps/docs/content/docs/platform/enterprise/verified-domains.mdx +++ b/apps/docs/content/docs/platform/enterprise/verified-domains.mdx @@ -1,22 +1,25 @@ --- title: Verified Domains -description: Prove ownership of your email domains before configuring single sign-on +description: Prove ownership of your email domains for single sign-on and directory provisioning --- import { Callout } from 'fumadocs-ui/components/callout' +import { Image } from '@/components/ui/image' import { FAQ } from '@/components/ui/faq' -Verified Domains let organization owners and admins on Enterprise plans prove they control an email domain (like `acme.com`) with a DNS TXT record. Verifying a domain is the security precondition for configuring single sign-on for it. +Verified Domains let organization owners and admins on Enterprise plans prove they control an email domain (like `acme.com`) with a DNS TXT record. Verify a domain before configuring single sign-on for it or provisioning members at that domain through SCIM. - Configuring SSO for a domain requires it to be verified first. Verifying proves your organization controls the domain — without it, anyone could point another company's domain at their own identity provider. Domains you had already configured for SSO are automatically treated as verified. + Configuring SSO for a domain requires it to be verified first. Verifying proves your organization controls the domain — without it, anyone could point another company's domain at their own identity provider. The domain-verification migration preserved domains configured for SSO before this requirement was introduced. --- ## Verify a domain -Go to **Settings → Organization → Single sign-on** in your organization settings. Domains are managed in the **Verified domains** section at the top of that page, directly above the identity provider configuration. +Go to **Settings → Organization → Single sign-on → Domains**. The **Verified domains** section is shared by sign-in and directory provisioning. + +Domains tab showing a verified domain and the DNS record for a pending domain 1. Enter the domain, for example `acme.com`, and click **Add domain**. 2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`). @@ -24,10 +27,10 @@ Go to **Settings → Organization → Single sign-on** in your organization sett 4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**. - Some DNS providers — GoDaddy, Namecheap, Hover, and most cPanel panels — append your zone to whatever you type in the host field. If yours does, enter the host with the trailing zone removed, or you will end up with `_sim-challenge.acme.com.acme.com` and verification will never succeed. If you manage the `acme.com` zone, enter `_sim-challenge`. To verify the subdomain `eng.acme.com` from that same zone, enter `_sim-challenge.eng`. Cloudflare and Route 53 take the full host as shown. + Some DNS providers append the zone automatically. If yours does, enter `_sim-challenge` when managing the `acme.com` zone, or `_sim-challenge.eng` to verify `eng.acme.com` from that zone. Check the resulting record name: it must match the full host Sim shows, without a repeated domain such as `_sim-challenge.acme.com.acme.com`. -DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. Leave the TXT record published — removing it can cause the domain to fail a later re-verification. +DNS changes can take time to propagate. If verification does not succeed immediately, check the record name and value, then wait and retry. Keep the TXT record published. Sim verifies it when you select **Verify**; removing the DNS record does not itself revoke a completed verification. Add each domain you own separately. Subdomains (`eng.acme.com`) are verified independently of the apex. @@ -40,12 +43,12 @@ Add each domain you own separately. Subdomains (`eng.acme.com`) are verified ind { question: 'Where does the TXT record go?', answer: - 'On a dedicated host, _sim-challenge., rather than the root of your domain — this avoids colliding with your SPF, DMARC, or other root TXT records.', + 'On the dedicated host _sim-challenge.. Add the supplied TXT value there without replacing existing records on your domain.', }, { question: 'What happens to domains we already use for SSO?', answer: - 'They are automatically treated as verified, so existing single sign-on keeps working with no action needed.', + 'Domains configured before domain verification was introduced were preserved as verified by the migration. New domains must complete DNS verification.', }, { question: 'Can two organizations verify the same domain?', @@ -55,7 +58,7 @@ Add each domain you own separately. Subdomains (`eng.acme.com`) are verified ind { question: 'What if I remove a verified domain?', answer: - 'You lose the ownership proof, so you cannot configure SSO for that domain until you re-add and re-verify it. Removing it does not sign anyone out — an already-configured SSO provider keeps working.', + 'New SSO sign-ins for that domain stop immediately, including through an existing provider. SCIM cannot create users or change an email to that domain until it is verified again. Removing the domain does not itself end existing sessions. Re-add and verify it to restore the ownership proof.', }, ]} /> @@ -73,4 +76,4 @@ NEXT_PUBLIC_SSO_ENABLED=true `ENTERPRISE_ENABLED` turns both on together, but it needs its own browser twin — set `NEXT_PUBLIC_ENTERPRISE_ENABLED` alongside it, or the server and the settings page enable SSO while the login page still hides its SSO entry point. See the [self-hosted enterprise guide](/platform/enterprise/self-hosted). -Once enabled, verify domains from **Settings → Organization → Single sign-on**, in the **Verified domains** section above the identity provider configuration. The older `/workspace//settings/domains` path still resolves to the same page. +Once enabled, verify domains from **Settings → Organization → Single sign-on → Domains**. The older `/workspace//settings/domains` path still resolves to the same page. diff --git a/apps/docs/public/static/enterprise/scim-provisioning.png b/apps/docs/public/static/enterprise/scim-provisioning.png new file mode 100644 index 00000000000..57e8a4d23b1 Binary files /dev/null and b/apps/docs/public/static/enterprise/scim-provisioning.png differ diff --git a/apps/docs/public/static/enterprise/sso-domains.png b/apps/docs/public/static/enterprise/sso-domains.png new file mode 100644 index 00000000000..9e0d1815c3c Binary files /dev/null and b/apps/docs/public/static/enterprise/sso-domains.png differ diff --git a/apps/docs/public/static/enterprise/sso-form.png b/apps/docs/public/static/enterprise/sso-form.png index f44f5f80d14..162d93d2ccc 100644 Binary files a/apps/docs/public/static/enterprise/sso-form.png and b/apps/docs/public/static/enterprise/sso-form.png differ diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index 3a0303158ab..b10dcdee454 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import { normalizeEmail } from '@sim/utils/string' import { type NextRequest, NextResponse } from 'next/server' import { batchWorkspaceInvitationsContract } from '@/lib/api/contracts/invitations' @@ -117,7 +116,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { }) failed.push({ email: normalizedEmail, - error: getErrorMessage(error, 'Failed to create invitation'), + error: 'Failed to create invitation', }) } } diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 8460eba4129..ccbef3c9db2 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -173,6 +173,28 @@ describe('POST /api/workspaces/invitations/batch', () => { resetDbChainMock() }) + it('keeps unexpected database details out of per-email failures', async () => { + mockCreatePendingInvitation.mockRejectedValueOnce( + new Error('Failed query: select "id" from "user"; params: private-data') + ) + + const response = await POST( + createMockRequest('POST', { + workspaceIds: ['workspace-1'], + emails: ['new@example.com'], + permission: 'read', + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.failed).toEqual([ + { email: 'new@example.com', error: 'Failed to create invitation' }, + ]) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() + }) + it('blocks invites for personal workspaces with an upgrade prompt', async () => { mockGetWorkspaceWithOwner.mockResolvedValueOnce({ id: 'workspace-1', diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index b1f09e3054c..eef5fa375ec 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -689,7 +689,7 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] docsLink: 'https://docs.sim.ai/platform/enterprise/sso', unified: { id: 'sso', - description: 'Configure single sign-on for your organization.', + description: 'Manage sign-in, verified domains, and provisioning.', group: 'organization', order: 7, requiresHosted: true, diff --git a/apps/sim/ee/scim/TESTING.md b/apps/sim/ee/scim/TESTING.md new file mode 100644 index 00000000000..d27c54fa25d --- /dev/null +++ b/apps/sim/ee/scim/TESTING.md @@ -0,0 +1,119 @@ +# SCIM integration verification + +The local integration harness sends real HTTP requests to a running Next.js app +and inspects the resulting PostgreSQL records. It does not mock route handlers, +authentication, application operations, or persistence. Okta and Microsoft Entra +request shapes are emulated; this is not certification against either provider's +provisioning service. + +## Requirements + +- Bun and the repository dependencies installed. +- A running local app configured for Enterprise SCIM, using a dedicated local + PostgreSQL database with all migrations applied (including `0325`). Leave + Redis unconfigured so the app uses PostgreSQL for rate-limit storage. +- The app's local `BETTER_AUTH_SECRET`, at least 32 characters long. +- An app URL using HTTP on `localhost`, `127.0.0.1`, or `::1`. The database must + also use a loopback host, and its name must include `test` or `scim`. + +The harness creates two isolated synthetic organizations, owner accounts, active +Enterprise subscriptions, workspaces, verified `.test` domains, and signed owner +sessions. It obtains bearer tokens through the real administration API. Database +setup also supplies scenarios unavailable through the SCIM API, such as manual +suspension, pre-existing workspace access, and credential expiry. +The rate-limit scenario exhausts only its own connection's database bucket and +verifies that overlapping credentials share it while another tenant remains +unaffected. + +Use a disposable database. The harness deletes only its generated fixtures in a +`finally` block. If the process is forcibly terminated, discard the disposable +database or remove the organizations and users whose generated domain starts +with `scim-e2e-`. Existing organizations are not used by the suite. + +## Run + +From `apps/sim`, set these values for the local app and database, then run: + +```sh +export SCIM_E2E_BASE_URL=http://localhost:3000 +export SCIM_E2E_DATABASE_URL='postgresql://:@127.0.0.1:/' +export SCIM_E2E_AUTH_SECRET='' +export SCIM_E2E_REPORT_PATH=/tmp/sim-scim-e2e-report.json +bun run test:scim:e2e +``` + +The first three variables are required; the report path is optional. The app and +the harness must use the same database and authentication secret. The report +contains check names, outcomes, timing, and HTTP request count; it excludes +bearer credentials, session cookies, and secrets. A failed check exits nonzero +after cleanup. Request redirects are rejected and each request has a 60-second +timeout. + +## Coverage + +- Discovery, session-authenticated configuration, credential hashing, tenant + isolation, bearer authentication, content type, and malformed JSON errors. +- User and group creation, reads, replacement, PATCH, deletion, conflict errors, + stable pagination, count-only queries, and group/member attribute projection. +- Case-insensitive user lookup, external IDs, secondary/work/primary email + filters, account email drift, and filters reflecting manual suspension. +- Entra-shaped complex name and extension PATCH, case-insensitive core-qualified + password fields, atomic failure, and partial complex attribute selection. +- 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. +- 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 + reactivation, manual suspension protection, owner protection, and account + relinking after deletion and rehire. +- Credential scopes, overlapping rotation, the active credential limit, + connection rate limits and retry headers, revocation, connection + disable/re-enable, expiry, and activity records. + +## Continuous integration and PostgreSQL regressions + +The `OAuth and SCIM PostgreSQL` job in `.github/workflows/test-build.yml` runs +against both supported database provisioning paths, `db:push` and `db:migrate`. +After the OAuth and SCIM PostgreSQL tests, it starts a local Next.js app with +hosted Enterprise configuration and runs the HTTP suite above. Startup is +bounded to 120 seconds; the server is stopped when the step exits. A failure +uploads the credential-free scenario report and an allowlist of HTTP status log +lines. Raw application logs are not uploaded. + +The focused PostgreSQL suite uses the same database variable as the OAuth tests: + +```sh +OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL="$SCIM_E2E_DATABASE_URL" \ + bunx vitest run ee/scim/lib/managed-membership.postgres.test.ts +``` + +Without that variable, the PostgreSQL tests are skipped. With it, they execute +real Drizzle queries against the provisioned schema, covering the invitation +lookup, aliases (including quoted identifiers), unmanaged and foreign-tenant +accounts, disabled and unlocked connections, and the permission guard inside a +transaction. Hosted billing flags are configured for the test; subscription +and entitlement reads use real PostgreSQL with the transaction tripwire enabled. +The suite checks an active Enterprise subscription, an ended one, and a real +billing query failure that must propagate instead of releasing directory locks. +The same CI job runs `lib/auth/sso/application/admit-sso-user.postgres.test.ts`, +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. + +## Remaining provider verification + +Before claiming a provider integration has been validated, use an actual Okta +or Microsoft Entra tenant to run its connection test and provisioning job against +an externally reachable test deployment. Verify assignment, profile updates, +group pushes, unassignment, reactivation, and token rotation in the provider's +logs. The local suite does not exercise provider scheduling/retries, provider +portal configuration, real SSO redirects, production rate-limit infrastructure, +or billing-provider webhooks. + +Focused unit tests remain useful for malformed payload variants and policy +branches that do not belong in a local HTTP scenario: + +```sh +bunx vitest run ee/scim lib/api/server/routes/scim-route.test.ts +``` diff --git a/apps/sim/ee/scim/components/options.ts b/apps/sim/ee/scim/components/options.ts index 9005a3421a9..7d4f6e1743e 100644 --- a/apps/sim/ee/scim/components/options.ts +++ b/apps/sim/ee/scim/components/options.ts @@ -22,19 +22,18 @@ export const SETTING_TOGGLES = [ key: 'lockManualMembership', label: 'Lock managed membership', description: - 'Refuse invitations, role changes, and manual grants for members the directory provisions. The next sync would revert them anyway.', + 'Prevent manual invitations, role changes, and access grants for provisioned members.', }, { key: 'disableJit', label: 'Disable just-in-time provisioning', description: - 'Refuse membership for someone signing in with SSO who the directory never provisioned. The directory becomes the only way in.', + 'Prevent SSO from adding new organization members. Existing members can still sign in.', }, { key: 'autoMapPermissionGroupsByName', label: 'Match permission groups by name', - description: - 'When a pushed group has the same name as one of your permission groups, map them automatically. Nothing is created.', + description: 'Map directory groups to existing permission groups with the same name.', }, ] as const diff --git a/apps/sim/ee/scim/components/scim-section.test.tsx b/apps/sim/ee/scim/components/scim-section.test.tsx new file mode 100644 index 00000000000..0d7df7d6396 --- /dev/null +++ b/apps/sim/ee/scim/components/scim-section.test.tsx @@ -0,0 +1,143 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ScimConnectionView } from '@/lib/api/contracts/organization-scim' + +const { connectionQuery, issueCredential, useConnection, useGroups, useActivity } = vi.hoisted( + () => ({ + connectionQuery: { + data: undefined as { connection: ScimConnectionView } | undefined, + isLoading: false, + isError: false, + error: null as Error | null, + isFetching: false, + refetch: vi.fn(), + }, + issueCredential: vi.fn(), + useConnection: vi.fn(), + useGroups: vi.fn(), + useActivity: vi.fn(), + }) +) + +vi.mock('@/lib/core/config/deployment-shape', () => ({ + useDeploymentShape: () => ({ features: { scim: true } }), +})) + +vi.mock('@/ee/access-control/hooks/permission-groups', () => ({ + usePermissionGroups: () => ({ data: [] }), + useOrganizationWorkspaces: () => ({ data: [] }), +})) + +vi.mock('@/ee/scim/hooks/scim', () => ({ + useScimConnection: useConnection, + useScimGroupMappings: useGroups, + useScimActivity: useActivity, + useIssueScimCredential: () => ({ mutateAsync: issueCredential, isPending: false }), + useConfigureScimConnection: () => ({ mutateAsync: vi.fn(), isPending: false }), + useRevokeScimCredential: () => ({ mutateAsync: vi.fn(), isPending: false }), + useReconcileScimConnection: () => ({ mutateAsync: vi.fn(), isPending: false }), + useUpsertScimGroupMapping: () => ({ mutateAsync: vi.fn(), isPending: false }), + useDeleteScimGroupMapping: () => ({ mutateAsync: vi.fn(), isPending: false }), +})) + +import { ScimSection } from '@/ee/scim/components/scim-section' + +let container: HTMLDivElement +let root: Root + +function renderSection(active = true) { + act(() => { + root.render() + }) +} + +function issueToken() { + const button = Array.from(container.querySelectorAll('button')).find( + (entry) => entry.textContent === 'Issue token' + ) + expect(button).toBeDefined() + button?.click() +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + connectionQuery.data = { + connection: { + id: 'connection-1', + status: 'active', + baseUrl: 'https://sim.example.com/api/scim/v2', + settings: {}, + lastRequestAt: null, + reconciledAt: null, + createdAt: '2026-09-07T00:00:00.000Z', + credentials: [], + userCount: 0, + groupCount: 0, + }, + } + connectionQuery.isError = false + connectionQuery.error = null + useConnection.mockReturnValue(connectionQuery) + useGroups.mockReturnValue({ data: [], isLoading: false, isError: false }) + useActivity.mockReturnValue({ data: [], isLoading: false, isError: false }) + issueCredential.mockResolvedValue({ secret: 'one-time-token' }) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.clearAllMocks() +}) + +describe('SCIM credential recovery', () => { + it('keeps the issued token available when a background connection refresh fails', async () => { + renderSection() + await act(async () => issueToken()) + expect(document.querySelector('input[value="one-time-token"]')).not.toBeNull() + + connectionQuery.isError = true + connectionQuery.error = new Error('Connection refresh failed') + renderSection() + + expect(document.querySelector('input[value="one-time-token"]')).not.toBeNull() + }) + + it('retains a token issued while inactive and pauses its queries until the tab returns', async () => { + const issued = Promise.withResolvers<{ secret: string }>() + issueCredential.mockReturnValue(issued.promise) + renderSection() + act(issueToken) + renderSection(false) + await act(async () => issued.resolve({ secret: 'one-time-token' })) + + expect(document.querySelector('input[value="one-time-token"]')).toBeNull() + expect(useConnection).toHaveBeenLastCalledWith('org-1', false) + expect(useGroups).toHaveBeenLastCalledWith('org-1', false) + expect(useActivity).toHaveBeenLastCalledWith('org-1', false) + + renderSection() + expect(document.querySelector('input[value="one-time-token"]')).not.toBeNull() + }) + + it('shows an actionable error when the first connection request fails', () => { + connectionQuery.data = undefined + connectionQuery.isError = true + connectionQuery.error = new Error('Connection unavailable') + renderSection() + + expect(container).toHaveTextContent('Connection unavailable') + const retry = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Try again' + ) + expect(retry).toBeDefined() + act(() => retry?.click()) + expect(connectionQuery.refetch).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/ee/scim/components/scim-section.tsx b/apps/sim/ee/scim/components/scim-section.tsx index 53a701cbd69..4e7c231d6c8 100644 --- a/apps/sim/ee/scim/components/scim-section.tsx +++ b/apps/sim/ee/scim/components/scim-section.tsx @@ -12,6 +12,9 @@ import { ChipModalHeader, ChipSelect, ChipTag, + Expandable, + ExpandableContent, + Label, Switch, toast, } from '@sim/emcn' @@ -30,7 +33,10 @@ import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { + RESOURCE_LIST_STACK, + SettingsResourceRow, +} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useOrganizationWorkspaces, @@ -60,6 +66,8 @@ import { interface ScimSectionProps { organizationId: string + active: boolean + onOpenDomains: () => void } const RELATIVE_TIME = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }) @@ -103,8 +111,7 @@ function CredentialRow({ credential, onRevoke }: CredentialRowProps) { : 'no expiry' return ( } - title={{credential.tokenPrefix}…} + title={`${credential.tokenPrefix}…`} description={`Last used ${formatRelative(credential.lastUsedAt)} · ${expiry}`} trailing={ !group.isDefault) - const { data: workspaces = [] } = useOrganizationWorkspaces(organizationId) + const { data: workspaces = [] } = useOrganizationWorkspaces(organizationId, active) const deleteMapping = useDeleteScimGroupMapping() const names = { @@ -236,7 +244,7 @@ function GroupMappings({ organizationId }: GroupMappingsProps) { if (isLoading) { return Loading groups... } - if (isError) { + if (groups === undefined && isError) { return ( -
+
{group.mappings.length > 0 && (
{group.mappings.map((mapping) => ( @@ -295,9 +303,10 @@ function GroupMappings({ organizationId }: GroupMappingsProps) { interface ActivityListProps { organizationId: string + active: boolean } -function ActivityList({ organizationId }: ActivityListProps) { +function ActivityList({ organizationId, active }: ActivityListProps) { const { data: entries, isLoading, @@ -305,12 +314,12 @@ function ActivityList({ organizationId }: ActivityListProps) { error, isFetching, refetch, - } = useScimActivity(organizationId) + } = useScimActivity(organizationId, active) if (isLoading) { return Loading activity... } - if (isError) { + if (entries === undefined && isError) { return ( {entry.status} - + {entry.method} {entry.path} @@ -357,18 +366,22 @@ function ActivityList({ organizationId }: ActivityListProps) { interface ConnectionDetailsProps { organizationId: string + active: boolean connection: ScimConnectionView } -function ConnectionDetails({ organizationId, connection }: ConnectionDetailsProps) { +function ConnectionDetails({ organizationId, connection, active }: ConnectionDetailsProps) { const configure = useConfigureScimConnection() const issueCredential = useIssueScimCredential() const revokeCredential = useRevokeScimCredential() const reconcile = useReconcileScimConnection() + const [showRules, setShowRules] = useState(false) const [issuedSecret, setIssuedSecret] = useState(null) const [credentialExpiry, setCredentialExpiry] = useState('never') - const [pendingRevoke, setPendingRevoke] = useState(null) + const [pendingRevokeId, setPendingRevokeId] = useState(null) + const pendingRevoke = + connection.credentials.find((credential) => credential.id === pendingRevokeId) ?? null async function handleToggleSetting(key: (typeof SETTING_TOGGLES)[number]['key'], value: boolean) { try { @@ -395,7 +408,7 @@ function ConnectionDetails({ organizationId, connection }: ConnectionDetailsProp if (!pendingRevoke) return try { await revokeCredential.mutateAsync({ organizationId, credentialId: pendingRevoke.id }) - setPendingRevoke(null) + setPendingRevokeId(null) toast.success('Token revoked') } catch (error) { toast.error(getErrorMessage(error, 'Failed to revoke token')) @@ -418,102 +431,134 @@ function ConnectionDetails({ organizationId, connection }: ConnectionDetailsProp return ( <> - - - - - -

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

-
+ +
+ + + + + +

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

+
+
+
- {SETTING_TOGGLES.map((toggle) => ( + - void handleToggleSetting(toggle.key, checked)} - disabled={configure.isPending} - /> - - ))} - - -
- {connection.credentials.length === 0 ? ( - No tokens yet. - ) : ( - connection.credentials.map((credential) => ( - + {connection.credentials.length === 0 ? ( + No tokens yet. + ) : ( +
+ {connection.credentials.map((credential) => ( + setPendingRevokeId(credential.id)} + /> + ))} +
+ )} +
+ setCredentialExpiry(next as CredentialExpiry)} + options={[...CREDENTIAL_EXPIRY_OPTIONS]} /> - )) - )} -
- setCredentialExpiry(next as CredentialExpiry)} - options={[...CREDENTIAL_EXPIRY_OPTIONS]} - /> - = 2} - > - {issueCredential.isPending ? 'Issuing...' : 'Issue token'} - + = 2} + > + {issueCredential.isPending ? 'Issuing...' : 'Issue token'} + +
-
-
+ +
- setShowRules(!showRules)} + > + {showRules ? 'Hide rules' : 'Manage rules'} + + } > - - +

+ Control managed membership, first sign-in, and automatic group matching. +

+ + +
+ {SETTING_TOGGLES.map((toggle) => ( +
+
+ +

{toggle.description}

+
+ void handleToggleSetting(toggle.key, checked)} + disabled={configure.isPending} + /> +
+ ))} +
+
+
+ - -
- -
- - {reconcile.isPending ? 'Reconciling...' : 'Reconcile now'} - + + + + + + + + +
+ +
+ + {reconcile.isPending ? 'Reconciling...' : 'Reconcile now'} + +
-
- + + !open && setIssuedSecret(null)} > setIssuedSecret(null)}> @@ -521,16 +566,12 @@ function ConnectionDetails({ organizationId, connection }: ConnectionDetailsProp - - + /> setIssuedSecret(null)} @@ -539,8 +580,8 @@ function ConnectionDetails({ organizationId, connection }: ConnectionDetailsProp !open && setPendingRevoke(null)} + open={active && pendingRevoke !== null} + onOpenChange={(open) => !open && setPendingRevokeId(null)} title='Revoke token' text={[ 'Revoke ', @@ -559,23 +600,21 @@ function ConnectionDetails({ organizationId, connection }: ConnectionDetailsProp } /** - * Directory provisioning (SCIM) settings, rendered as a section of the SSO page. - * SSO decides who someone is; provisioning decides who exists and what they can - * reach, so the two are configured together. + * Manages the SCIM connection in the Provisioning tab of organization sign-in settings. */ -export function ScimSection({ organizationId }: ScimSectionProps) { - const { hosted, features } = useDeploymentShape() - /** Hosted ships provisioning with the enterprise plan, which the SSO page already gates; self-hosted follows the flag. */ - const available = hosted || features.scim +export function ScimSection({ organizationId, onOpenDomains, active }: ScimSectionProps) { + const { features } = useDeploymentShape() + /** The deployment flag also allows activation to wait until older app instances are drained. */ + const available = features.scim const { data, isLoading, isError, error, isFetching, refetch } = useScimConnection( organizationId, - available + available && active ) const configure = useConfigureScimConnection() if (!available) return null - if (isError) { + if (data === undefined && isError) { return ( -
- - void handleToggleEnabled(checked)} - disabled={isLoading || configure.isPending} - /> - +
+ +
+
+
+ +

+ Create, update, and deactivate members from your identity provider with SCIM 2.0. +

+
+ void handleToggleEnabled(checked)} + disabled={isLoading || configure.isPending} + /> +
- {connection && enabled && ( - - )} -
-
+ {!enabled && ( +
+

+ Verify an email domain, then enable provisioning to connect your directory. +

+ Manage domains +
+ )} +
+ + {connection && enabled && ( + + )} +
) } 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 new file mode 100644 index 00000000000..c89a95c8663 --- /dev/null +++ b/apps/sim/ee/scim/lib/application/groups/manage-groups.test.ts @@ -0,0 +1,125 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { scimConnection } from '@sim/db/schema' +import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + locks: vi.fn(), + findGroup: vi.fn(), + filterUsers: vi.fn(), + memberIds: vi.fn(), + members: vi.fn(), + addMember: vi.fn(), + removeMember: vi.fn(), + countMembers: vi.fn(), + touch: vi.fn(), + update: vi.fn(), + reconcile: vi.fn(), +})) + +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.locks, +})) +vi.mock('@/ee/scim/lib/projection/reconcile-user', () => ({ + reconcileUsersProjection: mocks.reconcile, +})) +vi.mock('@/ee/scim/lib/projection/auto-map', () => ({ + autoMapPermissionGroupByName: vi.fn(), + settleMappedPermissionGroupsExplicit: vi.fn(), +})) +vi.mock('@/ee/scim/lib/repository/groups', () => ({ + findScimGroupById: mocks.findGroup, + filterOwnedUsers: mocks.filterUsers, + loadGroupMemberIds: mocks.memberIds, + loadGroupMembers: mocks.members, + addGroupMember: mocks.addMember, + removeGroupMember: mocks.removeMember, + countGroupMembers: mocks.countMembers, + touchScimGroup: mocks.touch, + updateScimGroup: mocks.update, + deleteScimGroupRow: vi.fn(), + insertScimGroup: vi.fn(), + loadGroupMembersForGroups: vi.fn(), + pageScimGroups: vi.fn(), +})) +vi.mock('@/ee/scim/lib/application/audit', () => ({ recordScimAuditEntries: vi.fn() })) +vi.mock('@/ee/scim/lib/base-url', () => ({ scimBaseUrl: () => 'https://sim.test/api/scim/v2' })) + +import { replaceScimGroup } from '@/ee/scim/lib/application/groups/manage-groups' +import { toGroupResource } from '@/ee/scim/lib/protocol/resources' + +const principal: Principal = { + kind: 'scim_connection', + connectionId: 'conn-1', + organizationId: 'org-1', + credentialId: 'cred-1', + scopes: ['groups:write'], +} +const initialGroup = { + id: 'group-1', + externalId: null, + displayName: 'Engineering', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), +} + +afterAll(resetDbChainMock) + +describe('replaceScimGroup', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(scimConnection, [ + { id: 'conn-1', organizationId: 'org-1', status: 'active', settings: {} }, + ]) + let group = { ...initialGroup } + const memberIds = new Set(['user-1']) + mocks.findGroup.mockImplementation(async () => group) + mocks.filterUsers.mockImplementation(async (_tx, _connectionId, ids: string[]) => ids) + mocks.memberIds.mockImplementation(async () => [...memberIds]) + mocks.members.mockImplementation(async () => + [...memberIds].map((scimUserId) => ({ scimUserId, displayName: scimUserId })) + ) + mocks.addMember.mockImplementation(async (_tx, { scimUserId }: { scimUserId: string }) => { + const added = !memberIds.has(scimUserId) + memberIds.add(scimUserId) + return added + }) + mocks.removeMember.mockImplementation(async (_tx, { scimUserId }: { scimUserId: string }) => + memberIds.delete(scimUserId) + ) + mocks.countMembers.mockImplementation(async () => memberIds.size) + mocks.touch.mockImplementation(async () => { + group = { ...group, updatedAt: new Date('2026-01-02T00:00:00.000Z') } + }) + }) + + it('advances resource metadata and version for a membership-only replacement', async () => { + const previous = toGroupResource(initialGroup, 'https://sim.test/api/scim/v2') + const result = await replaceScimGroup.execute({ + principal, + input: { groupId: 'group-1', group: { displayName: 'Engineering', memberIds: ['user-2'] } }, + }) + expect(result.resource.members?.map((entry) => entry.value)).toEqual(['user-2']) + expect(result.resource.meta.lastModified).toBe('2026-01-02T00:00:00.000Z') + expect(result.resource.meta.version).not.toBe(previous.meta.version) + expect(mocks.update).not.toHaveBeenCalled() + expect(mocks.reconcile).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ scimUserIds: ['user-1', 'user-2'] }) + ) + }) + + it('preserves metadata and version when the replacement changes nothing', async () => { + const previous = toGroupResource(initialGroup, 'https://sim.test/api/scim/v2') + const result = await replaceScimGroup.execute({ + principal, + input: { groupId: 'group-1', group: { displayName: 'Engineering', memberIds: ['user-1'] } }, + }) + expect(result.resource.meta).toEqual(previous.meta) + expect(mocks.touch).not.toHaveBeenCalled() + }) +}) 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 7fc9ee761e0..41f71151eaa 100644 --- a/apps/sim/ee/scim/lib/application/groups/manage-groups.ts +++ b/apps/sim/ee/scim/lib/application/groups/manage-groups.ts @@ -293,6 +293,7 @@ export const replaceScimGroup = defineAuthorizedScimUseCase({ if (await addGroupMember(tx, { groupId: current.id, scimUserId })) touched.add(scimUserId) } await assertMemberCount(tx, current.id) + if (touched.size > 0) await touchScimGroup(tx, current.id) await reconcileUsersProjection(tx, { connectionId: context.connection.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 cce4073a03c..a905cacaca7 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 @@ -19,6 +19,7 @@ const mocks = vi.hoisted(() => ({ suspend: vi.fn(), unsuspend: vi.fn(), invalidate: vi.fn(), + revokeSessions: vi.fn(), captureEvent: vi.fn(), deleteAccount: vi.fn(), syncIdentity: vi.fn(), @@ -62,6 +63,7 @@ vi.mock('@/lib/organizations/members/lifecycle', () => ({ })) vi.mock('@/lib/organizations/members/revocation', () => ({ invalidateAfterSessionRevocation: mocks.invalidate, + revokeUserSessionsTx: mocks.revokeSessions, })) vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureEvent, @@ -103,6 +105,7 @@ vi.mock('@/ee/scim/lib/application/audit', () => ({ vi.mock('@/ee/scim/lib/base-url', () => ({ scimBaseUrl: () => 'https://sim.test/api/scim/v2' })) import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { provisionScimUser } from '@/ee/scim/lib/application/users/provision-user' import { ScimError, uniqueness } from '@/ee/scim/lib/protocol/errors' @@ -162,7 +165,7 @@ describe('provisionScimUser', () => { mocks.assertUserNameAvailable.mockResolvedValue(undefined) mocks.assertEmailAvailable.mockResolvedValue(undefined) mocks.consumeTombstone.mockResolvedValue(undefined) - mocks.syncIdentity.mockResolvedValue(undefined) + mocks.syncIdentity.mockResolvedValue(false) mocks.suspend.mockResolvedValue(undefined) mocks.unsuspend.mockResolvedValue(undefined) mocks.isInstanceMode.mockReturnValue(false) @@ -449,6 +452,28 @@ describe('provisionScimUser', () => { expect(mocks.invalidate).toHaveBeenCalledWith({ userId: 'u-new', organizationId: 'org-1' }) }) + it('refuses an inactive create that links the organization owner without reporting success', async () => { + stageConnection() + mocks.resolveIdentity.mockResolvedValue({ + action: 'link', + userId: 'owner', + via: 'verified-domain', + }) + mocks.ensureMember.mockResolvedValue({ + success: true, + memberId: 'm-owner', + alreadyMember: true, + }) + mocks.suspend.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Transfer ownership first') + ) + await expect(run(attributes({ active: false }))).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.consumeTombstone).not.toHaveBeenCalled() + expect(mocks.reconcile).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.deleteAccount).not.toHaveBeenCalled() + }) + it('relinks a tombstoned account instead of creating a new one', async () => { stageConnection() mocks.resolveIdentity.mockResolvedValue({ action: 'link', userId: 'u-old', via: 'tombstone' }) @@ -504,6 +529,20 @@ describe('provisionScimUser', () => { }) }) + it('ends sessions under the old address when a tombstone relink renames the account', async () => { + stageConnection() + mocks.resolveIdentity.mockResolvedValue({ action: 'link', userId: 'u-old', via: 'tombstone' }) + mocks.syncIdentity.mockResolvedValue(true) + stageReadBack('u-old', attributes(), null) + await run(attributes()) + expect(mocks.revokeSessions).toHaveBeenCalledWith(db, { + userId: 'u-old', + organizationId: 'org-1', + }) + expect(mocks.invalidate).toHaveBeenCalledWith({ userId: 'u-old', organizationId: 'org-1' }) + expect(mocks.suspend).not.toHaveBeenCalled() + }) + it('refuses to provision an account this connection already links', async () => { stageConnection() mocks.resolveIdentity.mockResolvedValue({ 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 c5728fedf60..9f37c414269 100644 --- a/apps/sim/ee/scim/lib/application/users/provision-user.ts +++ b/apps/sim/ee/scim/lib/application/users/provision-user.ts @@ -14,7 +14,10 @@ import { isInstanceOrganizationMode, } from '@/lib/organizations/instance-org' import { suspendMemberTx, unsuspendMemberTx } from '@/lib/organizations/members/lifecycle' -import { invalidateAfterSessionRevocation } from '@/lib/organizations/members/revocation' +import { + invalidateAfterSessionRevocation, + revokeUserSessionsTx, +} from '@/lib/organizations/members/revocation' import { captureServerEvent } from '@/lib/posthog/server' import { deleteUserAccount } from '@/lib/users/account-deletion' import { @@ -55,6 +58,7 @@ export interface ProvisionScimUserResult { /** The subscription seats were validated against, so the post-commit seat sync targets the same one. */ subscriptionId: string | undefined organizationId: string + emailChanged: boolean resource: ReturnType } @@ -153,6 +157,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ scimUserId: string joinedOrganization: boolean subscriptionId: string | undefined + emailChanged: boolean resource: ReturnType } try { @@ -165,6 +170,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ ...seatPolicy, }) if (!membership.success) throw membershipFailure(membership.failureCode) + let emailChanged = false /** * A relinked account takes the directory's current identity. A rename @@ -173,7 +179,14 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ * account does not have. */ if (resolution.action === 'link') { - await syncAccountIdentityTx(tx, { userId, email, name: attributes.name.formatted }) + emailChanged = await syncAccountIdentityTx(tx, { + userId, + email, + name: attributes.name.formatted, + }) + if (emailChanged && attributes.active) { + await revokeUserSessionsTx(tx, { userId, organizationId: context.organizationId }) + } /** A relinked account may still carry the suspension a lost deprovisioning left behind. */ if (attributes.active) await unsuspendMemberTx(tx, { userId, source: 'scim' }) } @@ -216,6 +229,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ scimUserId: inserted.id, joinedOrganization: !membership.alreadyMember, subscriptionId: seatPolicy.organizationSubscriptionId, + emailChanged, resource: toUserResource(toUserResourceRow(record, []), context.baseUrl), } }) @@ -271,7 +285,7 @@ export const provisionScimUser = defineAuthorizedScimUseCase({ */ afterSuccess: async ({ result, context }) => { /** A member provisioned already inactive had their sessions revoked inside the transaction. */ - if (!result.resource.active) { + if (!result.resource.active || result.emailChanged) { invalidateAfterSessionRevocation({ userId: result.userId, 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 4ac5a2e5c5d..c8b846e2223 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 @@ -65,6 +65,7 @@ vi.mock('@/ee/scim/lib/application/audit', () => ({ vi.mock('@/ee/scim/lib/base-url', () => ({ scimBaseUrl: () => 'https://sim.test/api/scim/v2' })) import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { patchScimUser, replaceScimUser } from '@/ee/scim/lib/application/users/update-user' const principal: Principal = { @@ -173,6 +174,25 @@ describe('user updates', () => { expect(mocks.invalidate).toHaveBeenCalledWith({ userId: 'u-1', organizationId: 'org-1' }) }) + it.each(['patch', 'replace'] as const)( + 'propagates owner protection through %s', + async (method) => { + stage() + mocks.suspend.mockRejectedValueOnce( + new OrchestrationError('conflict', 'Transfer ownership first') + ) + const useCase = method === 'patch' ? patchScimUser : replaceScimUser + const input = + method === 'patch' + ? { scimUserId: 'su-1', operations: [{ op: 'replace', path: 'active', value: false }] } + : { scimUserId: 'su-1', attributes: attributes({ active: false }) } + await expect(run(useCase, input)).rejects.toMatchObject({ code: 'conflict' }) + expect(mocks.updateScimUser).not.toHaveBeenCalled() + expect(mocks.reconcile).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + } + ) + it('proves the organization owns the new domain before moving the address, then signs the user out', async () => { stage() await run(patchScimUser, { diff --git a/apps/sim/ee/scim/lib/entitlement.test.ts b/apps/sim/ee/scim/lib/entitlement.test.ts index c03246659ff..27cf50f5cc8 100644 --- a/apps/sim/ee/scim/lib/entitlement.test.ts +++ b/apps/sim/ee/scim/lib/entitlement.test.ts @@ -33,12 +33,25 @@ describe('isScimEntitledForOrganization', () => { expect(mockEnterprisePlan).not.toHaveBeenCalled() }) - it('ships with the enterprise plan on the hosted product, with nothing to switch on', async () => { + it('honors the deployment disable flag on the hosted product', async () => { setEnvFlags({ isScimEnabled: false, isHosted: true }) + await expect(isScimEntitledForOrganization('org-1')).resolves.toBe(false) + expect(mockEnterprisePlan).not.toHaveBeenCalled() + }) + + it('requires an enterprise plan when provisioning is enabled on the hosted product', async () => { + setEnvFlags({ isScimEnabled: true, isHosted: true }) mockEnterprisePlan.mockResolvedValue(false) await expect(isScimEntitledForOrganization('org-1')).resolves.toBe(false) mockEnterprisePlan.mockResolvedValue(true) await expect(isScimEntitledForOrganization('org-1')).resolves.toBe(true) - expect(mockEnterprisePlan).toHaveBeenCalledWith('org-1') + expect(mockEnterprisePlan).toHaveBeenCalledWith('org-1', 'throw', undefined) + }) + + it('propagates billing read failures instead of treating the organization as unentitled', async () => { + setEnvFlags({ isScimEnabled: true, isHosted: true }) + const failure = new Error('Billing database unavailable') + mockEnterprisePlan.mockRejectedValue(failure) + await expect(isScimEntitledForOrganization('org-1')).rejects.toBe(failure) }) }) diff --git a/apps/sim/ee/scim/lib/entitlement.ts b/apps/sim/ee/scim/lib/entitlement.ts index 688eec2f206..2b9398d49b9 100644 --- a/apps/sim/ee/scim/lib/entitlement.ts +++ b/apps/sim/ee/scim/lib/entitlement.ts @@ -1,25 +1,33 @@ import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription' import { isHosted, isScimEnabled } from '@/lib/core/config/env-flags' +import type { DbOrTx } from '@/lib/db/types' /** * Whether this deployment serves directory provisioning at all. * * The hosted product ships it as part of the enterprise plan, the same way SSO - * ships: nothing to switch on. A self-hosted deployment turns it on with the - * enterprise switch (`ENTERPRISE_ENABLED`) or the feature's own variable - * (`SCIM_ENABLED`), which also lets an operator turn just this feature off. + * ships by default, with SCIM_ENABLED=false available to defer activation. + * A self-hosted deployment turns it on with the enterprise switch + * (`ENTERPRISE_ENABLED`) or the feature's own variable (`SCIM_ENABLED`), which + * also lets an operator turn just this feature off. */ export function isScimDeploymentEnabled(): boolean { - return isHosted || isScimEnabled + return isScimEnabled } /** * Whether directory provisioning may run for an organization: the deployment * serves it, and on the hosted product the organization holds the enterprise * plan. + * + * Billing read failures propagate because a false entitlement also releases + * managed-membership and JIT locks; an outage must not relax those policies. */ -export async function isScimEntitledForOrganization(organizationId: string): Promise { +export async function isScimEntitledForOrganization( + organizationId: string, + executor?: DbOrTx +): Promise { if (!isScimDeploymentEnabled()) return false if (!isHosted) return true - return isOrganizationOnEnterprisePlan(organizationId) + return isOrganizationOnEnterprisePlan(organizationId, 'throw', executor) } diff --git a/apps/sim/ee/scim/lib/identity/account-identity.test.ts b/apps/sim/ee/scim/lib/identity/account-identity.test.ts new file mode 100644 index 00000000000..56c4b34621f --- /dev/null +++ b/apps/sim/ee/scim/lib/identity/account-identity.test.ts @@ -0,0 +1,47 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { user } from '@sim/db/schema' +import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { assertAvailable } = vi.hoisted(() => ({ assertAvailable: vi.fn() })) + +vi.mock('@/ee/scim/lib/identity/resolve-user', () => ({ + assertEmailAvailable: assertAvailable, +})) + +import { syncAccountIdentityTx } from '@/ee/scim/lib/identity/account-identity' + +afterAll(resetDbChainMock) + +describe('syncAccountIdentityTx', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + queueTableRows(user, [{ email: 'ada@acme.test' }]) + }) + + it('reports an address change for session revocation and clears email verification', async () => { + await expect( + syncAccountIdentityTx(db, { userId: 'u-1', email: 'new@acme.test', name: 'Ada' }) + ).resolves.toBe(true) + expect(assertAvailable).toHaveBeenCalledWith(db, 'new@acme.test', 'u-1') + expect(dbChainMockFns.set).toHaveBeenCalledWith( + expect.objectContaining({ + email: 'new@acme.test', + normalizedEmail: 'new@acme.test', + emailVerified: false, + }) + ) + }) + + it('does not report a case-only address change or clear its verification', async () => { + await expect( + syncAccountIdentityTx(db, { userId: 'u-1', email: 'Ada@acme.test', name: 'Ada' }) + ).resolves.toBe(false) + expect(assertAvailable).not.toHaveBeenCalled() + expect(dbChainMockFns.set.mock.calls[0][0]).not.toHaveProperty('emailVerified') + }) +}) diff --git a/apps/sim/ee/scim/lib/identity/account-identity.ts b/apps/sim/ee/scim/lib/identity/account-identity.ts index c41afd03fe9..d7fc539ea8c 100644 --- a/apps/sim/ee/scim/lib/identity/account-identity.ts +++ b/apps/sim/ee/scim/lib/identity/account-identity.ts @@ -11,11 +11,12 @@ import { assertEmailAvailable } from '@/ee/scim/lib/identity/resolve-user' * rename that arrives as delete-and-recreate lands the same way as one that * arrives as a PATCH. The caller has already proven the organization owns the * new address's domain; this asserts nobody else holds it and applies it. + * Returns whether the email changed so callers revoke sessions established under the old address. */ export async function syncAccountIdentityTx( tx: DbOrTx, params: { userId: string; email?: string; name: string } -): Promise { +): Promise { let emailChanged = false if (params.email !== undefined) { const [current] = await tx @@ -40,4 +41,5 @@ export async function syncAccountIdentityTx( updatedAt: new Date(), }) .where(eq(user.id, params.userId)) + return emailChanged } diff --git a/apps/sim/ee/scim/lib/managed-membership.postgres.test.ts b/apps/sim/ee/scim/lib/managed-membership.postgres.test.ts new file mode 100644 index 00000000000..de48000ec6d --- /dev/null +++ b/apps/sim/ee/scim/lib/managed-membership.postgres.test.ts @@ -0,0 +1,206 @@ +/** + * @vitest-environment node + */ +import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { generateId } from '@sim/utils/id' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('@sim/db') +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') +vi.mock('@/lib/core/config/env-flags', () => ({ + ...envFlagsMock, + isHosted: true, + isScimEnabled: true, + isBillingEnabled: true, +})) + +const databaseUrl = process.env.OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL + +async function loadRuntime() { + const [{ db }, schema, { eq, inArray, sql }, { alias }, membership, entitlement] = + await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('drizzle-orm/pg-core'), + import('@/ee/scim/lib/managed-membership'), + import('@/ee/scim/lib/entitlement'), + ]) + return { db, schema, eq, inArray, sql, alias, ...membership, ...entitlement } +} + +describe.skipIf(!databaseUrl)('SCIM managed membership in PostgreSQL', () => { + let runtime: Awaited> + let orgId: string + let otherOrgId: string + let connectionId: string + let managedUserId: string + let unmanagedUserId: string + let managedEmail: string + + beforeAll(async () => { + process.env.DATABASE_URL = databaseUrl + runtime = await loadRuntime() + }, 30_000) + + beforeEach(async () => { + vi.stubEnv('DB_TX_TRIPWIRE', 'throw') + orgId = generateId() + otherOrgId = generateId() + connectionId = generateId() + managedUserId = generateId() + unmanagedUserId = generateId() + managedEmail = `${managedUserId}@scim-membership.test` + const { db, schema } = runtime + const now = new Date() + await db.insert(schema.organization).values( + [orgId, otherOrgId].map((id) => ({ + id, + name: 'SCIM predicate test', + slug: id, + createdAt: now, + })) + ) + await db.insert(schema.user).values( + [managedUserId, unmanagedUserId].map((id) => ({ + id, + name: 'SCIM predicate test', + email: `${id}@scim-membership.test`, + emailVerified: true, + createdAt: now, + updatedAt: now, + })) + ) + await db.insert(schema.subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: orgId, + status: 'active', + seats: 50, + metadata: { plan: 'enterprise', referenceId: orgId, seats: 50, monthlyPrice: 100 }, + periodStart: now, + periodEnd: new Date(now.getTime() + 86_400_000), + }) + await db.insert(schema.scimConnection).values({ + id: connectionId, + organizationId: orgId, + status: 'active', + settings: { lockManualMembership: true }, + }) + await db.insert(schema.scimUser).values({ + id: generateId(), + connectionId, + userId: managedUserId, + userName: managedEmail, + orderKey: managedUserId, + attributes: { + userName: managedEmail, + active: true, + displayName: 'Managed user', + name: { formatted: 'Managed user' }, + emails: [{ value: managedEmail, primary: true, type: 'work' }], + }, + }) + }) + + afterEach(async () => { + const { db, schema, eq, inArray } = runtime + await db.delete(schema.subscription).where(eq(schema.subscription.referenceId, orgId)) + await db.delete(schema.organization).where(inArray(schema.organization.id, [orgId, otherOrgId])) + await db.delete(schema.user).where(inArray(schema.user.id, [managedUserId, unmanagedUserId])) + }) + + async function invitee(organizationId: string, email: string) { + const { db, schema, sql, scimManagedUserPredicate } = runtime + const [row] = await db + .select({ + id: schema.user.id, + managed: scimManagedUserPredicate(organizationId, schema.user.id), + }) + .from(schema.user) + .where(sql`lower(${schema.user.email}) = ${email}`) + return row + } + + it('correlates the invitation lookup to its outer user without ambiguous inner join columns', async () => { + expect(await invitee(orgId, managedEmail)).toEqual({ id: managedUserId, managed: true }) + expect(await invitee(orgId, `${unmanagedUserId}@scim-membership.test`)).toEqual({ + id: unmanagedUserId, + managed: false, + }) + expect(await invitee(otherOrgId, managedEmail)).toEqual({ id: managedUserId, managed: false }) + }) + + it.each(['invited_user', 'invited"user'])( + 'qualifies an aliased outer table named %s', + async (name) => { + const { db, schema, alias, eq, scimManagedUserPredicate } = runtime + const invited = alias(schema.user, name) + const rows = await db + .select({ id: invited.id, managed: scimManagedUserPredicate(orgId, invited.id) }) + .from(invited) + .where(eq(invited.id, managedUserId)) + expect(rows).toEqual([{ id: managedUserId, managed: true }]) + } + ) + + it.each([ + { status: 'disabled', settings: { lockManualMembership: true } }, + { status: 'active', settings: { lockManualMembership: false } }, + { status: 'active', settings: {} }, + ])('allows edits when the connection is $status with $settings', async ({ status, settings }) => { + const { db, schema, eq, assertMembershipNotScimManaged } = runtime + await db + .update(schema.scimConnection) + .set({ status, settings }) + .where(eq(schema.scimConnection.id, connectionId)) + expect(await invitee(orgId, managedEmail)).toEqual({ id: managedUserId, managed: false }) + await expect( + db.transaction((executor) => + assertMembershipNotScimManaged({ organizationId: orgId, userId: managedUserId, executor }) + ) + ).resolves.toBeUndefined() + }) + + it('runs the permission guard and hosted entitlement through the same transaction', async () => { + const { db, assertMembershipNotScimManaged } = runtime + await expect( + db.transaction((executor) => + assertMembershipNotScimManaged({ organizationId: orgId, userId: managedUserId, executor }) + ) + ).rejects.toMatchObject({ detailCode: 'SCIM_MANAGED_MEMBERSHIP' }) + for (const input of [ + { organizationId: orgId, userId: unmanagedUserId }, + { organizationId: otherOrgId, userId: managedUserId }, + { organizationId: orgId, userId: "' OR true --" }, + ]) { + await expect( + db.transaction((executor) => assertMembershipNotScimManaged({ ...input, executor })) + ).resolves.toBeUndefined() + } + }) + + it('allows a previously managed member when the real Enterprise subscription has ended', async () => { + const { db, schema, eq, assertMembershipNotScimManaged } = runtime + await db + .update(schema.subscription) + .set({ status: 'canceled' }) + .where(eq(schema.subscription.referenceId, orgId)) + await expect( + db.transaction((executor) => + assertMembershipNotScimManaged({ organizationId: orgId, userId: managedUserId, executor }) + ) + ).resolves.toBeUndefined() + }) + + it('propagates a real billing query failure without relaxing directory policy', async () => { + const { db, sql, isScimEntitledForOrganization } = runtime + await expect( + db.transaction(async (executor) => { + await executor.execute(sql`set local search_path to pg_catalog`) + await isScimEntitledForOrganization(orgId, executor) + }) + ).rejects.toMatchObject({ cause: { code: '42P01' } }) + }) +}) diff --git a/apps/sim/ee/scim/lib/managed-membership.test.ts b/apps/sim/ee/scim/lib/managed-membership.test.ts index f0e1bf274d0..bf46cb151db 100644 --- a/apps/sim/ee/scim/lib/managed-membership.test.ts +++ b/apps/sim/ee/scim/lib/managed-membership.test.ts @@ -6,6 +6,9 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { ForbiddenOperationError } from '@/lib/core/application' +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') + const { mockDeploymentEnabled, mockEntitled } = vi.hoisted(() => ({ mockDeploymentEnabled: vi.fn(), mockEntitled: vi.fn(), @@ -45,7 +48,7 @@ describe('assertMembershipNotScimManaged', () => { queueProbe(true) mockEntitled.mockResolvedValue(false) await expect(assertMembershipNotScimManaged(params)).resolves.toBeUndefined() - expect(mockEntitled).toHaveBeenCalledWith('org-1') + expect(mockEntitled).toHaveBeenCalledWith('org-1', db) }) it('never reads the plan for a member the directory does not manage', async () => { diff --git a/apps/sim/ee/scim/lib/managed-membership.ts b/apps/sim/ee/scim/lib/managed-membership.ts index ca39aa5a662..8361c1af55a 100644 --- a/apps/sim/ee/scim/lib/managed-membership.ts +++ b/apps/sim/ee/scim/lib/managed-membership.ts @@ -1,5 +1,5 @@ import { scimConnection, scimUser } from '@sim/db/schema' -import { type AnyColumn, type SQL, sql } from 'drizzle-orm' +import { type AnyColumn, Column, getTableName, is, type SQL, sql } from 'drizzle-orm' import { ForbiddenOperationError } from '@/lib/core/application' import type { DbOrTx } from '@/lib/db/types' import { isScimDeploymentEnabled, isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' @@ -36,17 +36,27 @@ export function scimManagedUserPredicate( organizationId: string, userIdColumn: SQL | AnyColumn ): SQL { + const userId = is(userIdColumn, Column) ? qualifiedColumn(userIdColumn) : userIdColumn return sql`exists ( select 1 from ${scimUser} - join ${scimConnection} on ${scimConnection.id} = ${scimUser.connectionId} - where ${scimUser.userId} = ${userIdColumn} - and ${scimConnection.organizationId} = ${organizationId} - and ${scimConnection.status} = 'active' - and coalesce((${scimConnection.settings} ->> 'lockManualMembership')::boolean, false) = true + join ${scimConnection} on ${qualifiedColumn(scimConnection.id)} = ${qualifiedColumn(scimUser.connectionId)} + where ${qualifiedColumn(scimUser.userId)} = ${userId} + and ${qualifiedColumn(scimConnection.organizationId)} = ${organizationId} + and ${qualifiedColumn(scimConnection.status)} = 'active' + and coalesce((${qualifiedColumn(scimConnection.settings)} ->> 'lockManualMembership')::boolean, false) = true )` } +/** + * Drizzle removes Column qualifiers from single-table SELECT expressions, + * including nested SQL. Explicit identifiers preserve this subquery's joins + * and outer correlation, using the column's current table name or alias. + */ +function qualifiedColumn(column: AnyColumn): SQL { + return sql`${sql.identifier(getTableName(column.table))}.${sql.identifier(column.name)}` +} + /** Refuses a change to a member the directory owns. */ export async function assertMembershipNotScimManaged(params: { organizationId: string @@ -63,7 +73,7 @@ export async function assertMembershipNotScimManaged(params: { * manual changes on behalf of a directory that can no longer sync. Read only * once a managed row is found, so the common case costs nothing. */ - if (!(await isScimEntitledForOrganization(params.organizationId))) return + if (!(await isScimEntitledForOrganization(params.organizationId, params.executor))) return throw new ForbiddenOperationError( 'SCIM_MANAGED_MEMBERSHIP', 'This member is managed by the organization’s identity provider. Make the change there, or turn off managed-membership locking in the organization’s directory settings.' diff --git a/apps/sim/ee/scim/lib/projection/grants.test.ts b/apps/sim/ee/scim/lib/projection/grants.test.ts index 2051eebf577..bcbe26925f7 100644 --- a/apps/sim/ee/scim/lib/projection/grants.test.ts +++ b/apps/sim/ee/scim/lib/projection/grants.test.ts @@ -130,10 +130,26 @@ describe('planGrantChanges', () => { it('carries the previous level when a workspace changes level in either direction', () => { expect( planGrantChanges([workspace('ws-1', 'admin')], [workspace('ws-1', 'read')]).apply - ).toEqual([{ grant: workspace('ws-1', 'admin'), previousPermission: 'read' }]) + ).toEqual([{ grant: workspace('ws-1', 'admin'), previousGrant: workspace('ws-1', 'read') }]) expect( planGrantChanges([workspace('ws-1', 'read')], [workspace('ws-1', 'admin')]).apply - ).toEqual([{ grant: workspace('ws-1', 'read'), previousPermission: 'admin' }]) + ).toEqual([{ grant: workspace('ws-1', 'read'), previousGrant: workspace('ws-1', 'admin') }]) + }) + + it('repairs missing and lowered access even when the provenance still matches', () => { + const current = workspace('ws-1', 'admin') + expect(planGrantChanges([current], [current], []).apply).toEqual([ + { grant: current, previousGrant: current }, + ]) + expect(planGrantChanges([current], [current], [workspace('ws-1', 'read')]).apply).toEqual([ + { grant: current, previousGrant: current }, + ]) + expect( + planGrantChanges([workspace('ws-1', 'read')], [workspace('ws-1', 'read')], [current]) + ).toEqual({ + apply: [], + withdraw: [], + }) }) it('never withdraws a grant that is also desired at a different level', () => { diff --git a/apps/sim/ee/scim/lib/projection/grants.ts b/apps/sim/ee/scim/lib/projection/grants.ts index 131cf23f555..36789490fea 100644 --- a/apps/sim/ee/scim/lib/projection/grants.ts +++ b/apps/sim/ee/scim/lib/projection/grants.ts @@ -22,6 +22,8 @@ export interface ProjectionGrant { permissionType?: PermissionType /** Present on grants read back from provenance; a desired grant has no origin yet. */ origin?: ProjectionGrantOrigin + /** Manual workspace permission that predates or exceeds the directory grant. */ + baselinePermission?: PermissionType | null } /** One `scim_group_mapping` row the user reaches through a group they belong to. */ @@ -78,8 +80,8 @@ export function resolveDesiredGrants(rows: readonly MappingRow[]): ProjectionGra export interface GrantApplication { grant: ProjectionGrant - /** The level a previous pass set on a workspace, present when the level changes. */ - previousPermission?: PermissionType + /** Provenance retained even when the actual access has drifted away. */ + previousGrant?: ProjectionGrant } export interface GrantPlan { @@ -92,17 +94,18 @@ export interface GrantPlan { /** * Diffs the desired set against what the directory previously granted. * - * Only differences are returned, which is what makes a reconcile pass - * idempotent: identical inputs plan nothing. A workspace already granted at a - * different level is planned as an application carrying the previous level, so - * the executor can lower as well as raise. + * The actual access, when supplied, must also satisfy the mapping. Provenance + * alone cannot detect a permission removed or lowered outside the directory. + * Unchanged mappings with intact access plan nothing. */ export function planGrantChanges( desired: readonly ProjectionGrant[], - current: readonly ProjectionGrant[] + current: readonly ProjectionGrant[], + actual: readonly ProjectionGrant[] = current ): GrantPlan { const desiredByKey = new Map(desired.map((grant) => [grantKey(grant), grant])) const currentByKey = new Map(current.map((grant) => [grantKey(grant), grant])) + const actualByKey = new Map(actual.map((grant) => [grantKey(grant), grant])) const withdraw: ProjectionGrant[] = [] for (const [key, grant] of currentByKey) { @@ -120,7 +123,13 @@ export function planGrantChanges( grant.permissionType !== undefined && existing.permissionType !== undefined && grant.permissionType !== existing.permissionType - if (levelChanged) apply.push({ grant, previousPermission: existing.permissionType }) + const observed = actualByKey.get(key) + const accessMissing = + !observed || + (grant.permissionType !== undefined && + (!observed.permissionType || + permissionRank(observed.permissionType) < permissionRank(grant.permissionType))) + if (levelChanged || accessMissing) apply.push({ grant, previousGrant: existing }) } return { withdraw, apply } diff --git a/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts b/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts index e378b10032e..559f8b8f61b 100644 --- a/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts +++ b/apps/sim/ee/scim/lib/projection/reconcile-user.test.ts @@ -2,7 +2,15 @@ * @vitest-environment node */ import { db } from '@sim/db' -import { scimGroupMember, scimProjectionGrant, scimUser, workspace } from '@sim/db/schema' +import { + member, + permissionGroupMember, + permissions, + scimGroupMember, + scimProjectionGrant, + scimUser, + workspace, +} from '@sim/db/schema' import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -53,10 +61,19 @@ interface Scenario { current?: Array> mappings?: Array> ownedWorkspaces?: string[] + actualWorkspaces?: Array<{ id: string; permissionType: 'read' | 'write' | 'admin' }> + actualGroups?: string[] + actualRole?: string } -/** Queues the four reads the reconciler makes, in the order it makes them. */ -function stage({ current = [], mappings = [], ownedWorkspaces = ['ws-1'] }: Scenario) { +function stage({ + current = [], + mappings = [], + ownedWorkspaces = ['ws-1'], + actualWorkspaces, + actualGroups = [], + actualRole = 'member', +}: Scenario) { queueTableRows(scimUser, [{ userId: 'u-1' }]) queueTableRows(scimProjectionGrant, current) queueTableRows(scimGroupMember, mappings) @@ -64,6 +81,18 @@ function stage({ current = [], mappings = [], ownedWorkspaces = ['ws-1'] }: Scen workspace, ownedWorkspaces.map((id) => ({ id })) ) + queueTableRows( + permissions, + actualWorkspaces ?? + current + .filter((grant) => grant.targetKind === 'workspace') + .map((grant) => ({ id: grant.targetId, permissionType: grant.permissionType })) + ) + queueTableRows( + permissionGroupMember, + actualGroups.map((id) => ({ id })) + ) + queueTableRows(member, [{ role: actualRole }]) } const workspaceMapping = (workspaceId: string, permissionType: string) => ({ @@ -118,9 +147,16 @@ describe('reconcileUserProjection', () => { it('records access the person already held by hand as adopted, and counts no change', async () => { mocks.grantWorkspace.mockResolvedValue('unchanged') - stage({ mappings: [workspaceMapping('ws-1', 'write')] }) + stage({ + mappings: [workspaceMapping('ws-1', 'write')], + actualWorkspaces: [{ id: 'ws-1', permissionType: 'write' }], + }) const delta = await reconcileUserProjection(db, params) - expect(insertedValues()[0]).toMatchObject({ targetId: 'ws-1', origin: 'adopted' }) + expect(insertedValues()[0]).toMatchObject({ + targetId: 'ws-1', + origin: 'adopted', + baselinePermission: 'write', + }) expect(delta.added).toHaveLength(0) }) @@ -137,6 +173,142 @@ describe('reconcileUserProjection', () => { expect(dbChainMockFns.delete).not.toHaveBeenCalled() }) + it.each([null, 'read'] as const)( + 'repairs recorded workspace access that is actually %s', + async (permissionType) => { + stage({ + current: [ + { + targetKind: 'workspace', + targetId: 'ws-1', + permissionType: 'admin', + origin: 'directory', + }, + ], + mappings: [workspaceMapping('ws-1', 'admin')], + actualWorkspaces: permissionType ? [{ id: 'ws-1', permissionType }] : [], + }) + const delta = await reconcileUserProjection(db, params) + expect(mocks.grantWorkspace).toHaveBeenCalledWith(db, { + workspaceId: 'ws-1', + userId: 'u-1', + permission: 'admin', + }) + expect(delta.raised).toHaveLength(1) + } + ) + + it('repairs a missing permission-group membership and a manually lowered organization role', async () => { + stage({ + current: [ + { targetKind: 'permission_group', targetId: 'pg-1', origin: 'directory' }, + { targetKind: 'org_role', targetId: 'admin', origin: 'directory' }, + ], + mappings: [ + { targetKind: 'permission_group', permissionGroupId: 'pg-1' }, + { targetKind: 'org_role', role: 'admin' }, + ], + actualGroups: [], + actualRole: 'member', + }) + await reconcileUserProjection(db, params) + expect(mocks.addMember).toHaveBeenCalledWith(db, { + organizationId: 'org-1', + groupId: 'pg-1', + userId: 'u-1', + }) + expect(mocks.changeMemberRole).toHaveBeenCalledWith(db, { + organizationId: 'org-1', + userId: 'u-1', + role: 'admin', + }) + }) + + it('restores manual Read after a directory Admin mapping is removed', async () => { + stage({ + mappings: [workspaceMapping('ws-1', 'admin')], + actualWorkspaces: [{ id: 'ws-1', permissionType: 'read' }], + }) + await reconcileUserProjection(db, params) + const saved = insertedValues()[0] + expect(saved).toMatchObject({ + permissionType: 'admin', + baselinePermission: 'read', + origin: 'directory', + }) + + vi.clearAllMocks() + resetDbChainMock() + mocks.readPermission.mockResolvedValue('admin') + stage({ current: [saved] }) + await reconcileUserProjection(db, params) + expect(mocks.lowerWorkspace).toHaveBeenCalledWith(db, { + workspaceId: 'ws-1', + userId: 'u-1', + from: 'admin', + to: 'read', + }) + expect(mocks.revokeWorkspace).not.toHaveBeenCalled() + }) + + it('preserves an adopted manual Admin when the mapping is lowered', async () => { + stage({ + current: [ + { targetKind: 'workspace', targetId: 'ws-1', permissionType: 'admin', origin: 'adopted' }, + ], + mappings: [workspaceMapping('ws-1', 'read')], + }) + mocks.grantWorkspace.mockResolvedValue('unchanged') + await reconcileUserProjection(db, params) + expect(mocks.lowerWorkspace).not.toHaveBeenCalled() + expect(mocks.grantWorkspace).toHaveBeenCalledWith(db, { + workspaceId: 'ws-1', + userId: 'u-1', + permission: 'admin', + }) + expect(insertedValues()[0]).toMatchObject({ baselinePermission: 'admin' }) + }) + + it('never lowers below the manual baseline during a directory downgrade', async () => { + stage({ + current: [ + { + targetKind: 'workspace', + targetId: 'ws-1', + permissionType: 'admin', + origin: 'directory', + baselinePermission: 'write', + }, + ], + mappings: [workspaceMapping('ws-1', 'read')], + }) + await reconcileUserProjection(db, params) + expect(mocks.lowerWorkspace).toHaveBeenCalledWith(db, { + workspaceId: 'ws-1', + userId: 'u-1', + from: 'admin', + to: 'write', + }) + }) + + it('withdraws the entire covered access when managed membership is locked', async () => { + stage({ + current: [ + { + targetKind: 'workspace', + targetId: 'ws-1', + permissionType: 'admin', + origin: 'directory', + baselinePermission: 'read', + }, + ], + }) + mocks.readPermission.mockResolvedValue('admin') + await reconcileUserProjection(db, { ...params, settings: { lockManualMembership: true } }) + expect(mocks.revokeWorkspace).toHaveBeenCalled() + expect(mocks.lowerWorkspace).not.toHaveBeenCalled() + }) + it('forgets an adopted grant without touching the access unless the directory is the source of truth', async () => { stage({ current: [ diff --git a/apps/sim/ee/scim/lib/projection/reconcile-user.ts b/apps/sim/ee/scim/lib/projection/reconcile-user.ts index 12aab864efc..69260fe34a7 100644 --- a/apps/sim/ee/scim/lib/projection/reconcile-user.ts +++ b/apps/sim/ee/scim/lib/projection/reconcile-user.ts @@ -1,5 +1,8 @@ import { db } from '@sim/db' import { + member, + permissionGroupMember, + permissions, type ScimConnectionSettings, scimGroupMapping, scimGroupMember, @@ -44,14 +47,10 @@ const logger = createLogger('ScimProjection') * Turning directory group membership into Sim access. * * A SCIM group means nothing on its own; an administrator maps it to something - * Sim understands. This module computes what a user's mappings say they should - * have, compares it to what SCIM previously granted them, and applies only the - * difference. - * - * The comparison is against SCIM's own grants, recorded in - * `scim_projection_grant`, never against the user's total access. That is the - * distinction that keeps a directory sync from revoking access a workspace - * administrator granted by hand. + * Sim understands. This module compares those desired grants with both their + * recorded provenance and the user's actual access, repairing missing grants. + * Provenance and saved manual workspace levels determine what can be withdrawn + * without removing manual access when membership locking is off. */ export interface ProjectionDelta { @@ -96,6 +95,7 @@ async function currentGrants(tx: DbOrTx, scimUserId: string): Promise { + const workspaceIds = params.desired + .filter((grant) => grant.targetKind === 'workspace') + .map((grant) => grant.targetId) + const groupIds = params.desired + .filter((grant) => grant.targetKind === 'permission_group') + .map((grant) => grant.targetId) + const [workspaceRows, groupRows, memberRows] = await Promise.all([ + workspaceIds.length > 0 + ? tx + .select({ id: permissions.entityId, permissionType: permissions.permissionType }) + .from(permissions) + .where( + and( + eq(permissions.userId, params.userId), + eq(permissions.entityType, 'workspace'), + inArray(permissions.entityId, workspaceIds) + ) + ) + : [], + groupIds.length > 0 + ? tx + .select({ id: permissionGroupMember.permissionGroupId }) + .from(permissionGroupMember) + .where( + and( + eq(permissionGroupMember.organizationId, params.organizationId), + eq(permissionGroupMember.userId, params.userId), + inArray(permissionGroupMember.permissionGroupId, groupIds) + ) + ) + : [], + params.desired.some((grant) => grant.targetKind === 'org_role') + ? tx + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, params.organizationId), eq(member.userId, params.userId)) + ) + .limit(1) + : [], + ]) + return [ + ...workspaceRows.map((row) => ({ + targetKind: 'workspace' as const, + targetId: row.id, + permissionType: row.permissionType, + })), + ...groupRows.map((row) => ({ targetKind: 'permission_group' as const, targetId: row.id })), + ...memberRows + .filter((row) => row.role === 'admin') + .map(() => ({ targetKind: 'org_role' as const, targetId: 'admin' })), + ] +} + /** * What applying a grant did. `unchanged` means the person already held it by * some other route — a manual grant — and nothing was written. @@ -140,23 +200,30 @@ async function applyGrant( organizationId: string userId: string grant: ProjectionGrant - /** The level a previous pass set on a workspace, when lowering it. */ - previousPermission?: PermissionType + previousGrant?: ProjectionGrant + baselinePermission: PermissionType | null + lockManualMembership: boolean } ): Promise { const { grant } = params switch (grant.targetKind) { case 'workspace': { if (!grant.permissionType) return 'skipped' + const minimum = + !params.lockManualMembership && + params.baselinePermission && + permissionRank(params.baselinePermission) > permissionRank(grant.permissionType) + ? params.baselinePermission + : grant.permissionType if ( - params.previousPermission && - permissionRank(params.previousPermission) > permissionRank(grant.permissionType) + params.previousGrant?.permissionType && + permissionRank(params.previousGrant.permissionType) > permissionRank(minimum) ) { const lowered = await lowerWorkspaceAccessTx(tx, { workspaceId: grant.targetId, userId: params.userId, - from: params.previousPermission, - to: grant.permissionType, + from: params.previousGrant.permissionType, + to: minimum, }) if (lowered === 'lowered') return 'applied' /** The row is no longer at the level the directory set; ensure at least the desired level. */ @@ -164,7 +231,7 @@ async function applyGrant( const outcome = await grantWorkspaceAccessTx(tx, { workspaceId: grant.targetId, userId: params.userId, - permission: grant.permissionType, + permission: minimum, }) return outcome === 'unchanged' ? 'unchanged' : 'applied' } @@ -188,7 +255,7 @@ async function applyGrant( * * The owner is out of the directory's reach: ownership carries billing and the * last-owner guarantee, and a group that happens to contain the owner must not - * fail every sync over it. Returns false, and records no grant, so the mapping + * fail every sync over it. Returns `skipped` and records no grant, so the mapping * is simply inert for that one person. */ async function setOrganizationRole( @@ -259,6 +326,17 @@ async function withdrawGrant( ) { return true } + if (!params.lockManualMembership && grant.baselinePermission) { + if (permissionRank(current) > permissionRank(grant.baselinePermission)) { + await lowerWorkspaceAccessTx(tx, { + workspaceId: grant.targetId, + userId: params.userId, + from: current, + to: grant.baselinePermission, + }) + } + return true + } const outcome = await revokeWorkspaceAccessTx(tx, { workspaceId: grant.targetId, userId: params.userId, @@ -365,7 +443,12 @@ export async function reconcileUserProjection( const desired = mapped.filter( (grant) => grant.targetKind !== 'workspace' || !foreignWorkspaceIds.has(grant.targetId) ) - const plan = planGrantChanges(desired, current) + const actual = await actualGrants(tx, { + organizationId: params.organizationId, + userId: record.userId, + desired, + }) + const plan = planGrantChanges(desired, current, actual) const delta: ProjectionDelta = { added: [], removed: [], raised: [] } const lockManualMembership = params.settings.lockManualMembership === true @@ -392,14 +475,35 @@ export async function reconcileUserProjection( delta.removed.push(grant) } - for (const { grant, previousPermission } of plan.apply) { + for (const { grant, previousGrant } of plan.apply) { + const currentPermission = + grant.targetKind === 'workspace' + ? (actual.find( + (observed) => + observed.targetKind === 'workspace' && observed.targetId === grant.targetId + )?.permissionType ?? null) + : null + let baselinePermission = previousGrant?.baselinePermission ?? null + if ( + currentPermission && + (!previousGrant || + previousGrant.origin === 'adopted' || + (previousGrant.permissionType && + permissionRank(currentPermission) > permissionRank(previousGrant.permissionType))) && + (!baselinePermission || + permissionRank(currentPermission) > permissionRank(baselinePermission)) + ) { + baselinePermission = currentPermission + } let applied: GrantOutcome try { applied = await applyGrant(tx, { organizationId: params.organizationId, userId: record.userId, grant, - ...(previousPermission ? { previousPermission } : {}), + previousGrant, + baselinePermission, + lockManualMembership, }) } catch (error) { /** @@ -443,6 +547,7 @@ export async function reconcileUserProjection( targetKind: grant.targetKind, targetId: grant.targetId, permissionType: grant.permissionType ?? null, + baselinePermission, origin: applied === 'applied' ? 'directory' : 'adopted', createdAt: new Date(), updatedAt: new Date(), @@ -455,13 +560,14 @@ export async function reconcileUserProjection( ], set: { permissionType: grant.permissionType ?? null, + baselinePermission, ...(applied === 'applied' ? { origin: 'directory' } : {}), updatedAt: new Date(), }, }) if (applied !== 'applied') continue - if (previousPermission) delta.raised.push(grant) + if (previousGrant?.permissionType) delta.raised.push(grant) else delta.added.push(grant) } diff --git a/apps/sim/ee/scim/lib/protocol/canonical.test.ts b/apps/sim/ee/scim/lib/protocol/canonical.test.ts index 5c86c21da36..f68375f8289 100644 --- a/apps/sim/ee/scim/lib/protocol/canonical.test.ts +++ b/apps/sim/ee/scim/lib/protocol/canonical.test.ts @@ -79,6 +79,14 @@ describe('toCanonicalUser', () => { expect(JSON.stringify(user)).not.toContain('hunter2') }) + it.each(['Password', 'urn:ietf:params:scim:schemas:core:2.0:User:password'])( + 'also strips the write-only password attribute %s', + (attribute) => { + const user = parseUser({ userName: 'ada@acme.test', [attribute]: 'synthetic-password' }) + expect(JSON.stringify(user)).not.toContain('synthetic-password') + } + ) + it('accepts Entra’s string boolean for active', () => { expect(parseUser({ userName: 'ada@acme.test', active: 'False' }).active).toBe(false) }) diff --git a/apps/sim/ee/scim/lib/protocol/canonical.ts b/apps/sim/ee/scim/lib/protocol/canonical.ts index d5d366e6201..a38f86a1f28 100644 --- a/apps/sim/ee/scim/lib/protocol/canonical.ts +++ b/apps/sim/ee/scim/lib/protocol/canonical.ts @@ -3,7 +3,7 @@ import { isValidEmailSyntax } from '@sim/utils/string' import type { ScimGroupWriteParsed, ScimUserWriteParsed } from '@/lib/api/contracts/scim' import { SCIM_ENTERPRISE_USER_SCHEMA } from '@/ee/scim/lib/protocol/constants' import { invalidValue } from '@/ee/scim/lib/protocol/errors' -import { isRecord } from '@/ee/scim/lib/protocol/normalize' +import { isRecord, isScimPasswordAttribute } from '@/ee/scim/lib/protocol/normalize' /** Attributes Sim models itself; everything else is preserved under `extra`. */ const MODELLED_USER_KEYS = new Set([ @@ -82,7 +82,7 @@ function formatName( function collectExtra(body: ScimUserWriteParsed): Record | undefined { const extra: Record = {} for (const [key, value] of Object.entries(body)) { - if (MODELLED_USER_KEYS.has(key.toLowerCase())) continue + if (MODELLED_USER_KEYS.has(key.toLowerCase()) || isScimPasswordAttribute(key)) continue extra[key] = value } return Object.keys(extra).length > 0 ? extra : undefined diff --git a/apps/sim/ee/scim/lib/protocol/filter.test.ts b/apps/sim/ee/scim/lib/protocol/filter.test.ts index 48736770cad..28b5224cc43 100644 --- a/apps/sim/ee/scim/lib/protocol/filter.test.ts +++ b/apps/sim/ee/scim/lib/protocol/filter.test.ts @@ -36,7 +36,16 @@ describe('parseUserFilter', () => { it('parses the work-email filtered path Entra sends', () => { expect(parseUserFilter('emails[type eq "work"].value eq "ada@acme.test"')).toEqual([ - { field: 'email', value: 'ada@acme.test' }, + { field: 'workEmail', value: 'ada@acme.test' }, + ]) + }) + + it('keeps all-email and primary-only matching distinct from work-email matching', () => { + expect(parseUserFilter('emails.value eq "ada@home.test"')).toEqual([ + { field: 'email', value: 'ada@home.test' }, + ]) + expect(parseUserFilter('emails[primary eq true].value eq "ada@home.test"')).toEqual([ + { field: 'primaryEmail', value: 'ada@home.test' }, ]) }) @@ -78,6 +87,11 @@ describe('parseUserFilter', () => { expect(parseUserFilter('active eq false')).toEqual([{ field: 'active', value: 'false' }]) }) + it('normalizes quoted boolean values and refuses other strings', () => { + expect(parseUserFilter('active eq "False"')).toEqual([{ field: 'active', value: 'false' }]) + expect(scimTypeOf(() => parseUserFilter('active eq "not-a-boolean"'))).toBe('invalidFilter') + }) + it('refuses an unquoted value', () => { expect(scimTypeOf(() => parseUserFilter('userName eq ada'))).toBe('invalidFilter') }) diff --git a/apps/sim/ee/scim/lib/protocol/filter.ts b/apps/sim/ee/scim/lib/protocol/filter.ts index 30b3c6bfbc8..e2270f7aa3b 100644 --- a/apps/sim/ee/scim/lib/protocol/filter.ts +++ b/apps/sim/ee/scim/lib/protocol/filter.ts @@ -1,6 +1,6 @@ import { SCIM_MAX_FILTER_TERMS } from '@/ee/scim/lib/protocol/constants' import { invalidFilter } from '@/ee/scim/lib/protocol/errors' -import { normalizeAttributePath } from '@/ee/scim/lib/protocol/normalize' +import { normalizeAttributePath, normalizeScimBoolean } from '@/ee/scim/lib/protocol/normalize' /** * The filter grammar this server accepts, which is the subset the provisioning @@ -19,7 +19,14 @@ import { normalizeAttributePath } from '@/ee/scim/lib/protocol/normalize' */ /** Attributes a User filter may name, mapped to the field the repository knows. */ -export type ScimUserFilterField = 'id' | 'userName' | 'externalId' | 'email' | 'active' +export type ScimUserFilterField = + | 'id' + | 'userName' + | 'externalId' + | 'email' + | 'workEmail' + | 'primaryEmail' + | 'active' /** Attributes a Group filter may name. */ export type ScimGroupFilterField = 'id' | 'displayName' | 'externalId' @@ -34,8 +41,8 @@ const USER_FILTER_FIELDS: Record = { username: 'userName', externalid: 'externalId', 'emails.value': 'email', - 'emails[type eq "work"].value': 'email', - 'emails[primary eq true].value': 'email', + 'emails[type eq "work"].value': 'workEmail', + 'emails[primary eq true].value': 'primaryEmail', active: 'active', } @@ -177,6 +184,13 @@ function parseTerm(term: string): { attribute: string; value: string } { throw invalidFilter('Filter values must be quoted strings') } + if (normalizeAttributePath(attribute).toLowerCase() === 'active') { + const active = normalizeScimBoolean(value) + if (typeof active !== 'boolean') + throw invalidFilter('active must be compared with true or false') + return { attribute, value: String(active) } + } + return { attribute, value } } diff --git a/apps/sim/ee/scim/lib/protocol/normalize.ts b/apps/sim/ee/scim/lib/protocol/normalize.ts index 7d22fc0d559..41e898aea2d 100644 --- a/apps/sim/ee/scim/lib/protocol/normalize.ts +++ b/apps/sim/ee/scim/lib/protocol/normalize.ts @@ -69,6 +69,11 @@ export function normalizeAttributePath(path: string): string { return value } +/** Passwords are write-only, including case variants and core-schema-qualified names. */ +export function isScimPasswordAttribute(path: string): boolean { + return normalizeAttributePath(path).toLowerCase() === 'password' +} + /** * Microsoft's classic schema markers, sent by older provisioning jobs alongside * the core URNs. They carry no attributes and are never stored or returned. diff --git a/apps/sim/ee/scim/lib/protocol/resources.test.ts b/apps/sim/ee/scim/lib/protocol/resources.test.ts index 42a4db8db4b..31e1e2014d6 100644 --- a/apps/sim/ee/scim/lib/protocol/resources.test.ts +++ b/apps/sim/ee/scim/lib/protocol/resources.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { scimUserResourceSchema } from '@/lib/api/contracts/scim' +import { scimGroupResourceSchema, scimUserResourceSchema } from '@/lib/api/contracts/scim' import { SCIM_MAX_PAGE_SIZE } from '@/ee/scim/lib/protocol/constants' import type { ScimError } from '@/ee/scim/lib/protocol/errors' import { @@ -10,6 +10,7 @@ import { projectionWants, projectResource, resolvePage, + toGroupResource, toUserResource, } from '@/ee/scim/lib/protocol/resources' @@ -98,6 +99,28 @@ describe('toUserResource', () => { primary: true, }) }) + + it('never returns a password from legacy stored extra attributes', () => { + const base = userRow() + const resource = toUserResource( + { + ...base, + attributes: { + ...base.attributes, + extra: { + password: 'synthetic-password', + Password: 'synthetic-password', + 'urn:ietf:params:scim:schemas:core:2.0:User:password': 'synthetic-password', + title: 'Analyst', + }, + }, + }, + BASE_URL + ) + expect(resource.title).toBe('Analyst') + expect(JSON.stringify(resource)).not.toContain('synthetic-password') + expect(resource.schemas).toEqual(['urn:ietf:params:scim:schemas:core:2.0:User']) + }) }) describe('attribute projection', () => { @@ -124,6 +147,140 @@ describe('attribute projection', () => { expect(Object.keys(projected).sort()).toEqual(['id', 'meta', 'schemas', 'userName']) }) + it.each(['name.givenName', 'urn:ietf:params:scim:schemas:core:2.0:User:name.givenName'])( + 'returns the requested name sub-attribute %s', + (attributes) => { + const projected = projectResource( + toUserResource(userRow(), BASE_URL), + parseAttributeProjection({ attributes }) + ) + expect(projected.name).toEqual({ givenName: 'Ada' }) + expect(projected).not.toHaveProperty('emails') + expect(scimUserResourceSchema.safeParse(projected).success).toBe(true) + } + ) + + it('excludes a nested name attribute without dropping its siblings', () => { + const projected = projectResource( + toUserResource(userRow(), BASE_URL), + parseAttributeProjection({ excludedAttributes: 'name.formatted' }) + ) + expect(projected.name).toEqual({ givenName: 'Ada', familyName: 'Lovelace' }) + expect(scimUserResourceSchema.safeParse(projected).success).toBe(true) + }) + + it.each([ + 'userName.foo', + 'active.foo', + 'name.givenName.foo', + 'emails.value.foo', + 'groups.value.foo', + 'urn:ietf:params:scim:schemas:core:2.0:User:USERNAME.foo', + 'urn:okta:sim:2.0:user:custom:costCenter.foo', + 'urn:okta:sim:2.0:user:custom:tags.foo', + ])('does not return scalar values for a nonexistent descendant %s', (attributes) => { + const base = userRow() + const resource = toUserResource( + { + ...base, + attributes: { + ...base.attributes, + extra: { 'urn:okta:sim:2.0:user:custom': { costCenter: 'R&D', tags: ['staff'] } }, + }, + }, + BASE_URL + ) + const projected = projectResource(resource, parseAttributeProjection({ attributes })) + expect(Object.keys(projected).sort()).toEqual(['id', 'meta', 'schemas']) + expect(scimUserResourceSchema.safeParse(projected).success).toBe(true) + }) + + it('omits arrays with no matching sub-attributes while retaining valid selections', () => { + const projected = projectResource( + toUserResource(userRow(), BASE_URL), + parseAttributeProjection({ attributes: 'emails.unknown,name.givenName.foo,name.familyName' }) + ) + expect(projected).not.toHaveProperty('emails') + expect(projected.name).toEqual({ familyName: 'Lovelace' }) + }) + + it('keeps explicitly selected parents even when nonexistent descendants are also requested', () => { + const resource = toUserResource(userRow(), BASE_URL) + const projected = projectResource( + resource, + parseAttributeProjection({ + attributes: 'userName,userName.foo,name,name.givenName.foo,emails,emails.value.foo', + }) + ) + expect(projected.userName).toBe(resource.userName) + expect(projected.name).toEqual(resource.name) + expect(projected.emails).toEqual(resource.emails) + }) + + it('ignores exclusions of nonexistent scalar descendants', () => { + const resource = toUserResource(userRow(), BASE_URL) + const projected = projectResource( + resource, + parseAttributeProjection({ + excludedAttributes: 'userName.foo,name.givenName.foo,emails.value.foo', + }) + ) + expect(projected).toEqual(resource) + }) + + it('keeps an explicitly selected empty multi-valued attribute', () => { + const resource = toUserResource({ ...userRow(), groups: [] }, BASE_URL) + const projected = projectResource(resource, parseAttributeProjection({ attributes: 'groups' })) + expect(projected.groups).toEqual([]) + }) + + it('projects each multi-valued entry and still loads requested group sub-attributes', () => { + const projection = parseAttributeProjection({ attributes: 'emails.value,groups.value' }) + const projected = projectResource(toUserResource(userRow(), BASE_URL), projection) + expect(projectionWants(projection, 'groups')).toBe(true) + expect(projected.emails).toEqual([{ value: 'ada@acme.test' }]) + expect(projected.groups).toEqual([{ value: 'g1' }]) + expect(scimUserResourceSchema.safeParse(projected).success).toBe(true) + }) + + it('projects group member sub-attributes through the response contract', () => { + const projection = parseAttributeProjection({ attributes: 'members.value' }) + const resource = toGroupResource( + { + id: 'g1', + externalId: null, + displayName: 'Engineering', + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + members: [{ scimUserId: 'su1', displayName: 'Ada Lovelace' }], + }, + BASE_URL + ) + const projected = projectResource(resource, projection) + expect(projectionWants(projection, 'members')).toBe(true) + expect(projected.members).toEqual([{ value: 'su1' }]) + expect(scimGroupResourceSchema.safeParse(projected).success).toBe(true) + }) + + it('projects a schema-qualified extension attribute', () => { + const schema = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' + const base = userRow() + const resource = toUserResource( + { + ...base, + attributes: { ...base.attributes, enterprise: { department: 'Maths', costCenter: '123' } }, + }, + BASE_URL + ) + const projected = projectResource( + resource, + parseAttributeProjection({ attributes: `${schema}:department` }) + ) + expect(projected[schema]).toEqual({ department: 'Maths' }) + expect(projected).not.toHaveProperty('name') + expect(scimUserResourceSchema.safeParse(projected).success).toBe(true) + }) + it('refuses combining an include list with an exclude list', () => { let scimType: string | undefined try { diff --git a/apps/sim/ee/scim/lib/protocol/resources.ts b/apps/sim/ee/scim/lib/protocol/resources.ts index 70a5e4a5513..db57fb45fdc 100644 --- a/apps/sim/ee/scim/lib/protocol/resources.ts +++ b/apps/sim/ee/scim/lib/protocol/resources.ts @@ -7,6 +7,11 @@ import { SCIM_USER_SCHEMA, } from '@/ee/scim/lib/protocol/constants' import { invalidValue } from '@/ee/scim/lib/protocol/errors' +import { + isRecord, + isScimPasswordAttribute, + normalizeAttributePath, +} from '@/ee/scim/lib/protocol/normalize' export interface ScimResourceMeta { resourceType: 'User' | 'Group' @@ -75,6 +80,10 @@ export interface UserResourceRow { export function toUserResource(row: UserResourceRow, baseUrl: string): ScimUserResource { const stored = row.attributes const primaryType = stored.emails.find((entry) => entry.primary)?.type + const extra: Record = {} + for (const [attribute, value] of Object.entries(stored.extra ?? {})) { + if (!isScimPasswordAttribute(attribute)) extra[attribute] = value + } /** * The address comes from the Sim account rather than the stored resource. The @@ -97,9 +106,9 @@ export function toUserResource(row: UserResourceRow, baseUrl: string): ScimUserR schemas: [ SCIM_USER_SCHEMA, ...(stored.enterprise ? [SCIM_ENTERPRISE_USER_SCHEMA] : []), - ...Object.keys(stored.extra ?? {}).filter(isSchemaUrn), + ...Object.keys(extra).filter(isSchemaUrn), ], - ...(stored.extra ?? {}), + ...extra, id: row.id, ...(row.externalId ? { externalId: row.externalId } : {}), userName: row.userName, @@ -214,7 +223,14 @@ function parseAttributeList(value: string | undefined): Set | undefined if (!value) return undefined const names = value .split(',') - .map((name) => name.trim().toLowerCase()) + .map((name) => { + const normalized = normalizeAttributePath(name).toLowerCase() + if (normalized === 'enterprise') return SCIM_ENTERPRISE_USER_SCHEMA.toLowerCase() + if (normalized.startsWith('enterprise.')) { + return `${SCIM_ENTERPRISE_USER_SCHEMA.toLowerCase()}:${normalized.slice('enterprise.'.length)}` + } + return normalized + }) .filter(Boolean) return names.length > 0 ? new Set(names) : undefined } @@ -235,11 +251,15 @@ export function parseAttributeProjection(query: { export function projectionWants(projection: ScimAttributeProjection, attribute: string): boolean { const name = attribute.toLowerCase() if (projection.exclude?.has(name)) return false - if (projection.include) return projection.include.has(name) + if (projection.include) { + return [...projection.include].some( + (selected) => + selected === name || selected.startsWith(`${name}.`) || selected.startsWith(`${name}:`) + ) + } return true } -/** Attributes every resource keeps regardless of the projection requested. */ /** An `extra` key that is itself a schema URN carries a provider extension the resource must declare. */ function isSchemaUrn(key: string): boolean { return key.startsWith('urn:') @@ -247,6 +267,44 @@ function isSchemaUrn(key: string): boolean { const ALWAYS_RETURNED = new Set(['schemas', 'id', 'meta']) +/** Projects complex attributes and each entry of multi-valued attributes using RFC attribute paths. */ +function projectAttribute( + value: unknown, + path: string, + projection: ScimAttributeProjection, + inheritedInclude: boolean, + extensionRoot = false +): unknown { + if (projection.exclude?.has(path)) return undefined + const included = inheritedInclude || projection.include?.has(path) === true + const separator = extensionRoot ? ':' : '.' + if ( + !included && + projection.include && + ![...projection.include].some((selected) => selected.startsWith(`${path}${separator}`)) + ) { + return undefined + } + if (Array.isArray(value)) { + const projected = value + .map((entry) => projectAttribute(entry, path, projection, included)) + .filter((entry) => entry !== undefined) + return included || projected.length > 0 ? projected : undefined + } + if (!isRecord(value)) return included ? value : undefined + const projected: Record = {} + for (const [key, nested] of Object.entries(value)) { + const selected = projectAttribute( + nested, + `${path}${separator}${key.toLowerCase()}`, + projection, + included + ) + if (selected !== undefined) projected[key] = selected + } + return Object.keys(projected).length > 0 ? projected : undefined +} + /** Drops attributes the request did not ask for. */ export function projectResource( resource: Resource, @@ -260,7 +318,14 @@ export function projectResource( projected[key] = value continue } - if (projectionWants(projection, name)) projected[key] = value + const selected = projectAttribute( + value, + name, + projection, + !projection.include, + isSchemaUrn(name) + ) + if (selected !== undefined) projected[key] = selected } return projected as Resource } diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.test.ts b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts index a88095491b7..7e10da6c0da 100644 --- a/apps/sim/ee/scim/lib/protocol/user-patch.test.ts +++ b/apps/sim/ee/scim/lib/protocol/user-patch.test.ts @@ -8,9 +8,8 @@ import { SCIM_PATCH_OP_SCHEMA } from '@/ee/scim/lib/protocol/constants' import { applyUserPatch } from '@/ee/scim/lib/protocol/user-patch' /** - * Every fixture here is a request shape taken from Okta's or Microsoft's own - * provisioning documentation, not an invented one. The point of the test is that - * what those two products actually send is accepted. + * Covers RFC 7644 operation semantics and the request variants documented by + * Okta and Microsoft Entra. */ function baseUser(overrides: Partial = {}): ScimUserAttributes { @@ -169,6 +168,139 @@ describe('applyUserPatch', () => { expect(next.enterprise?.department).toBe('Maths') }) + it.each(['name', 'NAME', 'urn:ietf:params:scim:schemas:core:2.0:User:name'])( + 'updates the modelled name through the complex path %s', + (path) => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'replace', path, value: { givenName: 'Augusta' } }]) + ) + expect(next.name).toEqual({ + givenName: 'Augusta', + familyName: 'Lovelace', + formatted: 'Augusta Lovelace', + }) + expect(next.extra?.name).toBeUndefined() + } + ) + + it('preserves an explicit formatted name in a complex name update', () => { + const { next } = applyUserPatch( + baseUser(), + parseOperations([ + { + op: 'add', + path: 'name', + value: { formatted: 'Countess Lovelace', givenName: 'Augusta' }, + }, + ]) + ) + expect(next.name).toEqual({ + givenName: 'Augusta', + familyName: 'Lovelace', + formatted: 'Countess Lovelace', + }) + }) + + it('updates a whole enterprise extension and preserves omitted attributes', () => { + const { next } = applyUserPatch( + baseUser({ enterprise: { department: 'Maths', employeeNumber: '123' } }), + parseOperations([ + { + op: 'replace', + path: 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', + value: { department: 'Engineering' }, + }, + ]) + ) + expect(next.enterprise).toEqual({ department: 'Engineering', employeeNumber: '123' }) + expect(next.extra?.enterprise).toBeUndefined() + }) + + it.each([ + 'password', + 'Password', + 'urn:ietf:params:scim:schemas:core:2.0:User:password', + 'URN:IETF:PARAMS:SCIM:SCHEMAS:CORE:2.0:USER:PASSWORD', + ])('never stores the write-only password path %s', (path) => { + const original = baseUser() + const targeted = applyUserPatch( + original, + parseOperations([{ op: 'replace', path, value: 'synthetic-password' }]) + ) + const pathless = applyUserPatch( + original, + parseOperations([{ op: 'add', value: { [path]: 'synthetic-password' } }]) + ) + expect(targeted).toEqual({ next: original, changed: false }) + expect(pathless).toEqual({ next: original, changed: false }) + }) + + it('removes a previously persisted password when applying another update', () => { + const { next, changed } = applyUserPatch( + baseUser({ extra: { Password: 'synthetic-password', title: 'Analyst' } }), + parseOperations([{ op: 'replace', path: 'active', value: true }]) + ) + expect(changed).toBe(true) + expect(next.extra).toEqual({ title: 'Analyst' }) + }) + + it('updates a custom extension through Entra’s qualified attribute path', () => { + const schema = 'urn:ietf:params:scim:schemas:extension:CustomExtensionName:2.0:User' + const { next } = applyUserPatch( + baseUser({ extra: { [schema]: { tag: 'old', other: 'keep' } } }), + parseOperations([{ op: 'Replace', path: `${schema}:tag`, value: 'new' }]) + ) + expect(next.extra).toEqual({ [schema]: { tag: 'new', other: 'keep' } }) + }) + + it('adds a custom extension attribute on its first PATCH', () => { + const schema = 'urn:ietf:params:scim:schemas:extension:CustomExtensionName:2.0:User' + const { next } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'add', path: `${schema}:tag`, value: 'new' }]) + ) + expect(next.extra).toEqual({ [schema]: { tag: 'new' } }) + }) + + it('merges a path-less custom extension then removes one nested attribute', () => { + const schema = 'urn:okta:sim:2.0:user:custom' + const { next } = applyUserPatch( + baseUser({ extra: { [schema]: { costCenter: 'R&D' } } }), + parseOperations([ + { op: 'replace', value: { [schema]: { department: 'Engineering' } } }, + { op: 'remove', path: `${schema}:costCenter` }, + ]) + ) + expect(next.extra?.[schema]).toEqual({ department: 'Engineering', costCenter: undefined }) + }) + + it('accepts a new extension object in a path-less PATCH', () => { + const schema = 'urn:okta:sim:2.0:user:custom' + const { next } = applyUserPatch( + baseUser(), + parseOperations([{ op: 'add', value: { [schema]: { costCenter: 'R&D' } } }]) + ) + expect(next.extra).toEqual({ [schema]: { costCenter: 'R&D' } }) + }) + + it('removes a custom extension by its exact schema path', () => { + const schema = 'urn:okta:sim:2.0:user:custom' + const { next } = applyUserPatch( + baseUser({ extra: { [schema]: { costCenter: 'R&D' }, title: 'Analyst' } }), + parseOperations([{ op: 'remove', path: schema }]) + ) + expect(next.extra).toEqual({ title: 'Analyst' }) + }) + + it('removes only the addressed sub-attribute of a typed complex attribute', () => { + const { next } = applyUserPatch( + baseUser({ extra: { addresses: [{ type: 'work', locality: 'London', country: 'GB' }] } }), + parseOperations([{ op: 'remove', path: 'addresses[type eq "work"].locality' }]) + ) + expect(next.extra?.addresses).toEqual([{ type: 'work', locality: undefined, country: 'GB' }]) + }) + it('reports no change when a patch re-sends what is already stored', () => { const { changed } = applyUserPatch( baseUser(), diff --git a/apps/sim/ee/scim/lib/protocol/user-patch.ts b/apps/sim/ee/scim/lib/protocol/user-patch.ts index ce97b1459f6..073995f69b0 100644 --- a/apps/sim/ee/scim/lib/protocol/user-patch.ts +++ b/apps/sim/ee/scim/lib/protocol/user-patch.ts @@ -3,6 +3,7 @@ import type { ScimPatchOperation } from '@/lib/api/contracts/scim' import { invalidPath, invalidValue, mutability, noTarget } from '@/ee/scim/lib/protocol/errors' import { isRecord, + isScimPasswordAttribute, normalizeAttributePath, normalizeScimBoolean, unwrapSingleElement, @@ -113,7 +114,8 @@ function applyOperation( user: ScimUserAttributes, op: 'add' | 'replace' | 'remove', rawPath: string, - value: unknown + value: unknown, + resourceAttribute = false ): void { const path = normalizeAttributePath(rawPath) const key = path.toLowerCase() @@ -135,6 +137,23 @@ function applyOperation( } switch (key) { + case 'password': + return + + case 'name': + case 'enterprise': { + if (op === 'remove') { + if (key === 'name') user.name = { formatted: user.userName } + else user.enterprise = undefined + return + } + if (!isRecord(value)) throw invalidValue(`${path} requires an object value`) + for (const [sub, nested] of sortFormattedLast(Object.entries(value))) { + applyOperation(user, op, `${path}.${sub}`, nested) + } + return + } + case 'active': user.active = op === 'remove' ? true : requireBoolean(value, 'active') return @@ -247,13 +266,41 @@ function applyOperation( default: if (key.startsWith('meta')) throw mutability(`${rawPath} is read-only`) - applyExtraOperation(user, op, path, value) + applyExtraOperation(user, op, path, value, resourceAttribute) } } /** `attr`, `attr.sub`, or `attr[type eq "x"].sub` on an attribute Sim does not model. */ const EXTRA_PATH_PATTERN = - /^(?[A-Za-z][\w-]*)(?:\[\s*type\s+eq\s+(?"|')?(?[^"'\]]+)\k?\s*\])?(?:\.(?[A-Za-z][\w-]*))?$/ + /^(?[A-Za-z][\w-]*)(?:\[\s*type\s+eq\s+(?"|')?(?[^"'\]]+)\k?\s*\])?(?:\.(?[A-Za-z][\w-]*))?$/i + +/** Uses the resource's schema keys to distinguish an extension from one of its attributes. */ +function extensionTarget( + extra: Record, + path: string, + resourceAttribute: boolean, + value: unknown +): { schema: string; attribute?: string } | undefined { + if (!path.toLowerCase().startsWith('urn:')) return undefined + const lowered = path.toLowerCase() + const existing = Object.keys(extra) + .filter( + (schema) => + schema.toLowerCase().startsWith('urn:') && + (lowered === schema.toLowerCase() || lowered.startsWith(`${schema.toLowerCase()}:`)) + ) + .sort((left, right) => right.length - left.length)[0] + if (existing) { + return { + schema: existing, + ...(path.length > existing.length ? { attribute: path.slice(existing.length + 1) } : {}), + } + } + if (resourceAttribute && isRecord(value)) return { schema: path } + const separator = path.lastIndexOf(':') + if (separator <= 'urn:'.length) throw invalidPath(`User PATCH path ${path} is not supported`) + return { schema: path.slice(0, separator), attribute: path.slice(separator + 1) } +} /** * Applies an operation to an attribute Sim does not model. @@ -268,29 +315,63 @@ function applyExtraOperation( user: ScimUserAttributes, op: 'add' | 'replace' | 'remove', path: string, + value: unknown, + resourceAttribute: boolean +): void { + user.extra ??= {} + const extension = extensionTarget(user.extra, path, resourceAttribute, value) + if (extension) { + if (!extension.attribute) { + if (op === 'remove') delete user.extra[extension.schema] + else { + if (!isRecord(value)) throw invalidValue(`${path} requires an object value`) + const current = user.extra[extension.schema] + user.extra[extension.schema] = { ...(isRecord(current) ? current : {}), ...value } + } + return + } + const current = user.extra[extension.schema] + const attributes = isRecord(current) ? { ...current } : {} + applyExtraAttribute(attributes, op, extension.attribute, value) + user.extra[extension.schema] = attributes + return + } + applyExtraAttribute(user.extra, op, path, value) +} + +/** Applies a simple or typed complex path inside either the core resource or an extension. */ +function applyExtraAttribute( + attributes: Record, + op: 'add' | 'replace' | 'remove', + path: string, value: unknown ): void { const match = path.match(EXTRA_PATH_PATTERN) if (!match?.groups) throw invalidPath(`User PATCH path ${path} is not supported`) - const { attribute, type, sub } = match.groups - user.extra ??= {} + const { type, sub } = match.groups + const attribute = + Object.keys(attributes).find( + (key) => key.toLowerCase() === match.groups?.attribute.toLowerCase() + ) ?? match.groups.attribute if (!type && !sub) { - if (op === 'remove') user.extra[attribute] = undefined - else user.extra[attribute] = value + if (op === 'remove') attributes[attribute] = undefined + else attributes[attribute] = value return } if (type) { - const list = Array.isArray(user.extra[attribute]) ? [...user.extra[attribute]] : [] + const list = Array.isArray(attributes[attribute]) ? [...attributes[attribute]] : [] const index = list.findIndex( (entry) => isRecord(entry) && String(entry.type).toLowerCase() === type.toLowerCase() ) - if (op === 'remove') { + if (op === 'remove' && !sub) { if (index !== -1) list.splice(index, 1) } else if (sub) { + if (op === 'remove' && index === -1) return const current = index !== -1 && isRecord(list[index]) ? list[index] : { type } - const next = { ...current, [sub]: value } + const key = Object.keys(current).find((key) => key.toLowerCase() === sub.toLowerCase()) ?? sub + const next = { ...current, [key]: op === 'remove' ? undefined : value } if (index === -1) list.push(next) else list[index] = next } else if (isRecord(value)) { @@ -299,14 +380,14 @@ function applyExtraOperation( } else { throw invalidValue(`${path} requires an object value`) } - user.extra[attribute] = list + attributes[attribute] = list return } - const current = isRecord(user.extra[attribute]) ? { ...user.extra[attribute] } : {} - if (op === 'remove') current[sub as string] = undefined - else current[sub as string] = value - user.extra[attribute] = current + const current = isRecord(attributes[attribute]) ? { ...attributes[attribute] } : {} + const key = Object.keys(current).find((key) => key.toLowerCase() === sub.toLowerCase()) ?? sub + current[key] = op === 'remove' ? undefined : value + attributes[attribute] = current } /** @@ -355,6 +436,11 @@ export function applyUserPatch( operations: readonly ScimPatchOperation[] ): UserPatchOutcome { const next = structuredClone(current) + if (next.extra) { + for (const attribute of Object.keys(next.extra)) { + if (isScimPasswordAttribute(attribute)) delete next.extra[attribute] + } + } for (const operation of operations) { if (operation.op === 'remove' && !operation.path) { @@ -372,19 +458,7 @@ export function applyUserPatch( * arrived as its own operation. */ for (const [attribute, nested] of sortFormattedLast(Object.entries(value))) { - /** - * RFC 7644's canonical form nests complex attributes — `{"name": {"givenName": …}}` - * and the enterprise extension keyed by its URN — so each sub-attribute is - * dispatched by its dotted path. - */ - const normalized = normalizeAttributePath(attribute).toLowerCase() - if (isRecord(nested) && (normalized === 'name' || normalized === 'enterprise')) { - for (const [sub, subValue] of sortFormattedLast(Object.entries(nested))) { - applyOperation(next, operation.op, `${normalized}.${sub}`, subValue) - } - continue - } - applyOperation(next, operation.op, attribute, nested) + applyOperation(next, operation.op, attribute, nested, true) } continue } diff --git a/apps/sim/ee/scim/lib/reconcile/job.test.ts b/apps/sim/ee/scim/lib/reconcile/job.test.ts index e06c7d24c32..4c154190c82 100644 --- a/apps/sim/ee/scim/lib/reconcile/job.test.ts +++ b/apps/sim/ee/scim/lib/reconcile/job.test.ts @@ -11,8 +11,22 @@ const mocks = vi.hoisted(() => ({ reconcileBatch: vi.fn(), listScimUserIds: vi.fn(), prune: vi.fn(), + acquireLock: vi.fn(), + listGroups: vi.fn(), + autoMap: vi.fn(), + settleGroups: vi.fn(), })) +vi.mock('@/lib/billing/organizations/membership', () => ({ + acquireOrganizationMutationLock: mocks.acquireLock, +})) +vi.mock('@/ee/scim/lib/projection/auto-map', () => ({ + autoMapPermissionGroupByName: mocks.autoMap, + settleMappedPermissionGroupsExplicit: mocks.settleGroups, +})) +vi.mock('@/ee/scim/lib/repository/groups', () => ({ + listScimGroupsForReconcile: mocks.listGroups, +})) vi.mock('@sim/utils/id', () => ({ generateId: () => 'run-1' })) vi.mock('@/ee/scim/lib/entitlement', () => ({ isScimEntitledForOrganization: mocks.isEntitled, @@ -79,6 +93,8 @@ describe('reconcileConnection', () => { mocks.prune.mockResolvedValue(undefined) mocks.reconcileBatch.mockResolvedValue(delta()) mocks.listScimUserIds.mockResolvedValue([]) + mocks.listGroups.mockResolvedValue([]) + mocks.autoMap.mockResolvedValue('mapped') }) afterEach(() => { @@ -174,6 +190,48 @@ describe('reconcileConnection', () => { }) }) + it('matches pre-existing groups before projecting users when automatic matching is enabled', async () => { + grantLease() + const settings = { autoMapPermissionGroupsByName: true } + const groups = [{ id: 'group-1', displayName: 'Engineering', orderKey: 'group-key-1' }] + mocks.listGroups.mockResolvedValueOnce(groups).mockResolvedValueOnce([]) + queueTableRows(scimConnection, [{ status: 'active', token: 'run-1', settings }]) + queueTableRows(scimConnection, [{ status: 'active', token: 'run-1', settings }]) + mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1'])).mockResolvedValueOnce([]) + stageBatch('run-1', settings) + + await reconcileConnection({ ...connection, settings }) + + expect(mocks.autoMap).toHaveBeenCalledWith(db, { + organizationId: 'org-1', + scimGroupId: 'group-1', + displayName: 'Engineering', + }) + expect(mocks.listGroups).toHaveBeenNthCalledWith(2, db, { + connectionId: 'conn-1', + afterOrderKey: 'group-key-1', + limit: 25, + }) + expect(mocks.settleGroups).toHaveBeenCalledWith(db, { + organizationId: 'org-1', + scimGroupId: 'group-1', + }) + expect(mocks.autoMap.mock.invocationCallOrder[0]).toBeLessThan( + mocks.reconcileBatch.mock.invocationCallOrder[0] + ) + }) + + it('stops automatic matching when the rule was disabled after the pass was queued', async () => { + grantLease() + queueTableRows(scimConnection, [{ status: 'active', token: 'run-1', settings: {} }]) + await reconcileConnection({ + ...connection, + settings: { autoMapPermissionGroupsByName: true }, + }) + expect(mocks.listGroups).not.toHaveBeenCalled() + expect(mocks.autoMap).not.toHaveBeenCalled() + }) + it('falls back to the settings the due query returned when the row cannot be re-read', async () => { grantLease() mocks.listScimUserIds.mockResolvedValueOnce(page(['su-1'])).mockResolvedValueOnce([]) diff --git a/apps/sim/ee/scim/lib/reconcile/job.ts b/apps/sim/ee/scim/lib/reconcile/job.ts index de6973e71ce..18e44d5ba1d 100644 --- a/apps/sim/ee/scim/lib/reconcile/job.ts +++ b/apps/sim/ee/scim/lib/reconcile/job.ts @@ -3,11 +3,17 @@ import { type ScimConnectionSettings, scimConnection } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { and, eq, isNull, lt, or, sql } from 'drizzle-orm' +import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership' import { isScimEntitledForOrganization } from '@/ee/scim/lib/entitlement' +import { + autoMapPermissionGroupByName, + settleMappedPermissionGroupsExplicit, +} from '@/ee/scim/lib/projection/auto-map' import { PROJECTION_BATCH_SIZE, reconcileUsersProjectionInBatches, } from '@/ee/scim/lib/projection/reconcile-user' +import { listScimGroupsForReconcile } from '@/ee/scim/lib/repository/groups' import { listScimUserIds } from '@/ee/scim/lib/repository/users' import { pruneScimRequestLog } from '@/ee/scim/lib/request-log' @@ -37,11 +43,6 @@ const LEASE_TTL_MS = 15 * 60 * 1000 */ const RECONCILE_INTERVAL_MS = 50 * 60 * 1000 -/** - * Users reconciled per transaction. The organization lock is held for the whole - * batch, so it is kept small enough that a tenant's own writes never wait long. - */ - export interface ScimReconcileReport { connectionId: string reconciledUsers: number @@ -79,11 +80,58 @@ async function holdsLease(connectionId: string, runId: string): Promise const [row] = await db .select({ token: scimConnection.reconcileLockToken }) .from(scimConnection) - .where(eq(scimConnection.id, connectionId)) + .where(and(eq(scimConnection.id, connectionId), eq(scimConnection.status, 'active'))) .limit(1) return row?.token === runId } +/** Matches existing groups before projecting users, so enabling matching does not require a directory rename. */ +async function reconcileExistingGroupNames( + connection: { id: string; organizationId: string }, + runId: string +): Promise { + let cursor: string | undefined + for (;;) { + const batch = await db.transaction(async (tx) => { + await acquireOrganizationMutationLock(tx, connection.organizationId) + const [fresh] = await tx + .select({ + status: scimConnection.status, + token: scimConnection.reconcileLockToken, + settings: scimConnection.settings, + }) + .from(scimConnection) + .where(eq(scimConnection.id, connection.id)) + .limit(1) + if (fresh?.status !== 'active' || fresh.token !== runId) return { stopped: true } + if (!fresh.settings.autoMapPermissionGroupsByName) return { stopped: false } + + const groups = await listScimGroupsForReconcile(tx, { + connectionId: connection.id, + afterOrderKey: cursor, + limit: PROJECTION_BATCH_SIZE, + }) + for (const group of groups) { + const mapped = await autoMapPermissionGroupByName(tx, { + organizationId: connection.organizationId, + scimGroupId: group.id, + displayName: group.displayName, + }) + if (mapped === 'mapped') { + await settleMappedPermissionGroupsExplicit(tx, { + organizationId: connection.organizationId, + scimGroupId: group.id, + }) + } + } + return { stopped: false, cursor: groups.at(-1)?.orderKey } + }) + if (batch.stopped) return false + if (!batch.cursor) return true + cursor = batch.cursor + } +} + /** Releases the lease; the watermark advances only when the pass finished, so a failed batch is retried next hour. */ async function releaseLease( connectionId: string, @@ -151,6 +199,12 @@ export async function reconcileConnection(connection: { try { /** Pruned before the pass, so a connection whose pass keeps failing still keeps its log bounded. */ await pruneScimRequestLog(connection.id) + if ( + connection.settings.autoMapPermissionGroupsByName && + !(await reconcileExistingGroupNames(connection, runId)) + ) { + return null + } let cursor: string | undefined for (;;) { const page = await listScimUserIds(db, { diff --git a/apps/sim/ee/scim/lib/repository/groups.ts b/apps/sim/ee/scim/lib/repository/groups.ts index 5f381d071c8..a9bdc1aae84 100644 --- a/apps/sim/ee/scim/lib/repository/groups.ts +++ b/apps/sim/ee/scim/lib/repository/groups.ts @@ -1,7 +1,7 @@ import { scimGroup, scimGroupMember, scimUser } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' -import { and, asc, count, eq, inArray, type SQL, sql } from 'drizzle-orm' +import { and, asc, count, eq, gt, inArray, type SQL, sql } from 'drizzle-orm' import type { DbOrTx } from '@/lib/db/types' import type { ScimFilterTerm, ScimGroupFilterField } from '@/ee/scim/lib/protocol/filter' import { buildOrderKey } from '@/ee/scim/lib/repository/users' @@ -53,6 +53,24 @@ export async function findScimGroupById( return row ?? null } +/** Stable, bounded pages for remapping groups that arrived before automatic matching was enabled. */ +export async function listScimGroupsForReconcile( + tx: DbOrTx, + params: { connectionId: string; afterOrderKey?: string; limit: number } +): Promise> { + return tx + .select({ id: scimGroup.id, displayName: scimGroup.displayName, orderKey: scimGroup.orderKey }) + .from(scimGroup) + .where( + and( + eq(scimGroup.connectionId, params.connectionId), + params.afterOrderKey ? gt(scimGroup.orderKey, params.afterOrderKey) : undefined + ) + ) + .orderBy(asc(scimGroup.orderKey)) + .limit(params.limit) +} + export async function pageScimGroups( tx: DbOrTx, params: { diff --git a/apps/sim/ee/scim/lib/repository/users.ts b/apps/sim/ee/scim/lib/repository/users.ts index 6f440b0c5ca..fc44fcc4e78 100644 --- a/apps/sim/ee/scim/lib/repository/users.ts +++ b/apps/sim/ee/scim/lib/repository/users.ts @@ -36,10 +36,31 @@ function userFilterCondition(term: ScimFilterTerm): SQL | u return eq(scimUser.userName, term.value.toLowerCase()) case 'externalId': return eq(scimUser.externalId, term.value) - case 'email': + case 'primaryEmail': return sql`lower(trim(${user.email})) = ${normalizeEmail(term.value)}` + case 'email': + case 'workEmail': { + const address = normalizeEmail(term.value) + const matchesStoredEmail = sql`exists ( + select 1 from jsonb_array_elements(${scimUser.attributes} -> 'emails') as stored_email(item) + where ${term.field === 'workEmail' ? sql`lower(stored_email.item ->> 'type') = 'work' and` : sql``} + ( + (stored_email.item ->> 'primary' = 'true' and lower(trim(${user.email})) = ${address}) + or ( + stored_email.item ->> 'primary' is distinct from 'true' + and lower(trim(stored_email.item ->> 'value')) <> lower(trim(${user.email})) + and lower(trim(stored_email.item ->> 'value')) = ${address} + ) + ) + )` + return term.field === 'email' + ? sql`(lower(trim(${user.email})) = ${address} or ${matchesStoredEmail})` + : matchesStoredEmail + } case 'active': - return eq(scimUser.active, term.value.toLowerCase() === 'true') + return term.value.toLowerCase() === 'true' + ? sql`(${scimUser.active} = true and ${user.suspendedAt} is null)` + : sql`(${scimUser.active} = false or ${user.suspendedAt} is not null)` } } diff --git a/apps/sim/ee/sso/components/sso-provider-settings.tsx b/apps/sim/ee/sso/components/sso-provider-settings.tsx new file mode 100644 index 00000000000..b1c43bf1ba6 --- /dev/null +++ b/apps/sim/ee/sso/components/sso-provider-settings.tsx @@ -0,0 +1,1127 @@ +'use client' + +import { useState } from 'react' +import { + Button, + Chip, + ChipCombobox, + ChipCopyInput, + ChipInput, + ChipSelect, + ChipSwitch, + ChipTextarea, + Expandable, + ExpandableContent, + Label, + Switch, + toast, +} from '@sim/emcn' +import { ChevronDown, Eye, EyeOff } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { saveDiscardActions } from '@/components/settings/save-discard-actions' +import type { SettingsAction } from '@/components/settings/settings-header' +import type { SsoProviderView, SsoRegistrationBody } from '@/lib/api/contracts/auth' +import { REDACTED_MARKER } from '@/lib/core/security/redaction' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { SettingsField } from '@/app/workspace/[workspaceId]/settings/components/settings-field' +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' +import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' +import { useSettingsUnsavedGuard } from '@/app/workspace/[workspaceId]/settings/hooks/use-settings-unsaved-guard' +import { SettingRow } from '@/ee/components/setting-row' +import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants' +import { useConfigureSSO } from '@/ee/sso/hooks/sso' + +const logger = createLogger('SSO') + +/** Claim names each protocol uses out of the box; shown as input placeholders. */ +const OIDC_DEFAULT_MAPPING = { id: 'sub', email: 'email', name: 'name', image: 'picture' } as const +const SAML_DEFAULT_MAPPING = { + id: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier', + email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name', +} as const + +const SAML_NAMEID_FORMATS = [ + { label: 'Provider default', value: '' }, + { + label: 'Email address', + value: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + }, + { label: 'Persistent', value: 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent' }, + { label: 'Transient', value: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient' }, + { label: 'Unspecified', value: 'urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified' }, +] as const + +const PROVIDER_ID_SUGGESTIONS = SSO_TRUSTED_PROVIDERS.map((id) => ({ label: id, value: id })) + +const CLIENT_SECRET_FIELD_ID = 'sso-client-secret' +/** Fixed width, so the mask never leaks how long the stored secret is. */ +const CLIENT_SECRET_MASK = '••••••••••••' + +interface ClientSecretFieldProps { + /** A secret is already saved, so the field opens as a masked fact rather than an input. */ + hasStoredSecret: boolean + /** Last four characters of the saved secret, when the API judged it safe to hint. */ + storedHint: string | null + isReplacing: boolean + onReplace: () => void + onCancelReplace: () => void + value: string + onChange: (value: string) => void + hasError: boolean +} + +/** + * A saved client secret is a fact, not an editable value — the browser never + * receives it. Rendering it as a static masked row with an explicit Replace + * action avoids the "will blank clear it?" ambiguity an empty input invites, and + * keeps a stray keystroke from arming a replacement. + */ +function ClientSecretField({ + hasStoredSecret, + storedHint, + isReplacing, + onReplace, + onCancelReplace, + value, + onChange, + hasError, +}: ClientSecretFieldProps) { + const [isRevealed, setIsRevealed] = useState(false) + + if (hasStoredSecret && !isReplacing) { + return ( +
+ + Replace +
+ ) + } + + return ( +
+ { + e.target.removeAttribute('readOnly') + setIsRevealed(true) + }} + onBlurCapture={() => setIsRevealed(false)} + onChange={(e) => onChange(e.target.value)} + inputClassName={!isRevealed ? '[-webkit-text-security:disc]' : undefined} + error={hasError} + endAdornment={ + value ? ( + + ) : undefined + } + /> + {/** Not "Cancel" — the header already owns that label for discarding the + whole edit, and these two do very different things. */} + {hasStoredSecret && Keep saved} +
+ ) +} + +/** Reads a string from stored provider JSON, tolerating malformed legacy configurations. */ +function readProviderConfigString( + serialized: string | null | undefined, + field: string +): string | null { + if (!serialized) return null + try { + const config: unknown = JSON.parse(serialized) + return isRecordLike(config) && typeof config[field] === 'string' ? config[field] : null + } catch { + return null + } +} + +const DEFAULT_FORM_DATA = { + providerType: 'oidc' as 'oidc' | 'saml', + providerId: '', + issuerUrl: '', + domain: '', + clientId: '', + clientSecret: '', + scopes: 'openid,profile,email', + entryPoint: '', + cert: '', + callbackUrl: '', + audience: '', + wantAssertionsSigned: true, + idpMetadata: '', + mapId: '', + mapEmail: '', + mapName: '', + identifierFormat: '', + authorizationEndpoint: '', + tokenEndpoint: '', + jwksEndpoint: '', + jitProvisioningEnabled: true, +} + +interface SsoProviderSettingsProps { + organizationId: string + existingProvider?: SsoProviderView + active: boolean + onOpenDomains: () => void +} + +export function SsoProviderSettings({ + organizationId, + existingProvider, + active, + onOpenDomains, +}: SsoProviderSettingsProps) { + const existingJitProvisioningEnabled = existingProvider?.jitProvisioningEnabled ?? true + const configureSSOMutation = useConfigureSSO() + + const [isEditing, setIsEditing] = useState(false) + const [showAdvanced, setShowAdvanced] = useState(false) + const [showMapping, setShowMapping] = useState(false) + + const [formData, setFormData] = useState(DEFAULT_FORM_DATA) + const [originalFormData, setOriginalFormData] = useState(DEFAULT_FORM_DATA) + const [showErrors, setShowErrors] = useState(false) + + const [isReplacingClientSecret, setIsReplacingClientSecret] = useState(false) + + /** + * Editing an OIDC provider always means a secret is stored — the contract + * requires one to register, and the API returns only its sentinel, never the + * value. Leaving the field blank therefore means "keep it", not "clear it". + */ + const hasStoredClientSecret = isEditing && existingProvider?.providerType === 'oidc' + /** Last four characters of the saved secret, when the API judged it safe to hint. */ + const storedClientSecretHint = hasStoredClientSecret + ? readProviderConfigString(existingProvider?.oidcConfig, 'clientSecretHint') + : null + + const hasChanges = (Object.keys(formData) as (keyof typeof formData)[]).some( + (k) => formData[k] !== originalFormData[k] + ) + + useSettingsUnsavedGuard({ isDirty: hasChanges }) + + const validateProviderId = (value: string): string[] => { + if (!value || !value.trim()) return ['Provider ID is required.'] + if (!/^[-a-z0-9]+$/i.test(value.trim())) return ['Use letters, numbers, and dashes only.'] + return [] + } + + const validateIssuerUrl = (value: string): string[] => { + const out: string[] = [] + if (!value || !value.trim()) return ['Issuer URL is required.'] + try { + const url = new URL(value.trim()) + const isLocalhost = url.hostname === 'localhost' || url.hostname === '127.0.0.1' + if (url.protocol !== 'https:' && !isLocalhost) { + out.push('Issuer URL must use HTTPS.') + } + } catch { + out.push('Enter a valid issuer URL like https://your-identity-provider.com/oauth2/default') + } + return out + } + + const validateDomain = (value: string): string[] => { + const out: string[] = [] + if (!value || !value.trim()) return ['Domain is required.'] + if (/^https?:\/\//i.test(value.trim())) out.push('Do not include protocol (https://).') + if (!/^[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(value.trim())) + out.push('Enter a valid domain like company.com') + return out + } + + const validateRequired = (label: string, value: string): string[] => { + const out: string[] = [] + if (!value || !value.trim()) out.push(`${label} is required.`) + return out + } + + const validateAll = (data: typeof formData) => { + const newErrors: Record = { + providerType: [], + providerId: validateProviderId(data.providerId), + issuerUrl: validateIssuerUrl(data.issuerUrl), + domain: validateDomain(data.domain), + clientId: [], + clientSecret: [], + entryPoint: [], + cert: [], + scopes: [], + callbackUrl: [], + audience: [], + } + + const providerType = data.providerType || 'oidc' + + if (providerType === 'oidc') { + newErrors.clientId = validateRequired('Client ID', data.clientId) + /** Skipped only while the stored secret is being kept. Once Replace is clicked the field is a real input again, so a blank or whitespace-only value has to fail rather than quietly overwrite a working secret. */ + newErrors.clientSecret = + hasStoredClientSecret && !isReplacingClientSecret + ? [] + : validateRequired('Client Secret', data.clientSecret) + if (!data.scopes || !data.scopes.trim()) { + newErrors.scopes = ['Scopes are required for OIDC providers'] + } + } else if (providerType === 'saml') { + newErrors.entryPoint = validateIssuerUrl(data.entryPoint || '') + if (!newErrors.entryPoint.length && !data.entryPoint) { + newErrors.entryPoint = ['Entry Point URL is required for SAML providers'] + } + newErrors.cert = validateRequired('Certificate', data.cert) + } + + return newErrors + } + + const errors = validateAll(formData) + + const hasAnyErrors = (errs: Record) => + Object.values(errs).some((l) => l.length > 0) + + const handleDiscard = () => { + setIsEditing(false) + setFormData(DEFAULT_FORM_DATA) + setOriginalFormData(DEFAULT_FORM_DATA) + setShowErrors(false) + setShowAdvanced(false) + setShowMapping(false) + setIsReplacingClientSecret(false) + } + + const handleSubmit = async (e?: React.FormEvent) => { + e?.preventDefault() + + setShowErrors(true) + const validation = validateAll(formData) + if (hasAnyErrors(validation)) { + if (validation.scopes.length > 0) setShowAdvanced(true) + return + } + + try { + const providerType = formData.providerType || 'oidc' + + const requestBody: SsoRegistrationBody = + providerType === 'oidc' + ? { + providerType: 'oidc', + providerId: formData.providerId, + issuer: formData.issuerUrl, + domain: formData.domain, + orgId: organizationId, + jitProvisioningEnabled: formData.jitProvisioningEnabled, + mapping: { + id: formData.mapId.trim() || OIDC_DEFAULT_MAPPING.id, + email: formData.mapEmail.trim() || OIDC_DEFAULT_MAPPING.email, + name: formData.mapName.trim() || OIDC_DEFAULT_MAPPING.name, + image: OIDC_DEFAULT_MAPPING.image, + }, + clientId: formData.clientId, + /** Blank on an edit means the admin did not retype it: send the sentinel so the server keeps the stored secret. Trimmed because a pasted secret often carries a trailing newline, and because a whitespace-only value must never be stored as the secret. */ + clientSecret: + hasStoredClientSecret && !formData.clientSecret.trim() + ? REDACTED_MARKER + : formData.clientSecret.trim(), + scopes: formData.scopes.split(',').map((s) => s.trim()), + ...(formData.authorizationEndpoint.trim() + ? { authorizationEndpoint: formData.authorizationEndpoint.trim() } + : {}), + ...(formData.tokenEndpoint.trim() + ? { tokenEndpoint: formData.tokenEndpoint.trim() } + : {}), + ...(formData.jwksEndpoint.trim() + ? { jwksEndpoint: formData.jwksEndpoint.trim() } + : {}), + } + : { + providerType: 'saml', + providerId: formData.providerId, + issuer: formData.issuerUrl, + domain: formData.domain, + orgId: organizationId, + jitProvisioningEnabled: formData.jitProvisioningEnabled, + mapping: { + id: formData.mapId.trim() || SAML_DEFAULT_MAPPING.id, + email: formData.mapEmail.trim() || SAML_DEFAULT_MAPPING.email, + name: formData.mapName.trim() || SAML_DEFAULT_MAPPING.name, + }, + entryPoint: formData.entryPoint, + cert: formData.cert, + wantAssertionsSigned: formData.wantAssertionsSigned, + ...(formData.callbackUrl ? { callbackUrl: formData.callbackUrl } : {}), + ...(formData.audience ? { audience: formData.audience } : {}), + ...(formData.idpMetadata ? { idpMetadata: formData.idpMetadata } : {}), + identifierFormat: formData.identifierFormat, + } + + await configureSSOMutation.mutateAsync(requestBody) + + logger.info('SSO provider configured', { providerId: formData.providerId }) + toast.success(isEditing ? 'SSO provider updated' : 'SSO provider configured') + setFormData(DEFAULT_FORM_DATA) + setOriginalFormData(DEFAULT_FORM_DATA) + setShowErrors(false) + setIsEditing(false) + setShowAdvanced(false) + setIsReplacingClientSecret(false) + } catch (err) { + const message = getErrorMessage(err, 'Unknown error occurred') + toast.error(message) + logger.error('Failed to configure SSO provider', { error: err }) + } + } + + const handleInputChange = (field: keyof typeof formData, value: string | boolean) => { + const next = { ...formData, [field]: value } + /** Claim names are protocol-specific, so an override must not survive a switch. */ + if (field === 'providerType') { + next.mapId = '' + next.mapEmail = '' + next.mapName = '' + setShowErrors(false) + } + + setFormData(next) + } + + const handleKeepSavedSecret = () => { + setIsReplacingClientSecret(false) + setFormData({ ...formData, clientSecret: '' }) + } + + const isSaml = formData.providerType === 'saml' + const mappingDefaults = isSaml ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING + const callbackUrl = + (isSaml && formData.callbackUrl) || + `${getBaseUrl()}/api/auth/${isSaml ? 'sso/saml2/callback' : 'sso/callback'}/${formData.providerId || existingProvider?.providerId || 'provider-id'}` + + const handleEdit = () => { + if (!existingProvider) return + + try { + let clientId = '' + let clientSecret = '' + let scopes = 'openid,profile,email' + let entryPoint = '' + let cert = '' + let callbackUrl = '' + let audience = '' + let wantAssertionsSigned = true + let idpMetadata = '' + /** Blank means "use the protocol default", so only carry over a stored value that differs — otherwise editing rewrites a default as an explicit override. */ + let mapping: { id?: string; email?: string; name?: string } = {} + let identifierFormat = '' + let authorizationEndpoint = '' + let tokenEndpoint = '' + let jwksEndpoint = '' + + if (existingProvider.providerType === 'oidc' && existingProvider.oidcConfig) { + const config = JSON.parse(existingProvider.oidcConfig) + clientId = config.clientId || '' + clientSecret = config.clientSecret === REDACTED_MARKER ? '' : config.clientSecret || '' + scopes = config.scopes?.join(',') || 'openid,profile,email' + mapping = config.mapping ?? {} + authorizationEndpoint = config.authorizationEndpoint || '' + tokenEndpoint = config.tokenEndpoint || '' + jwksEndpoint = config.jwksEndpoint || '' + } else if (existingProvider.providerType === 'saml' && existingProvider.samlConfig) { + const config = JSON.parse(existingProvider.samlConfig) + entryPoint = config.entryPoint || '' + cert = config.cert || '' + callbackUrl = config.callbackUrl || '' + audience = config.audience || '' + wantAssertionsSigned = config.wantAssertionsSigned ?? true + /** Two stored shapes: `{ metadata }` from the route, a bare string from older rows. Narrow on type, not truthiness — `{ metadata: '' }` is falsy at `.metadata` but truthy as an object, putting an object in a string field. */ + idpMetadata = + typeof config.idpMetadata === 'string' + ? config.idpMetadata + : (config.idpMetadata?.metadata ?? '') + mapping = config.mapping ?? {} + identifierFormat = config.identifierFormat || '' + } + + const defaults = + existingProvider.providerType === 'saml' ? SAML_DEFAULT_MAPPING : OIDC_DEFAULT_MAPPING + const overrideOf = (value: string | undefined, fallback: string) => + value && value !== fallback ? value : '' + + const snapshot = { + providerType: existingProvider.providerType ?? 'oidc', + providerId: existingProvider.providerId ?? '', + issuerUrl: existingProvider.issuer ?? '', + domain: existingProvider.domain ?? '', + clientId, + clientSecret, + scopes, + entryPoint, + cert, + callbackUrl, + audience, + wantAssertionsSigned, + idpMetadata, + mapId: overrideOf(mapping.id, defaults.id), + mapEmail: overrideOf(mapping.email, defaults.email), + mapName: overrideOf(mapping.name, defaults.name), + identifierFormat, + authorizationEndpoint, + tokenEndpoint, + jwksEndpoint, + jitProvisioningEnabled: existingProvider.jitProvisioningEnabled ?? true, + } + setFormData(snapshot) + setOriginalFormData(snapshot) + setIsEditing(true) + setShowErrors(false) + setShowAdvanced(false) + setIsReplacingClientSecret(false) + setShowMapping(Boolean(snapshot.mapId || snapshot.mapEmail || snapshot.mapName)) + } catch (err) { + logger.error('Failed to parse provider config', { error: err }) + toast.error('Failed to load provider configuration') + } + } + + if (existingProvider && !isEditing) { + const providerCallbackUrl = + (existingProvider.providerType === 'saml' && + readProviderConfigString(existingProvider.samlConfig, 'callbackUrl')) || + `${getBaseUrl()}/api/auth/${existingProvider.providerType === 'saml' ? 'sso/saml2/callback' : 'sso/callback'}/${existingProvider.providerId}` + + return ( +
+ {active && ( + + )} + + +
+ {existingProvider.providerId} + + {(existingProvider.providerType ?? 'oidc').toUpperCase()} + + {existingProvider.domain} + + {existingProvider.issuer} + + + + +

+ Configure this in your identity provider +

+
+ + {existingProvider.providerType === 'saml' && ( + + + + )} +
+
+ + + +

+ {existingJitProvisioningEnabled ? 'Automatic' : 'Invite only'} +

+

+ {existingJitProvisioningEnabled + ? 'New users join as Members and use a seat. Grant workspace access separately.' + : 'Invite or provision new members before they sign in. Existing members keep their access.'} +

+
+
+
+ ) + } + + return ( +
+ + + + + + {active && ( + void handleSubmit(), + onDiscard: handleDiscard, + }), + ]} + /> + )} + + {!existingProvider && ( +
+

+ Use a verified email domain for this connection. +

+ Manage domains +
+ )} + + +
+ + + handleInputChange('providerType', value as 'oidc' | 'saml') + } + options={[ + { label: 'OIDC', value: 'oidc' }, + { label: 'SAML', value: 'saml' }, + ]} + placeholder='Select provider type' + /> + + + 0 ? errors.providerId.join(' ') : undefined + } + > + {isEditing ? ( + <> + +

+ Cannot be changed after saving. +

+ + ) : ( + <> + handleInputChange('providerId', value)} + options={PROVIDER_ID_SUGGESTIONS} + placeholder='Select or enter a provider ID' + editable + /> +

+ Unique across Sim, e.g. acme-entra. Cannot be changed later. +

+ + )} +
+ + 0 ? errors.issuerUrl.join(' ') : undefined + } + > + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('issuerUrl', e.target.value)} + error={showErrors && errors.issuerUrl.length > 0} + /> + + + 0 ? errors.domain.join(' ') : undefined} + > + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('domain', e.target.value)} + error={showErrors && errors.domain.length > 0} + /> + + + {formData.providerType === 'oidc' ? ( + <> + 0 ? errors.clientId.join(' ') : undefined + } + > + e.target.removeAttribute('readOnly')} + onChange={(e) => handleInputChange('clientId', e.target.value)} + error={showErrors && errors.clientId.length > 0} + /> + + + 0 + ? errors.clientSecret.join(' ') + : undefined + } + > + setIsReplacingClientSecret(true)} + onCancelReplace={handleKeepSavedSecret} + value={formData.clientSecret} + onChange={(next) => handleInputChange('clientSecret', next)} + hasError={showErrors && errors.clientSecret.length > 0} + /> + + +
+ setShowAdvanced((value) => !value)} + rightIcon={ChevronDown} + aria-expanded={showAdvanced} + aria-controls='sso-advanced' + className='w-fit' + > + Advanced options + + + + +
+ 0 + ? errors.scopes.join(' ') + : undefined + } + > + handleInputChange('scopes', e.target.value)} + error={showErrors && errors.scopes.length > 0} + /> +

+ Comma-separated list of OIDC scopes to request +

+
+ + + handleInputChange('authorizationEndpoint', e.target.value) + } + /> + + + + handleInputChange('tokenEndpoint', e.target.value)} + /> + + + + handleInputChange('jwksEndpoint', e.target.value)} + /> +

+ Sim reads these from the issuer's discovery document. Set them only if + your provider does not publish one. +

+
+
+
+
+
+ + ) : ( + <> + 0 + ? errors.entryPoint.join(' ') + : undefined + } + > + handleInputChange('entryPoint', e.target.value)} + error={showErrors && errors.entryPoint.length > 0} + /> + + + 0 ? errors.cert.join(' ') : undefined} + > + handleInputChange('cert', e.target.value)} + className='min-h-20' + error={showErrors && errors.cert.length > 0} + rows={3} + /> + + +
+ setShowAdvanced((value) => !value)} + rightIcon={ChevronDown} + aria-expanded={showAdvanced} + aria-controls='sso-advanced' + className='w-fit' + > + Advanced options + + + + +
+ + handleInputChange('audience', e.target.value)} + /> + + + + handleInputChange('callbackUrl', e.target.value)} + /> + + +
+ + + handleInputChange('wantAssertionsSigned', checked) + } + /> +
+ + + handleInputChange('identifierFormat', value)} + options={[...SAML_NAMEID_FORMATS]} + placeholder='Provider default' + /> + + + + handleInputChange('idpMetadata', e.target.value)} + className='min-h-15' + rows={2} + /> + +
+
+
+
+ + )} + + + +

+ Configure this in your identity provider +

+
+ + {/** Sim publishes no SP metadata document; these are the values it would carry. */} + {isSaml && ( + + +

+ Use this as Sim's entity ID in your identity provider. +

+
+ )} + +
+ setShowMapping((value) => !value)} + rightIcon={ChevronDown} + aria-expanded={showMapping} + aria-controls='sso-mapping' + className='w-fit' + > + Attribute mapping + + + + +
+ + handleInputChange('mapEmail', e.target.value)} + /> + + + + handleInputChange('mapName', e.target.value)} + /> + + + + handleInputChange('mapId', e.target.value)} + /> +

+ Must be stable and unique per user — changing it later re-links accounts. +

+
+
+
+
+
+
+
+ + + + handleInputChange('jitProvisioningEnabled', value === 'automatic')} + aria-label='SSO member provisioning mode' + options={[ + { value: 'automatic', label: 'Automatic' }, + { value: 'invite-only', label: 'Invite only' }, + ]} + /> +

+ {formData.jitProvisioningEnabled + ? 'New users join as Members and use a seat. Grant workspace access separately.' + : 'Invite or provision new members before they sign in. Existing members keep their access.'} +

+
+
+ + ) +} diff --git a/apps/sim/ee/sso/components/sso-settings.test.tsx b/apps/sim/ee/sso/components/sso-settings.test.tsx index 2c96b29c160..f6941dba0fd 100644 --- a/apps/sim/ee/sso/components/sso-settings.test.tsx +++ b/apps/sim/ee/sso/components/sso-settings.test.tsx @@ -4,6 +4,7 @@ import { act, type ChangeEventHandler, type ReactNode } from 'react' import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' import { getErrorMessage } from '@sim/utils/errors' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' import { createRoot, type Root } from 'react-dom/client' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -27,7 +28,9 @@ vi.mock('@sim/emcn', () => ({ ), ChipCombobox: () =>
, - ChipCopyInput: ({ value }: { value?: string }) => , + ChipCopyInput: ({ value, id }: { value?: string; id?: string }) => ( + + ), ChipInput: ({ value, onChange, @@ -40,6 +43,29 @@ vi.mock('@sim/emcn', () => ({ placeholder?: string }) => , ChipSelect: () =>
, + ChipModalTabs: ({ + tabs, + value, + onChange, + }: { + tabs: Array<{ label: string; value: string }> + value: string + onChange: (value: string) => void + }) => ( +
+ {tabs.map((tab) => ( + + ))} +
+ ), ChipSwitch: ({ options, value, @@ -71,6 +97,7 @@ vi.mock('@sim/emcn', () => ({ }) =>