| | | 1 | | using System.Security.Claims; |
| | | 2 | | using System.Security.Cryptography; |
| | | 3 | | using System.Text; |
| | | 4 | | using System.Text.Json; |
| | | 5 | | using System.Text.Json.Nodes; |
| | | 6 | | using Elsa.Authorization; |
| | | 7 | | using Elsa.Common.Multitenancy; |
| | | 8 | | using Elsa.ExternalAuthentication.Contracts; |
| | | 9 | | using Elsa.ExternalAuthentication.Models; |
| | | 10 | | using Elsa.ExternalAuthentication.Notifications; |
| | | 11 | | using Elsa.ExternalAuthentication.Options; |
| | | 12 | | using Elsa.ExternalAuthentication.Permissions; |
| | | 13 | | using Elsa.ExternalAuthentication.Policies; |
| | | 14 | | using Elsa.Identity.Contracts; |
| | | 15 | | using Elsa.Identity.Entities; |
| | | 16 | | using Elsa.Identity.Models; |
| | | 17 | | using Microsoft.Extensions.Options; |
| | | 18 | | |
| | | 19 | | namespace Elsa.ExternalAuthentication.Services; |
| | | 20 | | |
| | | 21 | | /// <summary>Guards Elsa Role deletion against JIT-policy default-role references.</summary> |
| | | 22 | | /// <remarks> |
| | | 23 | | /// Roles are tenant-scoped, so impact and remediation only ever consider connections in the role's own tenant |
| | | 24 | | /// context: the resolved role's own <c>TenantId</c>. The tenant active on <see cref="ITenantAccessor"/> is used |
| | | 25 | | /// only as a fallback when the role cannot be resolved, because with multitenancy disabled (the default) the EF |
| | | 26 | | /// Core role store installs no tenant query filter and can resolve a tenant-owned role by ID regardless of the |
| | | 27 | | /// ambient tenant; trusting the ambient tenant instead of the resolved role's tenant would then let this |
| | | 28 | | /// contributor scan the wrong tenant's connections while the coordinator deletes a role belonging to another |
| | | 29 | | /// tenant. A connection carrying another tenant's ID is out of scope in both directions, for impact and for |
| | | 30 | | /// remediation. |
| | | 31 | | /// Host-scoped connections (<see cref="ConnectionScope.HostTenantId"/>, and configuration entries that leave the |
| | | 32 | | /// tenant blank, which are materialized at host scope) stay in scope for every tenant. The connection registry |
| | | 33 | | /// resolves the host scope for every signing-in tenant, and a connection's default role IDs are then resolved by |
| | | 34 | | /// <c>ExternalIdentityUserProvisioningService</c> through <see cref="IRoleProvider"/> in the signing-in user's |
| | | 35 | | /// tenant, so a host connection naming role ID X really does reference tenant A's role X. |
| | | 36 | | /// A tenant-agnostic role (<see cref="Tenant.AgnosticTenantId"/>) is visible from every tenant, so its tenant |
| | | 37 | | /// context is every tenant: impact and remediation for such a role scan every stored connection and every |
| | | 38 | | /// configuration entry regardless of tenant, instead of the single active tenant plus host scope. Authorizing a |
| | | 39 | | /// replacement role, however, is still performed through the ambient tenant's role services, so when the |
| | | 40 | | /// deletion target is agnostic the replacement role must itself be agnostic; a tenant-scoped replacement is |
| | | 41 | | /// rejected rather than being authorized in one tenant and written into every tenant's connections. |
| | | 42 | | /// In EF Core persistence a role's primary key is its ID alone, so a role ID is unique across all tenants there |
| | | 43 | | /// and an agnostic/tenant-scoped collision cannot exist. Only <c>MemoryRoleStore</c> can hold two roles that |
| | | 44 | | /// share an ID (its storage key includes the tenant); resolving a role ID against it can then be genuinely |
| | | 45 | | /// ambiguous. That ambiguity is never resolved by guessing: widening a tenant-scoped deletion would expose |
| | | 46 | | /// another tenant's references, and narrowing an agnostic deletion would leave an agnostic role's references |
| | | 47 | | /// dangling. It fails closed instead. The same collision makes a replacement candidate ambiguous too: a |
| | | 48 | | /// replacement ID that resolves to more than one role (an ambient match and an agnostic match, under |
| | | 49 | | /// <c>MemoryRoleStore</c>) is rejected as not agnostic rather than guessed at, so it is reported as |
| | | 50 | | /// <c>replacement_role_unavailable_or_unauthorized</c> instead of surfacing as an exception. |
| | | 51 | | /// </remarks> |
| | 24 | 52 | | public sealed class ExternalAuthenticationRoleDeletionDependencyContributor( |
| | 24 | 53 | | IIdentityProviderConnectionStore store, |
| | 24 | 54 | | IOptionsMonitor<ExternalAuthenticationOptions> options, |
| | 24 | 55 | | IEnumerable<IRoleAuthorizationService> roleAuthorizationServices, |
| | 24 | 56 | | IEnumerable<IRoleStore> roleStores, |
| | 24 | 57 | | IConnectionRegistryVersionStore registryVersions, |
| | 24 | 58 | | ConnectionRevisionCalculator revisionCalculator, |
| | 24 | 59 | | ExternalAuthenticationSecurityNotifier notifier, |
| | 24 | 60 | | IPermissionEvaluator permissionEvaluator, |
| | 24 | 61 | | ITenantAccessor tenantAccessor) : IRoleDeletionDependencyContributor |
| | | 62 | | { |
| | | 63 | | public const string SourceName = "external-authentication"; |
| | 160 | 64 | | public string Source => SourceName; |
| | | 65 | | |
| | | 66 | | /// <summary> |
| | | 67 | | /// Match the default DI container's direct-service semantics: when persistence replaces the in-memory |
| | | 68 | | /// store, the last registration is the active store. |
| | | 69 | | /// </summary> |
| | 93 | 70 | | private IRoleStore? ActiveRoleStore => roleStores.LastOrDefault(); |
| | | 71 | | |
| | | 72 | | public async ValueTask<RoleDeletionDependencySnapshot> InspectAsync(string roleId, CancellationToken cancellationTok |
| | | 73 | | { |
| | 52 | 74 | | var roleTenantId = await ResolveRoleTenantIdAsync(roleId, cancellationToken); |
| | 49 | 75 | | var dependencies = new List<RoleDeletionDependency>(); |
| | 49 | 76 | | var configuredConnections = options.CurrentValue.ConfigurationConnections ?? []; |
| | 49 | 77 | | var configurationIndex = 0; |
| | 108 | 78 | | foreach (var connection in configuredConnections) |
| | | 79 | | { |
| | | 80 | | // The index is part of the configuration path an operator edits, so out-of-scope entries are |
| | | 81 | | // skipped without renumbering the entries that remain. |
| | 5 | 82 | | if (IsInRoleTenantScope(GetConfigurationScopeTenantId(connection), roleTenantId)) |
| | 4 | 83 | | dependencies.AddRange(GetConfigurationDependencies(connection, configurationIndex, roleId)); |
| | 5 | 84 | | configurationIndex++; |
| | | 85 | | } |
| | | 86 | | |
| | 222 | 87 | | foreach (var connection in await FindConnectionsInRoleTenantScopeAsync(roleTenantId, cancellationToken)) |
| | | 88 | | { |
| | 62 | 89 | | if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out _, out var removesLast |
| | | 90 | | continue; |
| | 62 | 91 | | dependencies.Add(new( |
| | 62 | 92 | | Source, |
| | 62 | 93 | | connection.Id, |
| | 62 | 94 | | connection.Key, |
| | 62 | 95 | | policyBranch, |
| | 62 | 96 | | RoleDeletionDependencyOwnership.Database, |
| | 62 | 97 | | null, |
| | 62 | 98 | | connection.Revision, |
| | 62 | 99 | | removesLastDefaultRole)); |
| | | 100 | | } |
| | | 101 | | |
| | 49 | 102 | | var ordered = dependencies |
| | 34 | 103 | | .OrderBy(x => x.Ownership) |
| | 34 | 104 | | .ThenBy(x => x.OwnerId, StringComparer.Ordinal) |
| | 34 | 105 | | .ThenBy(x => x.ConfigurationPath, StringComparer.Ordinal) |
| | 49 | 106 | | .ToArray(); |
| | 49 | 107 | | return new(Source, CalculateVersion(ordered), false, ordered); |
| | 49 | 108 | | } |
| | | 109 | | |
| | | 110 | | public async ValueTask<RoleReferenceRemovalValidationResult> ValidateRemovalAsync(RoleReferenceRemovalRequest reques |
| | | 111 | | { |
| | 29 | 112 | | var roleAuthorizationService = roleAuthorizationServices.SingleOrDefault(); |
| | 29 | 113 | | if (roleAuthorizationService is null) |
| | 0 | 114 | | return new RoleReferenceRemovalValidationResult.Forbidden("role_authorization_unavailable"); |
| | 29 | 115 | | if (!permissionEvaluator.HasPermission(request.Actor, ExternalAuthenticationResourcePermissions.Connections, Cor |
| | 29 | 116 | | !permissionEvaluator.HasPermission(request.Actor, ExternalAuthenticationResourcePermissions.Policies, CoreVe |
| | 29 | 117 | | !permissionEvaluator.HasPermission(request.Actor, ExternalAuthenticationResourcePermissions.PolicyDefaultRol |
| | 3 | 118 | | return new RoleReferenceRemovalValidationResult.Forbidden("missing_policy_permissions"); |
| | 26 | 119 | | if (request.Dependencies.Count == 0 || |
| | 62 | 120 | | request.Dependencies.Any(x => x.Ownership != RoleDeletionDependencyOwnership.Database || !string.Equals(x.So |
| | 0 | 121 | | return new RoleReferenceRemovalValidationResult.Conflict("invalid_dependency_set"); |
| | | 122 | | |
| | 26 | 123 | | var current = await InspectAsync(request.RoleId, cancellationToken); |
| | 24 | 124 | | if (!string.Equals(current.Version, request.ExpectedContributorVersion, StringComparison.Ordinal) || |
| | 55 | 125 | | current.Dependencies.Any(x => x.Ownership == RoleDeletionDependencyOwnership.Configuration)) |
| | 2 | 126 | | return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed"); |
| | | 127 | | |
| | 54 | 128 | | var expectedOwners = request.Dependencies.Select(x => x.OwnerId).ToHashSet(StringComparer.Ordinal); |
| | 22 | 129 | | var currentOwners = current.Dependencies |
| | 30 | 130 | | .Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database) |
| | 30 | 131 | | .Select(x => x.OwnerId) |
| | 22 | 132 | | .ToHashSet(StringComparer.Ordinal); |
| | 22 | 133 | | if (!expectedOwners.IsSubsetOf(currentOwners)) |
| | 2 | 134 | | return new RoleReferenceRemovalValidationResult.Conflict("dependency_changed"); |
| | | 135 | | |
| | 20 | 136 | | var roleTenantId = await ResolveRoleTenantIdAsync(request.RoleId, cancellationToken); |
| | 82 | 137 | | foreach (var dependency in request.Dependencies) |
| | | 138 | | { |
| | 24 | 139 | | var connection = await FindConnectionInRoleTenantScopeAsync(dependency.OwnerId, roleTenantId, cancellationTo |
| | 24 | 140 | | if (connection is null || connection.Revision != dependency.ExpectedRevision || |
| | 24 | 141 | | !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out var roleIds, out _)) |
| | 0 | 142 | | return new RoleReferenceRemovalValidationResult.Conflict("connection_revision_changed"); |
| | 51 | 143 | | var remainingRoleIds = roleIds.Where(x => !string.Equals(x, request.RoleId, StringComparison.Ordinal)).ToArr |
| | 24 | 144 | | var requiresReplacement = remainingRoleIds.Length == 0 && request.SelectedReferences is not null; |
| | 24 | 145 | | if (requiresReplacement && |
| | 24 | 146 | | (string.IsNullOrWhiteSpace(request.ReplacementRoleId) || |
| | 24 | 147 | | string.Equals(request.ReplacementRoleId, request.RoleId, StringComparison.Ordinal))) |
| | 1 | 148 | | return new RoleReferenceRemovalValidationResult.Forbidden("replacement_role_unavailable_or_unauthorized" |
| | | 149 | | |
| | | 150 | | // Authorization below still resolves through the ambient tenant's role services, so an agnostic |
| | | 151 | | // deletion target may only be replaced by another agnostic role; a tenant-scoped replacement would |
| | | 152 | | // otherwise be authorized in this tenant and then written into every other tenant's connections. |
| | | 153 | | // This does not extend to host-scoped connections: IdentityProviderConnectionManagementService |
| | | 154 | | // forces every managed connection to host scope, and in a deployment without multitenancy roles |
| | | 155 | | // are created scoped to the default tenant rather than agnostic, so requiring an agnostic |
| | | 156 | | // replacement for host-scoped connections would make every replacement remediation impossible in |
| | | 157 | | // the default deployment. |
| | 23 | 158 | | if (requiresReplacement && |
| | 23 | 159 | | string.Equals(roleTenantId, Tenant.AgnosticTenantId, StringComparison.Ordinal) && |
| | 23 | 160 | | !await IsAgnosticRoleAsync(request.ReplacementRoleId, cancellationToken)) |
| | 4 | 161 | | return new RoleReferenceRemovalValidationResult.Forbidden("replacement_role_unavailable_or_unauthorized" |
| | | 162 | | |
| | 19 | 163 | | var rolesToAssign = requiresReplacement |
| | 19 | 164 | | ? new[] { request.ReplacementRoleId! } |
| | 19 | 165 | | : remainingRoleIds; |
| | 19 | 166 | | if (!await roleAuthorizationService.CanAssignRolesAsync(request.Actor, rolesToAssign, cancellationToken)) |
| | | 167 | | { |
| | 1 | 168 | | var code = requiresReplacement ? "replacement_role_unavailable_or_unauthorized" : "role_assignment_denie |
| | 1 | 169 | | return new RoleReferenceRemovalValidationResult.Forbidden(code); |
| | | 170 | | } |
| | 18 | 171 | | } |
| | | 172 | | |
| | 14 | 173 | | return new RoleReferenceRemovalValidationResult.Valid(); |
| | 27 | 174 | | } |
| | | 175 | | |
| | | 176 | | public async ValueTask<RoleReferenceRemovalResult> RemoveEditableReferencesAsync(RoleReferenceRemovalRequest request |
| | | 177 | | { |
| | 12 | 178 | | var roleAuthorizationService = roleAuthorizationServices.SingleOrDefault(); |
| | 12 | 179 | | if (roleAuthorizationService is null) |
| | 0 | 180 | | return new RoleReferenceRemovalResult.Failed("role_authorization_unavailable", []); |
| | 12 | 181 | | var validation = await ValidateRemovalAsync(request, cancellationToken); |
| | 11 | 182 | | if (validation is RoleReferenceRemovalValidationResult.Forbidden forbidden) |
| | 3 | 183 | | return new RoleReferenceRemovalResult.Failed(forbidden.Code, []); |
| | 8 | 184 | | if (validation is RoleReferenceRemovalValidationResult.Conflict conflict) |
| | 1 | 185 | | return new RoleReferenceRemovalResult.Conflict(conflict.Code, []); |
| | | 186 | | |
| | 7 | 187 | | var roleTenantId = await ResolveRoleTenantIdAsync(request.RoleId, cancellationToken); |
| | 7 | 188 | | var changedOwnerIds = new List<string>(); |
| | | 189 | | try |
| | | 190 | | { |
| | 40 | 191 | | foreach (var dependency in request.Dependencies.OrderBy(x => x.OwnerId, StringComparer.Ordinal)) |
| | | 192 | | { |
| | 9 | 193 | | var connection = await FindConnectionInRoleTenantScopeAsync(dependency.OwnerId, roleTenantId, cancellati |
| | 9 | 194 | | if (connection is null || connection.Revision != dependency.ExpectedRevision || |
| | 9 | 195 | | !TryGetRoleReference(connection.UnlinkedPolicy, request.RoleId, out _, out _, out var removesLastDef |
| | 1 | 196 | | return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds); |
| | | 197 | | |
| | 8 | 198 | | var candidate = IdentityProviderConnectionCloner.Clone(connection); |
| | 8 | 199 | | candidate.UnlinkedPolicy = candidate.UnlinkedPolicy is { } policy |
| | 8 | 200 | | ? policy with { Settings = RemoveRole(policy.Settings, request.RoleId, request.SelectedReferences is |
| | 8 | 201 | | : null; |
| | 8 | 202 | | candidate.UpdatedAt = DateTimeOffset.UtcNow; |
| | 8 | 203 | | candidate.MaterialRevision = revisionCalculator.CalculateMaterialRevision(candidate); |
| | | 204 | | |
| | 8 | 205 | | if (request.SelectedReferences is not null && removesLastDefaultRole) |
| | | 206 | | { |
| | 4 | 207 | | var roleStore = ActiveRoleStore; |
| | 4 | 208 | | if (roleStore is null) |
| | 0 | 209 | | return new RoleReferenceRemovalResult.Failed("replacement_role_unavailable_or_unauthorized", cha |
| | 4 | 210 | | var replacement = await roleStore.FindAsync(new() { Id = request.ReplacementRoleId }, cancellationTo |
| | 4 | 211 | | if (replacement is null || |
| | 4 | 212 | | !await roleAuthorizationService.CanAssignRolesAsync(request.Actor, [replacement.Id], cancellatio |
| | 0 | 213 | | return new RoleReferenceRemovalResult.Failed("replacement_role_unavailable_or_unauthorized", cha |
| | | 214 | | |
| | | 215 | | // Authorization above still resolves through the ambient tenant's role services, so an |
| | | 216 | | // agnostic deletion target may only be replaced by another agnostic role; a tenant-scoped |
| | | 217 | | // replacement would otherwise be authorized in this tenant and then written into every other |
| | | 218 | | // tenant's connections. This does not extend to host-scoped connections: see the matching |
| | | 219 | | // guard in ValidateRemovalAsync for why. The check is re-run through IsAgnosticRoleAsync |
| | | 220 | | // rather than trusting the TenantId on `replacement` from the FindAsync call above, because |
| | | 221 | | // a same-ID tenant-scoped role added after validation could make that lookup ambiguous; |
| | | 222 | | // IsAgnosticRoleAsync resolves the candidate itself and rejects an ambiguous match instead |
| | | 223 | | // of accepting whichever role FindAsync happened to return. |
| | 4 | 224 | | if (string.Equals(roleTenantId, Tenant.AgnosticTenantId, StringComparison.Ordinal) && |
| | 4 | 225 | | !await IsAgnosticRoleAsync(request.ReplacementRoleId, cancellationToken)) |
| | 0 | 226 | | return new RoleReferenceRemovalResult.Failed("replacement_role_unavailable_or_unauthorized", cha |
| | | 227 | | } |
| | | 228 | | |
| | 8 | 229 | | var update = await store.UpdateAsync(candidate, connection.Revision, cancellationToken); |
| | 8 | 230 | | if (update is not ConnectionMutationResult.Updated updated) |
| | 0 | 231 | | return new RoleReferenceRemovalResult.Conflict("connection_revision_changed", changedOwnerIds); |
| | | 232 | | |
| | 8 | 233 | | changedOwnerIds.Add(updated.Connection.Id); |
| | 8 | 234 | | await registryVersions.AdvanceAsync(cancellationToken); |
| | 8 | 235 | | await notifier.PublishAsync( |
| | 8 | 236 | | new IdentityProviderConnectionChanged( |
| | 8 | 237 | | ExternalAuthenticationSecurityNotifier.Context( |
| | 8 | 238 | | request.Actor.FindFirstValue(ClaimTypes.NameIdentifier) ?? request.Actor.FindFirstValue("sub |
| | 8 | 239 | | updated.Connection.TenantId, |
| | 8 | 240 | | updated.Connection.Id, |
| | 8 | 241 | | null, |
| | 8 | 242 | | SecurityEventOutcome.Succeeded, |
| | 8 | 243 | | "An Elsa Role reference was removed from an external authentication JIT policy."), |
| | 8 | 244 | | "default-role-removed", |
| | 8 | 245 | | updated.Connection.Revision, |
| | 8 | 246 | | updated.Connection.MaterialRevision), |
| | 8 | 247 | | cancellationToken); |
| | 8 | 248 | | } |
| | 6 | 249 | | } |
| | 0 | 250 | | catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) |
| | | 251 | | { |
| | 0 | 252 | | throw; |
| | | 253 | | } |
| | 0 | 254 | | catch |
| | | 255 | | { |
| | 0 | 256 | | return new RoleReferenceRemovalResult.Failed("storage_error", changedOwnerIds); |
| | | 257 | | } |
| | | 258 | | |
| | 6 | 259 | | return new RoleReferenceRemovalResult.Success(changedOwnerIds); |
| | 11 | 260 | | } |
| | | 261 | | |
| | | 262 | | /// <summary> |
| | | 263 | | /// Resolves the tenant context for the role being deleted: the role's own <c>TenantId</c> |
| | | 264 | | /// (<see cref="Tenant.AgnosticTenantId"/> normalized when the role is tenant-agnostic, in which case its |
| | | 265 | | /// tenant context is every tenant). In EF Core persistence a role ID is unique across all tenants (the |
| | | 266 | | /// <c>Roles</c> table keys on <c>Id</c> alone), so this lookup resolves to at most one role there. Only |
| | | 267 | | /// <c>MemoryRoleStore</c> can hold two roles that share an ID because its storage key includes the tenant; |
| | | 268 | | /// if the ID resolves to more than one role, which tenant's role the coordinator's own delete actually |
| | | 269 | | /// targets is already ambiguous, and this method cannot make the operation consistent by guessing in either |
| | | 270 | | /// direction -- widening would expose another tenant's references for what may be a tenant-scoped deletion, |
| | | 271 | | /// and narrowing would leave an agnostic role's references dangling. It fails closed instead. |
| | | 272 | | /// A missing store or no matching role falls back to the ambient tenant on <see cref="ITenantAccessor"/>, |
| | | 273 | | /// which is the only case where the ambient tenant is trusted: the role cannot be resolved at all, so there |
| | | 274 | | /// is no resolved tenant to prefer over it. |
| | | 275 | | /// </summary> |
| | | 276 | | private async ValueTask<string> ResolveRoleTenantIdAsync(string roleId, CancellationToken cancellationToken) |
| | | 277 | | { |
| | 79 | 278 | | var roles = await FindRolesByIdAsync(roleId, cancellationToken); |
| | 79 | 279 | | return roles.Length switch |
| | 79 | 280 | | { |
| | 0 | 281 | | 0 => tenantAccessor.TenantId.NormalizeTenantId(), |
| | 76 | 282 | | 1 => roles[0].TenantId.NormalizeTenantId(), |
| | 3 | 283 | | _ => throw new InvalidOperationException( |
| | 3 | 284 | | $"Role '{roleId}' resolves to {roles.Length} roles across tenant scopes; the deletion target is ambiguou |
| | 79 | 285 | | }; |
| | 76 | 286 | | } |
| | | 287 | | |
| | | 288 | | /// <summary> |
| | | 289 | | /// Resolves whether a candidate role ID (typically a replacement role) is itself tenant-agnostic. Unlike |
| | | 290 | | /// <see cref="ResolveRoleTenantIdAsync"/>, an ambiguous candidate -- a role ID that resolves to more than one |
| | | 291 | | /// role, which only <c>MemoryRoleStore</c> can produce -- is not the coordinator's own deletion target, so |
| | | 292 | | /// there is no operation to fail closed on by throwing; it is instead treated the same as an unresolved |
| | | 293 | | /// candidate and reported as not agnostic, since there is no single resolved role to trust as safe to write |
| | | 294 | | /// into every tenant's connections. |
| | | 295 | | /// </summary> |
| | | 296 | | private async ValueTask<bool> IsAgnosticRoleAsync(string? roleId, CancellationToken cancellationToken) |
| | | 297 | | { |
| | 10 | 298 | | if (string.IsNullOrWhiteSpace(roleId)) |
| | 0 | 299 | | return false; |
| | 10 | 300 | | var roles = await FindRolesByIdAsync(roleId, cancellationToken); |
| | 10 | 301 | | return roles.Length == 1 && string.Equals(roles[0].TenantId.NormalizeTenantId(), Tenant.AgnosticTenantId, String |
| | 10 | 302 | | } |
| | | 303 | | |
| | | 304 | | /// <summary>Loads every role matching the given ID from the active role store, or an empty result if none is config |
| | | 305 | | private async ValueTask<Role[]> FindRolesByIdAsync(string roleId, CancellationToken cancellationToken) |
| | | 306 | | { |
| | 89 | 307 | | var roleStore = ActiveRoleStore; |
| | 89 | 308 | | if (roleStore is null) |
| | 0 | 309 | | return []; |
| | 89 | 310 | | return (await roleStore.FindManyAsync(new() { Id = roleId }, cancellationToken)).ToArray(); |
| | 89 | 311 | | } |
| | | 312 | | |
| | | 313 | | /// <summary> |
| | | 314 | | /// Loads every stored connection in a single snapshot and filters it in memory to the role's tenant context. |
| | | 315 | | /// Composing the result from separate per-scope reads instead would let a connection's <c>TenantId</c> change |
| | | 316 | | /// between those reads (<see cref="RemoveEditableReferencesAsync"/> permits it via <c>UpdateAsync</c>), so the |
| | | 317 | | /// connection could fall between the reads and appear in neither result. A single snapshot has no gap to fall |
| | | 318 | | /// through. |
| | | 319 | | /// </summary> |
| | | 320 | | private async ValueTask<IReadOnlyCollection<IdentityProviderConnection>> FindConnectionsInRoleTenantScopeAsync(strin |
| | | 321 | | { |
| | 49 | 322 | | var connections = (await store.FindAsync(new(), cancellationToken)).Items; |
| | 116 | 323 | | return connections.Where(x => IsInRoleTenantScope(x.TenantId, roleTenantId)).ToArray(); |
| | 49 | 324 | | } |
| | | 325 | | |
| | | 326 | | /// <summary> |
| | | 327 | | /// Loads one dependency's connection, reporting a connection outside the role's tenant context as absent so |
| | | 328 | | /// that a caller-supplied owner ID cannot reach across a tenant boundary. An agnostic role's tenant context |
| | | 329 | | /// is every tenant, so any connection loaded by owner ID qualifies. |
| | | 330 | | /// </summary> |
| | | 331 | | private async ValueTask<IdentityProviderConnection?> FindConnectionInRoleTenantScopeAsync(string ownerId, string rol |
| | | 332 | | { |
| | 33 | 333 | | var connection = await store.FindByIdAsync(ownerId, cancellationToken); |
| | 33 | 334 | | return connection is not null && IsInRoleTenantScope(connection.TenantId, roleTenantId) ? connection : null; |
| | 33 | 335 | | } |
| | | 336 | | |
| | | 337 | | private static bool IsInRoleTenantScope(string? connectionTenantId, string roleTenantId) => |
| | 105 | 338 | | string.Equals(roleTenantId, Tenant.AgnosticTenantId, StringComparison.Ordinal) || |
| | 105 | 339 | | string.Equals(connectionTenantId, ConnectionScope.HostTenantId, StringComparison.Ordinal) || |
| | 105 | 340 | | string.Equals(connectionTenantId.NormalizeTenantId(), roleTenantId, StringComparison.Ordinal); |
| | | 341 | | |
| | | 342 | | /// <summary>A configuration entry that leaves the tenant blank is materialized at host scope.</summary> |
| | | 343 | | private static string GetConfigurationScopeTenantId(IdentityProviderConnection connection) => |
| | 5 | 344 | | string.IsNullOrWhiteSpace(connection.TenantId) ? ConnectionScope.HostTenantId : connection.TenantId; |
| | | 345 | | |
| | | 346 | | private IEnumerable<RoleDeletionDependency> GetConfigurationDependencies(IdentityProviderConnection connection, int |
| | | 347 | | { |
| | 4 | 348 | | if (!TryGetRoleReference(connection.UnlinkedPolicy, roleId, out var policyBranch, out var roleIds, out var remov |
| | 0 | 349 | | yield break; |
| | | 350 | | |
| | 4 | 351 | | var roleIndex = 0; |
| | 18 | 352 | | foreach (var configuredRoleId in ReadRoleIdsWithDuplicates(connection.UnlinkedPolicy!.Settings)) |
| | | 353 | | { |
| | 5 | 354 | | if (string.Equals(configuredRoleId, roleId, StringComparison.Ordinal)) |
| | | 355 | | { |
| | 4 | 356 | | yield return new( |
| | 4 | 357 | | Source, |
| | 4 | 358 | | string.IsNullOrWhiteSpace(connection.Id) ? $"configuration:{connectionIndex}" : connection.Id, |
| | 4 | 359 | | connection.Key, |
| | 4 | 360 | | policyBranch, |
| | 4 | 361 | | RoleDeletionDependencyOwnership.Configuration, |
| | 4 | 362 | | $"ExternalAuthentication:Connections:{connectionIndex}:UnlinkedPolicy:Settings:defaultRoleIds:{roleI |
| | 4 | 363 | | null, |
| | 4 | 364 | | removesLastDefaultRole); |
| | | 365 | | } |
| | | 366 | | |
| | 5 | 367 | | roleIndex++; |
| | | 368 | | } |
| | 4 | 369 | | } |
| | | 370 | | |
| | | 371 | | private static bool TryGetRoleReference(PolicySelection? policy, string roleId, out string policyBranch, out IReadOn |
| | | 372 | | { |
| | 98 | 373 | | policyBranch = string.Empty; |
| | 98 | 374 | | roleIds = []; |
| | 98 | 375 | | removesLastDefaultRole = false; |
| | 98 | 376 | | if (policy is null) |
| | 0 | 377 | | return false; |
| | | 378 | | |
| | 98 | 379 | | if (string.Equals(policy.Type, CreateUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal)) |
| | 97 | 380 | | policyBranch = "create-user"; |
| | 1 | 381 | | else if (string.Equals(policy.Type, MatchExternalUserUnlinkedIdentityPolicy.PolicyType, StringComparison.Ordinal |
| | 1 | 382 | | string.Equals(ReadString(policy.Settings, "noMatchAction"), "create-user", StringComparison.OrdinalIgno |
| | 1 | 383 | | policyBranch = "matcher-no-match-create-user"; |
| | | 384 | | else |
| | 0 | 385 | | return false; |
| | | 386 | | |
| | 98 | 387 | | roleIds = CreateUserUnlinkedIdentityPolicy.ReadRoleIds(policy.Settings); |
| | 98 | 388 | | if (!roleIds.Contains(roleId, StringComparer.Ordinal)) |
| | 0 | 389 | | return false; |
| | 98 | 390 | | removesLastDefaultRole = roleIds.Count == 1; |
| | 98 | 391 | | return true; |
| | | 392 | | } |
| | | 393 | | |
| | | 394 | | private static JsonElement RemoveRole(JsonElement settings, string roleId, string? replacementRoleId = null) |
| | | 395 | | { |
| | 8 | 396 | | var root = settings.ValueKind == JsonValueKind.Object |
| | 8 | 397 | | ? JsonNode.Parse(settings.GetRawText()) as JsonObject |
| | 8 | 398 | | : new(); |
| | 8 | 399 | | root ??= new(); |
| | 8 | 400 | | var remainingRoleIds = ReadRoleIdsWithDuplicates(settings) |
| | 9 | 401 | | .Where(x => !string.Equals(x, roleId, StringComparison.Ordinal)) |
| | 8 | 402 | | .Distinct(StringComparer.Ordinal) |
| | 8 | 403 | | .ToArray(); |
| | 8 | 404 | | if (remainingRoleIds.Length == 0 && !string.IsNullOrWhiteSpace(replacementRoleId)) |
| | 4 | 405 | | remainingRoleIds = [replacementRoleId]; |
| | 8 | 406 | | var roleNodes = new JsonNode?[remainingRoleIds.Length]; |
| | 26 | 407 | | for (var index = 0; index < remainingRoleIds.Length; index++) |
| | 5 | 408 | | roleNodes[index] = JsonValue.Create(remainingRoleIds[index]); |
| | 8 | 409 | | root["defaultRoleIds"] = new JsonArray(roleNodes); |
| | 8 | 410 | | return JsonSerializer.SerializeToElement(root); |
| | | 411 | | } |
| | | 412 | | |
| | | 413 | | private static IReadOnlyCollection<string> ReadRoleIdsWithDuplicates(JsonElement settings) => |
| | 12 | 414 | | settings.ValueKind == JsonValueKind.Object && |
| | 12 | 415 | | settings.TryGetProperty("defaultRoleIds", out var values) && |
| | 12 | 416 | | values.ValueKind == JsonValueKind.Array |
| | 12 | 417 | | ? values.EnumerateArray() |
| | 14 | 418 | | .Where(x => x.ValueKind == JsonValueKind.String) |
| | 14 | 419 | | .Select(x => x.GetString()) |
| | 14 | 420 | | .Where(x => !string.IsNullOrWhiteSpace(x)) |
| | 12 | 421 | | .Cast<string>() |
| | 12 | 422 | | .ToArray() |
| | 12 | 423 | | : []; |
| | | 424 | | |
| | | 425 | | private static string? ReadString(JsonElement settings, string propertyName) => |
| | 1 | 426 | | settings.ValueKind == JsonValueKind.Object && |
| | 1 | 427 | | settings.TryGetProperty(propertyName, out var value) && |
| | 1 | 428 | | value.ValueKind == JsonValueKind.String |
| | 1 | 429 | | ? value.GetString() |
| | 1 | 430 | | : null; |
| | | 431 | | |
| | | 432 | | private static string CalculateVersion(IEnumerable<RoleDeletionDependency> dependencies) |
| | | 433 | | { |
| | 49 | 434 | | var payload = string.Join( |
| | 49 | 435 | | "\n", |
| | 115 | 436 | | dependencies.Select(x => $"{x.OwnerId}|{x.OwnerKey}|{x.PolicyBranch}|{x.Ownership}|{x.ConfigurationPath}|{x. |
| | 49 | 437 | | return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))).ToLowerInvariant(); |
| | | 438 | | } |
| | | 439 | | } |