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 { + TotalResults = 2, + StartIndex = 1, + ItemsPerPage = 2, + Resources = [ResourceType("User", "/Users", ScimSchemas.User), ResourceType("Group", "/Groups", ScimSchemas.Group)] + }); + + [HttpGet("Schemas")] + public IActionResult Schemas() => + ScimResponse.Json(new ScimListResponse { + TotalResults = 2, + StartIndex = 1, + ItemsPerPage = 2, + Resources = [ + Schema(ScimSchemas.User, "User", [ + Attribute("userName", "string", true, true), + Attribute("externalId", "string", false, true), + Attribute("name", "complex", false, false), + Attribute("emails", "complex", false, false, true), + Attribute("active", "boolean", false, false), + Attribute("groups", "complex", false, false, true, "readOnly") + ]), + Schema(ScimSchemas.Group, "Group", [ + Attribute("displayName", "string", true, false), + Attribute("externalId", "string", false, true), + Attribute("members", "complex", false, false, true) + ]) + ] + }); + + private object ResourceType(string name, string endpoint, string schema) => + new { + schemas = new[] { ScimSchemas.ResourceType }, + id = name, + name, + endpoint, + schema, + meta = new { resourceType = "ResourceType", location = $"{BaseUrl}/ResourceTypes/{name}" } + }; + + private object Schema(string id, string name, object[] attributes) => + new { + schemas = new[] { ScimSchemas.Schema }, + id, + name, + attributes, + meta = new { resourceType = "Schema", location = $"{BaseUrl}/Schemas/{id}" } + }; + + private static object Attribute(string name, string type, bool required, bool caseExact, bool multiValued = false, + string mutability = "readWrite") => + new { name, type, multiValued, required, caseExact, mutability, returned = "default", uniqueness = "none" }; +} diff --git a/src/Modules/Grand.Module.Scim/Controllers/ScimGroupsController.cs b/src/Modules/Grand.Module.Scim/Controllers/ScimGroupsController.cs new file mode 100644 index 0000000000..27e049b2a6 --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Controllers/ScimGroupsController.cs @@ -0,0 +1,134 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure.Configuration; +using Grand.Module.Scim.Infrastructure; +using Grand.Module.Scim.Models; +using Grand.Module.Scim.Protocol; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Module.Scim.Controllers; + +/// SCIM /Groups: groups of the identity provider; an administrator maps them to GrandNode roles +public class ScimGroupsController(IScimGroupService groupService, ScimConfig config) : ScimControllerBase +{ + [HttpGet("Groups")] + public async Task List([FromQuery] string filter, [FromQuery] int? startIndex, [FromQuery] int? count, + [FromQuery] string excludedAttributes) + { + string displayName = null, externalId = null; + if (!string.IsNullOrWhiteSpace(filter)) + { + if (!ScimFilter.TryParse(filter, out var parsed) || parsed.Attribute is not ("displayname" or "externalid")) + return InvalidFilter("Only the filters displayName eq \"...\" and externalId eq \"...\" are supported"); + + if (parsed.Value == "") + return ScimResponse.Json(new ScimListResponse { StartIndex = 1 }); + if (parsed.Attribute == "displayname") + displayName = parsed.Value; + else + externalId = parsed.Value; + } + + var page = Page(startIndex, count, config); + var result = await groupService.List(displayName, externalId, page.Skip, page.Take); + var withMembers = !ExcludesMembers(excludedAttributes); + return ScimResponse.Json(new ScimListResponse { + TotalResults = result.Total, + StartIndex = page.StartIndex, + ItemsPerPage = result.Items.Count, + Resources = result.Items.Select(x => ToResource(x, withMembers)).ToList() + }); + } + + [HttpGet("Groups/{id}")] + public async Task Get(string id, [FromQuery] string excludedAttributes) + { + var result = await groupService.Get(id); + return result.Outcome == ScimOutcome.Ok + ? ScimResponse.Json(ToResource(result.Group, !ExcludesMembers(excludedAttributes))) + : Refused(result.Outcome, null); + } + + [HttpPost("Groups")] + public async Task Create() + { + var (body, error) = await ReadBody(); + if (error != null) + return error; + + var result = await groupService.Create(ToData(body)); + if (result.Outcome != ScimOutcome.Created) + return Refused(result.Outcome, result.Detail); + + var resource = ToResource(result.Group, true); + Response.Headers.Location = resource.Meta.Location; + return ScimResponse.Json(resource, StatusCodes.Status201Created); + } + + [HttpPut("Groups/{id}")] + public async Task Replace(string id) + { + var (body, error) = await ReadBody(); + if (error != null) + return error; + + var result = await groupService.Replace(id, ToData(body)); + return result.Outcome == ScimOutcome.Ok + ? ScimResponse.Json(ToResource(result.Group, true)) + : Refused(result.Outcome, result.Detail); + } + + [HttpPatch("Groups/{id}")] + public async Task Patch(string id) + { + var (body, error) = await ReadBody(); + if (error != null) + return error; + + if (!ScimGroupPatch.TryApply(body.Operations, out var changes, out var patchError)) + return Refused(ScimOutcome.Invalid, patchError); + + var result = await groupService.Get(id); + if (result.Outcome == ScimOutcome.Ok && (changes.DisplayName != null || changes.ExternalId != null)) + result = await groupService.Rename(id, changes.DisplayName, changes.ExternalId); + if (result.Outcome == ScimOutcome.Ok && changes.MembersChanged) + result = await groupService.ModifyMembers(id, changes.Add, changes.Remove, changes.ReplaceAll); + + return result.Outcome == ScimOutcome.Ok + ? ScimResponse.Json(ToResource(result.Group, true)) + : Refused(result.Outcome, result.Detail); + } + + [HttpDelete("Groups/{id}")] + public async Task Delete(string id) + { + var outcome = await groupService.Delete(id); + return outcome == ScimOutcome.Ok ? NoContent() : Refused(outcome, null); + } + + //Entra ID reads groups with excludedAttributes=members + private static bool ExcludesMembers(string excludedAttributes) => + excludedAttributes?.Split(',').Any(x => string.Equals(x.Trim(), "members", StringComparison.OrdinalIgnoreCase)) == true; + + private static ScimGroupData ToData(ScimGroupResource body) => + new(body.DisplayName, body.ExternalId, + body.Members?.Select(x => x.Value).Where(x => !string.IsNullOrEmpty(x)).ToList() ?? []); + + private ScimGroupResource ToResource(ScimGroupView group, bool withMembers) => + new() { + Schemas = [ScimSchemas.Group], + Id = group.Id, + ExternalId = group.ExternalId, + DisplayName = group.DisplayName, + Members = withMembers + ? group.MemberIds.Select(x => new ScimMultiValue { Value = x, Ref = $"{BaseUrl}/Users/{x}" }).ToList() + : null, + Meta = new ScimMeta { + ResourceType = "Group", + Created = group.Created, + LastModified = group.LastModified, + Location = $"{BaseUrl}/Groups/{group.Id}" + } + }; +} diff --git a/src/Modules/Grand.Module.Scim/Controllers/ScimUsersController.cs b/src/Modules/Grand.Module.Scim/Controllers/ScimUsersController.cs new file mode 100644 index 0000000000..cd7fc284ef --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Controllers/ScimUsersController.cs @@ -0,0 +1,150 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Infrastructure.Configuration; +using Grand.Module.Scim.Infrastructure; +using Grand.Module.Scim.Models; +using Grand.Module.Scim.Protocol; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Grand.Module.Scim.Controllers; + +/// SCIM /Users: staff accounts provisioned by the identity provider +public class ScimUsersController(IScimUserService userService, ScimConfig config) : ScimControllerBase +{ + [HttpGet("Users")] + public async Task List([FromQuery] string filter, [FromQuery] int? startIndex, [FromQuery] int? count) + { + string userName = null, externalId = null; + if (!string.IsNullOrWhiteSpace(filter)) + { + if (!ScimFilter.TryParse(filter, out var parsed) || parsed.Attribute is not ("username" or "externalid")) + return InvalidFilter("Only the filters userName eq \"...\" and externalId eq \"...\" are supported"); + + //an empty value matches no user (and must not drop the filter) + if (parsed.Value == "") + return ScimResponse.Json(new ScimListResponse { StartIndex = 1 }); + if (parsed.Attribute == "username") + userName = parsed.Value; + else + externalId = parsed.Value; + } + + var page = Page(startIndex, count, config); + var result = await userService.List(userName, externalId, page.Skip, page.Take); + return ScimResponse.Json(new ScimListResponse { + TotalResults = result.Total, + StartIndex = page.StartIndex, + ItemsPerPage = result.Items.Count, + Resources = result.Items.Select(ToResource).ToList() + }); + } + + [HttpGet("Users/{id}")] + public async Task Get(string id) + { + var result = await userService.Get(id); + return result.Outcome == ScimOutcome.Ok ? ScimResponse.Json(ToResource(result.User)) : Refused(result.Outcome, null); + } + + [HttpPost("Users")] + public async Task Create() + { + var (body, error) = await ReadBody(); + if (error != null) + return error; + + var result = await userService.Create(ToData(body, true)); + if (result.Outcome != ScimOutcome.Created) + return Refused(result.Outcome, result.Detail); + + var resource = ToResource(result.User); + Response.Headers.Location = resource.Meta.Location; + return ScimResponse.Json(resource, StatusCodes.Status201Created); + } + + [HttpPut("Users/{id}")] + public async Task Replace(string id) + { + var (body, error) = await ReadBody(); + if (error != null) + return error; + + var current = await userService.Get(id); + if (current.Outcome != ScimOutcome.Ok) + return Refused(current.Outcome, null); + + //a replace without active keeps the state: it must not activate a deactivated account + var result = await userService.Replace(id, ToData(body, current.User.Active)); + return result.Outcome == ScimOutcome.Ok + ? ScimResponse.Json(ToResource(result.User)) + : Refused(result.Outcome, result.Detail); + } + + [HttpPatch("Users/{id}")] + public async Task Patch(string id) + { + var (body, error) = await ReadBody(); + if (error != null) + return error; + + var current = await userService.Get(id); + if (current.Outcome != ScimOutcome.Ok) + return Refused(current.Outcome, null); + + var user = current.User; + var data = new ScimUserData(user.UserName, user.ExternalId, user.Email, user.GivenName, user.FamilyName, user.Active); + if (!ScimUserPatch.TryApply(data, body.Operations, out var patched, out var patchError)) + return Refused(ScimOutcome.Invalid, patchError); + + var result = await userService.Replace(id, patched); + return result.Outcome == ScimOutcome.Ok + ? ScimResponse.Json(ToResource(result.User)) + : Refused(result.Outcome, result.Detail); + } + + [HttpDelete("Users/{id}")] + public async Task Delete(string id) + { + var outcome = await userService.Delete(id); + return outcome == ScimOutcome.Ok ? NoContent() : Refused(outcome, null); + } + + private static ScimUserData ToData(ScimUserResource body, bool activeWhenMissing) => + new(body.UserName, body.ExternalId, PickEmail(body.Emails), body.Name?.GivenName, body.Name?.FamilyName, + body.Active ?? activeWhenMissing); + + //the primary e-mail, else the work e-mail, else the first one + private static string PickEmail(List emails) => + (emails?.FirstOrDefault(x => x.Primary == true) ?? + emails?.FirstOrDefault(x => string.Equals(x.Type, "work", StringComparison.OrdinalIgnoreCase)) ?? + emails?.FirstOrDefault())?.Value; + + private ScimUserResource ToResource(ScimUserView user) + { + var displayName = $"{user.GivenName} {user.FamilyName}".Trim(); + return new ScimUserResource { + Schemas = [ScimSchemas.User], + Id = user.Id, + ExternalId = user.ExternalId, + UserName = user.UserName, + Name = string.IsNullOrEmpty(user.GivenName) && string.IsNullOrEmpty(user.FamilyName) + ? null + : new ScimName { GivenName = user.GivenName, FamilyName = user.FamilyName, Formatted = displayName }, + DisplayName = displayName == "" ? null : displayName, + Emails = string.IsNullOrEmpty(user.Email) + ? null + : [new ScimMultiValue { Value = user.Email, Type = "work", Primary = true }], + Active = user.Active, + Groups = user.Groups.Select(x => new ScimMultiValue { + Value = x.Id, Display = x.DisplayName, Ref = $"{BaseUrl}/Groups/{x.Id}" + }).ToList(), + Meta = new ScimMeta { + ResourceType = "User", + Created = user.Created, + LastModified = user.LastModified, + Location = $"{BaseUrl}/Users/{user.Id}" + } + }; + } +} diff --git a/src/Modules/Grand.Module.Scim/Grand.Module.Scim.csproj b/src/Modules/Grand.Module.Scim/Grand.Module.Scim.csproj new file mode 100644 index 0000000000..bef073f488 --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Grand.Module.Scim.csproj @@ -0,0 +1,38 @@ + + + true + + + + + False + runtime + + + False + runtime + + + False + runtime + + + False + runtime + + + False + runtime + + + + + ..\..\Web\Grand.Web\Modules\Grand.Module.Scim\ + $(OutputPath) + + + + ..\..\Web\Grand.Web\Modules\Grand.Module.Scim\ + $(OutputPath) + + diff --git a/src/Modules/Grand.Module.Scim/Infrastructure/ScimAuthorizeFilter.cs b/src/Modules/Grand.Module.Scim/Infrastructure/ScimAuthorizeFilter.cs new file mode 100644 index 0000000000..c3eac20e4c --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Infrastructure/ScimAuthorizeFilter.cs @@ -0,0 +1,52 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; +using System.Security.Cryptography; +using System.Text; + +namespace Grand.Module.Scim.Infrastructure; + +/// +/// Lets in only the identity provider: the Bearer token of Scim:Token, compared in constant time. With a token shorter than +/// 32 characters, or without the OpenID Connect sign-in, every request is refused +/// +public class ScimAuthorizeFilter( + ScimConfig config, + OidcLoginConfig loginConfig, + OidcResourceServerConfig resourceServerConfig, + ILogger logger) : IAuthorizationFilter +{ + private const string Scheme = "Bearer "; + + public void OnAuthorization(AuthorizationFilterContext context) + { + if (!config.TokenUsable || !loginConfig.IsConfigured(resourceServerConfig)) + { + logger.LogWarning("SCIM request refused: Scim:Token is shorter than 32 characters or the OpenID Connect sign-in " + + "(OidcLogin) is not configured"); + Refuse(context); + return; + } + + string authorization = context.HttpContext.Request.Headers.Authorization; + if (authorization == null || !authorization.StartsWith(Scheme, StringComparison.OrdinalIgnoreCase) || + !Matches(authorization[Scheme.Length..].Trim(), config.Token)) + { + logger.LogWarning("SCIM request refused: missing or wrong Bearer token"); + Refuse(context); + } + } + + //hashing first gives both sides one length, so the comparison time tells nothing about the token + private static bool Matches(string presented, string expected) => + CryptographicOperations.FixedTimeEquals( + SHA256.HashData(Encoding.UTF8.GetBytes(presented)), + SHA256.HashData(Encoding.UTF8.GetBytes(expected))); + + private static void Refuse(AuthorizationFilterContext context) + { + context.HttpContext.Response.Headers.WWWAuthenticate = "Bearer"; + context.Result = ScimResponse.Error(StatusCodes.Status401Unauthorized, null, "Authentication required"); + } +} diff --git a/src/Modules/Grand.Module.Scim/Infrastructure/ScimJson.cs b/src/Modules/Grand.Module.Scim/Infrastructure/ScimJson.cs new file mode 100644 index 0000000000..fa4c6fa5ca --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Infrastructure/ScimJson.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Grand.Module.Scim.Infrastructure; + +/// +/// The JSON of the SCIM endpoint: camelCase, nulls left out, names read case-insensitively. Its own options, never the MVC +/// ones of the application (the admin panels serialize PascalCase) +/// +public static class ScimJson +{ + public const string ContentType = "application/scim+json"; + + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web) { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + /// A SCIM boolean: true/false, or the strings "true"/"false" in any letter case (Entra ID sends "False") + public static bool TryReadBoolean(JsonElement value, out bool result) + { + switch (value.ValueKind) + { + case JsonValueKind.True: + result = true; + return true; + case JsonValueKind.False: + result = false; + return true; + case JsonValueKind.String: + return bool.TryParse(value.GetString(), out result); + default: + result = false; + return false; + } + } +} + +/// Reads a SCIM boolean () and writes a JSON boolean +public class ScimBooleanConverter : JsonConverter +{ + public override bool? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + using var document = JsonDocument.ParseValue(ref reader); + return ScimJson.TryReadBoolean(document.RootElement, out var result) + ? result + : throw new JsonException("Not a boolean"); + } + + public override void Write(Utf8JsonWriter writer, bool? value, JsonSerializerOptions options) + { + if (value.HasValue) + writer.WriteBooleanValue(value.Value); + else + writer.WriteNullValue(); + } +} diff --git a/src/Modules/Grand.Module.Scim/Infrastructure/ScimResponse.cs b/src/Modules/Grand.Module.Scim/Infrastructure/ScimResponse.cs new file mode 100644 index 0000000000..e5aeed0c81 --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Infrastructure/ScimResponse.cs @@ -0,0 +1,25 @@ +using Grand.Module.Scim.Models; +using Microsoft.AspNetCore.Mvc; +using System.Globalization; +using System.Text.Json; + +namespace Grand.Module.Scim.Infrastructure; + +/// Responses of the SCIM endpoint, in application/scim+json +public static class ScimResponse +{ + public static ContentResult Json(object body, int status = 200) => + new() { + StatusCode = status, + ContentType = ScimJson.ContentType + "; charset=utf-8", + Content = JsonSerializer.Serialize(body, body.GetType(), ScimJson.Options) + }; + + /// RFC 7644 error type (invalidFilter, uniqueness, invalidValue...), null for none + public static ContentResult Error(int status, string scimType, string detail) => + Json(new ScimError { + Status = status.ToString(CultureInfo.InvariantCulture), + ScimType = scimType, + Detail = detail + }, status); +} diff --git a/src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs b/src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs new file mode 100644 index 0000000000..3ea12cb76c --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs @@ -0,0 +1,41 @@ +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Grand.Module.Scim.Infrastructure; + +/// The SCIM 2.0 endpoint (/scim/v2) of module Grand.Module.Scim +public class ScimStartup : IStartupApplication +{ + public void ConfigureServices(IServiceCollection services, IConfiguration configuration) + { + services.AddScoped(); + } + + public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) + { + var config = application.Services.GetRequiredService(); + var loginConfig = application.Services.GetRequiredService(); + var resourceServerConfig = application.Services.GetRequiredService(); + var logger = application.Services.GetRequiredService().CreateLogger(nameof(ScimStartup)); + + //without the externalId link a back-office account connects with its password in Mixed mode, and a SCIM account has + //none anybody knows + if (!config.ExternalIdIsSubject && !loginConfig.ProviderOnly) + logger.LogWarning("Scim:ExternalIdIsSubject is false and OidcLogin:SignInMode is Mixed: staff accounts created by " + + "SCIM cannot sign in until they are connected by hand. See docs/scim.md"); + + if (config.TokenUsable && loginConfig.IsConfigured(resourceServerConfig)) + return; + + logger.LogError("Module Grand.Module.Scim is on, but Scim:Token is shorter than 32 characters or the OpenID Connect " + + "sign-in (OidcLogin) is not configured: every SCIM request is refused. See docs/scim.md"); + } + + public int Priority => 0; + public bool BeforeConfigure => false; +} diff --git a/src/Modules/Grand.Module.Scim/Models/ScimResources.cs b/src/Modules/Grand.Module.Scim/Models/ScimResources.cs new file mode 100644 index 0000000000..57f543815e --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Models/ScimResources.cs @@ -0,0 +1,106 @@ +using Grand.Module.Scim.Infrastructure; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Grand.Module.Scim.Models; + +public static class ScimSchemas +{ + public const string User = "urn:ietf:params:scim:schemas:core:2.0:User"; + public const string Group = "urn:ietf:params:scim:schemas:core:2.0:Group"; + public const string ListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"; + public const string Error = "urn:ietf:params:scim:api:messages:2.0:Error"; + public const string PatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; + public const string ServiceProviderConfig = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"; + public const string ResourceType = "urn:ietf:params:scim:schemas:core:2.0:ResourceType"; + public const string Schema = "urn:ietf:params:scim:schemas:core:2.0:Schema"; +} + +public class ScimMeta +{ + public string ResourceType { get; set; } + public DateTime? Created { get; set; } + public DateTime? LastModified { get; set; } + public string Location { get; set; } +} + +public class ScimName +{ + public string GivenName { get; set; } + public string FamilyName { get; set; } + public string Formatted { get; set; } +} + +/// A multi-valued attribute item: an e-mail, a group of a user, a member of a group +public class ScimMultiValue +{ + public string Value { get; set; } + public string Type { get; set; } + public bool? Primary { get; set; } + public string Display { get; set; } + + [JsonPropertyName("$ref")] + public string Ref { get; set; } +} + +public class ScimUserResource +{ + public List Schemas { get; set; } + public string Id { get; set; } + public string ExternalId { get; set; } + public string UserName { get; set; } + public ScimName Name { get; set; } + public string DisplayName { get; set; } + public List Emails { get; set; } + + [JsonConverter(typeof(ScimBooleanConverter))] + public bool? Active { get; set; } + + public List Groups { get; set; } + public ScimMeta Meta { get; set; } +} + +public class ScimGroupResource +{ + public List Schemas { get; set; } + public string Id { get; set; } + public string ExternalId { get; set; } + public string DisplayName { get; set; } + public List Members { get; set; } + public ScimMeta Meta { get; set; } +} + +public class ScimListResponse +{ + public List Schemas { get; set; } = [ScimSchemas.ListResponse]; + public int TotalResults { get; set; } + public int StartIndex { get; set; } + public int ItemsPerPage { get; set; } + + [JsonPropertyName("Resources")] + public List Resources { get; set; } = []; +} + +public class ScimError +{ + public List Schemas { get; set; } = [ScimSchemas.Error]; + + /// The HTTP status as a string (RFC 7644, 3.12) + public string Status { get; set; } + + public string ScimType { get; set; } + public string Detail { get; set; } +} + +public class ScimPatchRequest +{ + public List Schemas { get; set; } + public List Operations { get; set; } = []; +} + +public class ScimPatchOperation +{ + public string Op { get; set; } + public string Path { get; set; } + public JsonElement? Value { get; set; } +} diff --git a/src/Modules/Grand.Module.Scim/Protocol/ScimFilter.cs b/src/Modules/Grand.Module.Scim/Protocol/ScimFilter.cs new file mode 100644 index 0000000000..a23cb1a92c --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Protocol/ScimFilter.cs @@ -0,0 +1,32 @@ +using System.Text.RegularExpressions; + +namespace Grand.Module.Scim.Protocol; + +/// +/// The one SCIM filter form the identity providers use to find a resource: attribute eq "value". Attribute and +/// operator in any letter case; anything else (other operators, and/or, presence) is refused +/// +/// Lower-cased attribute name +public partial record ScimFilter(string Attribute, string Value) +{ + [GeneratedRegex("""^\s*([A-Za-z][A-Za-z0-9._:]*)\s+eq\s+"((?:[^"\\]|\\.)*)"\s*$""", RegexOptions.IgnoreCase)] + private static partial Regex EqualityFilter(); + + [GeneratedRegex("""\\(.)""")] + private static partial Regex Escape(); + + public static bool TryParse(string filter, out ScimFilter result) + { + result = null; + if (string.IsNullOrWhiteSpace(filter)) + return false; + + var match = EqualityFilter().Match(filter); + if (!match.Success) + return false; + + result = new ScimFilter(match.Groups[1].Value.ToLowerInvariant(), + Escape().Replace(match.Groups[2].Value, "$1")); + return true; + } +} diff --git a/src/Modules/Grand.Module.Scim/Protocol/ScimGroupPatch.cs b/src/Modules/Grand.Module.Scim/Protocol/ScimGroupPatch.cs new file mode 100644 index 0000000000..7e330b0b5c --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Protocol/ScimGroupPatch.cs @@ -0,0 +1,182 @@ +using Grand.Module.Scim.Models; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Grand.Module.Scim.Protocol; + +/// What a SCIM PATCH of a group changes; operations apply in order +public class ScimGroupChanges +{ + /// Members to add, or with the whole new member list + public List Add { get; } = []; + + public List Remove { get; } = []; + public bool ReplaceAll { get; set; } + public bool MembersChanged { get; set; } + + /// New name; null keeps it + public string DisplayName { get; set; } + + /// New externalId; null keeps it + public string ExternalId { get; set; } +} + +/// +/// Reads a SCIM PATCH of a group in the shapes Entra ID and Okta send: members added and removed by value lists or by the +/// members[value eq "id"] path, a rename by path or by a value object without one +/// +public static partial class ScimGroupPatch +{ + [GeneratedRegex("""^members\[value eq "((?:[^"\\]|\\.)*)"\]$""", RegexOptions.IgnoreCase)] + private static partial Regex MemberPath(); + + public static bool TryApply(IList operations, out ScimGroupChanges changes, out string error) + { + changes = new ScimGroupChanges(); + error = null; + foreach (var operation in operations ?? []) + { + var op = operation.Op?.ToLowerInvariant(); + if (op is not ("add" or "replace" or "remove")) + { + error = "op must be add, replace or remove"; + return false; + } + + if (string.IsNullOrEmpty(operation.Path)) + { + if (op == "remove" || operation.Value is not { ValueKind: JsonValueKind.Object } value) + { + error = "An operation without a path needs an object value"; + return false; + } + + foreach (var property in value.EnumerateObject()) + switch (property.Name.ToLowerInvariant()) + { + case "displayname": + changes.DisplayName = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : null; + break; + case "externalid": + changes.ExternalId = property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : ""; + break; + case "members": + ApplyMembers(changes, op, property.Value); + break; + } + + continue; + } + + var memberPath = MemberPath().Match(operation.Path); + if (memberPath.Success) + { + if (op != "remove") + { + error = "Only remove is supported on a member filter path"; + return false; + } + + Remove(changes, [memberPath.Groups[1].Value.Replace("\\\"", "\"")]); + continue; + } + + switch (operation.Path.ToLowerInvariant()) + { + case "members": + ApplyMembers(changes, op, operation.Value); + break; + case "displayname": + if (op == "remove" || operation.Value is not { ValueKind: JsonValueKind.String } name) + { + error = "displayName must be a string"; + return false; + } + + changes.DisplayName = name.GetString(); + break; + case "externalid": + changes.ExternalId = op != "remove" && operation.Value is { ValueKind: JsonValueKind.String } id + ? id.GetString() + : ""; + break; + default: + error = "Unsupported path " + operation.Path; + return false; + } + } + + return true; + } + + private static void ApplyMembers(ScimGroupChanges changes, string op, JsonElement? value) + { + var ids = MemberIds(value); + switch (op) + { + case "add": + changes.MembersChanged = true; + foreach (var id in ids) + { + changes.Remove.Remove(id); + if (!changes.Add.Contains(id)) + changes.Add.Add(id); + } + + break; + case "replace": + changes.MembersChanged = true; + changes.ReplaceAll = true; + changes.Add.Clear(); + changes.Remove.Clear(); + changes.Add.AddRange(ids.Distinct()); + break; + default: + //remove without a value empties the group + if (value == null || value.Value.ValueKind == JsonValueKind.Null) + { + changes.MembersChanged = true; + changes.ReplaceAll = true; + changes.Add.Clear(); + changes.Remove.Clear(); + } + else + Remove(changes, ids); + + break; + } + } + + private static void Remove(ScimGroupChanges changes, IEnumerable ids) + { + changes.MembersChanged = true; + foreach (var id in ids) + { + changes.Add.Remove(id); + if (!changes.Remove.Contains(id)) + changes.Remove.Add(id); + } + } + + //[{"value":"id"}], or one {"value":"id"} + private static List MemberIds(JsonElement? value) + { + if (value == null) + return []; + var items = value.Value.ValueKind switch { + JsonValueKind.Array => value.Value.EnumerateArray().ToList(), + JsonValueKind.Object => [value.Value], + _ => [] + }; + return items + .Where(x => x.ValueKind == JsonValueKind.Object && x.TryGetProperty("value", out var v) && + v.ValueKind == JsonValueKind.String) + .Select(x => x.GetProperty("value").GetString()) + .Where(x => !string.IsNullOrEmpty(x)) + .ToList(); + } +} diff --git a/src/Modules/Grand.Module.Scim/Protocol/ScimUserPatch.cs b/src/Modules/Grand.Module.Scim/Protocol/ScimUserPatch.cs new file mode 100644 index 0000000000..eb4d4ad8f0 --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Protocol/ScimUserPatch.cs @@ -0,0 +1,145 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Module.Scim.Infrastructure; +using Grand.Module.Scim.Models; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace Grand.Module.Scim.Protocol; + +/// +/// Applies a SCIM PATCH to the kept attributes of a user. Accepts the shapes Entra ID and Okta send: a path or a value +/// object without one, ops in any letter case, booleans as strings. Attributes GrandNode does not keep (title, department, +/// phone numbers...) are ignored, so a provider's default attribute mapping does not fail +/// +public static partial class ScimUserPatch +{ + private const string UserSchemaPrefix = ScimSchemas.User + ":"; + + [GeneratedRegex("""^emails\[type eq "[a-z]+"\]\.value$""", RegexOptions.IgnoreCase)] + private static partial Regex TypedEmailPath(); + + public static bool TryApply(ScimUserData user, IList operations, out ScimUserData result, + out string error) + { + result = user; + error = null; + foreach (var operation in operations ?? []) + { + var op = operation.Op?.ToLowerInvariant(); + if (op is not ("add" or "replace" or "remove")) + { + error = "op must be add, replace or remove"; + return false; + } + + if (!string.IsNullOrEmpty(operation.Path)) + { + if (!TryApplyPath(ref result, operation.Path, op == "remove" ? null : operation.Value, out error)) + return false; + continue; + } + + if (op == "remove" || operation.Value is not { ValueKind: JsonValueKind.Object } value) + { + error = "An operation without a path needs an object value"; + return false; + } + + foreach (var property in value.EnumerateObject()) + if (!TryApplyPath(ref result, property.Name, property.Value, out error)) + return false; + } + + return true; + } + + /// Null for a remove + private static bool TryApplyPath(ref ScimUserData user, string path, JsonElement? value, out string error) + { + error = null; + if (path.StartsWith(UserSchemaPrefix, StringComparison.OrdinalIgnoreCase)) + path = path[UserSchemaPrefix.Length..]; + + switch (path.ToLowerInvariant()) + { + case "active": + if (value == null) + return true; + if (!ScimJson.TryReadBoolean(value.Value, out var active)) + { + error = "active must be a boolean"; + return false; + } + + user = user with { Active = active }; + return true; + case "username": + return TryString(value, "userName", out var userName, out error) && + Set(ref user, user with { UserName = userName }); + case "externalid": + return TryString(value, "externalId", out var externalId, out error) && + Set(ref user, user with { ExternalId = externalId }); + case "name.givenname": + return TryString(value, "name.givenName", out var givenName, out error) && + Set(ref user, user with { GivenName = givenName }); + case "name.familyname": + return TryString(value, "name.familyName", out var familyName, out error) && + Set(ref user, user with { FamilyName = familyName }); + case "name": + if (value is not { ValueKind: JsonValueKind.Object } name) + return Set(ref user, user with { GivenName = null, FamilyName = null }); + foreach (var part in name.EnumerateObject()) + if (part.Name.ToLowerInvariant() is "givenname" or "familyname" && + !TryApplyPath(ref user, "name." + part.Name, part.Value, out error)) + return false; + return true; + case "emails": + return Set(ref user, user with { Email = PickEmail(value) }); + default: + if (TypedEmailPath().IsMatch(path)) + return TryString(value, "emails.value", out var email, out error) && + Set(ref user, user with { Email = email }); + return true; + } + } + + //the primary e-mail, else the work e-mail, else the first one + private static string PickEmail(JsonElement? value) + { + if (value is not { ValueKind: JsonValueKind.Array } emails) + return null; + var items = emails.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.Object).ToList(); + var chosen = items.FirstOrDefault(x => x.TryGetProperty("primary", out var p) && ScimJson.TryReadBoolean(p, out var b) && b); + if (chosen.ValueKind == JsonValueKind.Undefined) + chosen = items.FirstOrDefault(x => + x.TryGetProperty("type", out var t) && string.Equals(t.GetString(), "work", StringComparison.OrdinalIgnoreCase)); + if (chosen.ValueKind == JsonValueKind.Undefined) + chosen = items.FirstOrDefault(); + return chosen.ValueKind == JsonValueKind.Object && chosen.TryGetProperty("value", out var v) && + v.ValueKind == JsonValueKind.String + ? v.GetString() + : null; + } + + private static bool TryString(JsonElement? value, string name, out string result, out string error) + { + error = null; + result = null; + if (value == null || value.Value.ValueKind == JsonValueKind.Null) + return true; + if (value.Value.ValueKind == JsonValueKind.String) + { + result = value.Value.GetString(); + return true; + } + + error = name + " must be a string"; + return false; + } + + private static bool Set(ref ScimUserData user, ScimUserData changed) + { + user = changed; + return true; + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Events/ScimCustomerDeletedEventHandlerTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Events/ScimCustomerDeletedEventHandlerTests.cs new file mode 100644 index 0000000000..8ac616dd0f --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Events/ScimCustomerDeletedEventHandlerTests.cs @@ -0,0 +1,39 @@ +using Grand.Business.Authentication.Events; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain.Customers; +using Grand.Infrastructure.Events; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Events; + +[TestClass] +public class ScimCustomerDeletedEventHandlerTests +{ + private IRepository _users; + private IRepository _groups; + private ScimCustomerDeletedEventHandler _handler; + + [TestInitialize] + public void Init() + { + _users = new MongoDBRepositoryTest(); + _groups = new MongoDBRepositoryTest(); + _handler = new ScimCustomerDeletedEventHandler(_users, _groups); + } + + //a customer deleted in the admin panel must not leave a SCIM record that blocks provisioning the user again + [TestMethod] + public async Task CustomerDeleted_RemovesScimUserAndMemberships() + { + await _users.InsertAsync(new ScimUser { Id = "c1", ExternalId = "oid-1" }); + await _users.InsertAsync(new ScimUser { Id = "c2", ExternalId = "oid-2" }); + await _groups.InsertAsync(new ScimGroup { DisplayName = "Shop admins", Members = ["c1", "c2"] }); + + await _handler.Handle(new EntityDeleted(new Customer { Id = "c1" }), CancellationToken.None); + + Assert.IsNull(await _users.GetByIdAsync("c1")); + Assert.IsNotNull(await _users.GetByIdAsync("c2")); + CollectionAssert.AreEqual(new[] { "c2" }, _groups.Table.Single().Members.ToList()); + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs new file mode 100644 index 0000000000..7a9e2c0542 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs @@ -0,0 +1,203 @@ +using Grand.Business.Authentication.Services; +using Grand.Business.Authentication.Services.Scim; +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.Common.Stores; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Data; +using Grand.Data.Tests.MongoDb; +using Grand.Domain; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Domain.Permissions; +using Grand.Domain.Stores; +using Grand.Domain.Vendors; +using Grand.Infrastructure.Configuration; +using Grand.Mediator; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using System.Linq.Expressions; + +namespace Grand.Business.Authentication.Tests.Services.Scim; + +/// +/// The SCIM services over real test repositories; the customer service is a mock that writes to the customer repository, +/// so the tests assert the stored account, not the calls +/// +public class ScimFixture +{ + public static readonly CustomerGroup Administrators = Group("g-admin", SystemCustomerGroupNames.Administrators); + public static readonly CustomerGroup StoreManager = Group("g-sm", SystemCustomerGroupNames.StoreManager); + public static readonly CustomerGroup Vendors = Group("g-vendors", SystemCustomerGroupNames.Vendors); + public static readonly CustomerGroup SalesManager = Group("g-sales", SystemCustomerGroupNames.SalesManager); + public static readonly CustomerGroup Registered = Group("g-registered", SystemCustomerGroupNames.Registered); + public static readonly CustomerGroup Guests = Group("g-guests", SystemCustomerGroupNames.Guests); + public static readonly CustomerGroup Custom = Group("g-custom", "", false); + public static readonly CustomerGroup OtherCustom = Group("g-custom2", "", false); + + private static readonly CustomerGroup[] AllGroups = + [Administrators, StoreManager, Vendors, SalesManager, Registered, Guests, Custom, OtherCustom]; + + public IRepository Customers { get; } = new MongoDBRepositoryTest(); + public IRepository Users { get; } = new MongoDBRepositoryTest(); + public IRepository Groups { get; } = new MongoDBRepositoryTest(); + public IRepository Links { get; } = new MongoDBRepositoryTest(); + + public Mock CustomerService { get; } = new(); + public Mock CustomerManager { get; } = new(); + public Mock GroupService { get; } = new(); + public Mock Resolver { get; } = new(); + public Mock LinkService { get; } = new(); + public Mock Sessions { get; } = new(); + public Mock Revocation { get; } = new(); + public Mock StoreService { get; } = new(); + public Mock VendorService { get; } = new(); + public Mock PermissionService { get; } = new(); + public Mock Mediator { get; } = new(); + + public ScimConfig Config { get; } = new() { Token = new string('t', 32) }; + public OidcLoginConfig LoginConfig { get; } = new(); + public CustomerConfig CustomerConfig { get; } = new(); + + public ScimFixture() + { + CustomerService.Setup(x => x.GetCustomerById(It.IsAny())) + .Returns((string id) => Customers.GetByIdAsync(id)); + CustomerService.Setup(x => x.GetCustomersByIds(It.IsAny())) + .ReturnsAsync((string[] ids) => (IList)Customers.Table.Where(x => ids.Contains(x.Id)).ToList()); + CustomerService.Setup(x => x.GetCustomerByEmail(It.IsAny(), It.IsAny())) + .Returns((string email, string _) => Customers.GetOneAsync(x => x.Email == email.ToLowerInvariant())); + CustomerService.Setup(x => x.GetCustomerByUsername(It.IsAny(), It.IsAny())) + .Returns((string name, string _) => Customers.GetOneAsync(x => x.Username == name.ToLowerInvariant())); + CustomerService.Setup(x => x.InsertGuestCustomer(It.IsAny())) + .Returns((Customer c) => + { + c.Groups.Add(Guests.Id); + return Customers.InsertAsync(c); + }); + CustomerService.Setup(x => x.InsertCustomerGroupInCustomer(It.IsAny(), It.IsAny())) + .Returns((CustomerGroup g, string id) => Customers.AddToCollectionField(id, x => x.Groups, g.Id)); + CustomerService.Setup(x => x.DeleteCustomerGroupInCustomer(It.IsAny(), It.IsAny())) + .Returns((CustomerGroup g, string id) => Customers.RemoveCollectionFieldItem(id, x => x.Groups, y => y == g.Id)); + CustomerService.Setup(x => x.UpdateCustomerField(It.IsAny(), + It.IsAny>>(), It.IsAny())) + .Returns((string id, Expression> e, string v) => Customers.UpdateField(id, e, v)); + CustomerService.Setup(x => x.UpdateCustomerField(It.IsAny(), + It.IsAny>>(), It.IsAny())) + .Returns((string id, Expression> e, bool v) => Customers.UpdateField(id, e, v)); + + CustomerService.Setup(x => x.UpdateUserField(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .Returns(async (Customer c, string key, string value, string _) => + { + var stored = await Customers.GetByIdAsync(c.Id); + stored.UserFields = stored.UserFields.Where(x => x.Key != key).ToList(); + if (!string.IsNullOrEmpty(value)) + stored.UserFields.Add(new UserField { Key = key, Value = value, StoreId = "" }); + await Customers.UpdateAsync(stored); + }); + + CustomerManager.Setup(x => x.RegisterCustomer(It.IsAny())) + .Returns(async (RegistrationRequest r) => + { + r.Customer.Email = r.Email; + r.Customer.Username = r.Username; + r.Customer.StoreId = r.StoreId; + r.Customer.Password = "hash"; + r.Customer.Active = r.IsApproved; + r.Customer.Groups.Remove(Guests.Id); + r.Customer.Groups.Add(Registered.Id); + await Customers.UpdateAsync(r.Customer); + }); + CustomerManager.Setup(x => x.ChangePassword(It.IsAny(), It.IsAny())) + .Returns(async (ChangePasswordRequest r, string _) => + { + var stored = await Customers.GetOneAsync(x => x.Email == r.Email); + stored.Password = "hash:" + r.NewPassword; + stored.UserFields = stored.UserFields.Where(x => x.Key != SystemCustomerFieldNames.PasswordToken).ToList(); + stored.UserFields.Add(new UserField { + Key = SystemCustomerFieldNames.PasswordToken, Value = Guid.NewGuid().ToString(), StoreId = "" + }); + await Customers.UpdateAsync(stored); + }); + + GroupService.Setup(x => x.GetAllByIds(It.IsAny())) + .ReturnsAsync((string[] ids) => (IList)AllGroups.Where(g => ids.Contains(g.Id)).ToList()); + GroupService.Setup(x => x.GetCustomerGroupById(It.IsAny())) + .ReturnsAsync((string id) => AllGroups.FirstOrDefault(g => g.Id == id)); + GroupService.Setup(x => x.GetCustomerGroupBySystemName(It.IsAny())) + .ReturnsAsync((string name) => AllGroups.FirstOrDefault(g => g.SystemName == name)); + GroupService.Setup(x => x.GetAllCustomerGroups(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(new PagedList(AllGroups, 0, int.MaxValue)); + GroupService.Setup(x => x.IsRegistered(It.IsAny())) + .ReturnsAsync((Customer c) => c.Groups.Contains(Registered.Id)); + + Resolver.Setup(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (Customer c, string sub, string email, string _) => + { + await Links.InsertAsync(new ExternalAuthentication { + CustomerId = c.Id, ExternalIdentifier = sub, Email = email, + ProviderSystemName = OidcCustomerResolver.ProviderSystemName + }); + return OidcLinkResult.Linked; + }); + LinkService.Setup(x => x.Remove(It.IsAny(), It.IsAny())) + .Returns(async (Customer c, string linkId) => + { + var link = await Links.GetOneAsync(x => x.Id == linkId && x.CustomerId == c.Id); + if (link == null) return false; + await Links.DeleteAsync(link); + return true; + }); + + //a back-office account: admin panel access (Administrators here), a staff store or a vendor + PermissionService.Setup(x => x.Authorize(It.IsAny(), It.IsAny())) + .ReturnsAsync((Permission _, Customer c) => c.Groups.Contains(Administrators.Id)); + + StoreService.Setup(x => x.GetStoreById(It.IsAny())) + .ReturnsAsync((string id) => new Store { Id = id }); + VendorService.Setup(x => x.GetVendorById(It.IsAny())) + .ReturnsAsync((string id) => new Vendor { Id = id }); + } + + private static CustomerGroup Group(string id, string systemName, bool isSystem = true) => + new() { Id = id, SystemName = systemName, Name = id, IsSystem = isSystem, Active = true }; + + public ScimRoleSync RoleSync() => + new(Users, Groups, CustomerService.Object, GroupService.Object, LoginConfig, Mediator.Object, + NullLogger.Instance); + + public ScimAccountLock AccountLock() => + new(CustomerService.Object, CustomerManager.Object, Links, Sessions.Object, Revocation.Object, + NullLogger.Instance); + + public ScimUserService UserService() => + new(Users, Groups, Links, CustomerService.Object, CustomerManager.Object, GroupService.Object, + PermissionService.Object, Resolver.Object, LinkService.Object, RoleSync(), AccountLock(), Mediator.Object, Config, + LoginConfig, CustomerConfig, NullLogger.Instance); + + public ScimGroupService GroupServiceUnderTest(ScimRoleSync roleSync = null) => + new(Groups, Users, GroupService.Object, StoreService.Object, VendorService.Object, roleSync ?? RoleSync(), + Mediator.Object, NullLogger.Instance); + + public async Task AddCustomer(string email, params string[] groupIds) + { + var customer = new Customer { + Email = email.ToLowerInvariant(), Username = email.ToLowerInvariant(), Active = true, StoreId = "" + }; + customer.Groups.Add(Registered.Id); + foreach (var id in groupIds) + customer.Groups.Add(id); + return await Customers.InsertAsync(customer); + } + + public Task Reload(string id) => Customers.GetByIdAsync(id); + + public static ScimUserData Data(string userName = "jan@example.com", string externalId = "oid-1", bool active = true, + string email = null, string given = "Jan", string family = "Nowak") => + new(userName, externalId, email, given, family, active); +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs new file mode 100644 index 0000000000..f6e40a8a23 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs @@ -0,0 +1,313 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Stores; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using Grand.Business.Authentication.Services.Scim; +using Grand.Infrastructure.Events; +using Grand.Domain.Customers; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Grand.Business.Authentication.Tests.Services.Scim; + +[TestClass] +public class ScimGroupServiceTests +{ + private ScimFixture _f; + + [TestInitialize] + public void Init() => _f = new ScimFixture(); + + private async Task User(string email = "jan@example.com", string externalId = "oid-1") => + (await _f.UserService().Create(ScimFixture.Data(userName: email, externalId: externalId))).User.Id; + + private async Task Group(string name, params string[] members) => + (await _f.GroupServiceUnderTest().Create(new ScimGroupData(name, "ext-" + name, members))).Group.Id; + + private async Task HasGroup(string customerId, string customerGroupId) => + (await _f.Reload(customerId)).Groups.Contains(customerGroupId); + + [TestMethod] + public async Task Create_NoDisplayName_Invalid() => + Assert.AreEqual(ScimOutcome.Invalid, + (await _f.GroupServiceUnderTest().Create(new ScimGroupData(" ", null, []))).Outcome); + + //Entra allows two groups with one name; refusing the second would fail its provisioning for good + [TestMethod] + public async Task Create_SameDisplayNameTwice_Allowed() + { + await Group("Shop admins"); + Assert.AreEqual(ScimOutcome.Created, + (await _f.GroupServiceUnderTest().Create(new ScimGroupData("shop ADMINS", null, []))).Outcome); + } + + [TestMethod] + public async Task Create_SameExternalIdTwice_Conflict() + { + await _f.GroupServiceUnderTest().Create(new ScimGroupData("One", "ext-1", [])); + Assert.AreEqual(ScimOutcome.Conflict, + (await _f.GroupServiceUnderTest().Create(new ScimGroupData("Two", "ext-1", []))).Outcome); + } + + //a group pushed by the identity provider grants nothing by itself + [TestMethod] + public async Task Create_UnmappedGroup_GrantsNothing() + { + var user = await User(); + await Group("Shop admins", user); + Assert.IsFalse(await HasGroup(user, ScimFixture.Administrators.Id)); + } + + [TestMethod] + public async Task Create_UnknownMember_Ignored() + { + var created = await _f.GroupServiceUnderTest().Create(new ScimGroupData("Shop admins", null, ["nobody"])); + Assert.AreEqual(ScimOutcome.Created, created.Outcome); + Assert.AreEqual(0, created.Group.MemberIds.Count); + } + + [TestMethod] + public async Task SetMapping_Administrators_MembersGetIt() + { + var user = await User(); + var group = await Group("Shop admins", user); + + var outcome = await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + + Assert.AreEqual(ScimOutcome.Ok, outcome); + Assert.IsTrue(await HasGroup(user, ScimFixture.Administrators.Id)); + } + + [TestMethod] + public async Task AddMember_ToMappedGroup_GetsRole_RemoveMember_LosesIt() + { + var user = await User(); + var group = await Group("Shop admins"); + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + + await _f.GroupServiceUnderTest().ModifyMembers(group, [user], [], false); + Assert.IsTrue(await HasGroup(user, ScimFixture.Administrators.Id)); + + await _f.GroupServiceUnderTest().ModifyMembers(group, [], [user], false); + Assert.IsFalse(await HasGroup(user, ScimFixture.Administrators.Id)); + } + + //removing a mapping must take its role away again, or every member keeps admin rights forever + [TestMethod] + public async Task ClearMapping_RevokesRole() + { + var user = await User(); + var group = await Group("Shop admins", user); + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + + await _f.GroupServiceUnderTest().SetMapping(group, "", null, null); + + Assert.IsFalse(await HasGroup(user, ScimFixture.Administrators.Id)); + } + + [TestMethod] + public async Task ChangeMapping_SwapsRoles() + { + var user = await User(); + var group = await Group("Shop admins", user); + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Custom.Id, null, null); + + Assert.IsFalse(await HasGroup(user, ScimFixture.Administrators.Id)); + Assert.IsTrue(await HasGroup(user, ScimFixture.Custom.Id)); + } + + [TestMethod] + public async Task DeleteGroup_RevokesRole() + { + var user = await User(); + var group = await Group("Shop admins", user); + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + + Assert.AreEqual(ScimOutcome.Ok, await _f.GroupServiceUnderTest().Delete(group)); + + Assert.IsFalse(await HasGroup(user, ScimFixture.Administrators.Id)); + } + + [TestMethod] + public async Task StoreManagerMapping_SetsStaffStore() + { + var user = await User(); + var group = await Group("Store A staff", user); + + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.StoreManager.Id, "storeA", null); + + var customer = await _f.Reload(user); + Assert.IsTrue(customer.Groups.Contains(ScimFixture.StoreManager.Id)); + Assert.AreEqual("storeA", customer.StaffStoreId); + } + + [TestMethod] + public async Task AdministratorsAndStoreManager_ConflictGrantsNeither() + { + var user = await User(); + var admins = await Group("Shop admins", user); + var staff = await Group("Store A staff", user); + await _f.GroupServiceUnderTest().SetMapping(admins, ScimFixture.Administrators.Id, null, null); + + await _f.GroupServiceUnderTest().SetMapping(staff, ScimFixture.StoreManager.Id, "storeA", null); + + var customer = await _f.Reload(user); + Assert.IsFalse(customer.Groups.Contains(ScimFixture.Administrators.Id)); + Assert.IsFalse(customer.Groups.Contains(ScimFixture.StoreManager.Id)); + Assert.IsTrue(string.IsNullOrEmpty(customer.StaffStoreId)); + CollectionAssert.Contains((await _f.UserService().GetConflicts()).ToList(), user); + } + + [TestMethod] + public async Task SetMapping_SalesManager_Invalid() + { + var group = await Group("Sales"); + Assert.AreEqual(ScimOutcome.Invalid, + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.SalesManager.Id, null, null)); + } + + [TestMethod] + [DataRow("g-registered")] + [DataRow("g-guests")] + [DataRow("no-such-group")] + public async Task SetMapping_NotMappable_Invalid(string customerGroupId) + { + var group = await Group("Some"); + Assert.AreEqual(ScimOutcome.Invalid, + await _f.GroupServiceUnderTest().SetMapping(group, customerGroupId, null, null)); + } + + [TestMethod] + public async Task SetMapping_StoreManagerWithoutStore_Invalid() + { + var group = await Group("Store staff"); + Assert.AreEqual(ScimOutcome.Invalid, + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.StoreManager.Id, "", null)); + } + + [TestMethod] + public async Task SetMapping_StoreManagerUnknownStore_Invalid() + { + _f.StoreService.Setup(x => x.GetStoreById("missing")).ReturnsAsync((Store)null); + var group = await Group("Store staff"); + Assert.AreEqual(ScimOutcome.Invalid, + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.StoreManager.Id, "missing", null)); + } + + [TestMethod] + public async Task SetMapping_VendorsWithoutVendor_Invalid() + { + var group = await Group("Vendor staff"); + Assert.AreEqual(ScimOutcome.Invalid, + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Vendors.Id, null, null)); + } + + [TestMethod] + public async Task SetMapping_CustomGroupWithStore_Invalid() + { + var group = await Group("Some"); + Assert.AreEqual(ScimOutcome.Invalid, + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Custom.Id, "storeA", null)); + } + + [TestMethod] + public async Task ReplaceMembers_ReplacesList() + { + var jan = await User(); + var ola = await User("ola@example.com", "oid-2"); + var group = await Group("Shop admins", jan); + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + + await _f.GroupServiceUnderTest().ModifyMembers(group, [ola], [], true); + + Assert.IsFalse(await HasGroup(jan, ScimFixture.Administrators.Id)); + Assert.IsTrue(await HasGroup(ola, ScimFixture.Administrators.Id)); + } + + [TestMethod] + public async Task List_FilterByDisplayName_CaseInsensitive() + { + await Group("Shop admins"); + await Group("Store staff"); + var page = await _f.GroupServiceUnderTest().List("SHOP admins", null, 0, 10); + Assert.AreEqual("Shop admins", page.Items.Single().DisplayName); + } + + [TestMethod] + public async Task GetMappings_ListsGroupsWithMemberCount() + { + var user = await User(); + var group = await Group("Shop admins", user); + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + + var mapping = (await _f.GroupServiceUnderTest().GetMappings()).Single(); + + Assert.AreEqual("Shop admins", mapping.DisplayName); + Assert.AreEqual(1, mapping.MemberCount); + Assert.AreEqual(ScimFixture.Administrators.Id, mapping.CustomerGroupId); + } + + //the mapping screen offers only the targets SetMapping accepts + [TestMethod] + public async Task GetMappableGroups_BackOfficeAndCustomOnly() + { + var ids = (await _f.GroupServiceUnderTest().GetMappableGroups()).Select(x => x.Id).ToList(); + CollectionAssert.AreEquivalent(new[] { + ScimFixture.Administrators.Id, ScimFixture.StoreManager.Id, ScimFixture.Vendors.Id, ScimFixture.Custom.Id, + ScimFixture.OtherCustom.Id + }, ids); + } + + //a break-glass account keeps its roles whatever the provider's groups say + [TestMethod] + public async Task BreakGlassMember_LeavingGroup_KeepsAdministrators() + { + var user = await User(); + var group = await Group("Shop admins", user); + await _f.GroupServiceUnderTest().SetMapping(group, ScimFixture.Administrators.Id, null, null); + _f.LoginConfig.BreakGlassAccounts = ["jan@example.com"]; + + await _f.GroupServiceUnderTest().ModifyMembers(group, [], [user], false); + + Assert.IsTrue(await HasGroup(user, ScimFixture.Administrators.Id)); + } + + //review I5: one membership change recomputes the members it changed, not the whole group + [TestMethod] + public async Task ModifyMembers_SyncsOnlyChangedMembers() + { + var jan = await User(); + var ola = await User("ola@example.com", "oid-2"); + var ewa = await User("ewa@example.com", "oid-3"); + var group = await Group("Shop admins", jan, ola); + var spy = new SyncSpy(_f); + + await _f.GroupServiceUnderTest(spy).ModifyMembers(group, [ewa], [ola], false); + + CollectionAssert.AreEquivalent(new[] { ewa, ola }, spy.Synced); + } + + [TestMethod] + public async Task CreateAndDelete_PublishEntityEvents() + { + var group = await Group("Shop admins"); + await _f.GroupServiceUnderTest().Delete(group); + + _f.Mediator.Verify(x => x.Publish(It.IsAny>(), It.IsAny()), Times.Once); + _f.Mediator.Verify(x => x.Publish(It.IsAny>(), It.IsAny()), Times.Once); + } + + private class SyncSpy(ScimFixture f) : ScimRoleSync(f.Users, f.Groups, f.CustomerService.Object, f.GroupService.Object, + f.LoginConfig, f.Mediator.Object, NullLogger.Instance) + { + public List Synced { get; } = []; + + public override Task SyncMembers(IEnumerable customerIds) + { + var ids = customerIds.ToList(); + Synced.AddRange(ids); + return base.SyncMembers(ids); + } + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs new file mode 100644 index 0000000000..17d2900af6 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs @@ -0,0 +1,513 @@ +using Grand.Business.Authentication.Services; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Common; +using Grand.Domain.Customers; +using Grand.Infrastructure.Events; +using Grand.Mediator; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Business.Authentication.Tests.Services.Scim; + +[TestClass] +public class ScimUserServiceTests +{ + private ScimFixture _f; + + [TestInitialize] + public void Init() => _f = new ScimFixture(); + + private async Task MapGroup(string customerGroupId, params string[] members) => + await _f.Groups.InsertAsync(new ScimGroup { + DisplayName = "IdP " + customerGroupId, CustomerGroupId = customerGroupId, Members = members.ToList() + }); + + [TestMethod] + public async Task Create_NewUser_StorelessRegisteredActiveAndLinked() + { + var result = await _f.UserService().Create(ScimFixture.Data(userName: "Jan@Example.com")); + + Assert.AreEqual(ScimOutcome.Created, result.Outcome); + var customer = await _f.Reload(result.User.Id); + Assert.AreEqual("jan@example.com", customer.Email); + Assert.AreEqual("", customer.StoreId); + Assert.IsTrue(customer.Active); + Assert.IsTrue(customer.Groups.Contains(ScimFixture.Registered.Id)); + Assert.IsNotNull(await _f.Users.GetByIdAsync(customer.Id)); + _f.CustomerManager.Verify(x => x.RegisterCustomer(It.Is(r => r.StoreId == "")), Times.Once); + _f.Resolver.Verify(x => x.LinkIdentity(It.Is(c => c.Id == customer.Id), "oid-1", "jan@example.com", ""), + Times.Once); + Assert.AreEqual("oid-1", result.User.ExternalId); + Assert.AreEqual("Jan", result.User.GivenName); + } + + [TestMethod] + public async Task Create_UserNameNotAnEmail_UsesWorkEmail() + { + var result = await _f.UserService().Create(ScimFixture.Data(userName: "jnowak", email: "jan@example.com")); + Assert.AreEqual(ScimOutcome.Created, result.Outcome); + Assert.AreEqual("jan@example.com", (await _f.Reload(result.User.Id)).Email); + } + + [TestMethod] + public async Task Create_NoEmailAnywhere_Invalid() + { + var result = await _f.UserService().Create(ScimFixture.Data(userName: "jnowak")); + Assert.AreEqual(ScimOutcome.Invalid, result.Outcome); + Assert.AreEqual(0, _f.Customers.Table.Count()); + } + + [TestMethod] + public async Task Create_ExternalIdNotSubject_NoLink() + { + _f.Config.ExternalIdIsSubject = false; + var result = await _f.UserService().Create(ScimFixture.Data()); + Assert.AreEqual(ScimOutcome.Created, result.Outcome); + _f.Resolver.Verify(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Create_SameExternalIdTwice_Conflict() + { + await _f.UserService().Create(ScimFixture.Data()); + var second = await _f.UserService().Create(ScimFixture.Data(userName: "other@example.com")); + Assert.AreEqual(ScimOutcome.Conflict, second.Outcome); + } + + //the user ruled: SCIM takes over an existing account and manages it from then on + [TestMethod] + public async Task Create_ExistingAdminInNoMappedGroup_TakenOverLosesAdministratorsKeepsHandGroup() + { + var existing = await _f.AddCustomer("jan@example.com", ScimFixture.Administrators.Id, ScimFixture.OtherCustom.Id); + + var result = await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual(ScimOutcome.Created, result.Outcome); + Assert.AreEqual(existing.Id, result.User.Id); + var customer = await _f.Reload(existing.Id); + Assert.IsFalse(customer.Groups.Contains(ScimFixture.Administrators.Id)); + Assert.IsTrue(customer.Groups.Contains(ScimFixture.OtherCustom.Id)); + Assert.IsTrue(customer.Groups.Contains(ScimFixture.Registered.Id)); + _f.CustomerManager.Verify(x => x.RegisterCustomer(It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Create_ExistingStoreManager_StaffStoreTakenOverAndCleared() + { + var existing = await _f.AddCustomer("jan@example.com", ScimFixture.StoreManager.Id); + await _f.CustomerService.Object.UpdateCustomerField(existing.Id, x => x.StaffStoreId, "storeA"); + + await _f.UserService().Create(ScimFixture.Data()); + + var customer = await _f.Reload(existing.Id); + Assert.IsFalse(customer.Groups.Contains(ScimFixture.StoreManager.Id)); + Assert.IsTrue(string.IsNullOrEmpty(customer.StaffStoreId)); + } + + [TestMethod] + public async Task Create_BreakGlassAccount_ConflictNothingWritten() + { + _f.LoginConfig.BreakGlassAccounts = ["jan@example.com"]; + var existing = await _f.AddCustomer("jan@example.com", ScimFixture.Administrators.Id); + + var result = await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual(ScimOutcome.Conflict, result.Outcome); + Assert.IsNull(await _f.Users.GetByIdAsync(existing.Id)); + Assert.IsTrue((await _f.Reload(existing.Id)).Groups.Contains(ScimFixture.Administrators.Id)); + } + + [TestMethod] + public async Task Create_SystemAccount_Conflict() + { + var existing = await _f.AddCustomer("jan@example.com"); + await _f.Customers.UpdateField(existing.Id, x => x.IsSystemAccount, true); + Assert.AreEqual(ScimOutcome.Conflict, (await _f.UserService().Create(ScimFixture.Data())).Outcome); + } + + [TestMethod] + public async Task Create_AccountLinkedToOtherSubject_Conflict() + { + var existing = await _f.AddCustomer("jan@example.com", ScimFixture.Administrators.Id); + await _f.Links.InsertAsync(new ExternalAuthentication { + CustomerId = existing.Id, ExternalIdentifier = "other-sub", + ProviderSystemName = OidcCustomerResolver.ProviderSystemName + }); + Assert.AreEqual(ScimOutcome.Conflict, (await _f.UserService().Create(ScimFixture.Data())).Outcome); + Assert.IsNull(await _f.Users.GetByIdAsync(existing.Id)); + } + + [TestMethod] + public async Task Create_InGroupMappedToAdministrators_GetsAdministrators() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await MapGroup(ScimFixture.Administrators.Id, created.User.Id); + + await _f.UserService().Replace(created.User.Id, ScimFixture.Data()); + + Assert.IsTrue((await _f.Reload(created.User.Id)).Groups.Contains(ScimFixture.Administrators.Id)); + } + + [TestMethod] + public async Task Replace_Inactive_LocksOut() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + + var result = await _f.UserService().Replace(created.User.Id, ScimFixture.Data(active: false)); + + Assert.AreEqual(ScimOutcome.Ok, result.Outcome); + Assert.IsFalse(result.User.Active); + Assert.IsFalse((await _f.Reload(created.User.Id)).Active); + Assert.IsTrue((await _f.Users.GetByIdAsync(created.User.Id)).DeactivatedByScim); + _f.Sessions.Verify(x => x.EndByCustomer(created.User.Id), Times.Once); + _f.Revocation.Verify(x => x.RevokeTokens("oid-1"), Times.Once); + _f.CustomerService.Verify(x => x.UpdateUserField(It.Is(c => c.Id == created.User.Id), + SystemCustomerFieldNames.RefreshToken, "", ""), Times.Once); + } + + [TestMethod] + public async Task Replace_ActiveAfterScimDeactivation_Reactivates() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Replace(created.User.Id, ScimFixture.Data(active: false)); + + var result = await _f.UserService().Replace(created.User.Id, ScimFixture.Data(active: true)); + + Assert.IsTrue(result.User.Active); + Assert.IsTrue((await _f.Reload(created.User.Id)).Active); + Assert.IsFalse((await _f.Users.GetByIdAsync(created.User.Id)).DeactivatedByScim); + } + + //an administrator who disabled the account by hand decides when it comes back + [TestMethod] + public async Task Replace_ActiveOnHandDisabledAccount_StaysInactive() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.Customers.UpdateField(created.User.Id, x => x.Active, false); + + var result = await _f.UserService().Replace(created.User.Id, ScimFixture.Data(active: true)); + + Assert.IsFalse(result.User.Active); + Assert.IsFalse((await _f.Reload(created.User.Id)).Active); + } + + [TestMethod] + public async Task Replace_EmailOfAnotherCustomer_Conflict() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.AddCustomer("taken@example.com"); + + var result = await _f.UserService().Replace(created.User.Id, ScimFixture.Data(userName: "taken@example.com")); + + Assert.AreEqual(ScimOutcome.Conflict, result.Outcome); + Assert.AreEqual("jan@example.com", (await _f.Reload(created.User.Id)).Email); + } + + [TestMethod] + public async Task Replace_NewEmail_UpdatesEmailAndUsername() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Replace(created.User.Id, ScimFixture.Data(userName: "Jan.Nowak@Example.com")); + var customer = await _f.Reload(created.User.Id); + Assert.AreEqual("jan.nowak@example.com", customer.Email); + Assert.AreEqual("jan.nowak@example.com", customer.Username); + } + + [TestMethod] + public async Task Replace_Unknown_NotFound() => + Assert.AreEqual(ScimOutcome.NotFound, (await _f.UserService().Replace("nope", ScimFixture.Data())).Outcome); + + [TestMethod] + public async Task Delete_DeactivatesRevokesRolesUnlinksAndKeepsCustomer() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await MapGroup(ScimFixture.Administrators.Id, created.User.Id); + await _f.UserService().Replace(created.User.Id, ScimFixture.Data()); + + var outcome = await _f.UserService().Delete(created.User.Id); + + Assert.AreEqual(ScimOutcome.Ok, outcome); + var customer = await _f.Reload(created.User.Id); + Assert.IsNotNull(customer); + Assert.IsFalse(customer.Active); + Assert.IsFalse(customer.Groups.Contains(ScimFixture.Administrators.Id)); + Assert.IsTrue((await _f.Users.GetByIdAsync(created.User.Id)).Deprovisioned); + Assert.AreEqual(0, _f.Links.Table.Count(x => x.CustomerId == created.User.Id)); + Assert.IsFalse(_f.Groups.Table.First().Members.Contains(created.User.Id)); + Assert.AreEqual(ScimOutcome.NotFound, (await _f.UserService().Get(created.User.Id)).Outcome); + } + + [TestMethod] + public async Task List_FilterByUserName_CaseInsensitive() + { + await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Create(ScimFixture.Data(userName: "ola@example.com", externalId: "oid-2")); + + var page = await _f.UserService().List("JAN@example.com", null, 0, 10); + + Assert.AreEqual(1, page.Total); + Assert.AreEqual("jan@example.com", page.Items.Single().UserName); + } + + //Entra's connection test filters for a random user name and expects an empty list, not an error + [TestMethod] + public async Task List_UnknownUserName_Empty() + { + var page = await _f.UserService().List(Guid.NewGuid().ToString(), null, 0, 10); + Assert.AreEqual(0, page.Total); + Assert.AreEqual(0, page.Items.Count); + } + + [TestMethod] + public async Task List_FilterByExternalId() + { + await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Create(ScimFixture.Data(userName: "ola@example.com", externalId: "oid-2")); + var page = await _f.UserService().List(null, "oid-2", 0, 10); + Assert.AreEqual("ola@example.com", page.Items.Single().UserName); + } + + //a customer who is not SCIM-managed is never listed, even with a matching e-mail + [TestMethod] + public async Task List_UnmanagedCustomer_NotListed() + { + await _f.AddCustomer("jan@example.com"); + Assert.AreEqual(0, (await _f.UserService().List("jan@example.com", null, 0, 10)).Total); + } + + //review H1: a taken-over account keeps the resolver's rules (password and second factor for staff) - SCIM vouches only + //for accounts it created, or anyone able to edit an e-mail at the provider would sign in as the account's owner + [TestMethod] + public async Task Create_TakenOverAccount_NotLinkedAhead() + { + await _f.AddCustomer("jan@example.com", ScimFixture.Administrators.Id); + + var result = await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual(ScimOutcome.Created, result.Outcome); + _f.Resolver.Verify(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + Assert.IsFalse((await _f.Users.GetByIdAsync(result.User.Id)).RegisteredByScim); + } + + //only staff accounts are taken over: a shopper with the e-mail is not the provider's to manage + [TestMethod] + public async Task Create_ShopperAccount_Conflict() + { + var shopper = await _f.AddCustomer("jan@example.com"); + + Assert.AreEqual(ScimOutcome.Conflict, (await _f.UserService().Create(ScimFixture.Data())).Outcome); + Assert.IsNull(await _f.Users.GetByIdAsync(shopper.Id)); + } + + [TestMethod] + public async Task Delete_BreakGlassSinceProvisioning_ConflictAccountUntouched() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + _f.LoginConfig.BreakGlassAccounts = ["jan@example.com"]; + + Assert.AreEqual(ScimOutcome.Conflict, await _f.UserService().Delete(created.User.Id)); + Assert.IsTrue((await _f.Reload(created.User.Id)).Active); + Assert.IsNotNull(await _f.Users.GetByIdAsync(created.User.Id)); + } + + //a managed account must not move onto a break-glass address: it would get the Admin password sign-in out of SCIM's reach + [TestMethod] + public async Task Replace_ToBreakGlassEmail_Conflict() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + _f.LoginConfig.BreakGlassAccounts = ["rescue@example.com"]; + + var result = await _f.UserService().Replace(created.User.Id, ScimFixture.Data(userName: "rescue@example.com")); + + Assert.AreEqual(ScimOutcome.Conflict, result.Outcome); + Assert.AreEqual("jan@example.com", (await _f.Reload(created.User.Id)).Email); + } + + //a refused replace writes nothing + [TestMethod] + public async Task Replace_NewEmailWithTakenExternalId_ConflictEmailUnchanged() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Create(ScimFixture.Data(userName: "ola@example.com", externalId: "oid-2")); + + var result = await _f.UserService().Replace(created.User.Id, + ScimFixture.Data(userName: "jan.nowak@example.com", externalId: "oid-2")); + + Assert.AreEqual(ScimOutcome.Conflict, result.Outcome); + Assert.AreEqual("jan@example.com", (await _f.Reload(created.User.Id)).Email); + } + + //Entra deactivates, deletes, and creates the user again when it is restored or reassigned + [TestMethod] + public async Task Create_AfterDeleteOfScimDeactivatedAccount_ActiveAgain() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Replace(created.User.Id, ScimFixture.Data(active: false)); + await _f.UserService().Delete(created.User.Id); + + var again = await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual(ScimOutcome.Created, again.Outcome); + Assert.IsTrue((await _f.Reload(created.User.Id)).Active); + } + + [TestMethod] + public async Task Create_AfterDeleteOfHandDisabledAccount_StaysInactive() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.Customers.UpdateField(created.User.Id, x => x.Active, false); + await _f.UserService().Delete(created.User.Id); + + await _f.UserService().Create(ScimFixture.Data()); + + Assert.IsFalse((await _f.Reload(created.User.Id)).Active); + } + + //review I4: a sync holding an older copy of the record must not undo a deactivation written meanwhile + [TestMethod] + public async Task Sync_WithStaleRecord_KeepsDeactivatedByScim() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + var stale = await _f.Users.GetByIdAsync(created.User.Id); + await _f.UserService().Replace(created.User.Id, ScimFixture.Data(active: false)); + + await _f.RoleSync().Sync(await _f.Reload(created.User.Id), stale); + + Assert.IsTrue((await _f.Users.GetByIdAsync(created.User.Id)).DeactivatedByScim); + } + + //an old cookie or API token must not come back to life when the account is activated again + [TestMethod] + public async Task Deactivate_RotatesPasswordToken() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + + await _f.UserService().Replace(created.User.Id, ScimFixture.Data(active: false)); + + _f.CustomerService.Verify(x => x.UpdateUserField(It.Is(c => c.Id == created.User.Id), + SystemCustomerFieldNames.PasswordToken, It.Is(v => IsGuid(v)), ""), Times.Once); + } + + [TestMethod] + public async Task CreateAndDelete_PublishEntityEvents() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Delete(created.User.Id); + + _f.Mediator.Verify(x => x.Publish(It.IsAny>(), It.IsAny()), Times.Once); + _f.Mediator.Verify(x => x.Publish(It.IsAny>(), It.IsAny()), + Times.AtLeastOnce); + } + + //a deleted user is gone for SCIM but recognised when the provider creates it again + [TestMethod] + public async Task Delete_UserNotListedNorCounted() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Delete(created.User.Id); + + Assert.AreEqual(0, (await _f.UserService().List(null, "oid-1", 0, 10)).Total); + Assert.AreEqual(0, await _f.UserService().Count()); + Assert.AreEqual(ScimOutcome.NotFound, (await _f.UserService().Replace(created.User.Id, ScimFixture.Data())).Outcome); + } + + [TestMethod] + public async Task Create_AfterDelete_SameAccountAndLinkedAgain() + { + var created = await _f.UserService().Create(ScimFixture.Data()); + await _f.UserService().Delete(created.User.Id); + + var again = await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual(created.User.Id, again.User.Id); + Assert.AreEqual(1, _f.Links.Table.Count(x => x.CustomerId == created.User.Id)); + } + + //an employee's shopping account on the company's domain is taken over: it keeps its orders and is not linked ahead + [TestMethod] + public async Task Create_ShopperOnTakeOverDomain_TakenOverNotLinkedAhead() + { + _f.Config.TakeOverDomains = ["example.com"]; + var shopper = await _f.AddCustomer("jan@example.com"); + + var result = await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual(ScimOutcome.Created, result.Outcome); + Assert.AreEqual(shopper.Id, result.User.Id); + _f.Resolver.Verify(x => x.LinkIdentity(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + _f.CustomerManager.Verify(x => x.RegisterCustomer(It.IsAny()), Times.Never); + } + + //anyone could have registered the address (nobody verified it): the provider's roles must not reach that person's password, + //sessions, second factor or social sign-ins + [TestMethod] + public async Task Create_ShopperOnTakeOverDomain_CredentialsResetBeforeRolesArrive() + { + _f.Config.TakeOverDomains = ["example.com"]; + var shopper = await _f.AddCustomer("jan@example.com"); + shopper.Password = "hash:shopper"; + shopper.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.TwoFactorEnabled, Value = "True", StoreId = "" }); + shopper.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.RefreshToken, Value = "rt", StoreId = "" }); + shopper.UserFields.Add(new UserField { Key = SystemCustomerFieldNames.PasswordToken, Value = "old", StoreId = "" }); + await _f.Customers.UpdateAsync(shopper); + await _f.Links.InsertAsync(new ExternalAuthentication { + CustomerId = shopper.Id, ProviderSystemName = "ExternalAuth.Facebook", ExternalIdentifier = "fb-1" + }); + await MapGroup(ScimFixture.Administrators.Id); + var group = _f.Groups.Table.Single(); + + var result = await _f.UserService().Create(ScimFixture.Data()); + await _f.GroupServiceUnderTest().ModifyMembers(group.Id, [result.User.Id], [], false); + + var customer = await _f.Reload(shopper.Id); + Assert.AreNotEqual("hash:shopper", customer.Password); + Assert.AreNotEqual("old", customer.GetUserFieldFromEntity(SystemCustomerFieldNames.PasswordToken)); + Assert.IsNull(customer.GetUserFieldFromEntity(SystemCustomerFieldNames.TwoFactorEnabled)); + Assert.IsNull(customer.GetUserFieldFromEntity(SystemCustomerFieldNames.RefreshToken)); + Assert.IsFalse(_f.Links.Table.Any(x => x.CustomerId == shopper.Id)); + _f.Sessions.Verify(x => x.EndByCustomer(shopper.Id), Times.Once); + Assert.IsTrue(customer.Groups.Contains(ScimFixture.Administrators.Id)); + } + + //a staff account was made by an administrator: its password and second factor stand behind its first link + [TestMethod] + public async Task Create_ExistingStaffAccount_KeepsCredentials() + { + var staff = await _f.AddCustomer("jan@example.com", ScimFixture.Administrators.Id); + staff.Password = "hash:staff"; + await _f.Customers.UpdateAsync(staff); + + await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual("hash:staff", (await _f.Reload(staff.Id)).Password); + _f.CustomerManager.Verify(x => x.ChangePassword(It.IsAny(), It.IsAny()), Times.Never); + } + + [TestMethod] + public async Task Create_BreakGlassAddressWithoutAccount_ConflictNothingRegistered() + { + _f.LoginConfig.BreakGlassAccounts = ["jan@example.com"]; + + var result = await _f.UserService().Create(ScimFixture.Data()); + + Assert.AreEqual(ScimOutcome.Conflict, result.Outcome); + _f.CustomerManager.Verify(x => x.RegisterCustomer(It.IsAny()), Times.Never); + Assert.AreEqual(0, _f.Customers.Table.Count()); + } + + [TestMethod] + public async Task Create_ShopperOnOtherDomain_Conflict() + { + _f.Config.TakeOverDomains = ["firma.pl"]; + var shopper = await _f.AddCustomer("jan@example.com"); + + Assert.AreEqual(ScimOutcome.Conflict, (await _f.UserService().Create(ScimFixture.Data())).Outcome); + Assert.IsNull(await _f.Users.GetByIdAsync(shopper.Id)); + } + + private static bool IsGuid(string value) => Guid.TryParse(value, out _); +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs new file mode 100644 index 0000000000..b396703ce3 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs @@ -0,0 +1,66 @@ +using Grand.Infrastructure.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class ScimConfigTests +{ + [TestMethod] + public void Defaults() + { + var config = new ScimConfig(); + Assert.IsTrue(config.ExternalIdIsSubject); + Assert.AreEqual(100, config.MaxResults); + Assert.IsFalse(config.TokenUsable); + } + + //a short secret is guessable: the endpoint must refuse every request rather than accept it + [TestMethod] + [DataRow(null, false)] + [DataRow("", false)] + [DataRow("0123456789012345678901234567890", false)] + [DataRow("01234567890123456789012345678901", true)] + public void TokenUsable(string token, bool expected) => + Assert.AreEqual(expected, new ScimConfig { Token = token }.TokenUsable); + + [TestMethod] + [DataRow(null, 100)] + [DataRow(500, 100)] + [DataRow(-1, 0)] + [DataRow(0, 0)] + [DataRow(10, 10)] + public void PageSize(int? requested, int expected) => + Assert.AreEqual(expected, new ScimConfig().PageSize(requested)); + + [TestMethod] + [DataRow(0)] + [DataRow(-3)] + public void NonPositiveMaxResults_FallsBackToDefault(int value) => + Assert.AreEqual(100, new ScimConfig { MaxResults = value }.PageSize(null)); + + //shopper accounts are taken over only with an e-mail of the company's own domains + [TestMethod] + [DataRow("jan@firma.pl", true)] + [DataRow("Jan@FIRMA.PL", true)] + [DataRow(" jan@firma.pl ", true)] + [DataRow("jan@dzial.firma.pl", false)] + [DataRow("jan@firma.pl.evil.com", false)] + [DataRow("jan@gmail.com", false)] + [DataRow("firma.pl", false)] + [DataRow("", false)] + [DataRow(null, false)] + public void IsTakeOverDomain(string email, bool expected) => + Assert.AreEqual(expected, new ScimConfig { TakeOverDomains = ["firma.pl", " @Other.com "] }.IsTakeOverDomain(email)); + + [TestMethod] + public void IsTakeOverDomain_LeadingAtInConfig() => + Assert.IsTrue(new ScimConfig { TakeOverDomains = [" @Other.com "] }.IsTakeOverDomain("ola@other.com")); + + [TestMethod] + public void IsTakeOverDomain_NoneConfigured_False() + { + Assert.IsFalse(new ScimConfig().IsTakeOverDomain("jan@firma.pl")); + Assert.IsFalse(new ScimConfig { TakeOverDomains = null }.IsTakeOverDomain("jan@firma.pl")); + } +} diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/ScimRolesTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimRolesTests.cs new file mode 100644 index 0000000000..f3505a529b --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimRolesTests.cs @@ -0,0 +1,117 @@ +using Grand.Business.Authentication.Services.Scim; +using Grand.Domain.Customers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Business.Authentication.Tests.Services; + +[TestClass] +public class ScimRolesTests +{ + private const string Admin = "g-admin"; + private const string StoreManager = "g-sm"; + private const string Vendors = "g-vendors"; + private const string Custom = "g-custom"; + + private static string SystemNameOf(string groupId) => groupId switch { + Admin => SystemCustomerGroupNames.Administrators, + StoreManager => SystemCustomerGroupNames.StoreManager, + Vendors => SystemCustomerGroupNames.Vendors, + _ => "" + }; + + private static ScimGroup Mapped(string customerGroupId, string store = null, string vendor = null) => + new() { CustomerGroupId = customerGroupId, StaffStoreId = store, VendorId = vendor }; + + private static ScimWanted Wanted(params ScimGroup[] groups) => ScimRoles.Wanted(groups, SystemNameOf); + + [TestMethod] + public void NoGroups_NothingWanted() + { + var wanted = Wanted(); + Assert.AreEqual(0, wanted.GroupIds.Count); + Assert.IsNull(wanted.StaffStoreId); + Assert.IsNull(wanted.VendorId); + Assert.IsFalse(wanted.Conflict); + } + + [TestMethod] + public void AdministratorsAlone() + { + var wanted = Wanted(Mapped(Admin)); + CollectionAssert.AreEquivalent(new[] { Admin }, wanted.GroupIds.ToList()); + Assert.IsNull(wanted.StaffStoreId); + Assert.IsFalse(wanted.Conflict); + } + + [TestMethod] + public void StoreManager_SameStoreTwice_OneStore() + { + var wanted = Wanted(Mapped(StoreManager, "storeA"), Mapped(StoreManager, "storeA")); + CollectionAssert.AreEquivalent(new[] { StoreManager }, wanted.GroupIds.ToList()); + Assert.AreEqual("storeA", wanted.StaffStoreId); + Assert.IsFalse(wanted.Conflict); + } + + //guessing a store would give access to the wrong one + [TestMethod] + public void StoreManager_TwoStores_ConflictKeepsCustomGroups() + { + var wanted = Wanted(Mapped(StoreManager, "storeA"), Mapped(StoreManager, "storeB"), Mapped(Custom)); + CollectionAssert.AreEquivalent(new[] { Custom }, wanted.GroupIds.ToList()); + Assert.IsNull(wanted.StaffStoreId); + Assert.IsTrue(wanted.Conflict); + } + + //Administrators with a staff store is refused by the customer validator and locks the account out of the Admin panel + [TestMethod] + public void AdministratorsWithStoreManager_Conflict() + { + var wanted = Wanted(Mapped(Admin), Mapped(StoreManager, "storeA")); + Assert.AreEqual(0, wanted.GroupIds.Count); + Assert.IsNull(wanted.StaffStoreId); + Assert.IsTrue(wanted.Conflict); + } + + [TestMethod] + public void VendorsAlone() + { + var wanted = Wanted(Mapped(Vendors, vendor: "v1")); + CollectionAssert.AreEquivalent(new[] { Vendors }, wanted.GroupIds.ToList()); + Assert.AreEqual("v1", wanted.VendorId); + Assert.IsFalse(wanted.Conflict); + } + + [TestMethod] + public void VendorsWithStoreManager_Conflict() + { + var wanted = Wanted(Mapped(Vendors, vendor: "v1"), Mapped(StoreManager, "storeA")); + Assert.AreEqual(0, wanted.GroupIds.Count); + Assert.IsNull(wanted.VendorId); + Assert.IsNull(wanted.StaffStoreId); + Assert.IsTrue(wanted.Conflict); + } + + [TestMethod] + public void VendorsTwoVendors_Conflict() + { + var wanted = Wanted(Mapped(Vendors, vendor: "v1"), Mapped(Vendors, vendor: "v2")); + Assert.AreEqual(0, wanted.GroupIds.Count); + Assert.IsTrue(wanted.Conflict); + } + + [TestMethod] + public void CustomGroupsOnly() + { + var wanted = Wanted(Mapped(Custom), Mapped("g-other")); + CollectionAssert.AreEquivalent(new[] { Custom, "g-other" }, wanted.GroupIds.ToList()); + Assert.IsFalse(wanted.Conflict); + } + + [TestMethod] + public void UnmappedGroup_GivesNothing() + { + var wanted = Wanted(Mapped(null), Mapped("")); + Assert.AreEqual(0, wanted.GroupIds.Count); + Assert.IsFalse(wanted.Conflict); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj index b9b154255c..29d6f65697 100644 --- a/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj +++ b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Tests/Grand.Modules.Tests/Scim/ScimAuthorizeFilterTests.cs b/src/Tests/Grand.Modules.Tests/Scim/ScimAuthorizeFilterTests.cs new file mode 100644 index 0000000000..3017723539 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Scim/ScimAuthorizeFilterTests.cs @@ -0,0 +1,75 @@ +using Grand.Infrastructure.Configuration; +using Grand.Module.Scim.Infrastructure; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Scim; + +[TestClass] +public class ScimAuthorizeFilterTests +{ + private const string Token = "0123456789abcdef0123456789abcdef"; + + private ScimConfig _config; + private OidcLoginConfig _loginConfig; + private OidcResourceServerConfig _resourceServer; + + [TestInitialize] + public void Init() + { + _config = new ScimConfig { Token = Token }; + _loginConfig = new OidcLoginConfig { ClientId = "grandnode" }; + _resourceServer = new OidcResourceServerConfig { Authority = "https://idp.example.com" }; + } + + private AuthorizationFilterContext Run(string authorization) + { + var httpContext = new DefaultHttpContext(); + if (authorization != null) + httpContext.Request.Headers.Authorization = authorization; + var context = new AuthorizationFilterContext( + new ActionContext(httpContext, new RouteData(), new ActionDescriptor()), []); + new ScimAuthorizeFilter(_config, _loginConfig, _resourceServer, NullLogger.Instance) + .OnAuthorization(context); + return context; + } + + private static void AssertUnauthorized(AuthorizationFilterContext context) => + Assert.AreEqual(StatusCodes.Status401Unauthorized, (context.Result as ContentResult)?.StatusCode); + + [TestMethod] + public void RightToken_Passes() => Assert.IsNull(Run("Bearer " + Token).Result); + + [TestMethod] + public void SchemeCaseInsensitive_Passes() => Assert.IsNull(Run("bearer " + Token).Result); + + [TestMethod] + [DataRow(null)] + [DataRow("")] + [DataRow("Bearer")] + [DataRow("Basic " + Token)] + [DataRow("Bearer 0123456789abcdef0123456789abcdeX")] + [DataRow("Bearer " + Token + "x")] + public void WrongOrMissingToken_Unauthorized(string authorization) => AssertUnauthorized(Run(authorization)); + + //a short secret is a configuration mistake: nobody gets in, even with that secret + [TestMethod] + public void ShortConfiguredToken_Unauthorized() + { + _config.Token = "short"; + AssertUnauthorized(Run("Bearer short")); + } + + //accounts without a password and without a provider would be useless: the endpoint stays shut + [TestMethod] + public void OidcLoginNotConfigured_Unauthorized() + { + _loginConfig.ClientId = ""; + AssertUnauthorized(Run("Bearer " + Token)); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Scim/ScimFilterTests.cs b/src/Tests/Grand.Modules.Tests/Scim/ScimFilterTests.cs new file mode 100644 index 0000000000..69e1201f63 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Scim/ScimFilterTests.cs @@ -0,0 +1,31 @@ +using Grand.Module.Scim.Protocol; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Grand.Modules.Tests.Scim; + +[TestClass] +public class ScimFilterTests +{ + [TestMethod] + [DataRow("userName eq \"jan@example.com\"", "username", "jan@example.com")] + [DataRow("USERNAME EQ \"jan@example.com\"", "username", "jan@example.com")] + [DataRow(" externalId eq \"a-b-c\" ", "externalid", "a-b-c")] + [DataRow("displayName eq \"Shop \\\"admins\\\"\"", "displayname", "Shop \"admins\"")] + [DataRow("userName eq \"\"", "username", "")] + public void Parses(string filter, string attribute, string value) + { + Assert.IsTrue(ScimFilter.TryParse(filter, out var parsed)); + Assert.AreEqual(attribute, parsed.Attribute); + Assert.AreEqual(value, parsed.Value); + } + + [TestMethod] + [DataRow("userName co \"jan\"")] + [DataRow("userName eq jan")] + [DataRow("userName eq \"a\" and active eq true")] + [DataRow("userName pr")] + [DataRow("")] + [DataRow(null)] + public void RefusesAnythingElse(string filter) => + Assert.IsFalse(ScimFilter.TryParse(filter, out _)); +} diff --git a/src/Tests/Grand.Modules.Tests/Scim/ScimGroupPatchTests.cs b/src/Tests/Grand.Modules.Tests/Scim/ScimGroupPatchTests.cs new file mode 100644 index 0000000000..080d6387e4 --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Scim/ScimGroupPatchTests.cs @@ -0,0 +1,78 @@ +using Grand.Module.Scim.Infrastructure; +using Grand.Module.Scim.Models; +using Grand.Module.Scim.Protocol; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Text.Json; + +namespace Grand.Modules.Tests.Scim; + +[TestClass] +public class ScimGroupPatchTests +{ + private static ScimGroupChanges Apply(string operationsJson) + { + var ops = JsonSerializer.Deserialize("{\"Operations\":" + operationsJson + "}", ScimJson.Options) + .Operations; + Assert.IsTrue(ScimGroupPatch.TryApply(ops, out var changes, out var error), error); + return changes; + } + + [TestMethod] + public void Entra_AddMembers() + { + var changes = Apply("[{\"op\":\"Add\",\"path\":\"members\",\"value\":[{\"value\":\"u1\"},{\"value\":\"u2\"}]}]"); + CollectionAssert.AreEqual(new[] { "u1", "u2" }, changes.Add.ToList()); + Assert.IsFalse(changes.ReplaceAll); + Assert.IsTrue(changes.MembersChanged); + } + + [TestMethod] + public void Entra_RemoveMembersByValue() + { + var changes = Apply("[{\"op\":\"Remove\",\"path\":\"members\",\"value\":[{\"value\":\"u1\"}]}]"); + CollectionAssert.AreEqual(new[] { "u1" }, changes.Remove.ToList()); + } + + [TestMethod] + public void Okta_RemoveMemberByFilterPath() + { + var changes = Apply("[{\"op\":\"remove\",\"path\":\"members[value eq \\\"u7\\\"]\"}]"); + CollectionAssert.AreEqual(new[] { "u7" }, changes.Remove.ToList()); + } + + [TestMethod] + public void RemoveAllMembers() + { + var changes = Apply("[{\"op\":\"remove\",\"path\":\"members\"}]"); + Assert.IsTrue(changes.ReplaceAll); + Assert.AreEqual(0, changes.Add.Count); + } + + [TestMethod] + public void ReplaceMembers() + { + var changes = Apply("[{\"op\":\"replace\",\"path\":\"members\",\"value\":[{\"value\":\"u3\"}]}]"); + Assert.IsTrue(changes.ReplaceAll); + CollectionAssert.AreEqual(new[] { "u3" }, changes.Add.ToList()); + } + + [TestMethod] + public void Okta_ReplaceWithoutPath_Rename() + { + var changes = Apply("[{\"op\":\"replace\",\"value\":{\"id\":\"g1\",\"displayName\":\"New name\"}}]"); + Assert.AreEqual("New name", changes.DisplayName); + Assert.IsFalse(changes.MembersChanged); + } + + [TestMethod] + public void ReplaceDisplayNamePath() => + Assert.AreEqual("Renamed", Apply("[{\"op\":\"Replace\",\"path\":\"displayName\",\"value\":\"Renamed\"}]").DisplayName); + + [TestMethod] + public void UnknownPath_Refused() + { + var ops = JsonSerializer.Deserialize( + "{\"Operations\":[{\"op\":\"add\",\"path\":\"owners\",\"value\":[]}]}", ScimJson.Options).Operations; + Assert.IsFalse(ScimGroupPatch.TryApply(ops, out _, out _)); + } +} diff --git a/src/Tests/Grand.Modules.Tests/Scim/ScimUserPatchTests.cs b/src/Tests/Grand.Modules.Tests/Scim/ScimUserPatchTests.cs new file mode 100644 index 0000000000..80e0511aec --- /dev/null +++ b/src/Tests/Grand.Modules.Tests/Scim/ScimUserPatchTests.cs @@ -0,0 +1,85 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Module.Scim.Infrastructure; +using Grand.Module.Scim.Models; +using Grand.Module.Scim.Protocol; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Text.Json; + +namespace Grand.Modules.Tests.Scim; + +[TestClass] +public class ScimUserPatchTests +{ + private static readonly ScimUserData User = new("jan@example.com", "oid-1", null, "Jan", "Nowak", true); + + private static IList Ops(string json) => + JsonSerializer.Deserialize(json, ScimJson.Options).Operations; + + private static ScimUserData Apply(string operationsJson) + { + Assert.IsTrue(ScimUserPatch.TryApply(User, Ops("{\"Operations\":" + operationsJson + "}"), out var result, + out var error), error); + return result; + } + + //Entra ID sends the boolean as a string with a capital letter, and capitalises the op + [TestMethod] + public void Entra_ReplaceActiveStringFalse_Deactivates() => + Assert.IsFalse(Apply("[{\"op\":\"Replace\",\"path\":\"active\",\"value\":\"False\"}]").Active); + + [TestMethod] + public void Okta_ReplaceWithoutPath_ObjectValue() + { + var result = Apply("[{\"op\":\"replace\",\"value\":{\"active\":false,\"name\":{\"givenName\":\"Janek\"}}}]"); + Assert.IsFalse(result.Active); + Assert.AreEqual("Janek", result.GivenName); + Assert.AreEqual("Nowak", result.FamilyName); + } + + [TestMethod] + public void ReplaceBooleanTrue() => + Assert.IsTrue(Apply("[{\"op\":\"replace\",\"path\":\"active\",\"value\":true}]").Active); + + [TestMethod] + public void NamePaths() + { + var result = Apply("[{\"op\":\"Add\",\"path\":\"name.givenName\",\"value\":\"Anna\"}," + + "{\"op\":\"Replace\",\"path\":\"name.familyName\",\"value\":\"Kowalska\"}]"); + Assert.AreEqual("Anna", result.GivenName); + Assert.AreEqual("Kowalska", result.FamilyName); + } + + [TestMethod] + public void WorkEmailPath() => + Assert.AreEqual("new@example.com", + Apply("[{\"op\":\"Replace\",\"path\":\"emails[type eq \\\"work\\\"].value\",\"value\":\"new@example.com\"}]") + .Email); + + [TestMethod] + public void UserNameAndExternalId() + { + var result = Apply("[{\"op\":\"replace\",\"path\":\"userName\",\"value\":\"anna@example.com\"}," + + "{\"op\":\"replace\",\"path\":\"externalId\",\"value\":\"oid-9\"}]"); + Assert.AreEqual("anna@example.com", result.UserName); + Assert.AreEqual("oid-9", result.ExternalId); + } + + [TestMethod] + public void RemoveGivenName() => + Assert.IsNull(Apply("[{\"op\":\"remove\",\"path\":\"name.givenName\"}]").GivenName); + + //Entra's default mapping sends attributes GrandNode does not keep (title, department...): they are ignored + [TestMethod] + public void UnknownPath_Ignored() => + Assert.AreEqual(User, Apply("[{\"op\":\"Add\",\"path\":\"title\",\"value\":\"Engineer\"}]")); + + [TestMethod] + public void UnknownOp_Refused() => + Assert.IsFalse(ScimUserPatch.TryApply(User, Ops("{\"Operations\":[{\"op\":\"move\",\"path\":\"active\"}]}"), + out _, out _)); + + [TestMethod] + public void ActiveNotABoolean_Refused() => + Assert.IsFalse(ScimUserPatch.TryApply(User, + Ops("{\"Operations\":[{\"op\":\"replace\",\"path\":\"active\",\"value\":\"maybe\"}]}"), out _, out _)); +} diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs new file mode 100644 index 0000000000..73fc34f6fb --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs @@ -0,0 +1,193 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Configuration; +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.Domain; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Domain.Vendors; +using Grand.Infrastructure; +using Grand.Infrastructure.Configuration; +using Grand.Web.Admin.Controllers; +using Grand.Web.AdminShared.Models.ExternalAuthentication; +using Grand.Web.Common.Localization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.Extensions.Configuration; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Grand.Web.Admin.Tests.Controllers; + +[TestClass] +public class ExternalAuthenticationControllerScimTests +{ + private const string Token = "0123456789abcdef0123456789abcdef"; + + private readonly Mock _groups = new(); + private readonly Mock _users = new(); + private readonly Mock _stores = new(); + private readonly Mock _vendors = new(); + private readonly Mock _customers = new(); + private readonly Mock _translation = new(); + private readonly Mock _customerGroups = new(); + private readonly Mock _context = new(); + private readonly Customer _currentCustomer = new() { Id = "me" }; + private ExternalAuthenticationController _controller; + + [TestInitialize] + public void Init() + { + _controller = new ExternalAuthenticationController(new Mock().Object, + new ExternalAuthenticationSettings(), new Mock().Object, new Mock().Object); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Scheme = "https"; + httpContext.Request.Host = new HostString("shop.example.com"); + _controller.ControllerContext = new ControllerContext { HttpContext = httpContext }; + _controller.TempData = new TempDataDictionary(httpContext, new Mock().Object); + + _groups.Setup(x => x.GetMappableGroups()).ReturnsAsync([ + new CustomerGroup { Id = "g-admin", Name = "Administrators" } + ]); + _groups.Setup(x => x.GetMappings()).ReturnsAsync([ + new ScimGroupMapping("sg1", "Shop admins", 2, "g-admin", "", "") + ]); + _stores.Setup(x => x.GetAllStores()).ReturnsAsync([new Store { Id = "s1", Shortcut = "Main" }]); + _vendors.Setup(x => x.GetAllVendors(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new PagedList([new Vendor { Id = "v1", Name = "Vendor" }], 0, int.MaxValue)); + _users.Setup(x => x.Count()).ReturnsAsync(5); + _users.Setup(x => x.GetConflicts()).ReturnsAsync(["c1"]); + _customers.Setup(x => x.GetCustomersByIds(It.IsAny())) + .ReturnsAsync([new Customer { Id = "c1", Email = "jan@example.com" }]); + _translation.Setup(x => x.GetResource(It.IsAny())).Returns((string key) => key); + var workContext = new Mock(); + workContext.Setup(x => x.CurrentCustomer).Returns(_currentCustomer); + _context.Setup(x => x.WorkContext).Returns(workContext.Object); + _customerGroups.Setup(x => x.IsAdmin(_currentCustomer)).ReturnsAsync(true); + _customerGroups.Setup(x => x.GetCustomerGroupById("g-admin")).ReturnsAsync(new CustomerGroup { + Id = "g-admin", IsSystem = true, SystemName = SystemCustomerGroupNames.Administrators + }); + } + + private Task Scim(string token = Token, bool moduleOn = true, string clientId = "shop") => + _controller.Scim(new ScimConfig { Token = token }, new OidcLoginConfig { ClientId = clientId }, + new OidcResourceServerConfig { Authority = "https://idp.example.com" }, + new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { + ["FeatureManagement:Grand.Module.Scim"] = moduleOn.ToString() + }).Build(), + _users.Object, _customers.Object); + + [TestMethod] + public async Task Scim_ShowsStatusButNeverTheToken() + { + var model = (ScimModel)((ViewResult)await Scim()).Model!; + + Assert.IsTrue(model.ModuleEnabled); + Assert.IsTrue(model.TokenUsable); + Assert.IsTrue(model.SignInConfigured); + Assert.AreEqual("https://shop.example.com/scim/v2", model.EndpointUrl); + Assert.AreEqual(5, model.ManagedAccounts); + Assert.AreEqual("jan@example.com", model.Conflicts.Single().Email); + foreach (var property in typeof(ScimModel).GetProperties().Where(x => x.PropertyType == typeof(string))) + Assert.IsFalse(((string)property.GetValue(model))?.Contains(Token) ?? false, property.Name); + } + + [TestMethod] + public async Task Scim_ModuleOffAndNoSignIn_Reported() + { + var model = (ScimModel)((ViewResult)await Scim(token: "short", moduleOn: false, clientId: "")).Model!; + + Assert.IsFalse(model.ModuleEnabled); + Assert.IsFalse(model.TokenUsable); + Assert.IsFalse(model.SignInConfigured); + } + + [TestMethod] + public async Task ScimGroupEdit_Unknown_RedirectsToScim() + { + _groups.Setup(x => x.Get("nope")).ReturnsAsync(new ScimGroupResult(ScimOutcome.NotFound)); + var result = await _controller.ScimGroupEdit("nope", _groups.Object, _stores.Object, _vendors.Object); + Assert.AreEqual("Scim", ((RedirectToActionResult)result).ActionName); + } + + [TestMethod] + public async Task ScimGroupEdit_Get_OffersMappableGroupsStoresVendors() + { + _groups.Setup(x => x.Get("sg1")).ReturnsAsync(new ScimGroupResult(ScimOutcome.Ok, + new ScimGroupView("sg1", "Shop admins", null, ["c1"], DateTime.UtcNow, null))); + + var model = (ScimGroupMappingModel)((ViewResult)await _controller.ScimGroupEdit("sg1", _groups.Object, + _stores.Object, _vendors.Object)).Model!; + + Assert.AreEqual("Shop admins", model.DisplayName); + Assert.AreEqual("g-admin", model.CustomerGroupId); + Assert.IsTrue(model.AvailableCustomerGroups.Any(x => x.Value == "g-admin")); + Assert.IsTrue(model.AvailableStores.Any(x => x.Value == "s1")); + Assert.IsTrue(model.AvailableVendors.Any(x => x.Value == "v1")); + } + + [TestMethod] + public async Task ScimGroupEdit_Post_InvalidTarget_StaysWithError() + { + _groups.Setup(x => x.SetMapping("sg1", "g-sm", "", "")).ReturnsAsync(ScimOutcome.Invalid); + _groups.Setup(x => x.Get("sg1")).ReturnsAsync(new ScimGroupResult(ScimOutcome.Ok, + new ScimGroupView("sg1", "Shop admins", null, [], DateTime.UtcNow, null))); + + var result = await _controller.ScimGroupEdit(new ScimGroupMappingModel { + Id = "sg1", CustomerGroupId = "g-sm", StaffStoreId = "", VendorId = "" + }, _groups.Object, _stores.Object, _vendors.Object, _translation.Object, _customerGroups.Object, _context.Object); + + Assert.IsInstanceOfType(result); + Assert.IsFalse(_controller.ModelState.IsValid); + } + + [TestMethod] + public async Task ScimGroupEdit_Post_Valid_SavesAndRedirects() + { + _groups.Setup(x => x.SetMapping("sg1", "g-admin", null, null)).ReturnsAsync(ScimOutcome.Ok); + _groups.Setup(x => x.Get("sg1")).ReturnsAsync(new ScimGroupResult(ScimOutcome.Ok, + new ScimGroupView("sg1", "Shop admins", null, [], DateTime.UtcNow, null))); + + var result = await _controller.ScimGroupEdit(new ScimGroupMappingModel { Id = "sg1", CustomerGroupId = "g-admin" }, + _groups.Object, _stores.Object, _vendors.Object, _translation.Object, _customerGroups.Object, _context.Object); + + Assert.AreEqual("Scim", ((RedirectToActionResult)result).ActionName); + _groups.Verify(x => x.SetMapping("sg1", "g-admin", null, null), Times.Once); + } + + //only an administrator gives the Administrators role, as on the customer edit screen + [TestMethod] + public async Task ScimGroupEdit_Post_AdministratorsByNonAdmin_Refused() + { + _customerGroups.Setup(x => x.IsAdmin(_currentCustomer)).ReturnsAsync(false); + _groups.Setup(x => x.Get("sg1")).ReturnsAsync(new ScimGroupResult(ScimOutcome.Ok, + new ScimGroupView("sg1", "Shop admins", null, [], DateTime.UtcNow, null))); + + var result = await _controller.ScimGroupEdit(new ScimGroupMappingModel { Id = "sg1", CustomerGroupId = "g-admin" }, + _groups.Object, _stores.Object, _vendors.Object, _translation.Object, _customerGroups.Object, _context.Object); + + Assert.IsInstanceOfType(result); + Assert.IsFalse(_controller.ModelState.IsValid); + _groups.Verify(x => x.SetMapping(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + //nor takes it away: changing a group mapped to Administrators is an administrator's decision too + [TestMethod] + public async Task ScimGroupEdit_Post_ChangeAwayFromAdministratorsByNonAdmin_Refused() + { + _customerGroups.Setup(x => x.IsAdmin(_currentCustomer)).ReturnsAsync(false); + _groups.Setup(x => x.Get("sg1")).ReturnsAsync(new ScimGroupResult(ScimOutcome.Ok, + new ScimGroupView("sg1", "Shop admins", null, [], DateTime.UtcNow, null))); + + var result = await _controller.ScimGroupEdit(new ScimGroupMappingModel { Id = "sg1", CustomerGroupId = "" }, + _groups.Object, _stores.Object, _vendors.Object, _translation.Object, _customerGroups.Object, _context.Object); + + Assert.IsInstanceOfType(result); + _groups.Verify(x => x.SetMapping(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } +} diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Methods.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Methods.cshtml index 18908801e1..26b0aca6fa 100644 --- a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Methods.cshtml +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Methods.cshtml @@ -106,4 +106,11 @@ } + @* SCIM provisioning of staff accounts lives on its own screen *@ + +

