SCIM directory sync
Provision users and groups from an enterprise IdP into AuthServer and FGA.
This guide follows the common setup path first. A developer enables SCIM once, creates one connection for the customer organization, and gives the customer's IT administrator two values to paste into their identity provider.
SCIM is off by default. A provider must be able to call an absolute public HTTPS URL, so configure the externally reachable origin at the same time:
var publicOrigin = builder.Configuration["SqlOS:PublicOrigin"]
?? throw new InvalidOperationException("SqlOS:PublicOrigin is required for SCIM.");
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.PublicOrigin = publicOrigin;
options.AuthServer.Issuer = $"{publicOrigin.TrimEnd('/')}/sqlos/auth";
options.AuthServer.EnableScim = true;
});PublicOrigin must be an absolute HTTPS origin only, such as https://identity.example.com, with no path, query, or fragment. The default service-provider path is /sqlos/scim/v2, so the resulting Base URL is https://identity.example.com/sqlos/scim/v2. Set ScimBasePath only when the host application needs a different public path.
If PublicOrigin is omitted, SqlOS can return only a relative path. That is useful for same-origin local curl requests but cannot be pasted into Entra or Okta.
Aspire Dev Tunnels can give a local SqlOS host a temporary TLS-terminated public endpoint. On a current Aspire AppHost, add the integration:
aspire add devtunnelsThen expose the SqlOS-enabled project from the AppHost:
var app = builder.AddProject<Projects.App>("app");
builder.AddDevTunnel("sqlos-scim")
.WithReference(app)
.WithAnonymousAccess();Run the AppHost and copy the tunnel's public https://...devtunnels.ms endpoint from the Aspire dashboard. Configure that exact origin as SqlOS:PublicOrigin, keep Issuer at {PublicOrigin}/sqlos/auth, and restart the host before creating the connection. Use the resulting {PublicOrigin}/sqlos/scim/v2 value as the provider's Base or Tenant URL.
Anonymous access makes the tunneled application endpoint reachable from the internet; the SCIM path still requires its bearer token. Use a dedicated test tenant and test identities, keep dashboard authentication enabled, do not reuse production secrets, and stop or remove the tunnel after the provider test. Dev Tunnels are for development, not production.
Open /sqlos/admin/auth/, select the organization, and open SCIM. Create an enabled connection. If you plan to map directory groups to roles, set its grant boundary to the organization's root FGA resource ID; mapped grants can only target that resource or its descendants. You can also set the boundary later.
The create action returns:
Copy the token before leaving the result. SqlOS stores its hash, not the plaintext value, so it cannot show the token again.
SqlOS allows one enabled SCIM connection per organization. Keep that connection for the directory's lifetime. If you must replace it, disable the old connection before enabling another one.
The Base URL does not need an organization ID. The bearer token identifies both the connection and its organization, which keeps provider setup simple and prevents a client from choosing another tenant in its request.
| Provider field | SqlOS value |
|---|---|
| Base URL, Tenant URL, or SCIM connector URL | The Base URL returned by SqlOS |
| API token or Secret Token | The one-time bearer token |
| Authentication mode | Bearer token |
| Unique user field | userName, normally the user's work email |
| Supported resources | Users and Groups |
For Okta, enable API integration, paste the Base URL and token, test the credentials, then enable Create Users, Update User Attributes, Deactivate Users, and group push as needed.
For Microsoft Entra, paste the Base URL into Tenant URL, paste the bearer token into Secret Token, test the connection, and start with a small assigned user or group before enabling the full provisioning scope.
Continue with the provider-specific setup after the connection test:
Google Workspace does not provide a public generic SCIM push client. Do not paste these values into the Google Admin SDK or treat its pull-based directory API as SCIM. Use a supported IdP, a purpose-built Google connector, or your own SCIM 2.0 client.
Start with a single test user. Confirm that the IdP can query, create, update, deactivate, and reactivate that user. Then push one small group and verify member add and remove before assigning the whole directory.
SqlOS supports the provider behaviors needed for this flow:
| Operation | Supported behavior |
|---|---|
| User lookup | Exact eq filters on id, userName, externalId, and emails.value |
| Group lookup | Exact eq filters on id, displayName, and externalId |
| User PATCH | Pathless Okta-style values and paths for active, displayName, name parts, userName, and emails |
| Group PATCH | Full member replacement, member add, member remove, and members[value eq "..."] removal |
| Discovery | Service provider configuration plus collection and item reads for resource types and schemas |
Other filter operators such as co, sw, pr, and, and or are not accepted. Unsupported filters return a SCIM invalidFilter error instead of being silently ignored.
SqlOS bounds one PATCH document to 100 operations and one Group resource to 10,000 distinct members. An oversized request fails atomically with 413 and scimType: "tooMany"; partition a larger directory group into smaller application-specific groups rather than retrying the same payload.
Check these surfaces after the first sync:
You can also validate discovery and lookup without changing data:
export SCIM_BASE_URL="https://app.example.com/sqlos/scim/v2"
export SCIM_TOKEN="paste-the-token-shown-by-sqlos"
curl --fail-with-body --silent --show-error \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Accept: application/scim+json" \
"$SCIM_BASE_URL/ServiceProviderConfig"
curl --fail-with-body --silent --show-error --get \
-H "Authorization: Bearer $SCIM_TOKEN" \
-H "Accept: application/scim+json" \
--data-urlencode 'filter=userName eq "ada@example.com"' \
"$SCIM_BASE_URL/Users"For a production rollout, follow the complete SCIM plus SAML access-lifecycle guide. The short version is that SCIM decides who belongs to the organization, SAML authenticates those already-provisioned people, and SAML JIT provisioning stays off.
The most predictable enterprise setup gives each protocol one job: SCIM decides who has organization access, while SAML authenticates those already-provisioned people. Use one stable work-email source in the IdP for every identity field that SqlOS links:
| IdP value | Destination |
|---|---|
Stable work email, commonly user.userprincipalname in Entra | SCIM userName |
| The same work email | SCIM emails[type eq "work"].value |
| The same work email | SAML attribute named email, or the attribute configured as the SAML connection's email field |
| IdP object identifier | SCIM externalId; preserve its exact casing |
Use user.mail instead only when that tenant guarantees it is populated and stable. The important rule is that the SCIM primary email and SAML email claim normalize to the same address.
Create the SAML connection with linking enabled and JIT provisioning disabled:
var draft = await adminService.CreateSsoConnectionDraftAsync(
new SqlOSCreateSsoConnectionDraftRequest(
OrganizationId: organizationId,
DisplayName: "Acme Entra SSO",
PrimaryDomain: "acme.com",
AutoProvisionUsers: false,
AutoLinkByEmail: true));In the dashboard or delegated SSO portal, the equivalent policy is Require SSO for existing members = on and Allow JIT provisioning = off. Provision the test user through SCIM before attempting the first SAML sign-in. SqlOS can then bind the IdP subject to that existing member when the organization owns the asserted email domain; it does not treat SCIM or membership as proof of global identity.
That setting gives the smoothest email-first routing for a trusted organization domain. For a narrower routing policy, leave AutoLinkByEmail / Require SSO for existing members off and have the application start the organization's SAML connection explicitly. First subject link still requires domain ownership.
Do not enable SAML JIT provisioning when IdP assignment and SCIM deprovisioning are supposed to control access. Otherwise a valid SAML assertion for a not-yet-provisioned person can create a user or membership outside the SCIM assignment lifecycle. First-time SAML linking of an existing global user still requires the organization to own that email domain; SCIM assignment alone is not enough.
SqlOS is the SCIM service provider and the IdP is the SCIM client. The IdP sends provisioning requests to SqlOS; SqlOS does not poll the IdP through these endpoints.
| SCIM object | SqlOS AuthServer object | SqlOS FGA object |
|---|---|---|
| User | SqlOSUser, external identity, and organization membership | user subject used for authorization checks |
| Group | connection-owned external link | mirrored SqlOSFgaUserGroup |
| Group member | active organization member | SqlOSFgaUserGroupMembership |
| Optional mapping | connection mapping rule | SCIM-managed SqlOSFgaGrant for the group subject |
Directory groups mirror even when no authorization mapping exists. Add mapping rules only for groups that should grant a product role on a known FGA resource. Mapped grants must stay inside the connection's grant boundary, the organization's root resource, so a tenant's IdP cannot grant roles on another tenant's resources by choosing group names. See SCIM group mapping.
SqlOS users are host-global, so a directory only controls the parts of a person that belong to its organization:
SqlOSUserEmail, or set or move the verified primary email, only when the address is at an active, unrevoked verified domain of the connection's organization (see Verify domain ownership). An unverified PrimaryDomain does not count. Otherwise SqlOS answers 400 with scimType: invalidValue, writes nothing, and records a failed scim.user.rejected sync event with reason untrusted_email_domain. Re-sending the address a user already has, for example with active: false, writes no email and is always allowed.409 uniqueness and a scim.user.rejected event with reason unverified_email_match until the owner verifies the address.scim.user.lifecycle_released audit and sync event record which anchors applied. From then on the connection's email, name, and active changes affect only its own membership, FGA subject, and link fields.Upgrading does not rewrite existing data. Ownership is re-evaluated on the next write for each user, and users that SCIM created earlier with an email outside the organization's verified domains keep that email. To find them, use the SCIM sync events view or query (SQL Server syntax; on PostgreSQL quote the identifiers, use TRUE, and concatenate with ||):
SELECT link.ConnectionId, link.EntityId AS UserId, email.Email
FROM SqlOSScimExternalIds AS link
JOIN SqlOSScimConnections AS connection ON connection.Id = link.ConnectionId
JOIN SqlOSUserEmails AS email ON email.UserId = link.EntityId AND email.IsPrimary = 1
WHERE link.ResourceType = 'User'
AND link.OwnsUserLifecycle = 1
AND NOT EXISTS (
SELECT 1 FROM SqlOSOrganizationDomains AS domain
WHERE domain.OrganizationId = connection.OrganizationId
AND domain.Status = 'active'
AND domain.RevokedAt IS NULL
AND email.NormalizedEmail LIKE '%@' + UPPER(domain.Domain));Okta commonly deactivates a user with PATCH or PUT and active: false; Entra and other clients may send DELETE. SqlOS treats both as soft deprovisioning for that organization:
A SqlOS user can belong to more than one organization. Deprovisioning from Acme must not deactivate the same person's membership or sessions in Contoso. The bearer token, resource lookup, membership updates, group changes, and session revocation all stay within the token's organization.
Manual grants, non-SCIM groups, and memberships in other organizations are not deleted by SCIM. Review those separate access paths if a person still has access after directory deprovisioning.
Open the connection and choose Rotate token. Copy the replacement immediately and update the IdP before its next provisioning run.
Rotation invalidates the previous token. There is no recovery path for a lost plaintext token; rotate it again. A disabled connection also rejects its token until the connection is re-enabled.
Disabling a connection immediately revokes the FGA grants managed by that connection. It does not delete mirrored groups, directory membership history, manual grants, or unrelated FGA state. Re-enabling accepts the token again, but managed grants return only after the IdP pushes or resynchronizes the affected groups.
Use code-first seeding for platform-owned tenants and repeatable authorization mappings. Keep token material in a secret store:
builder.AddSqlOS<AppDbContext>(options =>
{
options.AuthServer.SeedScimConnection("acme", scim =>
{
scim.OrganizationSlug = "acme";
scim.DisplayName = "Acme directory sync";
scim.TokenSecretName = "ACME_SCIM_BEARER_TOKEN";
// Mapped grants must target this resource or one of its descendants.
scim.GrantBoundaryResourceId = "org::acme";
scim.MapGroup("Acme-Admins", mapping =>
{
mapping.RoleKey = "org_admin";
mapping.ResourceId = "org::acme";
});
});
});TokenSecretName is the name of a process environment variable; SqlOS reads its value during startup reconciliation. Populate ACME_SCIM_BEARER_TOKEN from your deployment secret system. Token values must contain at least 32 characters and cannot contain whitespace. Generate a high-entropy value, store it as a secret, and never commit it to source control.
GrantBoundaryResourceId is the organization's root resource in your FGA tree. Every mapped grant, including one whose resource ID comes from a pattern template, must target that resource or one of its descendants. The resource must exist when startup reconciles the seed, so declare it with Fga.Seed or create it first. A seed without a boundary still starts and syncs users and groups, but its mappings create no grants. See SCIM group mapping.
Startup configuration is authoritative for seeded connections and seeded mapping rules. Each restart reapplies the configured display name, enabled state, grant boundary, token secret, and mappings. Changing or removing the seeded boundary revokes managed grants that are no longer inside it. Rotate a seeded credential by replacing the deployment secret and restarting SqlOS, then update the IdP with that same value. The dashboard intentionally makes seeded lifecycle, token, and mapping controls read-only so a later restart cannot silently restore an older credential.
To stage a connection before its secret is available, set scim.Enabled = false. Before connecting the IdP, generate and store the secret, keep TokenSecretName pointed at it, change scim.Enabled = true, and restart. Do not rotate or enable a seeded connection from the dashboard; those values belong in startup configuration.
Removing or renaming a seed disables the orphaned persisted connection, clears its bearer credential, and revokes its SCIM-managed grants during reconciliation. The dashboard remains the support surface for status, token metadata, read-only seeded state, optional dashboard-owned mappings, and persisted sync activity.
| Symptom | What it means | Resolution |
|---|---|---|
401 Unauthorized | Missing, incorrect, rotated, disabled-connection, or wrong-environment token | Rotate or recopy the token and confirm the IdP is using the matching Base URL |
404 Not Found | Resource is not part of this token's organization, or SCIM is disabled | Check the organization, connection state, resource ID, and EnableScim configuration |
400 with invalidFilter | The client sent an unsupported filter or operator | Use an exact supported eq filter from the table above |
400 with invalidSyntax or invalidPath | The PATCH body, operation, or path is not supported | Inspect the SCIM error response and compare the request with the SCIM reference |
409 with uniqueness | The requested username, email, or external ID conflicts with an existing connection resource, or the email belongs to an account that has not verified it | Correct the IdP identity values, or have the account owner verify the address, before retrying |
400 with invalidValue and email domain is not verified | The user's email is outside the organization's verified domains | Verify the domain for the organization, or send an address at a verified domain |
| Users sync but groups do not | Group push or group assignment is not enabled | Enable the provider's group provisioning and start with one small test group |
| Group mirrors but grants no access | Mirroring does not automatically create authorization, and mappings need a grant boundary | Set the connection's grant boundary, then add and enable a mapping rule for the correct FGA role and a resource inside it |
| User still has access after deactivation | Access comes from another organization or a non-SCIM source | Inspect organization memberships, manual grants, and non-SCIM groups |
Dashboard shows only /sqlos/scim/v2 | PublicOrigin is missing | Configure the absolute public HTTPS origin and matching issuer, then restart before copying the provider URL |
| Host fails while reconciling a seeded connection | An enabled seed's configured Token or TokenSecretName did not resolve on this startup, or its GrantBoundaryResourceId does not exist or excludes a fixed mapping resource | Provide a 32+ character whitespace-free configured secret, or keep the seed disabled until the secret is available. Create the boundary resource first and keep fixed mapping resources inside it |
The sync log records operations that reached persistence, application-level reconciliation failures, and user writes refused by the email-ownership rules (scim.user.rejected). Request parsing, authentication, filter, and PATCH validation errors are returned directly to the IdP and may not create a sync event. Audit Logs record resulting security changes. Use the IdP provisioning log, SCIM response, SqlOS sync activity, and Audit Logs together when debugging a rollout.
Run the provider-shaped HTTP suite, service tests, real-SQL protocol tests, and docs gate:
dotnet test tests/SqlOS.Tests/SqlOS.Tests.csproj \
--filter 'FullyQualifiedName~SqlOSScim'
dotnet test tests/SqlOS.IntegrationTests/SqlOS.IntegrationTests.csproj \
--filter 'FullyQualifiedName~ScimProtocolIntegrationTests'
./scripts/docs-check.sh