Repository navigation
Provision staff accounts from Entra ID and Okta over SCIM 2.0 - #992
Merged
Merged
Conversation
added 8 commits
October 24, 2026 11:38
A taken-over account is no longer linked to the provider subject ahead of its first sign-in: an e-mail at the identity provider proves nothing about who owns the GrandNode account, so it connects once with its password and second factor. Only staff accounts are taken over; shoppers get 409. Break-glass, system and deleted accounts are now left alone by delete, role sync and e-mail changes. A deleted SCIM user keeps a deprovisioned record, so the same user created again gets its account back (active when SCIM disabled it). A customer deleted in the admin panel leaves SCIM. Records and groups are written field by field, so concurrent requests do not undo each other; a membership change recomputes only the members it changed. Entity events are published, duplicate group names are accepted, deactivation rotates the password token, and only an administrator maps a group to Administrators.
An employee who already shops with a registered account on the work e-mail was refused (409) because only staff accounts were taken over. Scim:TakeOverDomains lists the company's domains: a shopper account with such an e-mail is taken over too, keeps its orders and password and is connected once with that password. Shopper accounts on any other domain stay out of the identity provider's reach.
| var at = email?.Trim().LastIndexOf('@') ?? -1; | ||
| if (at < 1 || TakeOverDomains == null) | ||
| return false; | ||
| var domain = email.Trim()[(at + 1)..]; |
added 2 commits
October 25, 2026 00:20
…eak-glass addresses A shopper account on Scim:TakeOverDomains may have been registered by anyone with the address (GrandNode never verified it). Taking it over kept its password, so in Mixed mode whoever registered it could sign in with the roles the identity provider then granted. Its password, sessions, refresh token, second factor and non-OpenID Connect sign-ins now go before the roles arrive; staff accounts keep theirs. Create also refused a break-glass address only when it already had an account.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves #issueNumber
Type: feature
Issue
Staff of the company that runs the store sign in through the built-in OpenID Connect login (#978) with the company's Entra ID or Okta tenant. Their GrandNode accounts, customer groups and staff store / vendor are still kept up to date by hand. An employee who leaves keeps access until someone disables the account by hand, or, for a provider session, until the next revalidation. A password-based session is not touched at all.
Solution
SCIM 2.0 provisioning (RFC 7643/7644) at
/scim/v2, so the identity provider manages the account lifecycle and the roles.Module
Grand.Module.Scim, off by default (FeatureManagement:Grand.Module.Scim).Users,Groupsand the discovery endpoints. It accepts the request shapes Entra ID and Okta send:attr eq "value"filters, PATCH with or without a path,"False"as a string,members[value eq "id"],excludedAttributes=members.Scim:Token, at least 32 characters), compared in constant time. Every request is refused while the token is too short or the OpenID Connect sign-in is not configured.Users (
Grand.Business.Authentication/Services/Scim).New employees get a store-less registered account. It has a random password, gets no welcome e-mail, and is linked to the OpenID Connect subject (
externalId), so the first sign-in needs no password.An existing staff account with the same e-mail is taken over and keeps its credentials. So is a shopper account whose e-mail is in one of the company's domains (
Scim:TakeOverDomains, e.g. an employee's shopping account). GrandNode never verified who registered that address, so a taken-over shopper account loses its credentials before any role arrives:A taken-over account is not linked ahead. It connects once by the usual rules. An employee whose shopping account was taken over sets a password through password recovery first.
Without a mapped back-office group, a provisioned employee has an ordinary customer account and can shop with it. A group mapped to a custom customer group can carry employee prices or discounts.
Deactivation locks the account out at once:
DELETE keeps the customer (inactive, no SCIM roles) and marks the record deprovisioned. The same user created again gets its account back.
Groups. Main Admin > External authentication > SCIM provisioning maps each pushed group to one of:
How roles are granted:
Never touched: shoppers outside
Scim:TakeOverDomains, and system, deleted and break-glass accounts. A break-glass address (OidcLogin:BreakGlassAccounts) is refused even when it has no account yet.Storage: two new collections,
ScimUser(id = customer id) andScimGroup. No migration: uniqueness comes from primary keys. Resources are inDefaultLanguage.xmlandUpgrade/en_250.xml.Documentation:
docs/scim.md, with setup for Entra ID (objectId→externalId,Claims:Subject = "oid") and Okta.Breaking changes
None. The module is off by default and the
Scimsection is new. Existing screens, models and interfaces are unchanged.ExternalAuthenticationControlleronly gains actions, and the External authentication page gains a link card.Testing
dotnet test src/Tests/Grand.Business.Authentication.Tests,src/Tests/Grand.Modules.Tests,src/Tests/Grand.Web.Admin.Tests(filterScimfor the new ones).OidcResourceServer:Authority,OidcLogin:ClientId/ClientSecret). On a fresh installation, switchExternalAuth.OpenIdConnecton in Admin > External authentication.FeatureManagement:Grand.Module.Scim = trueandScim:Token(32+ characters). Add your admin e-mail toOidcLogin:BreakGlassAccounts, then start GrandNode.GET /scim/v2/ServiceProviderConfigwithout a token returns 401. WithAuthorization: Bearer <token>it returns 200.POST /scim/v2/Userswith{"userName":"jan@example.com","externalId":"<provider subject>","active":true,"name":{"givenName":"Jan","familyName":"Nowak"}}returns 201. The customer exists, is registered and active, and has an OpenID Connect link to the subject. Jan signs in through the provider without a GrandNode password.POST /scim/v2/Groupswith{"displayName":"Shop admins"}, thenPATCH /scim/v2/Groups/{id}with{"Operations":[{"op":"Add","path":"members","value":[{"value":"<user id>"}]}]}.PATCH /scim/v2/Users/{id}with{"Operations":[{"op":"Replace","path":"active","value":"False"}]}. Jan is inactive, and his open admin session ends on the next request. Remove him from the group: Administrators is taken away.DELETE /scim/v2/Users/{id}returns 204, and a GET returns 404. The customer is kept inactive. POST the same user again: the same account is active again.With
Scim:TakeOverDomains: ["<that domain>"], the same POST takes the account over (201). It keeps its orders and is not linked ahead. Its old password no longer signs in, and its open session is gone.docs/scim.mdsection 3. Entra's "Test connection" must pass.Verified locally end to end: 50/50 checks against a running instance (Kestrel, fresh MongoDB database,
SignInMode: Mixed) with Keycloak 26.8 as the OpenID Connect provider. Real browser sign-ins through Keycloak (Playwright), SCIM requests in the shapes Entra ID sends, and account state read from MongoDB. The checks cover steps 4–11, the takeover of a shopper account registered beforehand by someone else, and a group name containing HTML (shown as text on the admin screen). Keycloak cannot push SCIM itself, so the requests came from the script. Nothing was run against a real Entra ID or Okta tenant.