From ee959fbd6380b55e6edc84d5df13210e7aa66f37 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 11:38:32 +0200 Subject: [PATCH 01/11] Add SCIM provisioning configuration, entities and contracts --- .../Authentication/IScimGroupService.cs | 35 +++++++++++ .../Authentication/IScimUserService.cs | 29 +++++++++ .../Utilities/Authentication/ScimResults.cs | 61 +++++++++++++++++++ src/Core/Grand.Domain/Customers/ScimGroup.cs | 25 ++++++++ src/Core/Grand.Domain/Customers/ScimUser.cs | 26 ++++++++ .../Configuration/ScimConfig.cs | 33 ++++++++++ src/Core/Grand.Infrastructure/StartupBase.cs | 1 + .../Services/ScimConfigTests.cs | 41 +++++++++++++ src/Web/Grand.Web/App_Data/appsettings.json | 14 ++++- 9 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs create mode 100644 src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs create mode 100644 src/Business/Grand.Business.Core/Utilities/Authentication/ScimResults.cs create mode 100644 src/Core/Grand.Domain/Customers/ScimGroup.cs create mode 100644 src/Core/Grand.Domain/Customers/ScimUser.cs create mode 100644 src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs 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 000000000..7443cb65c --- /dev/null +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs @@ -0,0 +1,35 @@ +using Grand.Business.Core.Utilities.Authentication; + +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(); + + /// + /// 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 000000000..cf10adc22 --- /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 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. 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 000000000..4e4264ad6 --- /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 000000000..527d3e3b8 --- /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 000000000..222e3bcea --- /dev/null +++ b/src/Core/Grand.Domain/Customers/ScimUser.cs @@ -0,0 +1,26 @@ +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; } +} diff --git a/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs new file mode 100644 index 000000000..bc6070063 --- /dev/null +++ b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs @@ -0,0 +1,33 @@ +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; + + /// 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 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 e2a1af27b..bed3e03a4 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/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs new file mode 100644 index 000000000..1de65a37b --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs @@ -0,0 +1,41 @@ +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)); +} diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 7f7d5a7fa..44a50c365 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,16 @@ //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, + //largest page of a list response + "MaxResults": 100 + }, "Logging": { "LogLevel": { "Default": "Information", From 1791816ca009fe7ea9205c9c688fa925c3af1373 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 11:42:42 +0200 Subject: [PATCH 02/11] Compute the roles a SCIM user gets from its mapped groups --- .../Services/Scim/ScimRoles.cs | 62 ++++++++++ .../Services/ScimRolesTests.cs | 117 ++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 src/Business/Grand.Business.Authentication/Services/Scim/ScimRoles.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/ScimRolesTests.cs 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 000000000..a7f04a796 --- /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/Tests/Grand.Business.Authentication.Tests/Services/ScimRolesTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimRolesTests.cs new file mode 100644 index 000000000..f3505a529 --- /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); + } +} From 1760f17d8c382632156a8c7a263e425d19c9298b Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 11:53:55 +0200 Subject: [PATCH 03/11] Provision staff accounts over SCIM: create, take over, update, lock out and delete --- .../Services/Scim/ScimAccountLock.cs | 47 +++ .../Services/Scim/ScimRoleSync.cs | 142 +++++++ .../Services/Scim/ScimUserService.cs | 361 ++++++++++++++++++ .../Startup/StartupApplication.cs | 4 + .../Services/Scim/ScimFixture.cs | 172 +++++++++ .../Services/Scim/ScimUserServiceTests.cs | 276 +++++++++++++ 6 files changed, 1002 insertions(+) create mode 100644 src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs create mode 100644 src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs create mode 100644 src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs 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 000000000..66080ccbc --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs @@ -0,0 +1,47 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Data; +using Grand.Domain.Customers; +using Microsoft.Extensions.Logging; + +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 and its Frontend API refresh +/// token dropped +/// +public class ScimAccountLock( + ICustomerService customerService, + 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, ""); + logger.LogInformation("SCIM deactivated 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/ScimRoleSync.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs new file mode 100644 index 000000000..832156295 --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs @@ -0,0 +1,142 @@ +using Grand.Business.Core.Interfaces.Common.Directory; +using Grand.Business.Core.Interfaces.Customers; +using Grand.Data; +using Grand.Domain.Customers; +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 +/// +public class ScimRoleSync( + IRepository users, + IRepository groups, + ICustomerService customerService, + IGroupService groupService, + 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 the record + public virtual async Task Sync(Customer customer, ScimUser record) + { + ArgumentNullException.ThrowIfNull(customer); + ArgumentNullException.ThrowIfNull(record); + + 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 == null) + continue; + var customer = await customerService.GetCustomerById(id); + if (customer is { Deleted: false }) + 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); + + record.GrantedStaffStoreId = await ApplyField(customer.Id, customer.StaffStoreId, record.GrantedStaffStoreId, + wanted.StaffStoreId, x => x.StaffStoreId); + record.GrantedVendorId = await ApplyField(customer.Id, customer.VendorId, record.GrantedVendorId, wanted.VendorId, + x => x.VendorId); + + record.GrantedGroupIds = wanted.GroupIds.Where(x => current.Contains(x) || known.ContainsKey(x)).ToList(); + record.RoleConflict = wanted.Conflict; + await users.UpdateAsync(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/ScimUserService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs new file mode 100644 index 000000000..27c1ee3b7 --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs @@ -0,0 +1,361 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Directory; +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.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 . 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, + IOidcCustomerResolver oidcCustomerResolver, + IOidcLinkService oidcLinkService, + ScimRoleSync roleSync, + ScimAccountLock accountLock, + 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"); + + var externalId = data.ExternalId ?? ""; + if (externalId != "" && await users.GetOneAsync(x => x.ExternalId == externalId) != null) + return Conflict("A user with this externalId is already provisioned"); + + var customer = await customerService.GetCustomerByEmail(email); + var record = new ScimUser { ExternalId = externalId }; + var registered = false; + if (customer != null) + { + if (await users.GetByIdAsync(customer.Id) != null) + return Conflict("The user is already provisioned"); + + var refusal = await TakeOverRefusal(customer, externalId); + if (refusal != null) + { + logger.LogWarning("SCIM refused to take over customer {CustomerId}: {Reason}", customer.Id, refusal); + return Conflict(refusal); + } + + record.Id = customer.Id; + await roleSync.TakeOver(customer, record); + } + else + { + if (await customerService.GetCustomerByUsername(email) != null) + return Conflict("The user name is already taken"); + + customer = await Register(email); + record.Id = customer.Id; + registered = true; + } + + try + { + await users.InsertAsync(record); + } + catch (Exception ex) + { + //a concurrent request provisioned the same account first (the record id is the customer id) + logger.LogInformation("Storing the SCIM user of customer {CustomerId} failed ({ExceptionType}); re-reading", + customer.Id, ex.GetType().Name); + if (await users.GetByIdAsync(customer.Id) == null) + throw; + return Conflict("The user is already provisioned"); + } + + logger.LogInformation(registered ? "SCIM registered customer {CustomerId}" : "SCIM took over customer {CustomerId}", + 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); + + //an account may have become a break-glass account since it was provisioned + var refusal = Protected(customer); + if (refusal != null) + return Conflict(refusal); + + var email = EmailOf(data); + if (email == null) + return Invalid("userName or emails must hold an e-mail address"); + + if (email != customer.Email) + { + if (OwnedByOther(await customerService.GetCustomerByEmail(email), customer) || + OwnedByOther(await customerService.GetCustomerByUsername(email), customer)) + return Conflict("The e-mail address belongs to another account"); + + //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; + } + + var externalId = data.ExternalId ?? ""; + if (externalId != record.ExternalId) + { + var recordId = record.Id; + if (externalId != "" && await users.GetOneAsync(x => x.ExternalId == externalId && x.Id != recordId) != null) + return Conflict("A user with this externalId is already provisioned"); + record.ExternalId = externalId; + } + + 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; + 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); + } + + public virtual async Task Delete(string id) + { + var record = string.IsNullOrEmpty(id) ? null : await users.GetByIdAsync(id); + if (record == null) + return ScimOutcome.NotFound; + + var customer = await customerService.GetCustomerById(id); + if (customer != null) + { + if (customer.Active) + await accountLock.Deactivate(customer); + 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.DeleteAsync(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).Select(x => x.Id)); + + public virtual Task Count() => users.CountAsync(users.Table); + + 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); + record.DeactivatedByScim = true; + } + } + else if (!customer.Active && record.DeactivatedByScim) + { + await accountLock.Activate(customer); + record.DeactivatedByScim = false; + } + + await EnsureLink(customer, record, email); + //the sync stores the record + 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)); + } + + /// + /// With externalId as the OpenID Connect subject, the account is linked to it as if connected with its password, so the + /// first sign-in needs none. A link to another subject is kept (the administrator decides) and only logged + /// + private async Task EnsureLink(Customer customer, ScimUser record, string email) + { + if (!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; + } + + //also turns a link made by e-mail into one made with the password + if (link == null || link.LinkedByEmail) + { + 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) + { + 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, "")); + return customer; + } + + private async Task TakeOverRefusal(Customer customer, string externalId) + { + var refusal = Protected(customer); + 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"; + + 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; + } + + private string Protected(Customer customer) + { + 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; + } + + 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 == null) + 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 019a59241..740c63425 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,9 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) 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 000000000..99f88acae --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs @@ -0,0 +1,172 @@ +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.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.Common; +using Grand.Domain.Customers; +using Grand.Domain.Stores; +using Grand.Domain.Vendors; +using Grand.Infrastructure.Configuration; +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 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); + }); + + 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.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; + }); + + 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, NullLogger.Instance); + + public ScimAccountLock AccountLock() => + new(CustomerService.Object, Links, Sessions.Object, Revocation.Object, NullLogger.Instance); + + public ScimUserService UserService() => + new(Users, Groups, Links, CustomerService.Object, CustomerManager.Object, GroupService.Object, Resolver.Object, + LinkService.Object, RoleSync(), AccountLock(), Config, LoginConfig, CustomerConfig, 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/ScimUserServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs new file mode 100644 index 000000000..4da476036 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs @@ -0,0 +1,276 @@ +using Grand.Business.Authentication.Services; +using Grand.Business.Core.Utilities.Authentication; +using Grand.Business.Core.Utilities.Customers; +using Grand.Domain.Customers; +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"); + 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.IsNull(await _f.Users.GetByIdAsync(created.User.Id)); + 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); + } +} From e592da9dad51368dced74f65a2f5e84554f1b0a2 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 11:57:44 +0200 Subject: [PATCH 04/11] Store SCIM groups and map them to customer groups, staff stores and vendors --- .../Services/Scim/ScimGroupService.cs | 228 +++++++++++++++++ .../Startup/StartupApplication.cs | 1 + .../Services/Scim/ScimFixture.cs | 4 + .../Services/Scim/ScimGroupServiceTests.cs | 237 ++++++++++++++++++ 4 files changed, 470 insertions(+) create mode 100644 src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs 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 000000000..ae1944082 --- /dev/null +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs @@ -0,0 +1,228 @@ +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 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; every change recomputes the roles of the members it adds or removes +/// +public class ScimGroupService( + IRepository groups, + IRepository users, + IGroupService groupService, + IStoreService storeService, + IVendorService vendorService, + ScimRoleSync roleSync, + 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"); + var refusal = await UniquenessRefusal(displayName, data.ExternalId, null); + if (refusal != null) + return new ScimGroupResult(ScimOutcome.Conflict, Detail: refusal); + + var group = new ScimGroup { + DisplayName = displayName, + ExternalId = data.ExternalId ?? "", + Members = await ManagedOnly(data.MemberIds) + }; + await groups.InsertAsync(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"); + var refusal = await UniquenessRefusal(displayName, data.ExternalId, group.Id); + if (refusal != null) + return new ScimGroupResult(ScimOutcome.Conflict, Detail: refusal); + + var before = group.Members.ToList(); + group.DisplayName = displayName; + group.ExternalId = data.ExternalId ?? ""; + group.Members = await ManagedOnly(data.MemberIds); + await groups.UpdateAsync(group); + await roleSync.SyncMembers(before.Concat(group.Members)); + return new ScimGroupResult(ScimOutcome.Ok, View(group)); + } + + 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 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); + + var before = group.Members.ToList(); + var added = await ManagedOnly(add); + var removed = (remove ?? []).ToHashSet(); + group.Members = (replaceAll ? added : before.Concat(added)) + .Where(x => !removed.Contains(x)) + .Distinct() + .ToList(); + await groups.UpdateAsync(group); + await roleSync.SyncMembers(before.Concat(group.Members)); + return new ScimGroupResult(ScimOutcome.Ok, View(group)); + } + + 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"); + var refusal = await UniquenessRefusal(name, externalId, group.Id); + if (refusal != null) + return new ScimGroupResult(ScimOutcome.Conflict, Detail: refusal); + + group.DisplayName = name; + if (externalId != null) + group.ExternalId = externalId; + await groups.UpdateAsync(group); + return new ScimGroupResult(ScimOutcome.Ok, View(group)); + } + + 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 SetMapping(string groupId, string customerGroupId, string staffStoreId, + string vendorId) + { + var group = await Load(groupId); + if (group == null) + return ScimOutcome.NotFound; + + if (string.IsNullOrEmpty(customerGroupId)) + { + group.CustomerGroupId = ""; + group.StaffStoreId = ""; + group.VendorId = ""; + } + else + { + var target = await groupService.GetCustomerGroupById(customerGroupId); + if (!await ValidTarget(target, staffStoreId, vendorId)) + return ScimOutcome.Invalid; + group.CustomerGroupId = target.Id; + group.StaffStoreId = target.SystemName == SystemCustomerGroupNames.StoreManager && target.IsSystem + ? staffStoreId + : ""; + group.VendorId = target.SystemName == SystemCustomerGroupNames.Vendors && target.IsSystem ? vendorId : ""; + } + + await groups.UpdateAsync(group); + await roleSync.SyncMembers(group.Members); + logger.LogInformation("SCIM group {GroupId} mapped to customer group {CustomerGroupId}", group.Id, + group.CustomerGroupId); + return ScimOutcome.Ok; + } + + //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; + + var isStoreManager = target.IsSystem && target.SystemName == SystemCustomerGroupNames.StoreManager; + var isVendors = target.IsSystem && target.SystemName == SystemCustomerGroupNames.Vendors; + if (isStoreManager) + return string.IsNullOrEmpty(vendorId) && !string.IsNullOrEmpty(staffStoreId) && + await storeService.GetStoreById(staffStoreId) != null; + if (isVendors) + return string.IsNullOrEmpty(staffStoreId) && !string.IsNullOrEmpty(vendorId) && + await vendorService.GetVendorById(vendorId) != null; + return string.IsNullOrEmpty(staffStoreId) && string.IsNullOrEmpty(vendorId); + } + + private async Task UniquenessRefusal(string displayName, string externalId, string exceptId) + { + var lowered = displayName.ToLowerInvariant(); + if (await groups.AnyAsync(groups.Table.Where(x => x.Id != exceptId && x.DisplayName.ToLower() == lowered))) + return "A group with this displayName already exists"; + if (!string.IsNullOrEmpty(externalId) && + await groups.AnyAsync(groups.Table.Where(x => x.Id != exceptId && x.ExternalId == externalId))) + return "A group with this externalId already exists"; + return null; + } + + //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)).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); +} diff --git a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs index 740c63425..f42469bb2 100644 --- a/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs +++ b/src/Business/Grand.Business.Authentication/Startup/StartupApplication.cs @@ -30,6 +30,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); } public void Configure(WebApplication application, IWebHostEnvironment webHostEnvironment) diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs index 99f88acae..b5b1c2fd2 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs @@ -153,6 +153,10 @@ public ScimUserService UserService() => new(Users, Groups, Links, CustomerService.Object, CustomerManager.Object, GroupService.Object, Resolver.Object, LinkService.Object, RoleSync(), AccountLock(), Config, LoginConfig, CustomerConfig, NullLogger.Instance); + public ScimGroupService GroupServiceUnderTest() => + new(Groups, Users, GroupService.Object, StoreService.Object, VendorService.Object, RoleSync(), + NullLogger.Instance); + public async Task AddCustomer(string email, params string[] groupIds) { var customer = new Customer { 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 000000000..4d6887724 --- /dev/null +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs @@ -0,0 +1,237 @@ +using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Stores; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +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); + + [TestMethod] + public async Task Create_SameDisplayNameTwice_Conflict() + { + await Group("Shop admins"); + Assert.AreEqual(ScimOutcome.Conflict, + (await _f.GroupServiceUnderTest().Create(new ScimGroupData("shop ADMINS", null, []))).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); + } +} From 11fd2710d23b6a425ece5481be445f4ba3a9bcf9 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 12:13:19 +0200 Subject: [PATCH 05/11] Add the SCIM 2.0 endpoint as module Grand.Module.Scim --- GrandNode.slnx | 1 + .../Controllers/ScimControllerBase.cs | 57 ++++++ .../Controllers/ScimDiscoveryController.cs | 86 +++++++++ .../Controllers/ScimGroupsController.cs | 134 +++++++++++++ .../Controllers/ScimUsersController.cs | 150 +++++++++++++++ .../Grand.Module.Scim.csproj | 38 ++++ .../Infrastructure/ScimAuthorizeFilter.cs | 52 +++++ .../Infrastructure/ScimJson.cs | 58 ++++++ .../Infrastructure/ScimResponse.cs | 25 +++ .../Infrastructure/ScimStartup.cs | 34 ++++ .../Grand.Module.Scim/Models/ScimResources.cs | 106 ++++++++++ .../Grand.Module.Scim/Protocol/ScimFilter.cs | 32 +++ .../Protocol/ScimGroupPatch.cs | 182 ++++++++++++++++++ .../Protocol/ScimUserPatch.cs | 145 ++++++++++++++ .../Grand.Modules.Tests.csproj | 1 + .../Scim/ScimAuthorizeFilterTests.cs | 75 ++++++++ .../Scim/ScimFilterTests.cs | 31 +++ .../Scim/ScimGroupPatchTests.cs | 78 ++++++++ .../Scim/ScimUserPatchTests.cs | 85 ++++++++ 19 files changed, 1370 insertions(+) create mode 100644 src/Modules/Grand.Module.Scim/Controllers/ScimControllerBase.cs create mode 100644 src/Modules/Grand.Module.Scim/Controllers/ScimDiscoveryController.cs create mode 100644 src/Modules/Grand.Module.Scim/Controllers/ScimGroupsController.cs create mode 100644 src/Modules/Grand.Module.Scim/Controllers/ScimUsersController.cs create mode 100644 src/Modules/Grand.Module.Scim/Grand.Module.Scim.csproj create mode 100644 src/Modules/Grand.Module.Scim/Infrastructure/ScimAuthorizeFilter.cs create mode 100644 src/Modules/Grand.Module.Scim/Infrastructure/ScimJson.cs create mode 100644 src/Modules/Grand.Module.Scim/Infrastructure/ScimResponse.cs create mode 100644 src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs create mode 100644 src/Modules/Grand.Module.Scim/Models/ScimResources.cs create mode 100644 src/Modules/Grand.Module.Scim/Protocol/ScimFilter.cs create mode 100644 src/Modules/Grand.Module.Scim/Protocol/ScimGroupPatch.cs create mode 100644 src/Modules/Grand.Module.Scim/Protocol/ScimUserPatch.cs create mode 100644 src/Tests/Grand.Modules.Tests/Scim/ScimAuthorizeFilterTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Scim/ScimFilterTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Scim/ScimGroupPatchTests.cs create mode 100644 src/Tests/Grand.Modules.Tests/Scim/ScimUserPatchTests.cs diff --git a/GrandNode.slnx b/GrandNode.slnx index efa35892c..08363a56a 100644 --- a/GrandNode.slnx +++ b/GrandNode.slnx @@ -33,6 +33,7 @@ + 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 000000000..d10abc0f2 --- /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 000000000..0555b26fb --- /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 000000000..27e049b2a --- /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 000000000..cd7fc284e --- /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 000000000..bef073f48 --- /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 000000000..c3eac20e4 --- /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 000000000..fa4c6fa5c --- /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 000000000..e5aeed0c8 --- /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 000000000..da385c6e4 --- /dev/null +++ b/src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs @@ -0,0 +1,34 @@ +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(); + if (config.TokenUsable && loginConfig.IsConfigured(resourceServerConfig)) + return; + + application.Services.GetRequiredService().CreateLogger(nameof(ScimStartup)) + .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 000000000..57f543815 --- /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 000000000..a23cb1a92 --- /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 000000000..7e330b0b5 --- /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 000000000..eb4d4ad8f --- /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.Modules.Tests/Grand.Modules.Tests.csproj b/src/Tests/Grand.Modules.Tests/Grand.Modules.Tests.csproj index b9b154255..29d6f6569 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 000000000..301772353 --- /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 000000000..69e1201f6 --- /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 000000000..080d6387e --- /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 000000000..80e0511ae --- /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 _)); +} From 998f025e9852a569151deeaa5d42ab08825c885a Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 19:41:09 +0200 Subject: [PATCH 06/11] Map SCIM groups to roles on the Admin external authentication screens --- .../Services/Scim/ScimGroupService.cs | 3 + .../Authentication/IScimGroupService.cs | 4 + .../Services/Scim/ScimFixture.cs | 4 + .../Services/Scim/ScimGroupServiceTests.cs | 11 ++ ...ternalAuthenticationControllerScimTests.cs | 146 ++++++++++++++++++ .../ExternalAuthentication/Methods.cshtml | 7 + .../Views/ExternalAuthentication/Scim.cshtml | 67 ++++++++ .../ScimGroupEdit.cshtml | 36 +++++ .../ExternalAuthenticationController.cs | 98 ++++++++++++ .../ExternalAuthentication/ScimModel.cs | 60 +++++++ .../App_Data/Resources/DefaultLanguage.xml | Bin 1590302 -> 1600256 bytes .../App_Data/Resources/Upgrade/en_250.xml | 78 ++++++++++ 12 files changed, 514 insertions(+) create mode 100644 src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/Scim.cshtml create mode 100644 src/Web/Grand.Web.Admin/Areas/Admin/Views/ExternalAuthentication/ScimGroupEdit.cshtml create mode 100644 src/Web/Grand.Web.AdminShared/Models/ExternalAuthentication/ScimModel.cs diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs index ae1944082..749b61383 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs @@ -149,6 +149,9 @@ public virtual async Task> GetMappings() => 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) { diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs index 7443cb65c..647606a56 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimGroupService.cs @@ -1,4 +1,5 @@ using Grand.Business.Core.Utilities.Authentication; +using Grand.Domain.Customers; namespace Grand.Business.Core.Interfaces.Authentication; @@ -27,6 +28,9 @@ public interface IScimGroupService 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 diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs index b5b1c2fd2..ac192fd5d 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs @@ -8,6 +8,7 @@ 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.Stores; @@ -113,6 +114,9 @@ public ScimFixture() .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)); diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs index 4d6887724..91e16b568 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs @@ -234,4 +234,15 @@ public async Task GetMappings_ListsGroupsWithMemberCount() 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); + } } 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 000000000..6d10d2b52 --- /dev/null +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs @@ -0,0 +1,146 @@ +using Grand.Business.Core.Interfaces.Authentication; +using Grand.Business.Core.Interfaces.Common.Configuration; +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.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 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); + } + + 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); + + 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); + + var result = await _controller.ScimGroupEdit(new ScimGroupMappingModel { Id = "sg1", CustomerGroupId = "g-admin" }, + _groups.Object, _stores.Object, _vendors.Object, _translation.Object); + + Assert.AreEqual("Scim", ((RedirectToActionResult)result).ActionName); + _groups.Verify(x => x.SetMapping("sg1", "g-admin", null, null), Times.Once); + } +} 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 18908801e..26b0aca6f 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 000000000..cbb7ea788 --- /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 000000000..4fc91b604 --- /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 1504452b7..f4f9ed3b9 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs @@ -1,6 +1,9 @@ using Grand.Business.Core.Extensions; using Grand.Business.Core.Interfaces.Authentication; using Grand.Business.Core.Interfaces.Common.Configuration; +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.Configuration; @@ -10,6 +13,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 +110,99 @@ 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) + { + 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"); + } + + var group = await scimGroupService.Get(model.Id); + if (outcome == ScimOutcome.NotFound || group.Outcome != ScimOutcome.Ok) + return RedirectToAction("Scim"); + + ModelState.AddModelError("", + translationService.GetResource("Admin.Plugins.ExternalAuthenticationMethods.Scim.Mapping.Invalid")); + await PrepareScimGroupMapping(model, group.Group, scimGroupService, storeService, vendorService); + return View(model); + } + + 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 000000000..794e249a1 --- /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 9c81057e3ee07061b3d77f9b3edd891a08be68e1..129c17fd28783567631ec9bbab1f3b5d65a014af 100644 GIT binary patch delta 3306 zcmbtXU2GIp6u$e@pWRNoyPYk&wW*y|h+AU2Rv-ilY1gG-jioJxBGCl4yZsBaTXwe) zc;Ls3(I*qK#1kJ-8{ZTYniz}%QDW5a02CfHnyAzVHBlmc(gzd$&YhjkY-tr^nwhXmw=bZ1Hd;7*K$x~k@Pw5Wbsk?NyUZ$7p6?&!a(Y<<=uISZzjqcO^dO)w$RXwQJ z>8te6`{C6wx^Y<&*PmFMUW!BIHO@SiEmAqm<^o&jJo|_Py@Lp;&?`j zzD;2~BF7WtSR0Nt@B;6g2PzG}8H~n|iAga^9TW)FO<5!&wtfCc;}Cpd zv`35Eq~ImTSz4p`hi+9JLXhpTOp0t_z9vM6t_8iMtPQUw|5YXE8W&l*^tlpQw;P)e zLt_|5(%3g6q43UTco+vmWh4h#j(VHD^>pXFZ`DanL_wDfG^YWK?d&MJmj=Wh+X^Vf zY=ktYA_v(NEJ_06+gxiF?Zs8Lm4~3*M4Pyl&du zEOwje!fcet6Ly?ZdnMu)t-$|)*cx_mv zq2slK60K@lZWUb+kW?YZ&#tX8v$5RIpeP{mtU-58>=C=fvow3tu}7K=SSE{LsXyo4 zR3>RwxcZJE6}nFf7P&#qKBa7;xuZ%sm3R4fNsEok%kD%d@`gq{WJHQdX!Noil7$h? zT=Sa|qVEm_4TJTTL3_s2B2JDM)h6mZP#!JxUGX|{vihn^s;@Br$bf`p>#IBH^bKbR z-Fd<1tzv%JT0;vx9IY_6eazZ+JMzIrpGJyX(Sn%(Q}|Az&UcbSceG1mRP(iB=&o5w zw+D761hyhPc&l43$Kjmw{zo)tT2q09ZQ)Ylv-ffp{u+;X_D#Oi3SAsl% zy^`E7z?3$>`*rDH>;a^q$5a}Yt_6l%?bP?Jt48IcQ;6B1;B=g-euCakv*THI`zs%l!{XJ87ZS z)9#64Ph2O&uvOu8R`j}nhhu>FZsQ=TTS~#xMsC*tXg&8)J6uq~w;0#H~D#KZh5&g^v?nrqo^Koxs zidAVWVDSBE@^Pt(a}P2y>pUUs8~P3qfU# z+18fQtt7*RnIF{{$J8R>5cthE?iTFK&1)QAOxi_f7s(k}N>1Y@#)KT>_Fgg`M{MbEtL#O=N_{LlwmV&NZxe*=d*>81bx delta 68 zcmZp;mN>5?v7v>rg{g(Pg{6hHg{_6Xg` <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 managed by SCIM are linked to the identity provider before their first sign-in. 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. + + + 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 From 5056e17eec2f927d3622cfeaec588ac11435a5fe Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 19:45:43 +0200 Subject: [PATCH 07/11] Document SCIM provisioning for Entra ID and Okta --- docs/scim.md | 143 ++++++++++++++++++ .../Infrastructure/ScimStartup.cs | 13 +- 2 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 docs/scim.md diff --git a/docs/scim.md b/docs/scim.md new file mode 100644 index 000000000..ce0e0815b --- /dev/null +++ b/docs/scim.md @@ -0,0 +1,143 @@ +# 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. +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. Every SCIM write links the account to it, so the first sign-in needs no password. See section 3. | +| `MaxResults` | `100` | Largest page of a list response. | + +4. Restart, open **SCIM provisioning** in the admin panel, and copy the **SCIM endpoint (tenant URL)**. + +## 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. +* `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. 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. An +account with the e-mail already exists: **SCIM takes it over** and manages it from then on (section 5). Refused with +`409 uniqueness`: system accounts, deleted accounts, break-glass accounts (`OidcLogin:BreakGlassAccounts`), 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. A new e-mail that belongs to another account +is refused with `409`. + +**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. + +**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. A later create with the same e-mail takes it over again. + +**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. + +* **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` and what SCIM granted. +* `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/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs b/src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs index da385c6e4..3ea12cb76 100644 --- a/src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs +++ b/src/Modules/Grand.Module.Scim/Infrastructure/ScimStartup.cs @@ -21,12 +21,19 @@ public void Configure(WebApplication application, IWebHostEnvironment webHostEnv 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; - application.Services.GetRequiredService().CreateLogger(nameof(ScimStartup)) - .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"); + 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; From c69f2342857107333a30309d23f88013c1ff3805 Mon Sep 17 00:00:00 2001 From: support Date: Sat, 24 Oct 2026 23:48:08 +0200 Subject: [PATCH 08/11] Harden SCIM provisioning after review A taken-over account is no longer linked to the provider subject ahead of its first sign-in: an e-mail at the identity provider proves nothing about who owns the GrandNode account, so it connects once with its password and second factor. Only staff accounts are taken over; shoppers get 409. Break-glass, system and deleted accounts are now left alone by delete, role sync and e-mail changes. A deleted SCIM user keeps a deprovisioned record, so the same user created again gets its account back (active when SCIM disabled it). A customer deleted in the admin panel leaves SCIM. Records and groups are written field by field, so concurrent requests do not undo each other; a membership change recomputes only the members it changed. Entity events are published, duplicate group names are accepted, deactivation rotates the password token, and only an administrator maps a group to Administrators. --- docs/scim.md | 51 +++-- .../Events/ScimCustomerDeletedEventHandler.cs | 26 +++ .../Services/Scim/ScimAccountLock.cs | 6 +- .../Services/Scim/ScimGroupService.cs | 144 ++++++------ .../Services/Scim/ScimProtection.cs | 18 ++ .../Services/Scim/ScimRoleSync.cs | 61 ++++- .../Services/Scim/ScimUserService.cs | 210 +++++++++++------- .../Authentication/IScimUserService.cs | 6 +- src/Core/Grand.Domain/Customers/ScimUser.cs | 12 + .../ScimCustomerDeletedEventHandlerTests.cs | 39 ++++ .../Services/Scim/ScimFixture.cs | 23 +- .../Services/Scim/ScimGroupServiceTests.cs | 69 +++++- .../Services/Scim/ScimUserServiceTests.cs | 157 ++++++++++++- ...ternalAuthenticationControllerScimTests.cs | 51 ++++- .../ExternalAuthenticationController.cs | 41 +++- .../App_Data/Resources/DefaultLanguage.xml | Bin 1600256 -> 1600840 bytes .../App_Data/Resources/Upgrade/en_250.xml | 5 +- 17 files changed, 731 insertions(+), 188 deletions(-) create mode 100644 src/Business/Grand.Business.Authentication/Events/ScimCustomerDeletedEventHandler.cs create mode 100644 src/Business/Grand.Business.Authentication/Services/Scim/ScimProtection.cs create mode 100644 src/Tests/Grand.Business.Authentication.Tests/Events/ScimCustomerDeletedEventHandlerTests.cs diff --git a/docs/scim.md b/docs/scim.md index ce0e0815b..ecc332f1e 100644 --- a/docs/scim.md +++ b/docs/scim.md @@ -32,10 +32,14 @@ What it is not: | 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. Every SCIM write links the account to it, so the first sign-in needs no password. See section 3. | +| `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. | | `MaxResults` | `100` | Largest page of a list response. | -4. Restart, open **SCIM provisioning** in the admin panel, and copy the **SCIM endpoint (tenant URL)**. +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 @@ -47,6 +51,9 @@ What it is not: 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. @@ -65,25 +72,38 @@ What it is not: ## 4. Behaviour -**Create.** No account with the e-mail: a registered, active account is created. 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. An -account with the e-mail already exists: **SCIM takes it over** and manages it from then on (section 5). Refused with -`409 uniqueness`: system accounts, deleted accounts, break-glass accounts (`OidcLogin:BreakGlassAccounts`), a store-bound -account with per-store identity, an account linked to another OpenID Connect identity, a second user with the same -`externalId`. +**Create.** No account with the e-mail: a registered, active account is created and linked to the user's OpenID Connect +subject. 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. -**Update.** E-mail (and a user name that was the e-mail), first and last name. A new e-mail that belongs to another account -is refused with `409`. +A **staff** account (admin panel access, a staff store or a vendor) with the e-mail already exists: **SCIM takes it over** and +manages its roles from then on (section 5). 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. + +Refused with `409 uniqueness`: +* an account that is not staff (a storefront customer); +* system accounts, deleted accounts and break-glass accounts (`OidcLogin:BreakGlassAccounts`); +* 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. +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. A later create with the same e-mail takes it over again. +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. @@ -98,8 +118,10 @@ Map each group on the admin screen to one of: | 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. +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 @@ -137,7 +159,8 @@ Errors use the SCIM error schema: `400 invalidFilter` / `invalidValue` / `invali Two collections, created on first use (no migration): -* `ScimUser`: one document per managed account, its id **is** the customer id. It holds the `externalId` and what SCIM granted. +* `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 000000000..6594cf380 --- /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 index 66080ccbc..008644d43 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs @@ -8,8 +8,8 @@ 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 and its Frontend API refresh -/// token dropped +/// 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, @@ -33,6 +33,8 @@ public virtual async Task Deactivate(Customer customer) //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); } diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs index 749b61383..b0c47bc10 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimGroupService.cs @@ -5,13 +5,17 @@ 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; every change recomputes the roles of the members it adds or removes +/// 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, @@ -20,6 +24,7 @@ public class ScimGroupService( IStoreService storeService, IVendorService vendorService, ScimRoleSync roleSync, + IMediator mediator, ILogger logger) : IScimGroupService { public virtual async Task Create(ScimGroupData data) @@ -29,9 +34,8 @@ public virtual async Task Create(ScimGroupData data) var displayName = data.DisplayName?.Trim(); if (string.IsNullOrEmpty(displayName)) return Invalid("displayName is required"); - var refusal = await UniquenessRefusal(displayName, data.ExternalId, null); - if (refusal != null) - return new ScimGroupResult(ScimOutcome.Conflict, Detail: refusal); + if (await ExternalIdTaken(data.ExternalId, null)) + return Conflict(); var group = new ScimGroup { DisplayName = displayName, @@ -39,6 +43,7 @@ public virtual async Task Create(ScimGroupData data) 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)); @@ -55,17 +60,12 @@ public virtual async Task Replace(string id, ScimGroupData data var displayName = data.DisplayName?.Trim(); if (string.IsNullOrEmpty(displayName)) return Invalid("displayName is required"); - var refusal = await UniquenessRefusal(displayName, data.ExternalId, group.Id); - if (refusal != null) - return new ScimGroupResult(ScimOutcome.Conflict, Detail: refusal); - - var before = group.Members.ToList(); - group.DisplayName = displayName; - group.ExternalId = data.ExternalId ?? ""; - group.Members = await ManagedOnly(data.MemberIds); - await groups.UpdateAsync(group); - await roleSync.SyncMembers(before.Concat(group.Members)); - return new ScimGroupResult(ScimOutcome.Ok, View(group)); + 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) @@ -99,6 +99,7 @@ public virtual async Task Delete(string id) 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; @@ -110,17 +111,7 @@ public virtual async Task ModifyMembers(string id, IList !removed.Contains(x)) - .Distinct() - .ToList(); - await groups.UpdateAsync(group); - await roleSync.SyncMembers(before.Concat(group.Members)); - return new ScimGroupResult(ScimOutcome.Ok, View(group)); + return await ChangeMembers(group, await ManagedOnly(add), remove ?? [], replaceAll); } public virtual async Task Rename(string id, string displayName, string externalId) @@ -132,15 +123,15 @@ public virtual async Task Rename(string id, string displayName, var name = displayName == null ? group.DisplayName : displayName.Trim(); if (string.IsNullOrEmpty(name)) return Invalid("displayName is required"); - var refusal = await UniquenessRefusal(name, externalId, group.Id); - if (refusal != null) - return new ScimGroupResult(ScimOutcome.Conflict, Detail: refusal); + if (externalId != null && await ExternalIdTaken(externalId, group.Id)) + return Conflict(); - group.DisplayName = name; + await groups.UpdateField(group.Id, x => x.DisplayName, name); if (externalId != null) - group.ExternalId = externalId; - await groups.UpdateAsync(group); - return new ScimGroupResult(ScimOutcome.Ok, View(group)); + 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() => @@ -159,29 +150,53 @@ public virtual async Task SetMapping(string groupId, string custome if (group == null) return ScimOutcome.NotFound; - if (string.IsNullOrEmpty(customerGroupId)) + string target = "", store = "", vendor = ""; + if (!string.IsNullOrEmpty(customerGroupId)) { - group.CustomerGroupId = ""; - group.StaffStoreId = ""; - group.VendorId = ""; + 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 { - var target = await groupService.GetCustomerGroupById(customerGroupId); - if (!await ValidTarget(target, staffStoreId, vendorId)) - return ScimOutcome.Invalid; - group.CustomerGroupId = target.Id; - group.StaffStoreId = target.SystemName == SystemCustomerGroupNames.StoreManager && target.IsSystem - ? staffStoreId - : ""; - group.VendorId = target.SystemName == SystemCustomerGroupNames.Vendors && target.IsSystem ? vendorId : ""; + 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); } - await groups.UpdateAsync(group); - await roleSync.SyncMembers(group.Members); - logger.LogInformation("SCIM group {GroupId} mapped to customer group {CustomerGroupId}", group.Id, - group.CustomerGroupId); - return ScimOutcome.Ok; + 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 @@ -190,27 +205,22 @@ private async Task ValidTarget(CustomerGroup target, string staffStoreId, if (!ScimRoleSync.IsMappable(target)) return false; - var isStoreManager = target.IsSystem && target.SystemName == SystemCustomerGroupNames.StoreManager; - var isVendors = target.IsSystem && target.SystemName == SystemCustomerGroupNames.Vendors; - if (isStoreManager) + if (IsSystemGroup(target, SystemCustomerGroupNames.StoreManager)) return string.IsNullOrEmpty(vendorId) && !string.IsNullOrEmpty(staffStoreId) && await storeService.GetStoreById(staffStoreId) != null; - if (isVendors) + 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 async Task UniquenessRefusal(string displayName, string externalId, string exceptId) - { - var lowered = displayName.ToLowerInvariant(); - if (await groups.AnyAsync(groups.Table.Where(x => x.Id != exceptId && x.DisplayName.ToLower() == lowered))) - return "A group with this displayName already exists"; - if (!string.IsNullOrEmpty(externalId) && - await groups.AnyAsync(groups.Table.Where(x => x.Id != exceptId && x.ExternalId == externalId))) - return "A group with this externalId already exists"; - return null; - } + 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) @@ -218,7 +228,8 @@ 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)).Select(x => x.Id))).ToList(); + 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); @@ -228,4 +239,7 @@ private static ScimGroupView View(ScimGroup group) => 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 000000000..b69879113 --- /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 index 832156295..e39d7db17 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimRoleSync.cs @@ -2,6 +2,9 @@ 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; @@ -10,13 +13,16 @@ 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 +/// 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 = [ @@ -27,12 +33,19 @@ public class ScimRoleSync( 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 the record + /// 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(); @@ -60,10 +73,10 @@ 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 == null) + if (record is not { Deprovisioned: false }) continue; var customer = await customerService.GetCustomerById(id); - if (customer is { Deleted: false }) + if (customer != null) await Sync(customer, record); } } @@ -110,14 +123,44 @@ private async Task Apply(Customer customer, ScimUser record, ScimWanted wanted) foreach (var id in toAdd.Where(known.ContainsKey)) await customerService.InsertCustomerGroupInCustomer(known[id], customer.Id); - record.GrantedStaffStoreId = await ApplyField(customer.Id, customer.StaffStoreId, record.GrantedStaffStoreId, + 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); - record.GrantedVendorId = await ApplyField(customer.Id, customer.VendorId, record.GrantedVendorId, wanted.VendorId, + var grantedVendor = await ApplyField(customer.Id, customer.VendorId, record.GrantedVendorId, wanted.VendorId, x => x.VendorId); - record.GrantedGroupIds = wanted.GroupIds.Where(x => current.Contains(x) || known.ContainsKey(x)).ToList(); - record.RoleConflict = wanted.Conflict; - await users.UpdateAsync(record); + //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, diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs index 27c1ee3b7..be2eb9b78 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs @@ -1,5 +1,7 @@ +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; @@ -7,6 +9,8 @@ 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; @@ -16,8 +20,10 @@ 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 . No welcome message is sent - SCIM provisions employees, not -/// shoppers. Logs carry customer ids, never e-mails +/// linked ahead by . Only staff accounts 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). No welcome message is sent - SCIM provisions employees, not shoppers. +/// Logs carry customer ids, never e-mails /// public class ScimUserService( IRepository users, @@ -26,10 +32,12 @@ public class ScimUserService( ICustomerService customerService, ICustomerManagerService customerManagerService, IGroupService groupService, + IPermissionService permissionService, IOidcCustomerResolver oidcCustomerResolver, IOidcLinkService oidcLinkService, ScimRoleSync roleSync, ScimAccountLock accountLock, + IMediator mediator, ScimConfig config, OidcLoginConfig loginConfig, CustomerConfig customerConfig, @@ -46,53 +54,46 @@ public virtual async Task Create(ScimUserData data) return Invalid("userName or emails must hold an e-mail address"); var externalId = data.ExternalId ?? ""; - if (externalId != "" && await users.GetOneAsync(x => x.ExternalId == externalId) != null) + if (await ExternalIdTaken(externalId, null)) return Conflict("A user with this externalId is already provisioned"); var customer = await customerService.GetCustomerByEmail(email); - var record = new ScimUser { ExternalId = externalId }; - var registered = false; - if (customer != null) - { - if (await users.GetByIdAsync(customer.Id) != null) - return Conflict("The user is already provisioned"); - - var refusal = await TakeOverRefusal(customer, externalId); - if (refusal != null) - { - logger.LogWarning("SCIM refused to take over customer {CustomerId}: {Reason}", customer.Id, refusal); - return Conflict(refusal); - } - - record.Id = customer.Id; - await roleSync.TakeOver(customer, record); - } - else + if (customer == null) { if (await customerService.GetCustomerByUsername(email) != null) return Conflict("The user name is already taken"); + return await Register(email, externalId, data); + } - customer = await Register(email); - record.Id = customer.Id; - registered = true; + 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); } - try + if (record == null) { - await users.InsertAsync(record); + 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); } - catch (Exception ex) + else { - //a concurrent request provisioned the same account first (the record id is the customer id) - logger.LogInformation("Storing the SCIM user of customer {CustomerId} failed ({ExceptionType}); re-reading", - customer.Id, ex.GetType().Name); - if (await users.GetByIdAsync(customer.Id) == null) - throw; - return Conflict("The user is already provisioned"); + record.ExternalId = externalId; + record.Deprovisioned = false; + await users.UpdateAsync(record); + await mediator.EntityUpdated(record); + logger.LogInformation("SCIM provisioned customer {CustomerId} again", customer.Id); } - logger.LogInformation(registered ? "SCIM registered customer {CustomerId}" : "SCIM took over customer {CustomerId}", - customer.Id); return await Apply(customer, record, data, email, ScimOutcome.Created); } @@ -104,8 +105,9 @@ public virtual async Task Replace(string id, ScimUserData data) 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 = Protected(customer); + var refusal = ScimProtection.Refusal(customer, loginConfig); if (refusal != null) return Conflict(refusal); @@ -113,12 +115,23 @@ public virtual async Task Replace(string id, ScimUserData data) if (email == null) return Invalid("userName or emails must hold an e-mail address"); - if (email != customer.Email) + 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) { @@ -130,13 +143,11 @@ public virtual async Task Replace(string id, ScimUserData data) customer.Email = email; } - var externalId = data.ExternalId ?? ""; if (externalId != record.ExternalId) { - var recordId = record.Id; - if (externalId != "" && await users.GetOneAsync(x => x.ExternalId == externalId && x.Id != recordId) != null) - return Conflict("A user with this externalId is already provisioned"); + 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); @@ -152,7 +163,7 @@ public virtual async Task Get(string id) public virtual async Task> List(string userName, string externalId, int skip, int take) { - var query = users.Table; + var query = users.Table.Where(x => !x.Deprovisioned); if (!string.IsNullOrEmpty(externalId)) query = query.Where(x => x.ExternalId == externalId); if (!string.IsNullOrEmpty(userName)) @@ -180,36 +191,50 @@ public virtual async Task> List(string userName, string e 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 record = string.IsNullOrEmpty(id) ? null : await users.GetByIdAsync(id); - if (record == null) + var (customer, record) = await Load(id); + if (customer == null) return ScimOutcome.NotFound; - var customer = await customerService.GetCustomerById(id); - if (customer != null) + //an account may have become a break-glass account since it was provisioned + var refusal = ScimProtection.Refusal(customer, loginConfig); + if (refusal != null) { - if (customer.Active) - await accountLock.Deactivate(customer); - await roleSync.Revoke(customer, record); + logger.LogWarning("SCIM refused to delete customer {CustomerId}: {Reason}", customer.Id, refusal); + return ScimOutcome.Conflict; + } - var link = await links.GetOneAsync(x => x.ProviderSystemName == LinkProvider && x.CustomerId == id); - if (link != null) - await oidcLinkService.Remove(customer, link.Id); + 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.DeleteAsync(record); + 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).Select(x => x.Id)); + await users.ToListAsync(users.Table.Where(x => x.RoleConflict && !x.Deprovisioned).Select(x => x.Id)); - public virtual Task Count() => users.CountAsync(users.Table); + 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) @@ -224,30 +249,38 @@ private async Task Apply(Customer customer, ScimUser record, Sci if (customer.Active) { await accountLock.Deactivate(customer); - record.DeactivatedByScim = true; + await SetDeactivatedByScim(record, true); } } else if (!customer.Active && record.DeactivatedByScim) { await accountLock.Activate(customer); - record.DeactivatedByScim = false; + await SetDeactivatedByScim(record, false); } await EnsureLink(customer, record, email); - //the sync stores the record 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); + } + /// - /// With externalId as the OpenID Connect subject, the account is linked to it as if connected with its password, so the - /// first sign-in needs none. A link to another subject is kept (the administrator decides) and only logged + /// 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 (!config.ExternalIdIsSubject || string.IsNullOrEmpty(record.ExternalId)) + if (!record.RegisteredByScim || !config.ExternalIdIsSubject || string.IsNullOrEmpty(record.ExternalId)) return; var customerId = customer.Id; @@ -259,8 +292,7 @@ private async Task EnsureLink(Customer customer, ScimUser record, string email) return; } - //also turns a link made by e-mail into one made with the password - if (link == null || link.LinkedByEmail) + if (link == null) { var result = await oidcCustomerResolver.LinkIdentity(customer, record.ExternalId, email, ""); if (result != OidcLinkResult.Linked) @@ -269,7 +301,7 @@ private async Task EnsureLink(Customer customer, ScimUser record, string email) } } - private async Task Register(string email) + private async Task Register(string email, string externalId, ScimUserData data) { var customer = await customerService.InsertGuestCustomer(new Customer { CustomerGuid = Guid.NewGuid(), @@ -282,12 +314,38 @@ private async Task Register(string email) var password = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); await customerManagerService.RegisterCustomer(new RegistrationRequest(customer, email, email, password, PasswordFormat.Hashed, "")); - return customer; + + 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); } - private async Task TakeOverRefusal(Customer customer, string externalId) + //false when a concurrent request provisioned the same account first (the record id is the customer id) + private async Task Insert(ScimUser record) { - var refusal = Protected(customer); + 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; @@ -297,6 +355,10 @@ private async Task TakeOverRefusal(Customer customer, string externalId) if (!await groupService.IsRegistered(customer)) return "Only registered accounts can be managed by SCIM"; + //a shopper with the e-mail is not the provider's to manage + if (mustBeStaff && !await permissionService.IsBackOfficeAccount(customer)) + return "Only staff accounts are taken over by SCIM"; + if (!config.ExternalIdIsSubject || externalId == "") return null; @@ -310,21 +372,17 @@ private async Task TakeOverRefusal(Customer customer, string externalId) return subjectLink != null ? "The identity is linked to another account" : null; } - private string Protected(Customer customer) - { - 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; - } + //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 == null) + if (record is not { Deprovisioned: false }) return (null, null); var customer = await customerService.GetCustomerById(id); return customer is { Deleted: false } ? (customer, record) : (null, null); diff --git a/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs index cf10adc22..c21420f88 100644 --- a/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs +++ b/src/Business/Grand.Business.Core/Interfaces/Authentication/IScimUserService.cs @@ -3,9 +3,9 @@ namespace Grand.Business.Core.Interfaces.Authentication; /// -/// SCIM provisioning of staff accounts: the identity provider creates (or takes over 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. System, deleted and break-glass accounts are never managed +/// 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 { diff --git a/src/Core/Grand.Domain/Customers/ScimUser.cs b/src/Core/Grand.Domain/Customers/ScimUser.cs index 222e3bcea..908cb724a 100644 --- a/src/Core/Grand.Domain/Customers/ScimUser.cs +++ b/src/Core/Grand.Domain/Customers/ScimUser.cs @@ -23,4 +23,16 @@ public class ScimUser : BaseEntity /// 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/Tests/Grand.Business.Authentication.Tests/Events/ScimCustomerDeletedEventHandlerTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Events/ScimCustomerDeletedEventHandlerTests.cs new file mode 100644 index 000000000..8ac616dd0 --- /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 index ac192fd5d..ad2106b17 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs @@ -2,6 +2,7 @@ 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; @@ -11,9 +12,11 @@ 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; @@ -52,6 +55,8 @@ public class ScimFixture 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(); @@ -138,6 +143,10 @@ await Links.InsertAsync(new ExternalAuthentication { 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())) @@ -148,18 +157,20 @@ private static CustomerGroup Group(string id, string systemName, bool isSystem = new() { Id = id, SystemName = systemName, Name = id, IsSystem = isSystem, Active = true }; public ScimRoleSync RoleSync() => - new(Users, Groups, CustomerService.Object, GroupService.Object, NullLogger.Instance); + new(Users, Groups, CustomerService.Object, GroupService.Object, LoginConfig, Mediator.Object, + NullLogger.Instance); public ScimAccountLock AccountLock() => new(CustomerService.Object, Links, Sessions.Object, Revocation.Object, NullLogger.Instance); public ScimUserService UserService() => - new(Users, Groups, Links, CustomerService.Object, CustomerManager.Object, GroupService.Object, Resolver.Object, - LinkService.Object, RoleSync(), AccountLock(), Config, LoginConfig, CustomerConfig, NullLogger.Instance); + 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() => - new(Groups, Users, GroupService.Object, StoreService.Object, VendorService.Object, RoleSync(), - 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) { diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs index 91e16b568..f6e40a8a2 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimGroupServiceTests.cs @@ -2,6 +2,10 @@ 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; @@ -27,14 +31,23 @@ 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_Conflict() + public async Task Create_SameDisplayNameTwice_Allowed() { await Group("Shop admins"); - Assert.AreEqual(ScimOutcome.Conflict, + 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() @@ -245,4 +258,56 @@ public async Task GetMappableGroups_BackOfficeAndCustomOnly() 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 index 4da476036..c018e3d29 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs @@ -2,6 +2,8 @@ using Grand.Business.Core.Utilities.Authentication; using Grand.Business.Core.Utilities.Customers; using Grand.Domain.Customers; +using Grand.Infrastructure.Events; +using Grand.Mediator; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -127,7 +129,7 @@ public async Task Create_SystemAccount_Conflict() [TestMethod] public async Task Create_AccountLinkedToOtherSubject_Conflict() { - var existing = await _f.AddCustomer("jan@example.com"); + 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 @@ -230,7 +232,7 @@ public async Task Delete_DeactivatesRevokesRolesUnlinksAndKeepsCustomer() Assert.IsNotNull(customer); Assert.IsFalse(customer.Active); Assert.IsFalse(customer.Groups.Contains(ScimFixture.Administrators.Id)); - Assert.IsNull(await _f.Users.GetByIdAsync(created.User.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); @@ -273,4 +275,155 @@ 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)); + } + + private static bool IsGuid(string value) => Guid.TryParse(value, out _); } diff --git a/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs b/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs index 6d10d2b52..73fc34f6f 100644 --- a/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs +++ b/src/Tests/Grand.Web.Admin.Tests/Controllers/ExternalAuthenticationControllerScimTests.cs @@ -1,5 +1,6 @@ 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; @@ -7,6 +8,7 @@ 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; @@ -31,6 +33,9 @@ public class ExternalAuthenticationControllerScimTests 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] @@ -58,6 +63,13 @@ public void Init() _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") => @@ -126,7 +138,7 @@ public async Task ScimGroupEdit_Post_InvalidTarget_StaysWithError() var result = await _controller.ScimGroupEdit(new ScimGroupMappingModel { Id = "sg1", CustomerGroupId = "g-sm", StaffStoreId = "", VendorId = "" - }, _groups.Object, _stores.Object, _vendors.Object, _translation.Object); + }, _groups.Object, _stores.Object, _vendors.Object, _translation.Object, _customerGroups.Object, _context.Object); Assert.IsInstanceOfType(result); Assert.IsFalse(_controller.ModelState.IsValid); @@ -136,11 +148,46 @@ public async Task ScimGroupEdit_Post_InvalidTarget_StaysWithError() 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); + _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/Controllers/ExternalAuthenticationController.cs b/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs index f4f9ed3b9..d1af99fc7 100644 --- a/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs +++ b/src/Web/Grand.Web.Admin/Controllers/ExternalAuthenticationController.cs @@ -1,11 +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; @@ -171,8 +173,22 @@ public async Task ScimGroupEdit(string id, [FromServices] IScimGr [AutoValidateAntiforgeryToken] public async Task ScimGroupEdit(ScimGroupMappingModel model, [FromServices] IScimGroupService scimGroupService, [FromServices] IStoreService storeService, - [FromServices] IVendorService vendorService, [FromServices] IRequestTranslationService translationService) + [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) { @@ -180,16 +196,29 @@ public async Task ScimGroupEdit(ScimGroupMappingModel model, return RedirectToAction("Scim"); } - var group = await scimGroupService.Get(model.Id); - if (outcome == ScimOutcome.NotFound || group.Outcome != ScimOutcome.Ok) + if (outcome == ScimOutcome.NotFound) return RedirectToAction("Scim"); + return await ScimGroupEditError(model, group.Group, "Admin.Plugins.ExternalAuthenticationMethods.Scim.Mapping.Invalid", + scimGroupService, storeService, vendorService, translationService); + } - ModelState.AddModelError("", - translationService.GetResource("Admin.Plugins.ExternalAuthenticationMethods.Scim.Mapping.Invalid")); - await PrepareScimGroupMapping(model, group.Group, scimGroupService, storeService, vendorService); + 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) { diff --git a/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml b/src/Web/Grand.Web/App_Data/Resources/DefaultLanguage.xml index 129c17fd28783567631ec9bbab1f3b5d65a014af..fbf940308bea40b0b91b6381a2df7fb215a00faa 100644 GIT binary patch delta 259 zcmZp;mU!Y;VnYjK3sVbo3rh=Y3tJ0&3r7oQ3s(zw3r`Dg3ttO=i$IHDi%^Sji%5%T zi&%^J76~~M@n8mL22TcG1_g#>h9ZVkhD3%EAXz-!+C-vW*cvFD%22_O$xsXw&Sc1A zNS`kIi$hkn7$}hm6jA`n*K3DLU_87u7tt%T_zG1a?@Y=NC-^+ z#3VL-fu{%yb1{Sebi)!x>*)^?1h|+J8S!7; kgt}xhLk3tb9ca7)(5h0P+6*8oak{=UbL#d9K@tTL04mNw*8l(j delta 99 zcmX?cE3x5PVnYjK3sVbo3rh=Y3tJ0&3r7oQ3s(zw3r`Dg3ttO=i$IHDi%^Sji%5%T zi&%^J7700%>0$N~a;ypri3~-PAM%M$&p9c=H9f{eB4WFXsf2~x^nfr4q3u4Q5`V-2 DR!AR3 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 45bdb283a..17ce4f394 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 @@ -91,7 +91,7 @@ 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 managed by SCIM are linked to the identity provider before their first sign-in. The endpoint and its token are set in appsettings (module Grand.Module.Scim, section Scim); see docs/scim.md. + 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. @@ -156,6 +156,9 @@ 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 From 419907b736ee539ba99b5823d799633cbfba144c Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 00:01:10 +0200 Subject: [PATCH 09/11] Take over employees' shopping accounts on the company's e-mail domains An employee who already shops with a registered account on the work e-mail was refused (409) because only staff accounts were taken over. Scim:TakeOverDomains lists the company's domains: a shopper account with such an e-mail is taken over too, keeps its orders and password and is connected once with that password. Shopper accounts on any other domain stay out of the identity provider's reach. --- docs/scim.md | 12 ++++++--- .../Services/Scim/ScimUserService.cs | 15 ++++++----- .../Configuration/ScimConfig.cs | 19 +++++++++++++ .../Services/Scim/ScimUserServiceTests.cs | 27 +++++++++++++++++++ .../Services/ScimConfigTests.cs | 25 +++++++++++++++++ src/Web/Grand.Web/App_Data/appsettings.json | 2 ++ 6 files changed, 89 insertions(+), 11 deletions(-) diff --git a/docs/scim.md b/docs/scim.md index ecc332f1e..62b7ad088 100644 --- a/docs/scim.md +++ b/docs/scim.md @@ -33,6 +33,7 @@ What it is not: |---|---|---| | `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 @@ -73,16 +74,19 @@ What it is not: ## 4. Behaviour **Create.** No account with the e-mail: a registered, active account is created and linked to the user's OpenID Connect -subject. It belongs to no store, so with per-store identity it is usable on every store host. Its password is random and +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: **SCIM takes it over** and -manages its roles from then on (section 5). It is **not** linked ahead. An e-mail at the identity provider proves nothing about +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, addresses and password, 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. Refused with `409 uniqueness`: -* an account that is not staff (a storefront customer); +* 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 accounts (`OidcLogin:BreakGlassAccounts`); * a store-bound account with per-store identity; * an account linked to another OpenID Connect identity; diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs index be2eb9b78..06b9736e2 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs @@ -20,10 +20,11 @@ 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 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). No welcome message is sent - SCIM provisions employees, not shoppers. -/// Logs carry customer ids, never e-mails +/// 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). No welcome message is sent - SCIM provisions employees, not shoppers. Logs carry customer ids, never +/// e-mails /// public class ScimUserService( IRepository users, @@ -355,9 +356,9 @@ private async Task TakeOverRefusal(Customer customer, string externalId, if (!await groupService.IsRegistered(customer)) return "Only registered accounts can be managed by SCIM"; - //a shopper with the e-mail is not the provider's to manage - if (mustBeStaff && !await permissionService.IsBackOfficeAccount(customer)) - return "Only staff accounts are taken over 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; diff --git a/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs index bc6070063..3e91365d7 100644 --- a/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs @@ -18,12 +18,31 @@ public class ScimConfig ///
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); 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) { diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs index c018e3d29..8e1e0a95c 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs @@ -425,5 +425,32 @@ public async Task Create_AfterDelete_SameAccountAndLinkedAgain() 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 its password, and connects + //once with that password - it 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); + } + + [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 index 1de65a37b..b396703ce 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/ScimConfigTests.cs @@ -38,4 +38,29 @@ public void PageSize(int? requested, int expected) => [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/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 44a50c365..6ad5570e5 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -328,6 +328,8 @@ "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; other shopper accounts are refused + "TakeOverDomains": [], //largest page of a list response "MaxResults": 100 }, From 0da3a11e3988d0bf82646443c45821bd0168dd58 Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 00:20:52 +0200 Subject: [PATCH 10/11] Reset the credentials of a shopper account SCIM takes over, refuse break-glass addresses A shopper account on Scim:TakeOverDomains may have been registered by anyone with the address (GrandNode never verified it). Taking it over kept its password, so in Mixed mode whoever registered it could sign in with the roles the identity provider then granted. Its password, sessions, refresh token, second factor and non-OpenID Connect sign-ins now go before the roles arrive; staff accounts keep theirs. Create also refused a break-glass address only when it already had an account. --- docs/scim.md | 10 ++- .../Services/Scim/ScimAccountLock.cs | 28 +++++++++ .../Services/Scim/ScimUserService.cs | 15 ++++- .../Configuration/ScimConfig.cs | 4 +- .../Services/Scim/ScimFixture.cs | 14 ++++- .../Services/Scim/ScimUserServiceTests.cs | 61 ++++++++++++++++++- src/Web/Grand.Web/App_Data/appsettings.json | 2 +- 7 files changed, 125 insertions(+), 9 deletions(-) diff --git a/docs/scim.md b/docs/scim.md index 62b7ad088..1e63609ed 100644 --- a/docs/scim.md +++ b/docs/scim.md @@ -80,14 +80,20 @@ 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, addresses and password, and is deactivated when the employee leaves. It is **not** linked ahead. An e-mail at the identity provider proves nothing about +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 accounts (`OidcLogin:BreakGlassAccounts`); +* 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`. diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs index 008644d43..6086adbd2 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimAccountLock.cs @@ -1,8 +1,10 @@ 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; @@ -13,6 +15,7 @@ namespace Grand.Business.Authentication.Services.Scim; /// public class ScimAccountLock( ICustomerService customerService, + ICustomerManagerService customerManagerService, IRepository links, IOidcSessionService sessionService, IOidcTokenRevocationService tokenRevocationService, @@ -38,6 +41,31 @@ public virtual async Task Deactivate(Customer customer) 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); diff --git a/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs index 06b9736e2..5378d1e1e 100644 --- a/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs +++ b/src/Business/Grand.Business.Authentication/Services/Scim/ScimUserService.cs @@ -23,7 +23,8 @@ namespace Grand.Business.Authentication.Services.Scim; /// 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). No welcome message is sent - SCIM provisions employees, not shoppers. Logs carry customer ids, never +/// 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( @@ -54,6 +55,10 @@ public virtual async Task Create(ScimUserData 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"); @@ -85,6 +90,14 @@ public virtual async Task Create(ScimUserData data) 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 { diff --git a/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs index 3e91365d7..a0541bee1 100644 --- a/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs +++ b/src/Core/Grand.Infrastructure/Configuration/ScimConfig.cs @@ -20,8 +20,8 @@ public class ScimConfig /// /// 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); any other shopper account is refused. Exact domains only, no - /// subdomains + /// 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; } = []; diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs index ad2106b17..7a9e2c054 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimFixture.cs @@ -112,6 +112,17 @@ public ScimFixture() 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()); @@ -161,7 +172,8 @@ public ScimRoleSync RoleSync() => NullLogger.Instance); public ScimAccountLock AccountLock() => - new(CustomerService.Object, Links, Sessions.Object, Revocation.Object, NullLogger.Instance); + 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, diff --git a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs index 8e1e0a95c..17d2900af 100644 --- a/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs +++ b/src/Tests/Grand.Business.Authentication.Tests/Services/Scim/ScimUserServiceTests.cs @@ -1,6 +1,7 @@ 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; @@ -425,8 +426,7 @@ public async Task Create_AfterDelete_SameAccountAndLinkedAgain() 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 its password, and connects - //once with that password - it is not linked ahead + //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() { @@ -442,6 +442,63 @@ public async Task Create_ShopperOnTakeOverDomain_TakenOverNotLinkedAhead() _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() { diff --git a/src/Web/Grand.Web/App_Data/appsettings.json b/src/Web/Grand.Web/App_Data/appsettings.json index 6ad5570e5..f05cb0470 100644 --- a/src/Web/Grand.Web/App_Data/appsettings.json +++ b/src/Web/Grand.Web/App_Data/appsettings.json @@ -328,7 +328,7 @@ "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; other shopper accounts are refused + //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 From 8980de921f0daef038aa2986a9341a54f1f5c0ff Mon Sep 17 00:00:00 2001 From: support Date: Sun, 25 Oct 2026 07:33:18 +0100 Subject: [PATCH 11/11] Document switching the OpenID Connect method on before SCIM provisioning --- docs/scim.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/scim.md b/docs/scim.md index 1e63609ed..2ab1c571f 100644 --- a/docs/scim.md +++ b/docs/scim.md @@ -24,7 +24,9 @@ What it is not: ## 2. Configuration 1. Configure and test the OpenID Connect sign-in first (`OidcResourceServer` and `OidcLogin`). Without it every SCIM - request is refused. + 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`: