Skip to content

Commit 12e9b86

Browse files
waleedlatif1claude
andcommitted
fix(scim): certification pass — removal side effects, hourly sweep, dead surface, docs
- Organization removal no longer deletes personal API keys except when the directory deprovisions; a member removing themselves keeps their own session. Every other caller keeps the session revocation - The reconcile interval is shorter than the cron period, so the once-an-hour guarantee actually holds - Removed: defaultWorkspaceGrants (no client, no docs), the unused tombstone and delete helpers, parameters no caller varied, the duplicate discovery list helper; the routes barrel exports the SCIM builders; group cap raised to 5,000 - Docs: per-resource filters and limits, authentication failures absent from Activity, name matching and the permanent explicit-membership switch, default group not a target, credential expiry choices, SSO and both flags as prerequisites, what disabling the connection does Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DGXcwsHzYGp6pUDWRtJKpz
1 parent c10ea0b commit 12e9b86

30 files changed

Lines changed: 101 additions & 178 deletions

File tree

apps/docs/content/docs/platform/enterprise/scim.mdx

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Directory provisioning connects your identity provider to Sim over SCIM 2.0. You
1313
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.
1414

1515
<Callout type="info">
16-
Enterprise plans. Requires at least one [verified domain](/platform/enterprise/verified-domains) for your organization. Self-hosted deployments turn it on with `SCIM_ENABLED=true` and `NEXT_PUBLIC_SCIM_ENABLED=true`, alongside the [SSO variables](/platform/enterprise/sso#self-hosted-setup).
16+
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 turn it on with `SCIM_ENABLED=true` (the server) and `NEXT_PUBLIC_SCIM_ENABLED=true` (the settings page), alongside the [SSO variables](/platform/enterprise/sso#self-hosted-setup).
1717
</Callout>
1818

1919
## What it does
@@ -54,7 +54,7 @@ https://<your-sim-domain>/api/scim/v2
5454
<Step>
5555
### Issue a credential
5656

57-
Select **Issue credential**. The token appears once — copy it straight into your provider.
57+
Choose whether the credential expires (never, 90 days, or a year) and select **Issue credential**. The token appears once — copy it straight into your provider.
5858

5959
Two credentials 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.
6060
</Step>
@@ -121,7 +121,11 @@ Groups mean nothing to Sim until you say what they stand for. In **Settings →
121121
- a **workspace**, at Read, Write, or Admin
122122
- the **organization admin role**
123123

124-
A group can carry several mappings. When two groups grant the same workspace at different levels, the stronger one wins.
124+
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.
125+
126+
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.
127+
128+
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.
125129

126130
</Step>
127131

@@ -141,7 +145,7 @@ If you want the directory to be the only way in, enable **Disable just-in-time p
141145

142146
## Watching a sync
143147

144-
**Settings → SSO → Directory provisioning → Activity** lists recent 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.
148+
**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.
145149

146150
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.
147151

@@ -150,7 +154,9 @@ Sim also re-applies every group mapping once an hour, so drift cannot persist. Y
150154
- Base URL: `https://<your-sim-domain>/api/scim/v2`
151155
- Authentication: `Authorization: Bearer <credential>`
152156
- Resources: `/Users`, `/Groups`, plus `/ServiceProviderConfig`, `/ResourceTypes`, and `/Schemas`
153-
- Filters: `eq`, joined with `and`, on `id`, `userName`, `externalId`, `emails.value`, `active`, and `displayName`
157+
- 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`
158+
- Limits: 1,500 requests per minute per connection, 1 MB per request, 5,000 members per group
159+
- `userName` is stored and returned lower-cased; attributes Sim does not model are kept and returned as sent, and a PUT preserves ones it omits
154160
- Page size: up to 100 per request
155161

156162
<FAQ items={[
@@ -178,6 +184,10 @@ Sim also re-applies every group mapping once an hour, so drift cannot persist. Y
178184
question: "Does a deactivation sign someone out immediately?",
179185
answer: "Their sessions are deleted at once and their personal API keys stop authenticating immediately. The organization's security version changes at the same time, so a browser still holding a cached session cookie is re-checked on its next request and refused."
180186
},
187+
{
188+
question: "What happens to deactivated members if we turn provisioning off?",
189+
answer: "They stay deactivated. Disabling the connection stops the directory from making changes; it does not undo the ones it made. To restore someone without the directory, remove them from the organization and invite them again."
190+
},
181191
{
182192
question: "Can we still invite people manually?",
183193
answer: "Only with managed membership locking turned off. With it on, Sim refuses manual changes for provisioned members, because the directory would revert them on its next sync."

apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -443,6 +443,7 @@ export const DELETE = withRouteHandler(
443443
userId: targetUserId,
444444
organizationId,
445445
memberId: targetMember[0].id,
446+
spareSessionToken: session.session.token,
446447
})
447448

448449
if (!result.success) {

apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const POST = withRouteHandler(
148148
userId: session.user.id,
149149
organizationId,
150150
memberId: selfMember.id,
151+
spareSessionToken: session.session.token,
151152
})
152153

153154
if (!removeResult.success) {

apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes/scim-route'
1+
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes'
22
import { resourceTypes } from '@/ee/scim/protocol/discovery'
33
import { notFound } from '@/ee/scim/protocol/errors'
44

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes/scim-route'
2-
import { discoveryList, resourceTypes } from '@/ee/scim/protocol/discovery'
1+
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes'
2+
import { resourceTypes } from '@/ee/scim/protocol/discovery'
3+
import { toListResponse } from '@/ee/scim/protocol/resources'
34

4-
export const GET = defineScimDiscoveryRoute((baseUrl) => discoveryList(resourceTypes(baseUrl)))
5+
export const GET = defineScimDiscoveryRoute((baseUrl) =>
6+
toListResponse(resourceTypes(baseUrl), resourceTypes(baseUrl).length, 1)
7+
)

apps/sim/app/api/scim/v2/Schemas/[id]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes/scim-route'
1+
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes'
22
import { schemaDefinitions } from '@/ee/scim/protocol/discovery'
33
import { notFound } from '@/ee/scim/protocol/errors'
44

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes/scim-route'
2-
import { discoveryList, schemaDefinitions } from '@/ee/scim/protocol/discovery'
1+
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes'
2+
import { schemaDefinitions } from '@/ee/scim/protocol/discovery'
3+
import { toListResponse } from '@/ee/scim/protocol/resources'
34

4-
export const GET = defineScimDiscoveryRoute((baseUrl) => discoveryList(schemaDefinitions(baseUrl)))
5+
export const GET = defineScimDiscoveryRoute((baseUrl) =>
6+
toListResponse(schemaDefinitions(baseUrl), schemaDefinitions(baseUrl).length, 1)
7+
)

apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes/scim-route'
1+
import { defineScimDiscoveryRoute } from '@/lib/api/server/routes'
22
import { serviceProviderConfig } from '@/ee/scim/protocol/discovery'
33

44
/** Unauthenticated by design: a provider negotiates before it holds a credential. */

apps/sim/ee/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ Production deployment requires an active Enterprise subscription.
1919

2020
Enterprise features are imported directly throughout the codebase. The `ee/` directory
2121
is required at build time. Feature visibility is controlled at runtime via environment
22-
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`, `NEXT_PUBLIC_SCIM_ENABLED`).
22+
variables (e.g., `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED`, `SCIM_ENABLED` with `NEXT_PUBLIC_SCIM_ENABLED`).

apps/sim/ee/scim/application/admin/connection.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,7 @@ import { desc, eq } from 'drizzle-orm'
66
import type { ScimConnectionSettingsInput } from '@/lib/api/contracts/organization-scim'
77
import { acquireOrganizationMutationLock } from '@/lib/billing/organizations/membership'
88
import { OrchestrationError } from '@/lib/core/orchestration/types'
9-
import {
10-
assertWorkspaceInOrganization,
11-
loadConnectionView,
12-
requireConnection,
13-
} from '@/ee/scim/application/admin/connection-view'
9+
import { loadConnectionView, requireConnection } from '@/ee/scim/application/admin/connection-view'
1410
import {
1511
defineAuthorizedScimAdminUseCase,
1612
type ScimAdminUseCaseArgs,
@@ -36,21 +32,6 @@ export interface ConfigureScimConnectionInput {
3632
export const configureScimConnection = defineAuthorizedScimAdminUseCase({
3733
operation: scimAdminOperations.configure,
3834
async execute({ input, context }: ScimAdminUseCaseArgs<ConfigureScimConnectionInput>) {
39-
/**
40-
* A default grant hands every provisioned member a workspace, so the
41-
* workspace must be this organization's — the same check a group mapping
42-
* gets, or an administrator could name a workspace id from another tenant.
43-
*/
44-
for (const grant of input.settings?.defaultWorkspaceGrants ?? []) {
45-
await assertWorkspaceInOrganization(context.organizationId, grant.workspaceId)
46-
}
47-
48-
/**
49-
* Read, merge, and write under the organization lock, so two administrators
50-
* changing different settings at once both land instead of the later write
51-
* carrying a stale copy of the earlier one's field — and two first-time
52-
* enables, where there is no row yet to lock, cannot both insert.
53-
*/
5435
const { created, previousStatus, status } = await db.transaction(async (tx) => {
5536
await acquireOrganizationMutationLock(tx, context.organizationId)
5637
const [existing] = await tx

0 commit comments

Comments
 (0)