@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Summary"]

+ + @Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim"] + +
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Scim.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Scim.cshtml new file mode 100644 index 0000000000..cbb7ea788d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Scim.cshtml @@ -0,0 +1,67 @@ +@model ScimModel +@{ + //page title + ViewBag.Title = Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim"]; +} + +@* the endpoint comes from appsettings and is shown read-only; the token never reaches the page *@ + + +
+ @Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Description"] +
+ @if (!Model.ModuleEnabled) + { +
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.ModuleOff"]
+ } + else if (!Model.SignInConfigured || !Model.TokenUsable) + { +
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Refused"]
+ } +
+
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Endpoint"]
+
@Model.EndpointUrl
+
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Token"]
+
+ @(Model.TokenUsable ? Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Token.Set"] : Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Token.NotSet"]) +
+
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.ManagedAccounts"]
+
@Model.ManagedAccounts
+
+
+ + @* a group grants nothing until it is mapped here *@ + + + + + {{ DisplayName }} + + + + + + + + + + @if (Model.Conflicts.Any()) + { + +
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Conflicts.Info"]
+
    + @foreach (var conflict in Model.Conflicts) + { +
  • + @conflict.Email +
  • + } +
+
+ } +
diff --git a/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/ScimGroupEdit.cshtml b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/ScimGroupEdit.cshtml new file mode 100644 index 0000000000..4fc91b604d --- /dev/null +++ b/src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/ScimGroupEdit.cshtml @@ -0,0 +1,36 @@ +@model ScimGroupMappingModel +@{ + //page title + ViewBag.Title = Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.EditMapping"]; + var none = new SelectListItem(Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Fields.None"], ""); +} +@* the group's name comes from the identity provider; only the role its members get is edited here *@ +
+ + + + + + + +
+
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Mapping.Info"]
+
+ + + +
+
+
@Loc["Admin.Plugins.ExternalAuthenticationMethods.Scim.Fields.MemberCount"]
+
@Model.MemberCount
+
+
+
+
diff --git a/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs b/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs index 1504452b7d..d1af99fc7e 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs @@ -1,8 +1,13 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Configuration; +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.Domain.Permissions; using Grand.Domain.Customers; +using Grand.Infrastructure; using Grand.Infrastructure.Configuration; using Grand.Infrastructure.Plugins; using Grand.Web.AdminShared.Extensions.Mapping; @@ -10,6 +15,8 @@ using Grand.Web.Common.DataSource; using Grand.Web.Common.Security.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Configuration; using Grand.Web.Common.Localization; namespace Grand.Web.Admin.Controllers; @@ -105,6 +112,126 @@ public async Task KeepOidcLinks([FromServices] IOidcLinkService l private static bool ResourceServerConfigured(OidcResourceServerConfig config) => config.Enabled && !string.IsNullOrWhiteSpace(config.Authority); + /// SCIM provisioning: the endpoint comes from appsettings and is shown read-only (never the token) + public async Task Scim([FromServices] ScimConfig scimConfig, [FromServices] OidcLoginConfig loginConfig, + [FromServices] OidcResourceServerConfig resourceServerConfig, [FromServices] IConfiguration configuration, + [FromServices] IScimUserService scimUserService, [FromServices] ICustomerService customerService) + { + var conflictIds = await scimUserService.GetConflicts(); + var conflicts = conflictIds.Count == 0 ? [] : await customerService.GetCustomersByIds(conflictIds.ToArray()); + return View(new ScimModel { + ModuleEnabled = configuration.GetValue("FeatureManagement:Grand.Module.Scim"), + TokenUsable = scimConfig.TokenUsable, + SignInConfigured = loginConfig.IsConfigured(resourceServerConfig), + EndpointUrl = $"{Request.Scheme}://{Request.Host}{Request.PathBase}/scim/v2", + ManagedAccounts = await scimUserService.Count(), + Conflicts = conflicts.Select(x => new ScimConflictModel { CustomerId = x.Id, Email = x.Email }).ToList() + }); + } + + [HttpPost] + public async Task ScimGroups(DataSourceRequest command, [FromServices] IScimGroupService scimGroupService, + [FromServices] IStoreService storeService, [FromServices] IVendorService vendorService) + { + var groups = (await scimGroupService.GetMappableGroups()).ToDictionary(x => x.Id, x => x.Name); + var stores = (await storeService.GetAllStores()).ToDictionary(x => x.Id, x => x.Shortcut); + var vendors = (await vendorService.GetAllVendors(showHidden: true)).ToDictionary(x => x.Id, x => x.Name); + var rows = (await scimGroupService.GetMappings()).Select(x => new ScimGroupRowModel { + Id = x.GroupId, + DisplayName = x.DisplayName, + MemberCount = x.MemberCount, + CustomerGroup = groups.GetValueOrDefault(x.CustomerGroupId ?? ""), + Store = stores.GetValueOrDefault(x.StaffStoreId ?? ""), + Vendor = vendors.GetValueOrDefault(x.VendorId ?? "") + }).ToList(); + + return Json(new DataSourceResult { + Data = rows.Skip((command.Page - 1) * command.PageSize).Take(command.PageSize), + Total = rows.Count + }); + } + + public async Task ScimGroupEdit(string id, [FromServices] IScimGroupService scimGroupService, + [FromServices] IStoreService storeService, [FromServices] IVendorService vendorService) + { + var group = await scimGroupService.Get(id); + if (group.Outcome != ScimOutcome.Ok) + return RedirectToAction("Scim"); + + var mapping = (await scimGroupService.GetMappings()).FirstOrDefault(x => x.GroupId == id); + var model = new ScimGroupMappingModel { + Id = group.Group.Id, + CustomerGroupId = mapping?.CustomerGroupId, + StaffStoreId = mapping?.StaffStoreId, + VendorId = mapping?.VendorId + }; + await PrepareScimGroupMapping(model, group.Group, scimGroupService, storeService, vendorService); + return View(model); + } + + [HttpPost] + [AutoValidateAntiforgeryToken] + public async Task ScimGroupEdit(ScimGroupMappingModel model, + [FromServices] IScimGroupService scimGroupService, [FromServices] IStoreService storeService, + [FromServices] IVendorService vendorService, [FromServices] IRequestTranslationService translationService, + [FromServices] IGroupService groupService, [FromServices] IContextAccessor contextAccessor) + { + var group = await scimGroupService.Get(model.Id); + if (group.Outcome != ScimOutcome.Ok) + return RedirectToAction("Scim"); + + //as on the customer screens, only an administrator gives (or takes away) the Administrators role + if (!await groupService.IsAdmin(contextAccessor.WorkContext.CurrentCustomer)) + { + var current = (await scimGroupService.GetMappings()).FirstOrDefault(x => x.GroupId == model.Id)?.CustomerGroupId; + if (await IsAdministrators(groupService, model.CustomerGroupId) || await IsAdministrators(groupService, current)) + return await ScimGroupEditError(model, group.Group, "Admin.Plugins.ExternalAuthenticationMethods.Scim.Mapping.AdministratorsOnly", + scimGroupService, storeService, vendorService, translationService); + } + + var outcome = await scimGroupService.SetMapping(model.Id, model.CustomerGroupId, model.StaffStoreId, model.VendorId); + if (outcome == ScimOutcome.Ok) + { + Success(translationService.GetResource("Admin.Plugins.ExternalAuthenticationMethods.Scim.Mapping.Saved")); + return RedirectToAction("Scim"); + } + + if (outcome == ScimOutcome.NotFound) + return RedirectToAction("Scim"); + return await ScimGroupEditError(model, group.Group, "Admin.Plugins.ExternalAuthenticationMethods.Scim.Mapping.Invalid", + scimGroupService, storeService, vendorService, translationService); + } + + private async Task ScimGroupEditError(ScimGroupMappingModel model, ScimGroupView group, string resource, + IScimGroupService scimGroupService, IStoreService storeService, IVendorService vendorService, + IRequestTranslationService translationService) + { + ModelState.AddModelError("", translationService.GetResource(resource)); + await PrepareScimGroupMapping(model, group, scimGroupService, storeService, vendorService); + return View(model); + } + + private static async Task IsAdministrators(IGroupService groupService, string customerGroupId) + { + if (string.IsNullOrEmpty(customerGroupId)) + return false; + var customerGroup = await groupService.GetCustomerGroupById(customerGroupId); + return customerGroup is { IsSystem: true, SystemName: SystemCustomerGroupNames.Administrators }; + } + + private static async Task PrepareScimGroupMapping(ScimGroupMappingModel model, ScimGroupView group, + IScimGroupService scimGroupService, IStoreService storeService, IVendorService vendorService) + { + model.DisplayName = group.DisplayName; + model.MemberCount = group.MemberIds.Count; + model.AvailableCustomerGroups = (await scimGroupService.GetMappableGroups()) + .Select(x => new SelectListItem(x.Name, x.Id)).ToList(); + model.AvailableStores = (await storeService.GetAllStores()) + .Select(x => new SelectListItem(x.Shortcut, x.Id)).ToList(); + model.AvailableVendors = (await vendorService.GetAllVendors(showHidden: true)) + .Select(x => new SelectListItem(x.Name, x.Id)).ToList(); + } + [HttpPost] public IActionResult Methods(DataSourceRequest command) { diff --git a/src/Web/Grand.Web.AdminShared/Models/ExternalAuthentication/ScimModel.cs b/src/Web/Grand.Web.AdminShared/Models/ExternalAuthentication/ScimModel.cs new file mode 100644 index 0000000000..794e249a15 --- /dev/null +++ b/src/Web/Grand.Web.AdminShared/Models/ExternalAuthentication/ScimModel.cs @@ -0,0 +1,60 @@ +using Grand.Infrastructure.ModelBinding; +using Grand.Infrastructure.Models; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace Grand.Web.AdminShared.Models.ExternalAuthentication; + +/// The SCIM provisioning screen: the state of the endpoint (never the token) and the accounts in a role conflict +public class ScimModel +{ + /// FeatureManagement:Grand.Module.Scim + public bool ModuleEnabled { get; set; } + + /// Only whether Scim:Token is long enough, never the token itself + public bool TokenUsable { get; set; } + + /// The OpenID Connect sign-in SCIM depends on is configured + public bool SignInConfigured { get; set; } + + public string EndpointUrl { get; set; } + public int ManagedAccounts { get; set; } + public IList Conflicts { get; set; } = []; +} + +public class ScimConflictModel +{ + public string CustomerId { get; set; } + public string Email { get; set; } +} + +/// A row of the grid of groups received from the identity provider +public class ScimGroupRowModel +{ + public string Id { get; set; } + public string DisplayName { get; set; } + public int MemberCount { get; set; } + public string CustomerGroup { get; set; } + public string Store { get; set; } + public string Vendor { get; set; } +} + +/// The role the members of a SCIM group get +public class ScimGroupMappingModel : BaseModel +{ + public string Id { get; set; } + public string DisplayName { get; set; } + public int MemberCount { get; set; } + + [GrandResourceDisplayName("Admin.Plugins.ExternalAuthenticationMethods.Scim.Fields.CustomerGroup")] + public string CustomerGroupId { get; set; } + + [GrandResourceDisplayName("Admin.Plugins.ExternalAuthenticationMethods.Scim.Fields.StaffStore")] + public string StaffStoreId { get; set; } + + [GrandResourceDisplayName("Admin.Plugins.ExternalAuthenticationMethods.Scim.Fields.Vendor")] + public string VendorId { get; set; } + + public IList AvailableCustomerGroups { get; set; } = []; + public IList AvailableStores { get; set; } = []; + public IList AvailableVendors { get; set; } = []; +} diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 9c81057e3e..fbf940308b 100644 Binary files a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml and b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml differ diff --git a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml index c3a246c9ab..17ce4f394a 100644 --- a/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml +++ b/src/Web/Grand.Web/App_Data/Resources/Upgrade/en_250.xml @@ -84,6 +84,87 @@ <p>The identity provider and the client are configured in the application settings file (appsettings), not here: section <code>OidcResourceServer</code> holds the identity provider (Authority), section <code>OidcLogin</code> holds the client of this store (ClientId, ClientSecret, Scopes).</p><p>Register this redirect URI at the identity provider for every URL of the store: <code>https://&lt;store-url&gt;/signin-oidc</code>.</p><p>Restart the application after changing the settings. Staff accounts connect once with their password; use the multi-factor authentication of the identity provider for them.</p><p>The order of the button is set by <code>OidcLogin:DisplayOrder</code>.</p> + + SCIM provisioning + + + Entra ID or Okta can create, update and lock out the accounts of your employees through SCIM 2.0. + + + Entra ID or Okta creates, updates and locks out the accounts of your employees through SCIM 2.0. A group from the identity provider grants nothing until you map it to a customer group below. Accounts SCIM creates are linked to the identity provider before their first sign-in; existing staff accounts it takes over connect once with their password. The endpoint and its token are set in appsettings (module Grand.Module.Scim, section Scim); see docs/scim.md. + + + Module Grand.Module.Scim is off (FeatureManagement in appsettings): the endpoint does not exist. + + + Every SCIM request is refused: set Scim:Token (at least 32 characters) and configure the OpenID Connect sign-in (OidcLogin). + + + SCIM endpoint (tenant URL) + + + Token + + + Set + + + Not set, or shorter than 32 characters + + + Accounts managed by SCIM + + + Groups from the identity provider + + + Group + + + Members + + + Customer group + + + The role the members get. Not mapped: the group grants nothing. + + + Store + + + Required with Store managers; leave empty otherwise. + + + Vendor + + + Required with Vendors; leave empty otherwise. + + + (none) + + + Map a group + + + Administrators and custom groups take no store or vendor. A member whose groups give roles that cannot be combined (Administrators with Store managers, two stores...) gets none of these roles. + + + This mapping is not allowed: choose Administrators or a custom group alone, Store managers with a store, or Vendors with a vendor. + + + The mapping is saved and the roles of the members are updated. + + + Only an administrator can map a group to Administrators or change such a mapping. + + + Role conflicts + + + The mapped groups of these accounts give back-office roles that cannot be combined, so SCIM granted none of them. Change the groups at the identity provider or the mappings. + Sign in with your organization account diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 7f7d5a7fa1..f05cb04709 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -168,7 +168,9 @@ "Grand.Module.Installer": true, "Grand.Module.Migration": true, "Grand.Module.ScheduledTasks": true, - "Grand.Module.Api": false + "Grand.Module.Api": false, + //SCIM 2.0 provisioning of staff accounts by the identity provider of OidcResourceServer (section Scim, docs/scim.md) + "Grand.Module.Scim": false }, "Redis": { ///Enable the Publish/Subscribe messaging with redis to manage memory cache on every server @@ -319,6 +321,18 @@ //ProviderOnly: e-mails that keep the password on the Admin login page (/admin/login?password=1); every such sign-in is logged "BreakGlassAccounts": [] }, + //SCIM 2.0 provisioning (module Grand.Module.Scim in FeatureManagement): Entra ID / Okta create, update and lock out staff accounts. + //Works only with the OpenID Connect sign-in configured (OidcLogin). See docs/scim.md. + "Scim": { + //Bearer token of the identity provider, at least 32 characters (shorter: every request is refused); keep it out of source control (env var Scim__Token or a secret store) + "Token": "", + //true: the SCIM externalId of a user is its OpenID Connect subject (Entra: objectId with OidcResourceServer:Claims:Subject "oid"), so the account is linked before the first sign-in + "ExternalIdIsSubject": true, + //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 and its password, second factor and social sign-ins are reset; other shopper accounts are refused + "TakeOverDomains": [], + //largest page of a list response + "MaxResults": 100 + }, "Logging": { "LogLevel": { "Default": "Information",