diff --git a/GrandNode.slnx b/GrandNode.slnx
index efa35892ce..08363a56ae 100644
--- a/GrandNode.slnx
+++ b/GrandNode.slnx
@@ -33,6 +33,7 @@
+
diff --git a/docs/scim.md b/docs/scim.md
new file mode 100644
index 0000000000..2ab1c571fe
--- /dev/null
+++ b/docs/scim.md
@@ -0,0 +1,178 @@
+# SCIM 2.0 provisioning of staff accounts
+
+How to let Microsoft Entra ID or Okta create, update and lock out the GrandNode accounts of your employees, and give
+them their GrandNode roles from the identity provider's groups.
+
+## 1. What it does
+
+GrandNode is a SCIM 2.0 **service provider** (RFC 7643, RFC 7644) at `/scim/v2`. The identity provider that already signs
+your staff in through OpenID Connect ([docs/oidc-resource-server.md](oidc-resource-server.md), sections 11 and 12) pushes:
+
+* **Users.** A new employee gets an account before the first sign-in. Name and e-mail follow the identity provider.
+ Deactivating or deleting the user at the provider locks the account out at once (section 4).
+* **Groups.** A group grants nothing until an administrator maps it to a customer group in
+ **Admin > Configuration > External authentication > SCIM provisioning**. From then on, membership of the group gives
+ the role and leaving the group takes it away.
+
+What it is not:
+
+* Not for storefront customers, not for B2B customer companies, and not for more than one identity provider. It
+ provisions the employees of the company that runs the store, through the one provider of `OidcResourceServer`.
+* No SAML. Sign-in stays OpenID Connect.
+* No `/Bulk`, `/Me`, sorting, ETags, `/.search` or password changes. The discovery documents say so.
+
+## 2. Configuration
+
+1. Configure and test the OpenID Connect sign-in first (`OidcResourceServer` and `OidcLogin`). Without it every SCIM
+ request is refused. On a fresh installation also switch the method on in **Admin > Configuration > External
+ authentication** (`ExternalAuth.OpenIdConnect`): until then the sign-in button fails, and provisioned staff cannot sign
+ in.
+2. Turn the module on: `FeatureManagement:Grand.Module.Scim = true`. The module is built with the solution
+ (`src/Modules/Grand.Module.Scim`).
+3. Set section `Scim`:
+
+| Key | Default | Meaning |
+|---|---|---|
+| `Token` | empty | The Bearer token the identity provider sends. At least 32 characters; shorter means every request is refused. Keep it out of source control (`Scim__Token` or a secret store). Generate it, for example, with `openssl rand -base64 48`. |
+| `ExternalIdIsSubject` | `true` | The SCIM `externalId` of a user is its OpenID Connect subject. An account SCIM **creates** is linked to it, so its first sign-in needs no password. A taken-over account is not (section 4). See section 3. |
+| `TakeOverDomains` | `[]` | E-mail domains of the company, e.g. `["firma.pl"]`. An existing shopper account with such an e-mail (an employee's shopping account) is taken over like a staff account. Exact domains only, no subdomains. See section 4. |
+| `MaxResults` | `100` | Largest page of a list response. |
+
+4. Put your own administrator e-mail in `OidcLogin:BreakGlassAccounts`. SCIM never touches a break-glass account. Every
+ other staff account that SCIM takes over loses its back-office roles until its groups are mapped (section 5), and that
+ includes yours.
+5. Restart, open **SCIM provisioning** in the admin panel, and copy the **SCIM endpoint (tenant URL)**. Push the groups and
+ map them before you assign users, so the first cycle does not demote the staff it takes over.
+
+## 3. Identity providers
+
+### 3.1 Microsoft Entra ID
+
+* Enterprise application of GrandNode > **Provisioning** > Automatic. Tenant URL: the endpoint from the admin screen;
+ Secret token: `Scim:Token`. **Test connection** must pass (it filters for a random `userName` and expects an empty list).
+* **Attribute mapping (users):** map `objectId` → `externalId`. Entra's default maps `mailNickname` there, which is not the
+ OpenID Connect identity.
+* **OpenID Connect:** set `OidcResourceServer:Claims:Subject = "oid"`. Entra's `sub` is different for every application and
+ never equals the object id. The `oid` claim comes with the `profile` scope, which `OidcLogin:Scopes` requests by default.
+ On an installation whose staff already sign in through Entra, the links stored so far hold the old `sub`. After the change
+ no stored link matches any more. Remove them (**External authentication > OpenID Connect account links > Remove**) and
+ let everybody connect again.
+* `userName` is the account's e-mail. Entra sends the UPN. If your UPNs are not mailboxes, map `userName` to `mail`.
+* Groups: assign the groups to the application. Entra reads them with `excludedAttributes=members`, and GrandNode
+ supports that.
+
+### 3.2 Okta
+
+* SCIM 2.0 app integration > **Provisioning** > Integration. SCIM connector base URL: the endpoint. Unique identifier field:
+ `userName`. Authentication mode: HTTP Header with the token. Enable Create, Update and Deactivate users, and Push Groups.
+* Okta fills its own `externalId` with the id GrandNode returns and does not send its user id by default. Choose one:
+ * map the SCIM `externalId` to the Okta user id (Profile Editor of the app, expression `user.getInternalProperty("id")`).
+ The Okta user id is Okta's OpenID Connect `sub`, so keep `ExternalIdIsSubject = true`. Check in your tenant that the
+ attribute is sent;
+ * or set `Scim:ExternalIdIsSubject = false` and `OidcLogin:SignInMode = ProviderOnly`, so an account is linked at its first
+ sign-in by its provider-verified e-mail. In `Mixed` mode a staff account links only with its password, and a SCIM
+ account has none anybody knows. GrandNode logs a warning at startup for that combination.
+
+## 4. Behaviour
+
+**Create.** No account with the e-mail: a registered, active account is created and linked to the user's OpenID Connect
+subject. Without a mapped back-office group it is an ordinary customer account, and the employee shops with it. Map a group to
+a custom customer group (e.g. "Employees") for employee prices or discounts. It belongs to no store, so with per-store identity it is usable on every store host. Its password is random and
+nobody knows it, and no welcome e-mail is sent.
+
+A **staff** account (admin panel access, a staff store or a vendor) with the e-mail already exists, or a **shopper** account
+whose e-mail is in `Scim:TakeOverDomains`: **SCIM takes it over** and manages its roles from then on (section 5). An
+employee's shopping account keeps its orders and addresses, and is deactivated when the employee leaves. It is **not** linked ahead. An e-mail at the identity provider proves nothing about
+who owns the GrandNode account, so the employee connects once at the first sign-in with the account's password (and second
+factor), exactly as without SCIM.
+
+A shopper account **loses its credentials** when SCIM takes it over: GrandNode never verified who registered the address, and
+the roles the identity provider gives must not reach a stranger's password. Its password becomes one nobody knows, its
+sessions, Frontend API refresh token and second factor are dropped, and its external sign-ins other than OpenID Connect
+(Facebook, Google...) are removed. The employee sets a new password through password recovery (the e-mail goes to the
+company mailbox) and then connects. A staff account keeps its credentials: an administrator created it.
+
+Refused with `409 uniqueness`:
+* a shopper account whose e-mail is not in `Scim:TakeOverDomains`. Without this rule, a user admin at the identity provider
+ could lock out, or give roles to, any storefront customer;
+* system accounts, deleted accounts and break-glass addresses (`OidcLogin:BreakGlassAccounts`), also one without an account yet;
+* a store-bound account with per-store identity;
+* an account linked to another OpenID Connect identity;
+* a second user with the same `externalId`.
+
+**Update.** E-mail (and a user name that was the e-mail), first and last name. Refused with `409`, with nothing written:
+* a new e-mail that belongs to another account, or a break-glass address;
+* an `externalId` another user has.
+
+**Deactivate** (`active: false`). The account becomes inactive, which the cookie, Frontend API and Backend API checks refuse on
+the next request. Its OpenID Connect sessions end, the provider access tokens of its subject issued so far are refused, and
+its Frontend API refresh token is dropped, and its password token changes, so cookies issued before stay invalid after a
+reactivation.
+
+**Reactivate** (`active: true`). Only an account SCIM deactivated. An account an administrator disabled by hand stays disabled,
+and SCIM reports it as inactive.
+
+**Delete.** The account is deactivated, loses every role SCIM granted and its OpenID Connect link, and leaves every group. The
+customer itself is kept with its orders and history, and SCIM no longer shows it. When the provider creates the same user
+again, it gets the same account back, active again if SCIM had deactivated it. A break-glass account is refused with `409`.
+A customer deleted in the admin panel leaves SCIM entirely. The provider can then create the user again as a new account.
+
+**Unknown attributes** of a user (title, department, phone numbers...) are accepted and ignored.
+
+## 5. Roles
+
+Map each group on the admin screen to one of:
+
+| Customer group | Also needs | Members get |
+|---|---|---|
+| Administrators | - | the Admin panel |
+| Store managers | a store | the Store panel of that store (`StaffStoreId`) |
+| Vendors | a vendor | the Vendor panel of that vendor (`VendorId`) |
+| a custom (non-system) group | - | that group |
+
+Sales managers cannot be mapped, because they need a sales employee. Only an administrator can map a group to
+Administrators, or change a group mapped to it. Group display names may repeat, as in Entra.
+
+* **Break-glass accounts** keep their roles whatever their groups say.
+* **Granted by SCIM only.** SCIM remembers what it granted and takes away only that. Groups an administrator assigned by hand
+ stay. Removing or changing a mapping takes its role away from every member at once.
+* **Takeover.** When SCIM takes over an existing account, its Administrators / Store managers / Vendors roles, its store and
+ vendor, and every mapped group it holds count as granted by SCIM. An employee who is in no mapped group loses them
+ immediately, which is what "managed by SCIM" means.
+* **Conflicts.** The back-office roles must be one of: Administrators alone, Store managers with one store, Vendors with one
+ vendor. If a user's groups give anything else (Administrators and Store managers, two stores, Vendors and Store managers),
+ SCIM grants **none** of these roles, still grants the custom groups, and lists the account under **Role conflicts** on the
+ admin screen.
+
+## 6. Security
+
+* Only the Bearer token of `Scim:Token` is accepted, compared in constant time. Discovery endpoints need it too. Rotate it
+ at the provider and in appsettings together.
+* The endpoint is refused entirely while the token is shorter than 32 characters or the OpenID Connect sign-in is not
+ configured. Startup logs an error in that case.
+* Logs carry customer ids, never e-mails or tokens.
+* Request bodies are limited to 1 MB.
+
+## 7. Endpoints
+
+| Method | Path | Notes |
+|---|---|---|
+| GET | `/scim/v2/Users` | `filter=userName eq "..."` or `externalId eq "..."`, `startIndex`, `count` |
+| GET, PUT, PATCH, DELETE | `/scim/v2/Users/{id}` | `id` is the customer id. PUT without `active` keeps the current state |
+| POST | `/scim/v2/Users` | `201` with `Location` |
+| GET | `/scim/v2/Groups` | `filter=displayName eq "..."` or `externalId eq "..."`, `excludedAttributes=members` |
+| GET, PUT, PATCH, DELETE | `/scim/v2/Groups/{id}` | PATCH: `members` add/remove/replace, `members[value eq "id"]` remove, rename |
+| POST | `/scim/v2/Groups` | members that are not SCIM-managed users are ignored |
+| GET | `/scim/v2/ServiceProviderConfig`, `/ResourceTypes`, `/Schemas` | discovery |
+
+Errors use the SCIM error schema: `400 invalidFilter` / `invalidValue` / `invalidSyntax`, `401`, `404`, `409 uniqueness`.
+
+## 8. Storage
+
+Two collections, created on first use (no migration):
+
+* `ScimUser`: one document per managed account, its id **is** the customer id. It holds the `externalId`, what SCIM granted,
+ whether SCIM created the account, and whether the provider deleted the user.
+* `ScimGroup`: the groups received and their mapping.
+
+Turning the module off stops provisioning. Accounts, roles and the stored records stay as they are.
diff --git a/src/Business/Grand.Business.Authentication/Events/ScimCustomerDeletedEventHandler.cs b/src/Business/Grand.Business.Authentication/Events/ScimCustomerDeletedEventHandler.cs
new file mode 100644
index 0000000000..6594cf3805
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Events/ScimCustomerDeletedEventHandler.cs
@@ -0,0 +1,26 @@
+using Grand.Data;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Events;
+using Grand.Mediator;
+
+namespace Grand.Business.Authentication.Events;
+
+///
+/// A customer deleted in the admin panel leaves SCIM: its record and its group memberships go, so the identity provider can
+/// provision the user again as a new account
+///
+public class ScimCustomerDeletedEventHandler(IRepository users, IRepository groups)
+ : INotificationHandler>
+{
+ public async Task Handle(EntityDeleted notification, CancellationToken cancellationToken)
+ {
+ var customerId = notification.Entity.Id;
+ var record = await users.GetByIdAsync(customerId);
+ if (record != null)
+ await users.DeleteAsync(record);
+
+ foreach (var group in await groups.ToListAsync(groups.Table.Where(x => x.Members.Contains(customerId)),
+ cancellationToken))
+ await groups.RemoveCollectionFieldItem(group.Id, x => x.Members, m => m == customerId);
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs
new file mode 100644
index 0000000000..6086adbd20
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs
@@ -0,0 +1,77 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Business.Core.Utilities.Customers;
+using Grand.Data;
+using Grand.Domain.Customers;
+using Microsoft.Extensions.Logging;
+using System.Security.Cryptography;
+
+namespace Grand.Business.Authentication.Services.Scim;
+
+///
+/// Locks a SCIM-managed account out at once: inactive (refused by the cookie, Frontend API and Backend API checks on the next
+/// request), its provider sessions ended, the provider access tokens of its subject refused, its Frontend API refresh
+/// token dropped and its password token rotated (cookies issued before do not come back with a reactivation)
+///
+public class ScimAccountLock(
+ ICustomerService customerService,
+ ICustomerManagerService customerManagerService,
+ IRepository links,
+ IOidcSessionService sessionService,
+ IOidcTokenRevocationService tokenRevocationService,
+ ILogger logger)
+{
+ public virtual async Task Deactivate(Customer customer)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+
+ await customerService.UpdateCustomerField(customer.Id, x => x.Active, false);
+ customer.Active = false;
+ await sessionService.EndByCustomer(customer.Id);
+
+ var link = await links.GetOneAsync(x =>
+ x.ProviderSystemName == OidcCustomerResolver.ProviderSystemName && x.CustomerId == customer.Id);
+ if (link != null)
+ await tokenRevocationService.RevokeTokens(link.ExternalIdentifier);
+
+ //an empty value removes the user field
+ await customerService.UpdateUserField(customer, SystemCustomerFieldNames.RefreshToken, "");
+ //a new password token invalidates the cookies issued so far, so they stay dead after a reactivation
+ await customerService.UpdateUserField(customer, SystemCustomerFieldNames.PasswordToken, Guid.NewGuid().ToString());
+ logger.LogInformation("SCIM deactivated customer {CustomerId}", customer.Id);
+ }
+
+ ///
+ /// Leaves a shopper account SCIM takes over to the owner of its mailbox: whoever registered it (an e-mail nobody verified)
+ /// must not get the roles the identity provider gives. The password becomes one nobody knows (a new password token ends
+ /// the cookies), the sessions, the Frontend API refresh token, the second factor and the external sign-ins other than
+ /// OpenID Connect go. The employee sets a password through password recovery and connects at the first sign-in
+ ///
+ public virtual async Task ResetCredentials(Customer customer)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+
+ await sessionService.EndByCustomer(customer.Id);
+ await links.DeleteManyAsync(x =>
+ x.CustomerId == customer.Id && x.ProviderSystemName != OidcCustomerResolver.ProviderSystemName);
+
+ //an empty value removes the user field
+ await customerService.UpdateUserField(customer, SystemCustomerFieldNames.RefreshToken, "");
+ await customerService.UpdateUserField(customer, SystemCustomerFieldNames.TwoFactorEnabled, "");
+ await customerService.UpdateUserField(customer, SystemCustomerFieldNames.TwoFactorSecretKey, "");
+
+ //also writes a new password token
+ var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
+ await customerManagerService.ChangePassword(new ChangePasswordRequest(customer.Email, PasswordFormat.Hashed, password));
+ logger.LogInformation("SCIM reset the credentials of customer {CustomerId}", customer.Id);
+ }
+
+ public virtual async Task Activate(Customer customer)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+
+ await customerService.UpdateCustomerField(customer.Id, x => x.Active, true);
+ customer.Active = true;
+ logger.LogInformation("SCIM activated customer {CustomerId}", customer.Id);
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs
new file mode 100644
index 0000000000..b0c47bc10c
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs
@@ -0,0 +1,245 @@
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Common.Stores;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Business.Core.Utilities.Authentication;
+using Grand.Data;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Extensions;
+using Grand.Mediator;
+using Microsoft.Extensions.Logging;
+
+namespace Grand.Business.Authentication.Services.Scim;
+
+///
+/// SCIM groups and their mapping to GrandNode roles (). Only SCIM-managed accounts are kept
+/// as members; a membership change recomputes the roles of the members it adds or removes, a mapping change those of every
+/// member. Members and mappings are written field by field (added and pulled one by one), so concurrent requests of the
+/// provider and the administrator do not undo each other
+///
+public class ScimGroupService(
+ IRepository groups,
+ IRepository users,
+ IGroupService groupService,
+ IStoreService storeService,
+ IVendorService vendorService,
+ ScimRoleSync roleSync,
+ IMediator mediator,
+ ILogger logger) : IScimGroupService
+{
+ public virtual async Task Create(ScimGroupData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var displayName = data.DisplayName?.Trim();
+ if (string.IsNullOrEmpty(displayName))
+ return Invalid("displayName is required");
+ if (await ExternalIdTaken(data.ExternalId, null))
+ return Conflict();
+
+ var group = new ScimGroup {
+ DisplayName = displayName,
+ ExternalId = data.ExternalId ?? "",
+ Members = await ManagedOnly(data.MemberIds)
+ };
+ await groups.InsertAsync(group);
+ await mediator.EntityInserted(group);
+ await roleSync.SyncMembers(group.Members);
+ logger.LogInformation("SCIM group {GroupId} created with {Count} members", group.Id, group.Members.Count);
+ return new ScimGroupResult(ScimOutcome.Created, View(group));
+ }
+
+ public virtual async Task Replace(string id, ScimGroupData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var group = await Load(id);
+ if (group == null)
+ return new ScimGroupResult(ScimOutcome.NotFound);
+
+ var displayName = data.DisplayName?.Trim();
+ if (string.IsNullOrEmpty(displayName))
+ return Invalid("displayName is required");
+ if (await ExternalIdTaken(data.ExternalId, group.Id))
+ return Conflict();
+
+ await groups.UpdateField(group.Id, x => x.DisplayName, displayName);
+ await groups.UpdateField(group.Id, x => x.ExternalId, data.ExternalId ?? "");
+ return await ChangeMembers(group, await ManagedOnly(data.MemberIds), [], true);
+ }
+
+ public virtual async Task Get(string id)
+ {
+ var group = await Load(id);
+ return group == null ? new ScimGroupResult(ScimOutcome.NotFound) : new ScimGroupResult(ScimOutcome.Ok, View(group));
+ }
+
+ public virtual async Task> List(string displayName, string externalId, int skip, int take)
+ {
+ var query = groups.Table;
+ if (!string.IsNullOrEmpty(externalId))
+ query = query.Where(x => x.ExternalId == externalId);
+ if (!string.IsNullOrEmpty(displayName))
+ {
+ var lowered = displayName.Trim().ToLowerInvariant();
+ query = query.Where(x => x.DisplayName.ToLower() == lowered);
+ }
+
+ var total = await groups.CountAsync(query);
+ var items = take <= 0
+ ? []
+ : await groups.ToListAsync(query.OrderBy(x => x.CreatedOnUtc).ThenBy(x => x.Id).Skip(Math.Max(skip, 0)).Take(take));
+ return new ScimPage(items.Select(View).ToList(), total);
+ }
+
+ public virtual async Task Delete(string id)
+ {
+ var group = await Load(id);
+ if (group == null)
+ return ScimOutcome.NotFound;
+
+ await groups.DeleteAsync(group);
+ await mediator.EntityDeleted(group);
+ await roleSync.SyncMembers(group.Members);
+ logger.LogInformation("SCIM group {GroupId} deleted", group.Id);
+ return ScimOutcome.Ok;
+ }
+
+ public virtual async Task ModifyMembers(string id, IList add, IList remove,
+ bool replaceAll)
+ {
+ var group = await Load(id);
+ if (group == null)
+ return new ScimGroupResult(ScimOutcome.NotFound);
+ return await ChangeMembers(group, await ManagedOnly(add), remove ?? [], replaceAll);
+ }
+
+ public virtual async Task Rename(string id, string displayName, string externalId)
+ {
+ var group = await Load(id);
+ if (group == null)
+ return new ScimGroupResult(ScimOutcome.NotFound);
+
+ var name = displayName == null ? group.DisplayName : displayName.Trim();
+ if (string.IsNullOrEmpty(name))
+ return Invalid("displayName is required");
+ if (externalId != null && await ExternalIdTaken(externalId, group.Id))
+ return Conflict();
+
+ await groups.UpdateField(group.Id, x => x.DisplayName, name);
+ if (externalId != null)
+ await groups.UpdateField(group.Id, x => x.ExternalId, externalId);
+ var renamed = await groups.GetByIdAsync(group.Id);
+ await mediator.EntityUpdated(renamed);
+ return new ScimGroupResult(ScimOutcome.Ok, View(renamed));
+ }
+
+ public virtual async Task> GetMappings() =>
+ (await groups.ToListAsync(groups.Table.OrderBy(x => x.DisplayName)))
+ .Select(x => new ScimGroupMapping(x.Id, x.DisplayName, x.Members.Count, x.CustomerGroupId, x.StaffStoreId,
+ x.VendorId))
+ .ToList();
+
+ public virtual async Task> GetMappableGroups() =>
+ (await groupService.GetAllCustomerGroups(showHidden: true)).Where(ScimRoleSync.IsMappable).ToList();
+
+ public virtual async Task SetMapping(string groupId, string customerGroupId, string staffStoreId,
+ string vendorId)
+ {
+ var group = await Load(groupId);
+ if (group == null)
+ return ScimOutcome.NotFound;
+
+ string target = "", store = "", vendor = "";
+ if (!string.IsNullOrEmpty(customerGroupId))
+ {
+ var customerGroup = await groupService.GetCustomerGroupById(customerGroupId);
+ if (!await ValidTarget(customerGroup, staffStoreId, vendorId))
+ return ScimOutcome.Invalid;
+ target = customerGroup.Id;
+ store = IsSystemGroup(customerGroup, SystemCustomerGroupNames.StoreManager) ? staffStoreId : "";
+ vendor = IsSystemGroup(customerGroup, SystemCustomerGroupNames.Vendors) ? vendorId : "";
+ }
+
+ await groups.UpdateField(group.Id, x => x.CustomerGroupId, target);
+ await groups.UpdateField(group.Id, x => x.StaffStoreId, store);
+ await groups.UpdateField(group.Id, x => x.VendorId, vendor);
+ var mapped = await groups.GetByIdAsync(group.Id);
+ await mediator.EntityUpdated(mapped);
+ await roleSync.SyncMembers(mapped.Members);
+ logger.LogInformation("SCIM group {GroupId} mapped to customer group {CustomerGroupId}", group.Id, target);
+ return ScimOutcome.Ok;
+ }
+
+ ///
+ /// Adds and pulls members one by one (a replace writes the whole list) and recomputes the roles of the members whose
+ /// membership changed
+ ///
+ private async Task ChangeMembers(ScimGroup group, IList add, IList remove,
+ bool replaceAll)
+ {
+ var before = group.Members.ToHashSet();
+ var removed = remove.ToHashSet();
+ var after = (replaceAll ? add : before.Concat(add)).Where(x => !removed.Contains(x)).ToHashSet();
+
+ if (replaceAll)
+ await groups.UpdateField(group.Id, x => x.Members, after.ToList());
+ else
+ {
+ foreach (var id in after.Where(x => !before.Contains(x)))
+ await groups.AddToCollectionField(group.Id, x => x.Members, id);
+ foreach (var id in before.Where(x => !after.Contains(x)))
+ await groups.RemoveCollectionFieldItem(group.Id, x => x.Members, m => m == id);
+ }
+
+ var changed = before.Except(after).Concat(after.Except(before)).ToList();
+ var stored = await groups.GetByIdAsync(group.Id);
+ await mediator.EntityUpdated(stored);
+ await roleSync.SyncMembers(changed);
+ return new ScimGroupResult(ScimOutcome.Ok, View(stored));
+ }
+
+ //Administrators and custom groups take no store or vendor; StoreManager needs an existing store, Vendors an existing vendor
+ private async Task ValidTarget(CustomerGroup target, string staffStoreId, string vendorId)
+ {
+ if (!ScimRoleSync.IsMappable(target))
+ return false;
+
+ if (IsSystemGroup(target, SystemCustomerGroupNames.StoreManager))
+ return string.IsNullOrEmpty(vendorId) && !string.IsNullOrEmpty(staffStoreId) &&
+ await storeService.GetStoreById(staffStoreId) != null;
+ if (IsSystemGroup(target, SystemCustomerGroupNames.Vendors))
+ return string.IsNullOrEmpty(staffStoreId) && !string.IsNullOrEmpty(vendorId) &&
+ await vendorService.GetVendorById(vendorId) != null;
+ return string.IsNullOrEmpty(staffStoreId) && string.IsNullOrEmpty(vendorId);
+ }
+
+ private static bool IsSystemGroup(CustomerGroup group, string systemName) =>
+ group.IsSystem && group.SystemName == systemName;
+
+ //display names may repeat (Entra allows it); the provider's id of a group may not
+ private async Task ExternalIdTaken(string externalId, string exceptId) =>
+ !string.IsNullOrEmpty(externalId) &&
+ await groups.AnyAsync(groups.Table.Where(x => x.Id != exceptId && x.ExternalId == externalId));
+
+ //members that are not SCIM-managed accounts are dropped: a group can give roles only to accounts SCIM manages
+ private async Task> ManagedOnly(IEnumerable ids)
+ {
+ var wanted = (ids ?? []).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToArray();
+ if (wanted.Length == 0)
+ return [];
+ return (await users.ToListAsync(users.Table.Where(x => wanted.Contains(x.Id) && !x.Deprovisioned).Select(x => x.Id)))
+ .ToList();
+ }
+
+ private async Task Load(string id) => string.IsNullOrEmpty(id) ? null : await groups.GetByIdAsync(id);
+
+ private static ScimGroupView View(ScimGroup group) =>
+ new(group.Id, group.DisplayName, string.IsNullOrEmpty(group.ExternalId) ? null : group.ExternalId,
+ group.Members.ToList(), group.CreatedOnUtc, group.UpdatedOnUtc ?? group.CreatedOnUtc);
+
+ private static ScimGroupResult Invalid(string detail) => new(ScimOutcome.Invalid, Detail: detail);
+
+ private static ScimGroupResult Conflict() =>
+ new(ScimOutcome.Conflict, Detail: "A group with this externalId already exists");
+}
diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimProtection.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimProtection.cs
new file mode 100644
index 0000000000..b698791138
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimProtection.cs
@@ -0,0 +1,18 @@
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Configuration;
+
+namespace Grand.Business.Authentication.Services.Scim;
+
+/// Accounts SCIM never manages, whatever the identity provider sends
+internal static class ScimProtection
+{
+ /// Why SCIM must leave the account alone; null when it may manage it
+ public static string Refusal(Customer customer, OidcLoginConfig loginConfig)
+ {
+ if (customer.Deleted)
+ return "The account is deleted";
+ if (customer.IsSystemAccount || !string.IsNullOrEmpty(customer.SystemName))
+ return "System accounts are not managed by SCIM";
+ return loginConfig.IsBreakGlass(customer.Email) ? "Break-glass accounts are not managed by SCIM" : null;
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs
new file mode 100644
index 0000000000..e39d7db179
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs
@@ -0,0 +1,185 @@
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Data;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Configuration;
+using Grand.Infrastructure.Extensions;
+using Grand.Mediator;
+using Microsoft.Extensions.Logging;
+using System.Linq.Expressions;
+
+namespace Grand.Business.Authentication.Services.Scim;
+
+///
+/// Writes the roles a SCIM-managed account gets from its mapped groups (): customer groups, staff
+/// store and vendor. It takes away only what SCIM granted (), so groups an administrator assigned by
+/// hand stay. System, deleted and break-glass accounts are never touched. Only the fields of the record it owns are
+/// written, so a concurrent change of another field (a deactivation) is not overwritten
+///
+public class ScimRoleSync(
+ IRepository users,
+ IRepository groups,
+ ICustomerService customerService,
+ IGroupService groupService,
+ OidcLoginConfig loginConfig,
+ IMediator mediator,
+ ILogger logger)
+{
+ private static readonly string[] BackOfficeGroups = [
+ SystemCustomerGroupNames.Administrators, SystemCustomerGroupNames.StoreManager, SystemCustomerGroupNames.Vendors
+ ];
+
+ /// A customer group SCIM may give: Administrators, StoreManager, Vendors or a custom group
+ public static bool IsMappable(CustomerGroup group) =>
+ group != null && (!group.IsSystem || BackOfficeGroups.Contains(group.SystemName));
+
+ /// Recomputes the roles of the account from the groups it belongs to and stores what SCIM granted
+ public virtual async Task Sync(Customer customer, ScimUser record)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+ ArgumentNullException.ThrowIfNull(record);
+
+ //an account may have become a break-glass account since it was provisioned
+ if (ScimProtection.Refusal(customer, loginConfig) != null)
+ {
+ logger.LogWarning("SCIM user {CustomerId} is a protected account; its roles are left as they are", customer.Id);
+ return;
+ }
+
+ var customerId = customer.Id;
+ var memberOf = await groups.ToListAsync(groups.Table.Where(x => x.Members.Contains(customerId)));
+ var targetIds = memberOf.Select(x => x.CustomerGroupId).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToArray();
+ var targets = targetIds.Length == 0
+ ? new Dictionary()
+ : (await groupService.GetAllByIds(targetIds)).Where(IsMappable).ToDictionary(x => x.Id);
+
+ //a mapping to a group that was deleted or became unmappable gives nothing
+ var wanted = ScimRoles.Wanted(memberOf.Where(x => targets.ContainsKey(x.CustomerGroupId ?? "")),
+ id => targets[id].IsSystem ? targets[id].SystemName : "");
+
+ if (wanted.Conflict && !record.RoleConflict)
+ logger.LogWarning("SCIM user {CustomerId}: the mapped groups give back-office roles that cannot be combined; " +
+ "none of them is granted", customerId);
+ await Apply(customer, record, wanted);
+ }
+
+ /// Takes away every role SCIM granted
+ public virtual Task Revoke(Customer customer, ScimUser record) =>
+ Apply(customer, record, new ScimWanted([], null, null, false));
+
+ /// Recomputes the roles of the SCIM-managed accounts among the customers
+ public virtual async Task SyncMembers(IEnumerable customerIds)
+ {
+ foreach (var id in customerIds.Where(x => !string.IsNullOrEmpty(x)).Distinct())
+ {
+ var record = await users.GetByIdAsync(id);
+ if (record is not { Deprovisioned: false })
+ continue;
+ var customer = await customerService.GetCustomerById(id);
+ if (customer != null)
+ await Sync(customer, record);
+ }
+ }
+
+ ///
+ /// An account SCIM takes over is managed by SCIM from then on: its back-office roles (with its staff store and vendor)
+ /// and the mapped groups it holds count as granted by SCIM, so the next sync keeps only what the identity provider gives
+ ///
+ public virtual async Task TakeOver(Customer customer, ScimUser record)
+ {
+ ArgumentNullException.ThrowIfNull(customer);
+ ArgumentNullException.ThrowIfNull(record);
+
+ var managed = new HashSet(await groups.ToListAsync(groups.Table
+ .Where(x => x.CustomerGroupId != null && x.CustomerGroupId != "")
+ .Select(x => x.CustomerGroupId)));
+ foreach (var systemName in BackOfficeGroups)
+ {
+ var group = await groupService.GetCustomerGroupBySystemName(systemName);
+ if (group != null)
+ managed.Add(group.Id);
+ }
+
+ record.GrantedGroupIds = customer.Groups.Where(managed.Contains).ToList();
+ record.GrantedStaffStoreId = customer.StaffStoreId;
+ record.GrantedVendorId = customer.VendorId;
+ }
+
+ private async Task Apply(Customer customer, ScimUser record, ScimWanted wanted)
+ {
+ var current = customer.Groups.ToHashSet();
+ var granted = record.GrantedGroupIds ?? [];
+ var toRemove = granted.Where(x => !wanted.GroupIds.Contains(x) && current.Contains(x)).ToList();
+ var toAdd = wanted.GroupIds.Where(x => !current.Contains(x)).ToList();
+
+ var changed = toRemove.Concat(toAdd).Distinct().ToArray();
+ var known = changed.Length == 0
+ ? new Dictionary()
+ : (await groupService.GetAllByIds(changed)).ToDictionary(x => x.Id);
+ //a granted group deleted since is still taken away by its id
+ foreach (var id in toRemove)
+ await customerService.DeleteCustomerGroupInCustomer(known.GetValueOrDefault(id) ?? new CustomerGroup { Id = id },
+ customer.Id);
+ foreach (var id in toAdd.Where(known.ContainsKey))
+ await customerService.InsertCustomerGroupInCustomer(known[id], customer.Id);
+
+ var grantedGroups = wanted.GroupIds.Where(x => current.Contains(x) || known.ContainsKey(x)).ToList();
+ var grantedStore = await ApplyField(customer.Id, customer.StaffStoreId, record.GrantedStaffStoreId,
+ wanted.StaffStoreId, x => x.StaffStoreId);
+ var grantedVendor = await ApplyField(customer.Id, customer.VendorId, record.GrantedVendorId, wanted.VendorId,
+ x => x.VendorId);
+
+ //each field alone: a whole-record write would undo a concurrent change of another field
+ var recordChanged = false;
+ if (!grantedGroups.ToHashSet().SetEquals(granted))
+ {
+ await users.UpdateField(record.Id, x => x.GrantedGroupIds, grantedGroups);
+ record.GrantedGroupIds = grantedGroups;
+ recordChanged = true;
+ }
+
+ if (grantedStore != record.GrantedStaffStoreId)
+ {
+ await users.UpdateField(record.Id, x => x.GrantedStaffStoreId, grantedStore);
+ record.GrantedStaffStoreId = grantedStore;
+ recordChanged = true;
+ }
+
+ if (grantedVendor != record.GrantedVendorId)
+ {
+ await users.UpdateField(record.Id, x => x.GrantedVendorId, grantedVendor);
+ record.GrantedVendorId = grantedVendor;
+ recordChanged = true;
+ }
+
+ if (wanted.Conflict != record.RoleConflict)
+ {
+ await users.UpdateField(record.Id, x => x.RoleConflict, wanted.Conflict);
+ record.RoleConflict = wanted.Conflict;
+ recordChanged = true;
+ }
+
+ if (recordChanged)
+ await mediator.EntityUpdated(record);
+
+ if (toRemove.Count > 0 || toAdd.Count > 0)
+ logger.LogInformation("SCIM user {CustomerId}: {Added} customer groups granted, {Removed} taken away", customer.Id,
+ toAdd.Count, toRemove.Count);
+ }
+
+ //sets the wanted value, or clears the value SCIM set when nothing is wanted; returns what SCIM granted now
+ private async Task ApplyField(string customerId, string current, string granted, string wanted,
+ Expression> field)
+ {
+ if (!string.IsNullOrEmpty(wanted))
+ {
+ if (current != wanted)
+ await customerService.UpdateCustomerField(customerId, field, wanted);
+ return wanted;
+ }
+
+ if (!string.IsNullOrEmpty(granted) && current == granted)
+ await customerService.UpdateCustomerField(customerId, field, "");
+ return null;
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoles.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoles.cs
new file mode 100644
index 0000000000..a7f04a7968
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoles.cs
@@ -0,0 +1,62 @@
+using Grand.Domain.Customers;
+
+namespace Grand.Business.Authentication.Services.Scim;
+
+/// What the mapped SCIM groups of one user give
+/// The back-office roles cannot be combined, so none of them is given
+public record ScimWanted(IReadOnlyCollection GroupIds, string StaffStoreId, string VendorId, bool Conflict);
+
+///
+/// The roles a user gets from its mapped SCIM groups. The back-office roles must form a combination the customer validator
+/// accepts - Administrators alone, StoreManager with one store, Vendors with one vendor - otherwise none of them is given
+/// (least privilege: guessing would open the wrong store or panel); custom customer groups are given in every case
+///
+public static class ScimRoles
+{
+ /// The SCIM groups the user belongs to
+ /// System name of a customer group; empty for a custom group
+ public static ScimWanted Wanted(IEnumerable groups, Func systemNameOf)
+ {
+ var mapped = groups.Where(x => !string.IsNullOrEmpty(x.CustomerGroupId)).ToList();
+ var custom = new HashSet();
+ var admins = new HashSet();
+ var storeManagers = new HashSet();
+ var vendorGroups = new HashSet();
+ var stores = new HashSet();
+ var vendors = new HashSet();
+
+ foreach (var group in mapped)
+ switch (systemNameOf(group.CustomerGroupId))
+ {
+ case SystemCustomerGroupNames.Administrators:
+ admins.Add(group.CustomerGroupId);
+ break;
+ case SystemCustomerGroupNames.StoreManager:
+ storeManagers.Add(group.CustomerGroupId);
+ if (!string.IsNullOrEmpty(group.StaffStoreId))
+ stores.Add(group.StaffStoreId);
+ break;
+ case SystemCustomerGroupNames.Vendors:
+ vendorGroups.Add(group.CustomerGroupId);
+ if (!string.IsNullOrEmpty(group.VendorId))
+ vendors.Add(group.VendorId);
+ break;
+ default:
+ custom.Add(group.CustomerGroupId);
+ break;
+ }
+
+ var kinds = (admins.Count > 0 ? 1 : 0) + (storeManagers.Count > 0 ? 1 : 0) + (vendorGroups.Count > 0 ? 1 : 0);
+ var conflict = kinds > 1 ||
+ (storeManagers.Count > 0 && stores.Count != 1) ||
+ (vendorGroups.Count > 0 && vendors.Count != 1);
+ if (conflict)
+ return new ScimWanted(custom, null, null, true);
+
+ var wanted = new HashSet(custom);
+ wanted.UnionWith(admins);
+ wanted.UnionWith(storeManagers);
+ wanted.UnionWith(vendorGroups);
+ return new ScimWanted(wanted, stores.SingleOrDefault(), vendors.SingleOrDefault(), false);
+ }
+}
diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs
new file mode 100644
index 0000000000..5378d1e1e9
--- /dev/null
+++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs
@@ -0,0 +1,433 @@
+using Grand.Business.Core.Extensions;
+using Grand.Business.Core.Interfaces.Authentication;
+using Grand.Business.Core.Interfaces.Common.Directory;
+using Grand.Business.Core.Interfaces.Common.Security;
+using Grand.Business.Core.Interfaces.Customers;
+using Grand.Business.Core.Utilities.Authentication;
+using Grand.Business.Core.Utilities.Customers;
+using Grand.Data;
+using Grand.Domain.Common;
+using Grand.Domain.Customers;
+using Grand.Infrastructure.Configuration;
+using Grand.Infrastructure.Extensions;
+using Grand.Mediator;
+using Grand.SharedKernel.Extensions;
+using Microsoft.Extensions.Logging;
+using System.Security.Cryptography;
+
+namespace Grand.Business.Authentication.Services.Scim;
+
+///
+/// SCIM provisioning of staff accounts (). New accounts are store-less (usable on every store
+/// host with per-store identity) and registered without a password anyone knows: they sign in through the identity provider,
+/// linked ahead by . Only staff accounts, and shopper accounts of the company's
+/// domains (), are taken over by e-mail, and they are not linked ahead: an e-mail at
+/// the provider proves nothing about who owns the GrandNode account, so they connect once by the usual rules (password and
+/// second factor). A taken-over shopper account loses its credentials first ():
+/// anyone could have registered the address. No welcome message is sent - SCIM provisions employees, not shoppers. Logs carry customer ids, never
+/// e-mails
+///
+public class ScimUserService(
+ IRepository users,
+ IRepository groups,
+ IRepository links,
+ ICustomerService customerService,
+ ICustomerManagerService customerManagerService,
+ IGroupService groupService,
+ IPermissionService permissionService,
+ IOidcCustomerResolver oidcCustomerResolver,
+ IOidcLinkService oidcLinkService,
+ ScimRoleSync roleSync,
+ ScimAccountLock accountLock,
+ IMediator mediator,
+ ScimConfig config,
+ OidcLoginConfig loginConfig,
+ CustomerConfig customerConfig,
+ ILogger logger) : IScimUserService
+{
+ private const string LinkProvider = OidcCustomerResolver.ProviderSystemName;
+
+ public virtual async Task Create(ScimUserData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var email = EmailOf(data);
+ if (email == null)
+ return Invalid("userName or emails must hold an e-mail address");
+
+ //the Admin password sign-in of a break-glass address must stay out of the provider's reach, even before it has an account
+ if (loginConfig.IsBreakGlass(email))
+ return Conflict("Break-glass accounts are not managed by SCIM");
+
+ var externalId = data.ExternalId ?? "";
+ if (await ExternalIdTaken(externalId, null))
+ return Conflict("A user with this externalId is already provisioned");
+
+ var customer = await customerService.GetCustomerByEmail(email);
+ if (customer == null)
+ {
+ if (await customerService.GetCustomerByUsername(email) != null)
+ return Conflict("The user name is already taken");
+ return await Register(email, externalId, data);
+ }
+
+ var record = await users.GetByIdAsync(customer.Id);
+ if (record is { Deprovisioned: false })
+ return Conflict("The user is already provisioned");
+
+ //a user the provider deleted and creates again is the same managed account; any other account must be staff
+ var refusal = await TakeOverRefusal(customer, externalId, record == null);
+ if (refusal != null)
+ {
+ logger.LogWarning("SCIM refused to take over customer {CustomerId}: {Reason}", customer.Id, refusal);
+ return Conflict(refusal);
+ }
+
+ if (record == null)
+ {
+ record = new ScimUser { Id = customer.Id, ExternalId = externalId };
+ await roleSync.TakeOver(customer, record);
+ if (!await Insert(record))
+ return Conflict("The user is already provisioned");
+ logger.LogInformation("SCIM took over customer {CustomerId}", customer.Id);
+
+ //a shopper account (taken over for its domain) may have been registered by anyone with the address: its
+ //credentials go before the provider's roles arrive. A staff account was made by an administrator and keeps them
+ if (!await permissionService.IsBackOfficeAccount(customer))
+ {
+ await accountLock.ResetCredentials(customer);
+ customer = await customerService.GetCustomerById(customer.Id) ?? customer;
+ }
+ }
+ else
+ {
+ record.ExternalId = externalId;
+ record.Deprovisioned = false;
+ await users.UpdateAsync(record);
+ await mediator.EntityUpdated(record);
+ logger.LogInformation("SCIM provisioned customer {CustomerId} again", customer.Id);
+ }
+
+ return await Apply(customer, record, data, email, ScimOutcome.Created);
+ }
+
+ public virtual async Task Replace(string id, ScimUserData data)
+ {
+ ArgumentNullException.ThrowIfNull(data);
+
+ var (customer, record) = await Load(id);
+ if (customer == null)
+ return new ScimUserResult(ScimOutcome.NotFound);
+
+ //every check before the first write: a refused replace changes nothing
+ //an account may have become a break-glass account since it was provisioned
+ var refusal = ScimProtection.Refusal(customer, loginConfig);
+ if (refusal != null)
+ return Conflict(refusal);
+
+ var email = EmailOf(data);
+ if (email == null)
+ return Invalid("userName or emails must hold an e-mail address");
+
+ var emailChanged = email != customer.Email;
+ if (emailChanged)
+ {
+ //the Admin password sign-in of a break-glass address must stay out of the provider's reach
+ if (loginConfig.IsBreakGlass(email))
+ return Conflict("Break-glass accounts are not managed by SCIM");
+ if (OwnedByOther(await customerService.GetCustomerByEmail(email), customer) ||
+ OwnedByOther(await customerService.GetCustomerByUsername(email), customer))
+ return Conflict("The e-mail address belongs to another account");
+ }
+
+ var externalId = data.ExternalId ?? "";
+ if (externalId != record.ExternalId && await ExternalIdTaken(externalId, record.Id))
+ return Conflict("A user with this externalId is already provisioned");
+
+ if (emailChanged)
+ {
+ //a user name of its own (UsernamesEnabled) stays; one that was the e-mail follows it
+ if (customer.Username == customer.Email)
+ {
+ await customerService.UpdateCustomerField(customer.Id, x => x.Username, email);
+ customer.Username = email;
+ }
+
+ await customerService.UpdateCustomerField(customer.Id, x => x.Email, email);
+ customer.Email = email;
+ }
+
+ if (externalId != record.ExternalId)
+ {
+ await users.UpdateField(record.Id, x => x.ExternalId, externalId);
+ record.ExternalId = externalId;
+ await mediator.EntityUpdated(record);
+ }
+
+ return await Apply(customer, record, data, email, ScimOutcome.Ok);
+ }
+
+ public virtual async Task Get(string id)
+ {
+ var (customer, record) = await Load(id);
+ return customer == null
+ ? new ScimUserResult(ScimOutcome.NotFound)
+ : new ScimUserResult(ScimOutcome.Ok, await View(customer, record));
+ }
+
+ public virtual async Task> List(string userName, string externalId, int skip, int take)
+ {
+ var query = users.Table.Where(x => !x.Deprovisioned);
+ if (!string.IsNullOrEmpty(externalId))
+ query = query.Where(x => x.ExternalId == externalId);
+ if (!string.IsNullOrEmpty(userName))
+ {
+ //the SCIM user name is the account's e-mail
+ var customer = await customerService.GetCustomerByEmail(userName.Trim());
+ if (customer == null)
+ return new ScimPage([], 0);
+ var customerId = customer.Id;
+ query = query.Where(x => x.Id == customerId);
+ }
+
+ var total = await users.CountAsync(query);
+ var records = take <= 0
+ ? []
+ : await users.ToListAsync(query.OrderBy(x => x.CreatedOnUtc).ThenBy(x => x.Id).Skip(Math.Max(skip, 0)).Take(take));
+ var customers = records.Count == 0
+ ? new Dictionary()
+ : (await customerService.GetCustomersByIds(records.Select(x => x.Id).ToArray())).ToDictionary(x => x.Id);
+
+ var items = new List();
+ foreach (var record in records)
+ if (customers.TryGetValue(record.Id, out var customer) && !customer.Deleted)
+ items.Add(await View(customer, record));
+ return new ScimPage(items, total);
+ }
+
+ ///
+ /// The account is deactivated, loses the roles SCIM granted, its OpenID Connect link and its groups; the record stays,
+ /// marked deprovisioned, so the same user created again is recognised (and reactivated when SCIM deactivated it)
+ ///
+ public virtual async Task Delete(string id)
+ {
+ var (customer, record) = await Load(id);
+ if (customer == null)
+ return ScimOutcome.NotFound;
+
+ //an account may have become a break-glass account since it was provisioned
+ var refusal = ScimProtection.Refusal(customer, loginConfig);
+ if (refusal != null)
+ {
+ logger.LogWarning("SCIM refused to delete customer {CustomerId}: {Reason}", customer.Id, refusal);
+ return ScimOutcome.Conflict;
+ }
+
+ if (customer.Active)
+ {
+ await accountLock.Deactivate(customer);
+ await users.UpdateField(record.Id, x => x.DeactivatedByScim, true);
+ }
+
+ await roleSync.Revoke(customer, record);
+
+ var link = await links.GetOneAsync(x => x.ProviderSystemName == LinkProvider && x.CustomerId == id);
+ if (link != null)
+ await oidcLinkService.Remove(customer, link.Id);
+
+ foreach (var group in await groups.ToListAsync(groups.Table.Where(x => x.Members.Contains(id))))
+ await groups.RemoveCollectionFieldItem(group.Id, x => x.Members, m => m == id);
+
+ await users.UpdateField(record.Id, x => x.Deprovisioned, true);
+ record.Deprovisioned = true;
+ await mediator.EntityUpdated(record);
+ logger.LogInformation("SCIM user {CustomerId} deleted; the customer is kept inactive", id);
+ return ScimOutcome.Ok;
+ }
+
+ public virtual async Task> GetConflicts() =>
+ await users.ToListAsync(users.Table.Where(x => x.RoleConflict && !x.Deprovisioned).Select(x => x.Id));
+
+ public virtual Task Count() => users.CountAsync(users.Table.Where(x => !x.Deprovisioned));
+
+ private async Task Apply(Customer customer, ScimUser record, ScimUserData data, string email,
+ ScimOutcome outcome)
+ {
+ //an empty value removes the user field
+ await customerService.UpdateUserField(customer, SystemCustomerFieldNames.FirstName, data.GivenName ?? "");
+ await customerService.UpdateUserField(customer, SystemCustomerFieldNames.LastName, data.FamilyName ?? "");
+
+ if (!data.Active)
+ {
+ //an account disabled by hand stays the administrator's: SCIM does not take it over as its own deactivation
+ if (customer.Active)
+ {
+ await accountLock.Deactivate(customer);
+ await SetDeactivatedByScim(record, true);
+ }
+ }
+ else if (!customer.Active && record.DeactivatedByScim)
+ {
+ await accountLock.Activate(customer);
+ await SetDeactivatedByScim(record, false);
+ }
+
+ await EnsureLink(customer, record, email);
+ await roleSync.Sync(await customerService.GetCustomerById(customer.Id) ?? customer, record);
+
+ var stored = await customerService.GetCustomerById(customer.Id) ?? customer;
+ return new ScimUserResult(outcome, await View(stored, record));
+ }
+
+ //the one field alone: a whole-record write would undo a concurrent role sync
+ private async Task SetDeactivatedByScim(ScimUser record, bool value)
+ {
+ await users.UpdateField(record.Id, x => x.DeactivatedByScim, value);
+ record.DeactivatedByScim = value;
+ await mediator.EntityUpdated(record);
+ }
+
+ ///
+ /// An account SCIM created is linked to its externalId (the OpenID Connect subject) as if connected with its password, so
+ /// the first sign-in needs none. A taken-over account is not: it connects at sign-in by the usual rules. A link to another
+ /// subject is kept (the administrator decides) and only logged
+ ///
+ private async Task EnsureLink(Customer customer, ScimUser record, string email)
+ {
+ if (!record.RegisteredByScim || !config.ExternalIdIsSubject || string.IsNullOrEmpty(record.ExternalId))
+ return;
+
+ var customerId = customer.Id;
+ var link = await links.GetOneAsync(x => x.ProviderSystemName == LinkProvider && x.CustomerId == customerId);
+ if (link != null && link.ExternalIdentifier != record.ExternalId)
+ {
+ logger.LogWarning("SCIM user {CustomerId} is linked to another OpenID Connect subject; the link is kept",
+ customerId);
+ return;
+ }
+
+ if (link == null)
+ {
+ var result = await oidcCustomerResolver.LinkIdentity(customer, record.ExternalId, email, "");
+ if (result != OidcLinkResult.Linked)
+ logger.LogWarning("SCIM could not link customer {CustomerId} to its externalId ({Result})", customerId,
+ result);
+ }
+ }
+
+ private async Task Register(string email, string externalId, ScimUserData data)
+ {
+ var customer = await customerService.InsertGuestCustomer(new Customer {
+ CustomerGuid = Guid.NewGuid(),
+ Active = true,
+ StoreId = "",
+ LastActivityDateUtc = DateTime.UtcNow
+ });
+
+ //a password nobody knows: the account signs in through the identity provider
+ var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
+ await customerManagerService.RegisterCustomer(new RegistrationRequest(customer, email, email, password,
+ PasswordFormat.Hashed, ""));
+
+ var record = new ScimUser { Id = customer.Id, ExternalId = externalId, RegisteredByScim = true };
+ if (!await Insert(record))
+ return Conflict("The user is already provisioned");
+ logger.LogInformation("SCIM registered customer {CustomerId}", customer.Id);
+ return await Apply(customer, record, data, email, ScimOutcome.Created);
+ }
+
+ //false when a concurrent request provisioned the same account first (the record id is the customer id)
+ private async Task Insert(ScimUser record)
+ {
+ try
+ {
+ await users.InsertAsync(record);
+ }
+ catch (Exception ex)
+ {
+ logger.LogInformation("Storing the SCIM user of customer {CustomerId} failed ({ExceptionType}); re-reading",
+ record.Id, ex.GetType().Name);
+ if (await users.GetByIdAsync(record.Id) == null)
+ throw;
+ return false;
+ }
+
+ await mediator.EntityInserted(record);
+ return true;
+ }
+
+ /// False for a user the provider deleted and creates again: it was managed already
+ private async Task TakeOverRefusal(Customer customer, string externalId, bool mustBeStaff)
+ {
+ var refusal = ScimProtection.Refusal(customer, loginConfig);
+ if (refusal != null)
+ return refusal;
+
+ if (customerConfig.RegisterCustomersPerStore && !string.IsNullOrEmpty(customer.StoreId))
+ return "The e-mail address belongs to an account of one store";
+
+ if (!await groupService.IsRegistered(customer))
+ return "Only registered accounts can be managed by SCIM";
+
+ //a shopper with the e-mail is the provider's to manage only on the company's own domains (an employee's shopping account)
+ if (mustBeStaff && !config.IsTakeOverDomain(customer.Email) && !await permissionService.IsBackOfficeAccount(customer))
+ return "Only staff accounts and accounts of the company's e-mail domains are taken over by SCIM";
+
+ if (!config.ExternalIdIsSubject || externalId == "")
+ return null;
+
+ var customerId = customer.Id;
+ var link = await links.GetOneAsync(x => x.ProviderSystemName == LinkProvider && x.CustomerId == customerId);
+ if (link != null && link.ExternalIdentifier != externalId)
+ return "The account is linked to another identity";
+
+ var subjectLink = await links.GetOneAsync(x =>
+ x.ProviderSystemName == LinkProvider && x.ExternalIdentifier == externalId && x.CustomerId != customerId);
+ return subjectLink != null ? "The identity is linked to another account" : null;
+ }
+
+ //a deprovisioned user keeps its externalId for a re-creation, so it does not block another user
+ private async Task ExternalIdTaken(string externalId, string exceptId) =>
+ externalId != "" &&
+ await users.GetOneAsync(x => x.ExternalId == externalId && !x.Deprovisioned && x.Id != exceptId) != null;
+
+ private async Task<(Customer Customer, ScimUser Record)> Load(string id)
+ {
+ if (string.IsNullOrEmpty(id))
+ return (null, null);
+ var record = await users.GetByIdAsync(id);
+ if (record is not { Deprovisioned: false })
+ return (null, null);
+ var customer = await customerService.GetCustomerById(id);
+ return customer is { Deleted: false } ? (customer, record) : (null, null);
+ }
+
+ private async Task View(Customer customer, ScimUser record)
+ {
+ var customerId = customer.Id;
+ var memberOf = await groups.ToListAsync(groups.Table.Where(x => x.Members.Contains(customerId)));
+ return new ScimUserView(
+ customer.Id,
+ string.IsNullOrEmpty(record.ExternalId) ? null : record.ExternalId,
+ customer.Email,
+ customer.Email,
+ customer.GetUserFieldFromEntity(SystemCustomerFieldNames.FirstName),
+ customer.GetUserFieldFromEntity(SystemCustomerFieldNames.LastName),
+ customer.Active,
+ memberOf.Select(x => new ScimGroupRef(x.Id, x.DisplayName)).ToList(),
+ record.CreatedOnUtc,
+ record.UpdatedOnUtc ?? record.CreatedOnUtc);
+ }
+
+ private static bool OwnedByOther(Customer found, Customer customer) => found != null && found.Id != customer.Id;
+
+ //the first of userName and the e-mails that is an e-mail address, lower-cased
+ private static string EmailOf(ScimUserData data) =>
+ new[] { data.UserName, data.Email }
+ .Select(x => x?.Trim())
+ .FirstOrDefault(x => !string.IsNullOrEmpty(x) && CommonHelper.IsValidEmail(x))
+ ?.ToLowerInvariant();
+
+ private static ScimUserResult Conflict(string detail) => new(ScimOutcome.Conflict, Detail: detail);
+ private static ScimUserResult Invalid(string detail) => new(ScimOutcome.Invalid, Detail: detail);
+}
diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
index 019a592413..f42469bb24 100644
--- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
+++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs
@@ -1,4 +1,5 @@
using Grand.Business.Authentication.Services;
+using Grand.Business.Authentication.Services.Scim;
using Grand.Business.Core.Interfaces.Authentication;
using Grand.Infrastructure;
using Grand.Infrastructure.Configuration;
@@ -26,6 +27,10 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
services.AddScoped();
services.AddScoped();
services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
+ services.AddScoped();
}
public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment)
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs
new file mode 100644
index 0000000000..647606a568
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs
@@ -0,0 +1,39 @@
+using Grand.Business.Core.Utilities.Authentication;
+using Grand.Domain.Customers;
+
+namespace Grand.Business.Core.Interfaces.Authentication;
+
+///
+/// Groups pushed by the identity provider over SCIM and their mapping to GrandNode roles. Every change of a membership or
+/// a mapping recomputes the roles of the members it touches
+///
+public interface IScimGroupService
+{
+ Task Create(ScimGroupData data);
+
+ Task Replace(string id, ScimGroupData data);
+
+ Task Get(string id);
+
+ /// The groups, optionally those with the display name or the externalId (exact, display name case-insensitive)
+ Task> List(string displayName, string externalId, int skip, int take);
+
+ Task Delete(string id);
+
+ /// Adds and removes members; replaces the members with
+ Task ModifyMembers(string id, IList add, IList remove, bool replaceAll);
+
+ /// Changes the name and the externalId only (null keeps the value)
+ Task Rename(string id, string displayName, string externalId);
+
+ Task> GetMappings();
+
+ /// The customer groups a SCIM group can be mapped to
+ Task> GetMappableGroups();
+
+ ///
+ /// Maps the group to a customer group (Administrators; StoreManager with a store; Vendors with a vendor; a custom group);
+ /// an empty customer group removes the mapping. Invalid for any other target
+ ///
+ Task SetMapping(string groupId, string customerGroupId, string staffStoreId, string vendorId);
+}
diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs
new file mode 100644
index 0000000000..c21420f883
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs
@@ -0,0 +1,29 @@
+using Grand.Business.Core.Utilities.Authentication;
+
+namespace Grand.Business.Core.Interfaces.Authentication;
+
+///
+/// SCIM provisioning of staff accounts: the identity provider creates (or takes over a staff account by e-mail), updates,
+/// deactivates and deletes the GrandNode account of an employee. A deleted SCIM user keeps its customer, inactive and without
+/// the roles SCIM granted. Shopper, system, deleted and break-glass accounts are never managed
+///
+public interface IScimUserService
+{
+ Task Create(ScimUserData data);
+
+ /// SCIM user id (the customer id)
+ Task Replace(string id, ScimUserData data);
+
+ Task Get(string id);
+
+ /// The managed users, optionally those with the user name or the externalId (exact, user name case-insensitive)
+ Task> List(string userName, string externalId, int skip, int take);
+
+ Task Delete(string id);
+
+ /// Customer ids of the managed users whose mapped groups give back-office roles that cannot be combined
+ Task> GetConflicts();
+
+ /// How many accounts SCIM manages
+ Task Count();
+}
diff --git a/src/Business/Grand.Business.Core/Utilities/Authentication/ScimResults.cs b/src/Business/Grand.Business.Core/Utilities/Authentication/ScimResults.cs
new file mode 100644
index 0000000000..4e4264ad6f
--- /dev/null
+++ b/src/Business/Grand.Business.Core/Utilities/Authentication/ScimResults.cs
@@ -0,0 +1,61 @@
+namespace Grand.Business.Core.Utilities.Authentication;
+
+/// Result of a SCIM provisioning operation; the endpoint maps it to the HTTP status
+public enum ScimOutcome
+{
+ Ok,
+ Created,
+ NotFound,
+
+ /// The account or group cannot be managed this way (uniqueness, a protected account)
+ Conflict,
+
+ /// The request lacks or breaks a required value
+ Invalid
+}
+
+/// The attributes of a SCIM user that GrandNode keeps
+public record ScimUserData(string UserName, string ExternalId, string Email, string GivenName, string FamilyName, bool Active);
+
+public record ScimGroupRef(string Id, string DisplayName);
+
+/// A SCIM user as the endpoint returns it (id = customer id)
+public record ScimUserView(
+ string Id,
+ string ExternalId,
+ string UserName,
+ string Email,
+ string GivenName,
+ string FamilyName,
+ bool Active,
+ IList Groups,
+ DateTime Created,
+ DateTime? LastModified);
+
+/// Why the operation was refused, for the SCIM error; never an e-mail or another personal value
+public record ScimUserResult(ScimOutcome Outcome, ScimUserView User = null, string Detail = null);
+
+/// The attributes of a SCIM group that GrandNode keeps; members are SCIM user ids (customer ids)
+public record ScimGroupData(string DisplayName, string ExternalId, IList MemberIds);
+
+public record ScimGroupView(
+ string Id,
+ string DisplayName,
+ string ExternalId,
+ IList MemberIds,
+ DateTime Created,
+ DateTime? LastModified);
+
+/// Why the operation was refused, for the SCIM error
+public record ScimGroupResult(ScimOutcome Outcome, ScimGroupView Group = null, string Detail = null);
+
+public record ScimPage(IList Items, int Total);
+
+/// A group received from the identity provider and the role an administrator mapped to it
+public record ScimGroupMapping(
+ string GroupId,
+ string DisplayName,
+ int MemberCount,
+ string CustomerGroupId,
+ string StaffStoreId,
+ string VendorId);
diff --git a/src/Core/Grand.Domain/Customers/ScimGroup.cs b/src/Core/Grand.Domain/Customers/ScimGroup.cs
new file mode 100644
index 0000000000..527d3e3b80
--- /dev/null
+++ b/src/Core/Grand.Domain/Customers/ScimGroup.cs
@@ -0,0 +1,25 @@
+namespace Grand.Domain.Customers;
+
+///
+/// A group pushed by the identity provider over SCIM, and the GrandNode role its members get. A group grants nothing until an
+/// administrator maps it
+///
+public class ScimGroup : BaseEntity
+{
+ public string DisplayName { get; set; }
+
+ /// The identity provider's id of the group
+ public string ExternalId { get; set; }
+
+ /// Ids of the SCIM-managed customers in the group
+ public List Members { get; set; } = [];
+
+ /// Customer group the members get; empty while the group is not mapped
+ public string CustomerGroupId { get; set; }
+
+ /// Staff store of the members, with the StoreManager group
+ public string StaffStoreId { get; set; }
+
+ /// Vendor of the members, with the Vendors group
+ public string VendorId { get; set; }
+}
diff --git a/src/Core/Grand.Domain/Customers/ScimUser.cs b/src/Core/Grand.Domain/Customers/ScimUser.cs
new file mode 100644
index 0000000000..908cb724a2
--- /dev/null
+++ b/src/Core/Grand.Domain/Customers/ScimUser.cs
@@ -0,0 +1,38 @@
+namespace Grand.Domain.Customers;
+
+///
+/// A staff account managed by SCIM provisioning: its is the customer id, so one customer has
+/// at most one record. It also remembers what SCIM granted, so SCIM takes away only its own grants
+///
+public class ScimUser : BaseEntity
+{
+ /// The identity provider's id of the user (with Scim:ExternalIdIsSubject also its OpenID Connect subject)
+ public string ExternalId { get; set; }
+
+ /// Customer groups SCIM gave the account
+ public List GrantedGroupIds { get; set; } = [];
+
+ /// Staff store SCIM set on the account; empty when SCIM set none
+ public string GrantedStaffStoreId { get; set; }
+
+ /// Vendor SCIM set on the account; empty when SCIM set none
+ public string GrantedVendorId { get; set; }
+
+ /// The account was deactivated by SCIM, so SCIM may activate it again (never an account disabled by hand)
+ public bool DeactivatedByScim { get; set; }
+
+ /// The mapped groups of the user give back-office roles that cannot be combined: SCIM granted none of them
+ public bool RoleConflict { get; set; }
+
+ ///
+ /// SCIM created the account (it had no owner before), so SCIM links it to the user's OpenID Connect subject. A taken-over
+ /// account is connected at sign-in by the usual rules (password and second factor for staff)
+ ///
+ public bool RegisteredByScim { get; set; }
+
+ ///
+ /// The identity provider deleted the user: the record stays (inactive account, no roles) so a user created again is
+ /// recognised as the same managed account, but SCIM no longer shows it
+ ///
+ public bool Deprovisioned { get; set; }
+}
diff --git a/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs
new file mode 100644
index 0000000000..a0541bee17
--- /dev/null
+++ b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs
@@ -0,0 +1,52 @@
+namespace Grand.Infrastructure.Configuration;
+
+///
+/// appsettings section Scim: the SCIM 2.0 provisioning endpoint of module Grand.Module.Scim, through which the identity
+/// provider of OidcResourceServer creates, updates and locks out staff accounts
+///
+public class ScimConfig
+{
+ private const int MinTokenLength = 32;
+ private const int DefaultMaxResults = 100;
+
+ /// Bearer token the identity provider sends; keep it out of source control (env var Scim__Token or a secret store)
+ public string Token { get; set; }
+
+ ///
+ /// True: the SCIM externalId of a user is its OpenID Connect subject, and every SCIM write links the account to it,
+ /// so the first sign-in needs no password. False: accounts are linked at sign-in by the usual rules
+ ///
+ public bool ExternalIdIsSubject { get; set; } = true;
+
+ ///
+ /// E-mail domains of the company (e.g. firma.pl). An existing shopper account with an e-mail in one of them is taken over
+ /// like a staff account (an employee's shopping account), and its credentials are reset; any other shopper account is
+ /// refused. Exact domains only, no subdomains
+ ///
+ public string[] TakeOverDomains { get; set; } = [];
+
+ /// Largest page of a list response
+ public int MaxResults { get; set; } = DefaultMaxResults;
+
+ /// A token shorter than 32 characters is a configuration mistake: the endpoint refuses every request
+ public bool TokenUsable => !string.IsNullOrEmpty(Token) && Token.Length >= MinTokenLength;
+
+ /// The e-mail is in one of (case-insensitive, exact domain)
+ public bool IsTakeOverDomain(string email)
+ {
+ var at = email?.Trim().LastIndexOf('@') ?? -1;
+ if (at < 1 || TakeOverDomains == null)
+ return false;
+ var domain = email.Trim()[(at + 1)..];
+ return TakeOverDomains.Any(x =>
+ !string.IsNullOrWhiteSpace(x) &&
+ string.Equals(x.Trim().TrimStart('@'), domain, StringComparison.OrdinalIgnoreCase));
+ }
+
+ /// The page size of a list request: the requested count within 0..MaxResults, MaxResults when none was given
+ public int PageSize(int? requested)
+ {
+ var max = MaxResults > 0 ? MaxResults : DefaultMaxResults;
+ return requested is null ? max : Math.Clamp(requested.Value, 0, max);
+ }
+}
diff --git a/src/Core/Grand.Infrastructure/StartupBase.cs b/src/Core/Grand.Infrastructure/StartupBase.cs
index e2a1af27bb..bed3e03a40 100644
--- a/src/Core/Grand.Infrastructure/StartupBase.cs
+++ b/src/Core/Grand.Infrastructure/StartupBase.cs
@@ -223,6 +223,7 @@ private static void RegisterConfigurations(IServiceCollection services, IConfigu
services.StartupConfig(configuration.GetSection("FrontendAPI"));
services.StartupConfig(configuration.GetSection("OidcResourceServer"));
services.StartupConfig(configuration.GetSection("OidcLogin"));
+ services.StartupConfig(configuration.GetSection("Scim"));
services.StartupConfig(configuration.GetSection("Database"));
services.StartupConfig(configuration.GetSection("Amazon"));
services.StartupConfig(configuration.GetSection("Azure"));
diff --git a/src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs b/src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs
new file mode 100644
index 0000000000..d10abc0f25
--- /dev/null
+++ b/src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs
@@ -0,0 +1,57 @@
+using Grand.Business.Core.Utilities.Authentication;
+using Grand.Infrastructure.Configuration;
+using Grand.Module.Scim.Infrastructure;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using System.Text.Json;
+
+namespace Grand.Module.Scim.Controllers;
+
+///
+/// Base of the SCIM 2.0 endpoint: the identity provider's Bearer token only, bodies read and written with the SCIM JSON
+/// options (no MVC formatters, so application/scim+json needs no input formatter)
+///
+[ServiceFilter(typeof(ScimAuthorizeFilter))]
+[ApiExplorerSettings(IgnoreApi = true)]
+[RequestSizeLimit(MaxBodyBytes)]
+[Route("scim/v2")]
+public abstract class ScimControllerBase : ControllerBase
+{
+ //a SCIM user or group, or a patch of one; a large group membership patch stays far below
+ private const int MaxBodyBytes = 1024 * 1024;
+
+ protected string BaseUrl => $"{Request.Scheme}://{Request.Host}{Request.PathBase}/scim/v2";
+
+ /// The body, or the 400 response to return when it is missing or is not JSON of the expected shape
+ protected async Task<(T Body, IActionResult Error)> ReadBody() where T : class
+ {
+ try
+ {
+ var body = await JsonSerializer.DeserializeAsync(Request.Body, ScimJson.Options, HttpContext.RequestAborted);
+ return body == null ? (null, InvalidSyntax()) : (body, null);
+ }
+ catch (JsonException)
+ {
+ return (null, InvalidSyntax());
+ }
+ }
+
+ /// The zero-based skip and the page size of a list request (startIndex is one-based)
+ protected static (int StartIndex, int Skip, int Take) Page(int? startIndex, int? count, ScimConfig config)
+ {
+ var start = Math.Max(startIndex ?? 1, 1);
+ return (start, start - 1, config.PageSize(count));
+ }
+
+ protected static IActionResult Refused(ScimOutcome outcome, string detail) => outcome switch {
+ ScimOutcome.NotFound => ScimResponse.Error(StatusCodes.Status404NotFound, null, detail ?? "Resource not found"),
+ ScimOutcome.Conflict => ScimResponse.Error(StatusCodes.Status409Conflict, "uniqueness", detail),
+ _ => ScimResponse.Error(StatusCodes.Status400BadRequest, "invalidValue", detail)
+ };
+
+ protected static IActionResult InvalidFilter(string detail) =>
+ ScimResponse.Error(StatusCodes.Status400BadRequest, "invalidFilter", detail);
+
+ private static IActionResult InvalidSyntax() =>
+ ScimResponse.Error(StatusCodes.Status400BadRequest, "invalidSyntax", "The request body is not valid SCIM JSON");
+}
diff --git a/src/Modules/Grand.Module.Scim/Controllers/ScimDiscoveryController.cs b/src/Modules/Grand.Module.Scim/Controllers/ScimDiscoveryController.cs
new file mode 100644
index 0000000000..0555b26fba
--- /dev/null
+++ b/src/Modules/Grand.Module.Scim/Controllers/ScimDiscoveryController.cs
@@ -0,0 +1,86 @@
+using Grand.Infrastructure.Configuration;
+using Grand.Module.Scim.Infrastructure;
+using Grand.Module.Scim.Models;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Grand.Module.Scim.Controllers;
+
+/// SCIM discovery (RFC 7644, section 4): what this service provider supports
+public class ScimDiscoveryController(ScimConfig config) : ScimControllerBase
+{
+ [HttpGet("ServiceProviderConfig")]
+ public IActionResult ServiceProviderConfig() =>
+ ScimResponse.Json(new {
+ schemas = new[] { ScimSchemas.ServiceProviderConfig },
+ patch = new { supported = true },
+ bulk = new { supported = false, maxOperations = 0, maxPayloadSize = 0 },
+ filter = new { supported = true, maxResults = config.PageSize(null) },
+ changePassword = new { supported = false },
+ sort = new { supported = false },
+ etag = new { supported = false },
+ authenticationSchemes = new[] {
+ new {
+ type = "oauthbearertoken",
+ name = "Bearer token",
+ description = "The long-lived token of Scim:Token in appsettings",
+ primary = true
+ }
+ },
+ meta = new { resourceType = "ServiceProviderConfig", location = $"{BaseUrl}/ServiceProviderConfig" }
+ });
+
+ [HttpGet("ResourceTypes")]
+ public IActionResult ResourceTypes() =>
+ ScimResponse.Json(new ScimListResponse