Skip to content

Provision staff accounts from Entra ID and Okta over SCIM 2.0 - #992

Merged
KrzysztofPajak merged 11 commits into
developfrom
feature/scim
Oct 25, 2026
Merged

KrzysztofPajak merged 11 commits into
developfrom
feature/scim

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

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).

    • Endpoints: Users, Groups and 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.
    • Authentication: a static Bearer token (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:

      • its password is replaced with a random one, and the new password token ends its cookies;
      • its sessions end, and its Frontend API refresh token and second factor are dropped;
      • its external sign-ins other than OpenID Connect are removed.

      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:

      • the account is set inactive;
      • its provider sessions end;
      • the subject's provider tokens are revoked;
      • the Frontend API refresh token is dropped;
      • the password token is rotated.
    • 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:

    • Administrators;
    • Store managers with a store;
    • Vendors with a vendor;
    • a custom group.

    How roles are granted:

    • A group grants nothing until it is mapped.
    • SCIM takes away only what it granted.
    • Combinations the customer validator would refuse grant no back-office role and are listed as conflicts.
    • Only an administrator can map a group to Administrators.
  • 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) and ScimGroup. No migration: uniqueness comes from primary keys. Resources are in DefaultLanguage.xml and Upgrade/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 Scim section is new. Existing screens, models and interfaces are unchanged. ExternalAuthenticationController only gains actions, and the External authentication page gains a link card.

Testing

  1. Unit tests: dotnet test src/Tests/Grand.Business.Authentication.Tests, src/Tests/Grand.Modules.Tests, src/Tests/Grand.Web.Admin.Tests (filter Scim for the new ones).
  2. Configure the OpenID Connect sign-in (OidcResourceServer:Authority, OidcLogin:ClientId/ClientSecret). On a fresh installation, switch ExternalAuth.OpenIdConnect on in Admin > External authentication.
  3. Set FeatureManagement:Grand.Module.Scim = true and Scim:Token (32+ characters). Add your admin e-mail to OidcLogin:BreakGlassAccounts, then start GrandNode.
  4. GET /scim/v2/ServiceProviderConfig without a token returns 401. With Authorization: Bearer <token> it returns 200.
  5. POST /scim/v2/Users with {"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.
  6. POST /scim/v2/Groups with {"displayName":"Shop admins"}, then PATCH /scim/v2/Groups/{id} with {"Operations":[{"op":"Add","path":"members","value":[{"value":"<user id>"}]}]}.
  7. In Admin > Configuration > External authentication > SCIM provisioning, map "Shop admins" to Administrators. Jan becomes an administrator.
  8. 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.
  9. 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.
  10. Register a storefront customer, then POST a SCIM user with that e-mail: 409, and the account is untouched.
    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.
  11. POST a SCIM user with the break-glass e-mail: 409.
  12. With a real tenant, follow docs/scim.md section 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.

support 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.
Copilot AI balanced review requested due to automatic review settings October 24, 2026 21:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

Comment on lines +188 to +190
foreach (var record in records)
if (customers.TryGetValue(record.Id, out var customer) && !customer.Deleted)
items.Add(await View(customer, record));
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)..];
support 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.
@KrzysztofPajak
KrzysztofPajak merged commit fc860ac into develop Oct 25, 2026
8 of 9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the feature/scim branch October 25, 2026 06:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants