SCIM 2.0 Identity Provider
Overview
Waldur ships an optional SCIM 2.0 Service Provider mounted at /scim/v2/ so that external identity providers — Okta, Microsoft Entra ID, Keycloak, JumpCloud — can provision users and groups into Waldur using the standard RFC 7644 protocol. A companion on-demand outbound SCIM pull lets staff refresh user attributes from a remote SCIM directory.
This is a peer of the existing inbound user-provisioning paths and writes through the same multi-source attribute-merge policy as the Identity Bridge, so SCIM-provisioned attributes interleave cleanly with OIDC and ISD-pushed data.
Two different SCIM features. This page describes Waldur as a SCIM server (receiving provisioning from an IdP). For Waldur as a SCIM client pushing SSH entitlements to an HPC login-node service, see SCIM Entitlements. The two are independent and configured separately.
When to use SCIM
| Path | Best when |
|---|---|
| OIDC just-in-time (existing) | Users self-onboard on first login. No prior knowledge of the user is required. |
| Identity Bridge (existing) | A trusted ISD wants to push user data into Waldur via a Waldur-specific REST API. |
| Email invitations (existing) | Ad-hoc invites where the admin knows the user out of band. |
| SCIM Service Provider (this) | A corporate IdP (Okta / Entra ID / Keycloak) drives a complete user lifecycle: create, update, deactivate users and assign group-based roles — on a standard protocol every IdP speaks. |
| SCIM Pull (this) | Refresh user attributes from a remote SCIM directory without waiting for the user to log in. |
Architecture
flowchart LR
subgraph IdP["External Identity Provider"]
OKTA["Okta / Entra ID /<br/>Keycloak / JumpCloud"]
end
subgraph Waldur
SCIM["/scim/v2/<br/>Users + Groups"]
AUTH["ScimBearerAuthentication<br/>+ IsScimStaff + feature flag"]
MERGE["update_user_attributes_from_source()"]
ROLES["permissions.utils<br/>add_user / delete_user"]
DB["User<br/>+ UserRole<br/>+ attribute_sources"]
end
OKTA -->|SCIM 2.0 POST/PUT/PATCH/DELETE<br/>Authorization Bearer staff-token| SCIM
SCIM --> AUTH
AUTH --> SCIM
SCIM -->|user attributes| MERGE
SCIM -->|group membership| ROLES
MERGE --> DB
ROLES --> DB
User attribute writes converge on the same update_user_attributes_from_source() helper used by the Identity Bridge — so a SCIM source (scim:default) participates in the same multi-source ownership policy as isd:* sources.
Setup
- Add
scim2-models— already included inpyproject.toml. No extra installation step needed beyonduv sync. - Create a staff service-account user for the IdP to authenticate as. The service account must have
is_staff=Trueandis_active=True, and its token must not expire: new users get the defaulttoken_lifetime(1 hour), and an IdP only calls when something changes, so after a quiet hour every SCIM request would be rejected with 401Token has expired.Cleartoken_lifetimeafter creating the account — it can't be passed tocreate(), because the default is applied when the user is created. -
Issue an auth token for that user. The token's
keyis what the IdP sends asAuthorization: Bearer <key>.1 2 3 4 5 6 7 8 9 10 11
from rest_framework.authtoken.models import Token from waldur_core.core.models import User svc = User.objects.create( username="scim-okta-svc", is_staff=True, is_active=True ) svc.set_unusable_password() svc.token_lifetime = None # SCIM tokens must not expire svc.save() token, _ = Token.objects.get_or_create(user=svc) print(token.key) # paste into the IdP -
Enable the feature in Constance:
SCIM_INBOUND_ENABLED = True- Optionally narrow
SCIM_INBOUND_ALLOWED_ATTRIBUTES(defaultfirst_name, last_name, email, organization, affiliations). - Optionally rename the source label (
SCIM_INBOUND_SOURCE_NAME, defaultscim:default) — useful when you have multiple IdPs and want to distinguish them inattribute_sources.
-
Make
/scim/v2/reachable through the reverse proxy. The endpoint sits outside/api/, so the proxy in front of Waldur must forward it to the API. The Helm chart (APIIngressand Gateway APIHTTPRoute) and the docker-compose Caddy config route/scimto mastermind; older chart and compose releases do not, and a SCIM request then reaches the homeport SPA instead. With a custom proxy, add the route yourself. In Helm,ingress.whitelistSourceRangecovers/scimtoo — use it to accept provisioning only from the IdP's addresses (Ingress only; Gateway API has no standard equivalent). -
Point the IdP at
/scim/v2/using the token. The IdP's SCIM connector tester should seeGET /scim/v2/ServiceProviderConfigsucceed. -
Smoke test from the shell before pointing real users at it. Request an empty page of users: the discovery endpoints need no token, so
ServiceProviderConfiganswers 200 even for a wrong one.1 2 3 4
curl -s -o /dev/null -w '%{http_code}\n' \ -H "Authorization: Bearer <token>" \ -H "Accept: application/scim+json" \ "https://waldur.example.com/scim/v2/Users?count=0"200 means the token works; 401 an unknown or expired token (clear the service account's
token_lifetime); 403SCIM_INBOUND_ENABLEDoff or a non-staff token; atext/htmlanswer means the proxy does not route/scimto the API.
Endpoint reference
All endpoints live under /scim/v2/. They are outside the /api/ prefix and therefore excluded from the public OpenAPI schema.
Discovery (no auth required, only the feature flag)
| Method | Path | Returns |
|---|---|---|
| GET | /ServiceProviderConfig |
Server capabilities — patch supported, bulk not, etc. |
| GET | /ResourceTypes |
The two resource types Waldur exposes (User, Group). |
| GET | /ResourceTypes/{name} |
A single resource type. |
| GET | /Schemas |
Core User, Core Group, Enterprise User, plus the Waldur extension schema (urn:waldur:params:scim:schemas:extension:User:1.0). |
| GET | /Schemas/{urn} |
A single schema by its URN. |
Users (staff bearer required)
| Method | Path | Behaviour |
|---|---|---|
| POST | /Users |
Create a Waldur user. Lookup priority externalId → userName → primary email is applied first to detect duplicates → 409. |
| GET | /Users |
List with optional filter, startIndex, count. |
| GET | /Users/{uuid_hex} |
Read. |
| PUT | /Users/{uuid_hex} |
Full replace. userName is immutable post-creation; an attempt to change it returns 400 with scimType: mutability. |
| PATCH | /Users/{uuid_hex} |
RFC 7644 §3.5.2 PatchOp envelope. |
| DELETE | /Users/{uuid_hex} |
Soft-delete via remove_user_from_isd — honours FEDERATED_IDENTITY_DEACTIVATION_POLICY. |
Groups (staff bearer required)
| Method | Path | Behaviour |
|---|---|---|
| POST | /Groups |
Create a virtual group + grant the implied role to the listed members. |
| GET | /Groups |
List groups that have at least one active member; supports displayName eq "..." filter. |
| GET | /Groups/{display_name} |
Read with current members. |
| PUT | /Groups/{display_name} |
Replace the entire member set. |
| PATCH | /Groups/{display_name} |
Add / remove / replace members. |
| DELETE | /Groups/{display_name} |
Revoke all memberships in the group. |
Authentication and authorisation
flowchart TB
REQ["Incoming SCIM request"]
subgraph "Permission classes (applied in order)"
F["ScimFeatureEnabled<br/>(SCIM_INBOUND_ENABLED?)"]
S["IsScimStaff<br/>(user.is_staff and is_active?)"]
end
A["ScimBearerAuthentication"]
REQ -->|Authorization Bearer ...| A
A -->|token resolved core.AuthToken<br/>user authenticated| F
F -->|flag=False| R1["403 SCIM error"]
F -->|flag=True| S
S -->|not staff| R2["403 SCIM error"]
S -->|staff| OK["Handler runs"]
A -->|no or invalid token| R3["401"]
- Bearer scheme — accepts
Authorization: Bearer <token>(SCIM standard) andAuthorization: Token <token>(Waldur convention) for the samecore.AuthToken. - Staff-only —
is_staff=Trueis required even though the token resolves successfully, so a leaked end-user token cannot drive SCIM operations. - Feature flag — when
SCIM_INBOUND_ENABLED=Falsethe discovery endpoints also return 403, hiding the entire surface. - Content type — responses use
application/scim+json; requests accept bothapplication/scim+jsonandapplication/jsonso that DRF test clients and lax SCIM connectors both work.
Group naming convention
SCIM Groups are virtual in Waldur — there is no Group table. A SCIM Group corresponds to the set of users holding a given (scope, role) pair, where the scope is a Customer or Project and the role is a Waldur Role.
The displayName doubles as the SCIM id and uses a self-describing convention:
1 | |
| Component | Format | Example |
|---|---|---|
| Scope kind | customer or project |
customer |
| Scope UUID | 32 hex chars (no dashes) | f1b3... |
| Role name | [a-z0-9_\-\.]+, matched case-insensitively against Role.name |
CUSTOMER.OWNER |
Examples:
waldur:customer:f1b3a8caa4314b0ea5db61df37e25cd1:CUSTOMER.OWNERwaldur:project:af3ba2d3018d4cd58a9ac34bc94aeb45:PROJECT.MANAGER
flowchart TB
DN["displayName:<br/>waldur:customer:f1b3..:CUSTOMER.OWNER"]
REGEX["Regex: waldur:(customer or project):[0-9a-f]{32}:(role)"]
LOOKUP["Customer / Project lookup<br/>by uuid"]
ROLE["Role lookup<br/>(content_type, name__iexact)"]
GRANT["permissions.utils.add_user(scope, user, role)"]
REVOKE["permissions.utils.delete_user(scope, user, role)"]
DN --> REGEX
REGEX -->|scope kind and uuid| LOOKUP
REGEX -->|role name| ROLE
LOOKUP --> GRANT
LOOKUP --> REVOKE
ROLE --> GRANT
ROLE --> REVOKE
Resolver failure modes:
| Failure | Response |
|---|---|
displayName doesn't match the regex |
400 invalidValue with a remediation message |
| Scope UUID doesn't exist | 404 invalidValue |
| Role doesn't exist for that scope kind | 400 invalidValue |
| Member UUID doesn't exist | 400 invalidValue |
Listing strategy
GET /Groups enumerates only groups that have at least one active member. The catalogue of every (customer × role) and (project × role) combination is not materialised — most IdPs POST the groups they care about first and then PATCH members, so a sparse listing is acceptable in practice.
For lookups by exact name, supply ?filter=displayName eq "..." — the resolver runs against the parsed name without enumerating anything.
Multi-source attribute provenance
All inbound attribute writes go through update_user_attributes_from_source() (see Identity Bridge for the full lifecycle). The source label is SCIM_INBOUND_SOURCE_NAME (default scim:default).
stateDiagram-v2
[*] --> Unset: Field empty
Unset --> OwnedBySCIM: SCIM POST/PUT with non-empty value
Unset --> OwnedByPuhuri: Puhuri (Identity Bridge) sets value
OwnedBySCIM --> OwnedByPuhuri: Puhuri writes non empty value
OwnedBySCIM --> Cleared: SCIM the owner writes empty
OwnedBySCIM --> OwnedBySCIM: Puhuri writes empty preserved
OwnedByPuhuri --> OwnedBySCIM: SCIM writes non empty value
OwnedByPuhuri --> Cleared: Puhuri the owner writes empty
Key consequences:
- Empty values from non-owners are preserved. A SCIM
PUTthat omits an email previously set by Identity Bridge will not clear that email. - Per-attribute timestamps are refreshed on every write, even when the value is unchanged — useful for freshness-based reporting.
- Deactivation routes through
remove_user_from_isdand honoursFEDERATED_IDENTITY_DEACTIVATION_POLICY: with the defaultany_isd_removedany removal deactivates the user; withall_isds_removeda user is only deactivated when no active sources remain. - Deactivated users stay visible to SCIM.
GET /Users/{id}returns them withactive: false, they matchfilter=active eq false, and the IdP can reactivate them withPATCH {op: replace, path: active, value: true}.
Matching SCIM users to existing accounts
A provisioned user must become the same account the person gets at login, or they end up with two. Two settings decide how an incoming SCIM user is matched, for both /scim/v2/ and /scim/v2/sram/:
| Setting | Default | Meaning |
|---|---|---|
SCIM_USER_MATCH_WALDUR_ATTRIBUTE |
username |
Waldur field compared: username, email or civil_number. Anything other than username must be enabled in ENABLED_USER_PROFILE_ATTRIBUTES. The settings API rejects other values, and SCIM requests answer 503 while the setting is invalid. |
SCIM_USER_MATCH_SCIM_ATTRIBUTE |
userName |
Where the value is in the SCIM User: a top-level attribute (userName, emails = primary email), a sub-attribute (name.givenName), or an extension path such as urn:mace:surf.nl:sram:scim:extension:User.eduPersonUniqueId. |
- The lookup order is
externalId, then the configured match, then the primary email whenOIDC_MATCHMAKING_BY_EMAILis on. - More than one matching account is a 409; resolve the duplicate first.
- With
username, new accounts are named after the matched value exactly as sent (no lowercasing, no characters dropped), because a login looks the username up exactly as the identity provider presents it (seeOIDC_USER_FIELD). Matching tries that exact name first, then the lowercased form with characters outside[0-9a-z_.@+-]dropped, which is how accounts were named before. Changing the value later is rejected withscimType: mutability. - With
emailorcivil_number, new accounts are named afteruserName, lowercased and with other characters dropped, and the matched attribute is set on them. waldur find_username_collisionslists existing accounts whose usernames differ only in case or in such characters, which may be one person with two accounts. It changes nothing; merge them by hand.- For SRAM, pick the attribute your login uses. If users sign in through SRAM's OIDC with
subas username, useurn:mace:surf.nl:sram:scim:extension:User.eduPersonUniqueId. SRAM-provisioned accounts keep their username if the settings change later.
SCIM-to-Waldur attribute mapping
| SCIM attribute | Waldur User field | Direction | Notes |
|---|---|---|---|
userName |
username |
inbound (create only) | Immutable after create. Normalised to [0-9a-z_.@+-]+. |
externalId |
stored in attribute_sources["externalId"].value |
both | No DB column; participates in lookup priority. |
name.givenName / name.familyName |
first_name / last_name |
both | |
displayName |
derived (ignored on write) | outbound only | Composed from first + last name. |
emails[primary=true].value |
email |
both | First primary entry wins; falls back to first entry. |
phoneNumbers[primary=true].value |
phone_number |
both | |
active |
is_active |
both | false triggers remove_user_from_isd. |
enterprise:User.organization |
organization |
both | RFC 7643 enterprise extension. |
urn:waldur:...:civilNumber |
civil_number |
both | Waldur extension. |
urn:waldur:...:affiliations |
affiliations |
both | Waldur extension. |
urn:waldur:...:edupersonAssurance |
eduperson_assurance |
both | Waldur extension. |
meta.created / meta.lastModified |
date_joined / modified |
outbound only |
PATCH op support
The handler accepts the subset that Okta, Microsoft Entra ID, and Keycloak emit:
| Operation | Path | Behaviour |
|---|---|---|
replace |
omitted (value is a User dict) | Replace multiple top-level attributes at once (Okta style). |
replace |
active |
Toggle is_active; false runs the deactivation flow. |
replace |
name.givenName / name.familyName |
Update first / last name. |
replace / add / remove |
name |
Whole name object (givenName/familyName). |
replace / add / remove |
emails / phoneNumbers |
First primary entry wins. |
replace |
externalId |
Update; remove clears it. |
replace / add / remove |
urn:...:enterprise:2.0:User:organization |
URN-prefixed extension path (Entra ID style); also accepted for the Waldur extension fields and whole extension objects. |
replace |
userName |
Rejected — 400 mutability. |
add / remove / replace |
members |
Group membership delta. |
add / remove |
members[value eq "<uuid>"] |
Filter form used by Entra ID and Keycloak. |
| anything else | unsupported path | 400 invalidPath (fail loud rather than silently misapply). |
SCIM filter expression support
GET /Users?filter=... accepts the subset most IdPs emit. The parser builds Django Q objects from a hard-coded attribute whitelist; values are always parameterised by Django ORM.
| Operator | Status | Example |
|---|---|---|
eq, ne, co, sw, ew, pr |
supported | userName eq "alice" |
and, or, not, parens |
supported | userName sw "a" and not (emails ew "@guest.com") |
gt, lt, ge, le |
not supported (400 invalidFilter) |
|
value-path expressions (emails[type eq "work"]) |
not supported (400 invalidFilter) |
Filterable User attributes: userName, name.givenName, name.familyName, emails / emails.value, active, displayName.
Filterable Group attribute: displayName (use exact equality for lookup, e.g. displayName eq "waldur:customer:...:CUSTOMER.OWNER").
On-demand outbound SCIM pull
flowchart LR
OP["Staff operator"]
CMD["waldur scim_pull_user<br/>--username alice"]
ACT["POST /api/users/<uuid>/<br/>pull_scim_attributes/"]
subgraph Waldur
SVC["pull_user_attributes()"]
CLIENT["ScimPullClient"]
MERGE["update_user_attributes_from_source()<br/>source=scim:pull"]
USER["User<br/>+ attribute_sources"]
end
REMOTE["Remote SCIM 2.0 directory<br/>SCIM_PULL_API_URL"]
OP -->|CLI| CMD
OP -->|API| ACT
CMD --> SVC
ACT --> SVC
SVC --> CLIENT
CLIENT -->|GET /Users filter userName eq| REMOTE
CLIENT -->|user dict| SVC
SVC --> MERGE
MERGE --> USER
There is no periodic pull task — pulls happen only when an operator triggers them. This keeps load predictable and avoids accidentally drowning a remote SCIM service.
Management command
1 2 3 4 5 6 7 8 | |
API action
1 2 3 | |
Response (200):
1 2 3 4 | |
Failure modes:
| Cause | Response |
|---|---|
SCIM_PULL_API_URL / SCIM_PULL_API_KEY not set |
503 |
| Remote SCIM returns non-2xx | 502 |
| Caller not staff | 403 |
| User not found | 404 |
SRAM profile (/scim/v2/sram/)
SRAM (SURF Research Access Management, SURFscz/SBS) provisions services with its own SCIM client, which differs from generic identity providers in ways the generic endpoint cannot accommodate:
- it looks users and groups up with
filter=externalId eq "…"; - it requests
{scim_url}{meta.location}, so locations must be relative to the base URL; - its group names are free text (collaborations and their groups), not
waldur:…names; - its periodic sweep lists everything the service returns and DELETEs whatever SRAM does not know.
Waldur therefore serves SRAM on a separate base URL, /scim/v2/sram/, gated by SRAM_INTEGRATION_ENABLED in addition to SCIM_INBOUND_ENABLED and the staff token. Register https://<waldur>/scim/v2/sram as the service's SCIM URL in SRAM.
| Behaviour | /scim/v2/sram/ |
|---|---|
| Listing, lookup, update, delete | Only users and groups SRAM provisioned (waldur_sram.SramUser / SramGroup). A sweep cannot reach other accounts or groups. |
meta.location |
Relative: /Users/<uuid>, /Groups/<uuid> |
| Filters | Users: externalId, userName, emails, active. Groups: externalId, displayName. |
| User create | externalId is required. An existing account that matches (see Matching) is linked instead of duplicated, unless it is staff or support (409). Responses echo SRAM's own userName. |
| SSH keys | x509Certificates (base64 OpenSSH keys) are synced as the user's keys when SCIM_INBOUND_SSH_KEYS_ENABLED is on. Invalid keys are skipped. |
| Affiliations | eduPersonScopedAffiliation (comma-separated) becomes affiliations. |
| Groups | Stored with SRAM's URN, kind (collaboration or group), description, labels and resolved members. Unknown member ids are skipped. |
| Placeholder roles | Every collaboration and sub-group gets a customer-scoped role private to its organization, named CUSTOMER.<org-slug>.SRAM.<co>[.<group>] and described by the SRAM display name. It copies the permissions of SRAM_PLACEHOLDER_ROLE_TEMPLATE (none by default). Active members are granted it with UserRole.source = sram:<group uuid>; members that leave, are suspended or are deleted lose SRAM's grant. A grant made by hand is left alone. Deleting the group revokes every grant of the role, including hand-made ones, and deletes the role. The role can also be granted manually. The role hygiene report lists placeholders as org-role-unmanaged: their names are maintained by the SRAM sync. |
| Notifications and quotas | Role events from SRAM grants and revocations are logged with role_source and suppress_email: true: email hooks and system notifications skip them, webhooks still receive them. Placeholder holders count toward the organization's nc_user_count. |
| Organizations | The first URN segment is the SRAM organisation short name. It maps to the customer whose backend_id equals it. Otherwise a customer with exactly that name and an empty backend_id is adopted (its backend_id is set and a customer_update_succeeded event records it). Otherwise a customer is created. Several candidates → 409. Organizations are never deleted by SRAM. |
| User delete | Deactivates the user through remove_user_from_isd and unlinks it from SRAM. A later push re-links and reactivates it. |
| PATCH | Not supported (SRAM always sends full resources with PUT). |
| Responses | Echo SRAM's displayName, x509Certificates and extension blocks, so SRAM's change detection does not re-send unchanged users. |
SBS (observed with its current main) does not push the deletion of a sub-group on its own: the deletion is only propagated by the next sweep. Enable the sweep for the Waldur service once Waldur runs this release.
After an upgrade or a settings change, waldur sram_resync re-applies the last payload SRAM pushed for every group; SRAM itself only re-sends groups that changed.
Register Waldur as one SRAM service. All SRAM services pointing at the same Waldur share one set of SRAM-provisioned objects, so a sweep by one service would delete the objects another service provisioned.
SRAM project rules
Staff define rules, for all organizations at once, that give the holders of SRAM placeholder roles a role in matching projects of the same organization: /api/sram-project-rules/ (staff only; 404 while SRAM_INTEGRATION_ENABLED is off). /api/sram-groups/ lists what SRAM provisioned.
| Field | Meaning |
|---|---|
source_kind |
co, group or any |
labels |
The collaboration must carry one of them; a group is judged by its collaboration's labels. Empty = any. |
group_short_name_patterns |
Group short names (admins, adm*). Requires source_kind group or any. |
project_field / project_match / project_pattern |
Selects projects by backend_id or slug, exact, prefix or regex. Placeholders: {co_external_id}, {co_identifier} (external id without @scope), {co_short_name}, {org_short_name}, {group_short_name}. Values are regex-escaped for regex. Only projects of the group's organization are ever selected. |
project_role |
An active project-scoped role |
Example: workspaces created by an external system with backend_id = <co external id>_<workspace id> are matched by backend_id + prefix + {co_external_id}_.
- Everyone holding the placeholder role, whoever granted it, gets the project role with
UserRole.source = sram-rule:<rule uuid>:<group uuid>. An existing grant of that role is not duplicated, and reconciliation only ever revokes grants carrying its own source. - Reconciliation runs when a group is pushed (a collaboration push also covers its groups, whose labels it provides), when a placeholder role is granted or revoked (including by hand), when a rule is created, changed or deleted (retroactive, also from the Django admin), when a project is created or its
backend_id, slug or organization changes, and onwaldur sram_resync. - A grant that
validate_role_grantrefuses is skipped and logged. Rule grants are quiet: their events are logged without email. GET /api/sram-project-rules/<uuid>/preview/lists the matching groups with the projects and users the rule applies to.- Regular expressions run in PostgreSQL and are validated there on save. A rule whose projects cannot be selected is skipped and keeps its existing grants.
- When a grant is revoked, the other rules of that organization are re-checked, so a role two rules assert survives the removal of one of them.
Switching SRAM off
SRAM_INTEGRATION_ENABLED is the backend switch; the homeport feature sram.integration only shows or hides the SRAM administration page and the SRAM markers. While the setting is off, SRAM data is frozen: /scim/v2/sram/ and the SRAM APIs refuse requests, and nothing re-applies placeholder roles or project rules, not even when projects or placeholder grants change. Existing grants stay as they are. After switching it back on, run waldur sram_resync: it re-applies every group and revokes the grants of rules or groups deleted in the meantime.
Configuration reference
All keys live under the Constance fieldset SCIM Identity Provider in the Waldur admin.
| Key | Default | Purpose |
|---|---|---|
SCIM_INBOUND_ENABLED |
False |
Master switch for /scim/v2/. |
SCIM_INBOUND_SOURCE_NAME |
scim:default |
Source label written to attribute_sources for inbound writes. |
SCIM_INBOUND_ALLOWED_ATTRIBUTES |
first_name, last_name, email, organization, affiliations |
Subset of writable user attributes that SCIM is allowed to set. |
SCIM_USER_MATCH_WALDUR_ATTRIBUTE |
username |
Waldur field that links a SCIM user to an existing account. |
SCIM_USER_MATCH_SCIM_ATTRIBUTE |
userName |
SCIM attribute holding the matched value. |
SRAM_INTEGRATION_ENABLED |
False |
Serve the SRAM profile at /scim/v2/sram/. |
SRAM_PLACEHOLDER_ROLE_TEMPLATE |
"" |
Organization role whose permissions SRAM placeholder roles copy. |
SCIM_PULL_API_URL |
"" |
Base URL of the remote SCIM directory used by the on-demand pull. |
SCIM_PULL_API_KEY |
"" (secret) |
Bearer token for the remote directory. |
SCIM_PULL_SOURCE_NAME |
scim:pull |
Source label for pulled attributes. |
The pre-existing SCIM Entitlements (outbound push) fieldset (SCIM_MEMBERSHIP_SYNC_ENABLED, SCIM_API_URL, SCIM_API_KEY, SCIM_URN_NAMESPACE) remains separate and is documented in SCIM Entitlements.
End-to-end SCIM user lifecycle
sequenceDiagram
autonumber
participant IdP as External IdP
participant SCIM as /scim/v2/ (Waldur)
participant Helper as update_user_attributes_from_source
participant DB as Waldur DB
participant Login as Waldur login
IdP->>SCIM: POST /Users {userName, name, email}
SCIM->>Helper: payload, source=scim:default
Helper->>DB: User row created<br/>attribute_sources records scim:default
SCIM-->>IdP: 201 with Location header
IdP->>SCIM: POST /Groups {displayName: "waldur:project:abc:PROJECT.MANAGER", members:[user]}
SCIM->>DB: UserRole(user, role=PROJECT.MANAGER, scope=project)
SCIM-->>IdP: 201
Note over Login,DB: User logs in via OIDC.<br/>OIDC just-in-time matches the existing User<br/>(no duplicate created — userName matched)
IdP->>SCIM: PATCH /Users/<id> replace active=false
SCIM->>Helper: remove_user_from_isd(source=scim:default)
Helper->>DB: active_isds, attribute_sources cleaned<br/>is_active=False if policy says so
SCIM-->>IdP: 200
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
/scim/v2/... returns an HTML page, or a 404 from the proxy |
The reverse proxy doesn't forward /scim/ to the API, so the request reaches homeport or no backend. |
Upgrade to a Helm chart / docker-compose release that routes /scim, or add the route to your proxy (see Setup step 5). |
GET /scim/v2/ServiceProviderConfig returns 403 with detail: SCIM_INBOUND_ENABLED |
Feature flag is off. | Set SCIM_INBOUND_ENABLED=True in Constance. |
SCIM requests start returning 401 Token has expired. after a quiet period |
The service account still has a token_lifetime, and the IdP made no request within it. |
Clear it: User.objects.filter(username=...).update(token_lifetime=None). The IdP's existing key works again — expired tokens are rejected, not rotated. |
| All SCIM requests return 401 | Bearer scheme not used, or token doesn't match a core.AuthToken. |
Verify the IdP sends Authorization: Bearer <token> exactly; check Token.objects.filter(key=...).exists(). |
| All SCIM requests return 403 (with token) | Token user is not is_staff. |
Promote the service account: User.objects.filter(username=...).update(is_staff=True). |
POST /Groups returns 400 invalidValue |
displayName doesn't match the waldur: convention (see Group naming convention section above). |
Build the displayName from the URLs in the Waldur admin (Customer/Project UUID + role name). |
POST /Groups returns 400 with "Role X is not defined for project" |
The role hasn't been created on this deployment. | Ensure the role exists; for system roles, access CustomerRole.OWNER / ProjectRole.MANAGER once (e.g. via import_roles) so the row is materialised. |
| Attribute set via SCIM is not visible | Field not in SCIM_INBOUND_ALLOWED_ATTRIBUTES, or the field was already owned by another source which wrote an empty value (which is preserved). |
Widen SCIM_INBOUND_ALLOWED_ATTRIBUTES; inspect User.attribute_sources to see current owner. |
PATCH active=false doesn't deactivate the user |
FEDERATED_IDENTITY_DEACTIVATION_POLICY=all_isds_removed and the user still has another active source (e.g. isd:puhuri). |
Switch to any_isd_removed, or remove the user from the other source first. |
| SCIM tests pass but real Okta connector fails | Real SCIM clients use exact application/scim+json content type and stricter envelopes than the test client. |
Capture the failing request from Okta's system log; verify the SCIM envelope. |
Security notes
- The endpoint is gated on
is_staff— equivalent in authority to existing staff role-management APIs. Treat the service-account token like any other staff credential. - Bearer tokens leak silently in proxy logs and error trackers. SCIM tokens are validated exactly like
/api/tokens: oncetoken_lifetimepasses without a request, the token is rejected with 401 and left in place, never rotated. Because IdPs call only when something changes, leavetoken_lifetimeunset on the service account (see Setup) and rotate the credential deliberately instead — delete the service account'sToken, issue a new one, and update the IdP. /scim/v2/is outside the/api/prefix and excluded from the public OpenAPI schema.- All writable attributes are gated by both
SCIM_INBOUND_ALLOWED_ATTRIBUTESandWRITABLE_USER_FIELDS; privilege flags likeis_staff/is_superuser/is_active(directly) /token_lifetimecannot be set via SCIM payloads. - The SCIM filter parser only emits Django
Qobjects against a hard-coded attribute whitelist; values are parameterised by Django ORM.
Further reading
- RFC 7643 — SCIM Core Schema
- RFC 7644 — SCIM Protocol
- Identity Bridge — peer inbound provisioning path with the same multi-source attribute merge.
- SCIM Entitlements (outbound push) — independent feature that pushes entitlements from Waldur to HPC login-node SCIM services.
- User profile attributes — canonical list of
Userfields and which are writable from federated sources.