* fix(external-authentication): scope role-deletion impact to the role's tenant ExternalAuthenticationRoleDeletionDependencyContributor scanned every stored connection with an empty ConnectionFilter and every configured connection regardless of its tenant, so a role ID that exists in two tenants could report another tenant's references as its own impact -- and a configuration entry owned by another tenant could block a role deletion outright. Remediation had the same reach: it loaded a dependency's connection by the caller-supplied owner ID without checking which tenant owned it. Impact, prevalidation and remediation now only see connections in the role's tenant context, which is the tenant active on ITenantAccessor while the role-deletion coordinator runs. Host-scoped connections stay in scope for every tenant, because the connection registry resolves the host scope for every signing-in tenant and the provisioner resolves a connection's default role IDs in the signing-in user's tenant, so a host connection naming a role ID really does reference that tenant's role. Configuration entries that leave the tenant blank are host-scoped for the same reason the configuration source materializes them there. A connection carrying another tenant's ID is out of scope in both directions, and a connection loaded for remediation that is not in the role's tenant is treated as absent, which fails the request rather than mutating it. The stored connections are fetched per applicable scope so another tenant's rows are never materialized, and both connection stores already honor ConnectionFilter.Scope; the durable store now has a test pinning that, since the tenant boundary rests on it. Refs #8013 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scan every tenant when deleting a tenant-agnostic role Role stores expose tenant-agnostic roles (TenantId == "*") from every tenant, but the role-deletion contributor derived its dependency scan boundary from the ambient tenant only, so deleting an agnostic role while tenant A was active left references from other tenants dangling. Resolve the role being deleted once per operation, through the active role store, and scan every connection and configuration entry regardless of tenant when it is agnostic. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(external-authentication): share the active role store lookup Extract the duplicated "active role store is the last registration" resolution into a single ActiveRoleStore accessor and rename ToScope to ToConnectionScope for clarity. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): read one connection snapshot and prefer the agnostic role Reading the host and tenant scopes as two separate store queries let a connection whose TenantId changed mid-flight fall between the reads and escape both, letting role deletion proceed while a reference remained. FindConnectionsInRoleTenantScopeAsync now reads one snapshot and filters it in memory. IsAgnosticRoleAsync resolved a role by an unqualified ID lookup, which could return the ambient tenant's role instead of an agnostic role sharing its ID, silently narrowing impact scanning and leaving JIT-policy references in other tenants dangling; it now checks every role sharing the ID and gives the agnostic scope deterministic precedence. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(external-authentication): correct the scope-filter test comment Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): fail closed when a role ID resolves to more than one role A same-ID collision between a tenant-scoped role and an agnostic role can only occur in MemoryRoleStore (durable persistence keys roles by ID alone). In that case the coordinator's own deletion target is already ambiguous, so widening or narrowing the scope by guessing is wrong in either direction; throw instead of picking a side. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): scope role-deletion impact by the resolved role's tenant Replace the isAgnosticRole flag with ResolveRoleTenantIdAsync, which returns the resolved role's own TenantId and falls back to the ambient tenant only when the role cannot be resolved. With multitenancy disabled the EF role store installs no tenant query filter and can resolve a tenant-owned role by ID regardless of the ambient tenant, so scoping by the ambient tenant alone left that role's connection references out of scan while the coordinator deleted it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require an agnostic replacement when remediating an agnostic role Authorization for a replacement role still resolves through the ambient tenant's role services, so a deletion initiated in tenant A could authorize a tenant-A-only replacement and then write it into tenant B's connection policy, where that role does not exist. When the deletion target is agnostic, require the replacement role to be agnostic too. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): require agnostic replacements for host connections and reject ambiguous ones Extend the agnostic-replacement requirement to host-scoped connections, since a host connection is served to every signing-in tenant and a tenant-scoped replacement would resolve in the authorizing tenant but fail to resolve in every other tenant it serves. Recheck the replacement at removal time through the same agnostic-role resolution used at validation, instead of trusting whichever same-ID role a plain FindAsync happens to return, so a replacement collision introduced between validation and mutation is rejected. Resolve IsAgnosticRoleAsync's candidate directly and return true only when exactly one matching role is agnostic, so an ambiguous replacement ID is reported as replacement_role_unavailable_or_unauthorized instead of escaping as an exception. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(external-authentication): keep host-connection replacements allowed for default-tenant roles Revert the host-scope replacement guard added for host-scoped connections. IdentityProviderConnectionManagementService forces every managed connection to host scope, and in a deployment without multitenancy roles are created scoped to the default tenant rather than agnostic, so requiring an agnostic replacement for host-scoped connections would make every replacement remediation impossible in the default deployment. The replacement guard applies only when the deletion target itself is agnostic, as before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
827 lines
46 KiB
C#
827 lines
46 KiB
C#
using Elsa.Authorization;
|
|
using Elsa.Testing.Shared.Multitenancy;
|
|
using System.Security.Claims;
|
|
using System.Text.Json;
|
|
using Elsa.Common.Models;
|
|
using Elsa.Common.Multitenancy;
|
|
using Elsa.Common.Services;
|
|
using Elsa.ExternalAuthentication.Contracts;
|
|
using Elsa.ExternalAuthentication.Models;
|
|
using Elsa.ExternalAuthentication.Options;
|
|
using Elsa.ExternalAuthentication.Permissions;
|
|
using Elsa.ExternalAuthentication.Policies;
|
|
using Elsa.ExternalAuthentication.Services;
|
|
using Elsa.ExternalAuthentication.Stores.InMemory;
|
|
using Elsa.Identity.Contracts;
|
|
using Elsa.Identity.Entities;
|
|
using Elsa.Identity.Models;
|
|
using Elsa.Identity.Providers;
|
|
using Elsa.Identity.Services;
|
|
using Elsa.Mediator.Contracts;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using NSubstitute;
|
|
|
|
namespace Elsa.ExternalAuthentication.UnitTests.Foundational;
|
|
|
|
public class ExternalAuthenticationRoleDeletionDependencyContributorTests
|
|
{
|
|
[Fact]
|
|
public async Task InspectIncludesConfigurationAndDatabaseReferencesAcrossLifecycleStates()
|
|
{
|
|
var configurationConnection = Connection(
|
|
"configured",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user", "other-role" } })));
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
MatchExternalUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { noMatchAction = "create-user", defaultRoleIds = new[] { "workflow-user" } })));
|
|
databaseConnection.ArchivedAt = DateTimeOffset.UtcNow;
|
|
var (contributor, store, _) = await CreateContributorAsync([configurationConnection], databaseConnection);
|
|
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
|
|
Assert.False(snapshot.SupportsAtomicRemoval);
|
|
var configuration = Assert.Single(snapshot.Dependencies, x => x.Ownership == RoleDeletionDependencyOwnership.Configuration);
|
|
Assert.Equal("ExternalAuthentication:Connections:0:UnlinkedPolicy:Settings:defaultRoleIds:0", configuration.ConfigurationPath);
|
|
Assert.False(configuration.RemovesLastDefaultRole);
|
|
var database = Assert.Single(snapshot.Dependencies, x => x.Ownership == RoleDeletionDependencyOwnership.Database);
|
|
Assert.Equal("matcher-no-match-create-user", database.PolicyBranch);
|
|
Assert.Equal(1, database.ExpectedRevision);
|
|
Assert.True(database.RemovesLastDefaultRole);
|
|
Assert.NotNull(await store.FindByIdAsync(databaseConnection.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConfigurationReferenceCannotBeRemediated()
|
|
{
|
|
var configurationConnection = Connection(
|
|
"configured",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var (contributor, _, _) = await CreateContributorAsync([configurationConnection], databaseConnection);
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var databaseDependencies = snapshot.Dependencies.Where(x => x.Ownership == RoleDeletionDependencyOwnership.Database).ToArray();
|
|
|
|
var result = await contributor.ValidateRemovalAsync(new(
|
|
"workflow-user",
|
|
Administrator(),
|
|
snapshot.Version,
|
|
databaseDependencies));
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Conflict>(result);
|
|
Assert.Equal(
|
|
["workflow-user"],
|
|
configurationConnection.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemovesRoleFromEditablePolicyAndAdvancesRegistryVersion()
|
|
{
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var (contributor, store, versions) = await CreateContributorAsync([], databaseConnection);
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var request = new RoleReferenceRemovalRequest(
|
|
"workflow-user",
|
|
Administrator(),
|
|
snapshot.Version,
|
|
snapshot.Dependencies);
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Valid>(await contributor.ValidateRemovalAsync(request));
|
|
var result = Assert.IsType<RoleReferenceRemovalResult.Success>(await contributor.RemoveEditableReferencesAsync(request));
|
|
|
|
Assert.Equal([databaseConnection.Id], result.ChangedOwnerIds);
|
|
var updated = Assert.IsType<IdentityProviderConnection>(await store.FindByIdAsync(databaseConnection.Id));
|
|
Assert.Empty(updated.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray());
|
|
Assert.Equal(2, updated.Revision);
|
|
Assert.True(await versions.GetVersionAsync() > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplacesFinalDefaultRoleWhenSelectedForRemediation()
|
|
{
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var (contributor, store, _) = await CreateContributorAsync([], [databaseConnection], [new Role { Id = "replacement-role", Name = "Replacement role", Permissions = [] }]);
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var request = new RoleReferenceRemovalRequest(
|
|
"workflow-user",
|
|
Administrator(),
|
|
snapshot.Version,
|
|
snapshot.Dependencies)
|
|
{
|
|
SelectedReferences = [new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, databaseConnection.Id)],
|
|
ReplacementRoleId = "replacement-role"
|
|
};
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Valid>(await contributor.ValidateRemovalAsync(request));
|
|
var result = Assert.IsType<RoleReferenceRemovalResult.Success>(await contributor.RemoveEditableReferencesAsync(request));
|
|
|
|
Assert.Equal([databaseConnection.Id], result.ChangedOwnerIds);
|
|
var updated = Assert.IsType<IdentityProviderConnection>(await store.FindByIdAsync(databaseConnection.Id));
|
|
Assert.Equal(["replacement-role"], updated.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task UsesTheActiveRoleStoreWhenPersistenceReplacesTheDefaultStore()
|
|
{
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var connectionStore = new InMemoryIdentityProviderConnectionStore();
|
|
Assert.IsType<ConnectionMutationResult.Created>(await connectionStore.CreateAsync(databaseConnection));
|
|
var replacedStore = new MemoryRoleStore(new MemoryStore<Role>(), TestTenantAccessor.Default);
|
|
var activeStore = new MemoryRoleStore(new MemoryStore<Role>(), TestTenantAccessor.Default);
|
|
await activeStore.SaveAsync(new Role { Id = "workflow-user", Name = "Workflow user", Permissions = [] });
|
|
await activeStore.SaveAsync(new Role { Id = "replacement-role", Name = "Replacement role", Permissions = [] });
|
|
var roleAuthorizationService = new RoleAuthorizationService(new StoreBasedRoleProvider(activeStore), new PermissionEvaluator());
|
|
var services = new ServiceCollection().BuildServiceProvider();
|
|
var contributor = new ExternalAuthenticationRoleDeletionDependencyContributor(
|
|
connectionStore,
|
|
new MutableOptionsMonitor<ExternalAuthenticationOptions>(new ExternalAuthenticationOptions()),
|
|
[roleAuthorizationService],
|
|
[replacedStore, activeStore],
|
|
new InMemoryConnectionRegistryVersionStore(),
|
|
new ConnectionRevisionCalculator(),
|
|
new ExternalAuthenticationSecurityNotifier(services),
|
|
new PermissionEvaluator(),
|
|
TestTenantAccessor.Default);
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var request = new RoleReferenceRemovalRequest(
|
|
"workflow-user",
|
|
Administrator(),
|
|
snapshot.Version,
|
|
snapshot.Dependencies)
|
|
{
|
|
SelectedReferences = [new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, databaseConnection.Id)],
|
|
ReplacementRoleId = "replacement-role"
|
|
};
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Valid>(await contributor.ValidateRemovalAsync(request));
|
|
Assert.IsType<RoleReferenceRemovalResult.Success>(await contributor.RemoveEditableReferencesAsync(request));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RejectsMissingReplacementForSelectedFinalDefaultRole()
|
|
{
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var (contributor, store, _) = await CreateContributorAsync([], databaseConnection);
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var request = new RoleReferenceRemovalRequest(
|
|
"workflow-user",
|
|
Administrator(),
|
|
snapshot.Version,
|
|
snapshot.Dependencies)
|
|
{
|
|
SelectedReferences = [new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, databaseConnection.Id)]
|
|
};
|
|
|
|
var result = await contributor.ValidateRemovalAsync(request);
|
|
|
|
var forbidden = Assert.IsType<RoleReferenceRemovalValidationResult.Forbidden>(result);
|
|
Assert.Equal("replacement_role_unavailable_or_unauthorized", forbidden.Code);
|
|
var current = Assert.IsType<IdentityProviderConnection>(await store.FindByIdAsync(databaseConnection.Id));
|
|
Assert.Equal(["workflow-user"], current.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplacementRemovedAfterCoordinatorValidationFailsClosedBeforePolicyUpdate()
|
|
{
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var connectionStore = new InMemoryIdentityProviderConnectionStore();
|
|
Assert.IsType<ConnectionMutationResult.Created>(await connectionStore.CreateAsync(databaseConnection));
|
|
|
|
var backingRoleStore = new MemoryRoleStore(new MemoryStore<Role>(), TestTenantAccessor.Default);
|
|
await backingRoleStore.SaveAsync(new Role { Id = "workflow-user", Name = "Workflow user", Permissions = [] });
|
|
await backingRoleStore.SaveAsync(new Role { Id = "replacement-role", Name = "Replacement role", Permissions = [] });
|
|
var roleStore = new RoleStoreThatRemovesReplacementAfterContributorValidation(backingRoleStore, "replacement-role");
|
|
var roleAuthorizationService = new RoleAuthorizationService(new StoreBasedRoleProvider(roleStore), new PermissionEvaluator());
|
|
var versions = new InMemoryConnectionRegistryVersionStore();
|
|
var services = new ServiceCollection().BuildServiceProvider();
|
|
var contributor = new ExternalAuthenticationRoleDeletionDependencyContributor(
|
|
connectionStore,
|
|
new MutableOptionsMonitor<ExternalAuthenticationOptions>(new ExternalAuthenticationOptions()),
|
|
[roleAuthorizationService],
|
|
[roleStore],
|
|
versions,
|
|
new ConnectionRevisionCalculator(),
|
|
new ExternalAuthenticationSecurityNotifier(services),
|
|
new PermissionEvaluator(),
|
|
TestTenantAccessor.Default);
|
|
var securityNotifier = new RoleSecurityNotifier(Substitute.For<INotificationSender>(), TestTenantAccessor.Default, new SystemClock());
|
|
var coordinator = new RoleDeletionCoordinator(roleStore, roleAuthorizationService, [contributor], securityNotifier);
|
|
var impact = Assert.IsType<RoleDeletionInspectionResult.Success>(await coordinator.InspectAsync("workflow-user", Administrator())).Impact;
|
|
|
|
var result = await coordinator.RemediateAndDeleteAsync(new RoleDeletionRemediationCommand(
|
|
"workflow-user",
|
|
Administrator(),
|
|
impact.DependencyVersion,
|
|
true,
|
|
true,
|
|
true)
|
|
{
|
|
SelectedReferences = [new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, databaseConnection.Id)],
|
|
ReplacementRoleId = "replacement-role"
|
|
});
|
|
|
|
var incomplete = Assert.IsType<RoleDeletionOperationResult.Incomplete>(result);
|
|
Assert.Equal("replacement_role_unavailable_or_unauthorized", incomplete.Code);
|
|
Assert.True(roleStore.ReplacementRemoved);
|
|
Assert.NotNull(await roleStore.FindAsync(new() { Id = "workflow-user" }));
|
|
var current = Assert.IsType<IdentityProviderConnection>(await connectionStore.FindByIdAsync(databaseConnection.Id));
|
|
Assert.Equal(["workflow-user"], current.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task StaleConnectionRevisionFailsPrevalidationWithoutMutation()
|
|
{
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user", "other-role" } })));
|
|
var (contributor, store, _) = await CreateContributorAsync([], databaseConnection);
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var changed = (await store.FindByIdAsync(databaseConnection.Id))!;
|
|
changed.DisplayName = "Changed concurrently";
|
|
Assert.IsType<ConnectionMutationResult.Updated>(await store.UpdateAsync(changed, changed.Revision));
|
|
|
|
var result = await contributor.ValidateRemovalAsync(new(
|
|
"workflow-user",
|
|
Administrator(),
|
|
snapshot.Version,
|
|
snapshot.Dependencies));
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Conflict>(result);
|
|
var current = (await store.FindByIdAsync(databaseConnection.Id))!;
|
|
Assert.Contains("workflow-user", current.UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(ConnectionsUpdate)]
|
|
[InlineData(PoliciesUpdate)]
|
|
[InlineData(DefaultRolesUpdate)]
|
|
public async Task PrevalidationRequiresEveryPolicyRemediationPermission(string omittedPermission)
|
|
{
|
|
var databaseConnection = Connection(
|
|
"database",
|
|
new PolicySelection(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds = new[] { "workflow-user" } })));
|
|
var (contributor, _, _) = await CreateContributorAsync([], databaseConnection);
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
|
|
var result = await contributor.ValidateRemovalAsync(new(
|
|
"workflow-user",
|
|
Administrator(omittedPermission),
|
|
snapshot.Version,
|
|
snapshot.Dependencies));
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Forbidden>(result);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImpactExcludesConnectionsOwnedByAnotherTenant()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("workflow-user"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("workflow-user"), TenantB);
|
|
var otherTenantConfiguration = Connection("other-tenant-configured", CreateUserPolicy("workflow-user"), TenantB);
|
|
var (contributor, _, _) = await CreateContributorAsync(
|
|
[otherTenantConfiguration],
|
|
[ownConnection, otherTenantConnection],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
|
|
// Neither tenant B's stored connection nor its configuration entry -- which would block deletion
|
|
// outright -- is reported, while the tenant's own reference still is, so the filter is not simply
|
|
// reporting nothing.
|
|
var dependency = Assert.Single(snapshot.Dependencies);
|
|
Assert.Equal(ownConnection.Id, dependency.OwnerId);
|
|
Assert.Equal(RoleDeletionDependencyOwnership.Database, dependency.Ownership);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImpactIncludesHostScopedConnectionsForEveryTenant()
|
|
{
|
|
var hostConnection = Connection("host-connection", CreateUserPolicy("workflow-user"));
|
|
var hostConfiguration = Connection("host-configured", CreateUserPolicy("workflow-user"), ConnectionScope.DefaultTenantId);
|
|
var (contributor, _, _) = await CreateContributorAsync(
|
|
[hostConfiguration],
|
|
[hostConnection],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
|
|
// A host connection is served to every signing-in tenant and its default roles are resolved in that
|
|
// tenant, so it references this tenant's role. A blank configuration tenant is materialized at host scope.
|
|
Assert.Equal(
|
|
[hostConfiguration.Id, hostConnection.Id],
|
|
snapshot.Dependencies.Select(x => x.OwnerId).Order(StringComparer.Ordinal).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemediationCannotReachAnotherTenantsConnection()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("workflow-user", "other-role"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("workflow-user", "other-role"), TenantB);
|
|
var (contributor, store, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection, otherTenantConnection],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var request = new RoleReferenceRemovalRequest(
|
|
"workflow-user",
|
|
Administrator(),
|
|
snapshot.Version,
|
|
[
|
|
..snapshot.Dependencies,
|
|
// The owner ID of another tenant's connection, as a caller could supply it.
|
|
new RoleDeletionDependency(
|
|
ExternalAuthenticationRoleDeletionDependencyContributor.SourceName,
|
|
otherTenantConnection.Id,
|
|
otherTenantConnection.Key,
|
|
"create-user",
|
|
RoleDeletionDependencyOwnership.Database,
|
|
null,
|
|
1,
|
|
false)
|
|
]);
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Conflict>(await contributor.ValidateRemovalAsync(request));
|
|
var result = Assert.IsType<RoleReferenceRemovalResult.Conflict>(await contributor.RemoveEditableReferencesAsync(request));
|
|
|
|
// Nothing may half-run: neither the foreign connection nor the tenant's own connection is touched.
|
|
Assert.Empty(result.ChangedOwnerIds);
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(otherTenantConnection.Id), "workflow-user", "other-role");
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(ownConnection.Id), "workflow-user", "other-role");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemediationStopsWhenTheConnectionLeavesTheRoleTenantAfterValidation()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("workflow-user", "other-role"), TenantA);
|
|
var (contributor, store, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection],
|
|
tenantAccessor: new TestTenantAccessor(TenantA),
|
|
decorateStore: inner => new ConnectionStoreThatMovesConnectionToAnotherTenant(inner, ownConnection.Id, TenantB, lookupsBeforeMove: 1));
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var request = new RoleReferenceRemovalRequest("workflow-user", Administrator(), snapshot.Version, snapshot.Dependencies);
|
|
|
|
var result = Assert.IsType<RoleReferenceRemovalResult.Conflict>(await contributor.RemoveEditableReferencesAsync(request));
|
|
|
|
Assert.Equal("connection_revision_changed", result.Code);
|
|
Assert.Empty(result.ChangedOwnerIds);
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(ownConnection.Id), "workflow-user", "other-role");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImpactForAnAgnosticRoleIncludesAConnectionOwnedByAnotherTenant()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("agnostic-role"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("agnostic-role"), TenantB);
|
|
var (contributor, _, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection, otherTenantConnection],
|
|
additionalRoles: [new Role { Id = "agnostic-role", Name = "Agnostic role", TenantId = Tenant.AgnosticTenantId, Permissions = [] }],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
|
|
var snapshot = await contributor.InspectAsync("agnostic-role");
|
|
|
|
// The role is visible from every tenant, so its tenant context is every tenant: tenant B's reference is
|
|
// reported alongside tenant A's, unlike a tenant-scoped role (see ImpactExcludesConnectionsOwnedByAnotherTenant).
|
|
Assert.Equal(
|
|
[otherTenantConnection.Id, ownConnection.Id],
|
|
snapshot.Dependencies.Select(x => x.OwnerId).Order(StringComparer.Ordinal).ToArray());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ImpactIsScopedByTheResolvedRolesTenantRatherThanTheAmbientTenant()
|
|
{
|
|
// The ambient tenant is the default tenant (no tenant pushed), which is what an EF host runs as with
|
|
// multitenancy disabled: the EF role store installs no tenant query filter there and can resolve a
|
|
// tenant-owned role by ID regardless of the ambient tenant, unlike MemoryRoleStore, which always
|
|
// filters by the ambient tenant itself. RoleStoreWithoutAmbientTenantFilter stands in for that EF
|
|
// behavior. The role being deleted belongs to tenant A, so impact must be scoped by that resolved
|
|
// tenant, not by the unrelated ambient one.
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("tenant-a-role"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("tenant-a-role"), TenantB);
|
|
var connectionStore = new InMemoryIdentityProviderConnectionStore();
|
|
Assert.IsType<ConnectionMutationResult.Created>(await connectionStore.CreateAsync(ownConnection));
|
|
Assert.IsType<ConnectionMutationResult.Created>(await connectionStore.CreateAsync(otherTenantConnection));
|
|
var roleStore = new RoleStoreWithoutAmbientTenantFilter(
|
|
[new Role { Id = "tenant-a-role", Name = "Tenant A role", TenantId = TenantA, Permissions = [] }]);
|
|
var roleAuthorizationService = new RoleAuthorizationService(new StoreBasedRoleProvider(roleStore), new PermissionEvaluator());
|
|
var services = new ServiceCollection().BuildServiceProvider();
|
|
var contributor = new ExternalAuthenticationRoleDeletionDependencyContributor(
|
|
connectionStore,
|
|
new MutableOptionsMonitor<ExternalAuthenticationOptions>(new ExternalAuthenticationOptions()),
|
|
[roleAuthorizationService],
|
|
[roleStore],
|
|
new InMemoryConnectionRegistryVersionStore(),
|
|
new ConnectionRevisionCalculator(),
|
|
new ExternalAuthenticationSecurityNotifier(services),
|
|
new PermissionEvaluator(),
|
|
TestTenantAccessor.Default);
|
|
|
|
var snapshot = await contributor.InspectAsync("tenant-a-role");
|
|
|
|
// Scoped by the role's own tenant (A): tenant A's connection is reported, tenant B's is not. Scoping by
|
|
// the ambient default tenant instead -- what this test is guarding against -- would report neither.
|
|
var dependency = Assert.Single(snapshot.Dependencies);
|
|
Assert.Equal(ownConnection.Id, dependency.OwnerId);
|
|
Assert.Equal(RoleDeletionDependencyOwnership.Database, dependency.Ownership);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RoleIdThatResolvesToMoreThanOneRoleAcrossTenantScopesFailsClosed()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("workflow-user"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("workflow-user"), TenantB);
|
|
var (contributor, _, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection, otherTenantConnection],
|
|
additionalRoles: [new Role { Id = "workflow-user", Name = "Agnostic workflow user", TenantId = Tenant.AgnosticTenantId, Permissions = [] }],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
|
|
// Tenant A's own "workflow-user" role and an agnostic role sharing that same ID both exist in the
|
|
// in-memory role store, so the deletion target is ambiguous: the contributor cannot determine whether
|
|
// to scope its inspection and remediation to tenant A alone or to every tenant, and must fail closed
|
|
// rather than guess in either direction.
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => contributor.InspectAsync("workflow-user").AsTask());
|
|
|
|
var request = new RoleReferenceRemovalRequest(
|
|
"workflow-user",
|
|
Administrator(),
|
|
"irrelevant-version",
|
|
[
|
|
new RoleDeletionDependency(
|
|
ExternalAuthenticationRoleDeletionDependencyContributor.SourceName,
|
|
ownConnection.Id,
|
|
ownConnection.Key,
|
|
"create-user",
|
|
RoleDeletionDependencyOwnership.Database,
|
|
null,
|
|
1,
|
|
false)
|
|
]);
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => contributor.ValidateRemovalAsync(request).AsTask());
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => contributor.RemoveEditableReferencesAsync(request).AsTask());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemediationOfAnAgnosticRoleCanRemoveTheReferenceFromAnotherTenantsConnection()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("agnostic-role"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("agnostic-role"), TenantB);
|
|
var (contributor, store, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection, otherTenantConnection],
|
|
additionalRoles: [new Role { Id = "agnostic-role", Name = "Agnostic role", TenantId = Tenant.AgnosticTenantId, Permissions = [] }],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
var snapshot = await contributor.InspectAsync("agnostic-role");
|
|
var request = new RoleReferenceRemovalRequest("agnostic-role", Administrator(), snapshot.Version, snapshot.Dependencies);
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Valid>(await contributor.ValidateRemovalAsync(request));
|
|
var result = Assert.IsType<RoleReferenceRemovalResult.Success>(await contributor.RemoveEditableReferencesAsync(request));
|
|
|
|
Assert.Equal(
|
|
[otherTenantConnection.Id, ownConnection.Id],
|
|
result.ChangedOwnerIds.Order(StringComparer.Ordinal).ToArray());
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(ownConnection.Id));
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(otherTenantConnection.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemediationOfAnAgnosticRoleRejectsATenantScopedReplacementRole()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("agnostic-role"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("agnostic-role"), TenantB);
|
|
var (contributor, store, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection, otherTenantConnection],
|
|
additionalRoles:
|
|
[
|
|
new Role { Id = "agnostic-role", Name = "Agnostic role", TenantId = Tenant.AgnosticTenantId, Permissions = [] },
|
|
new Role { Id = "tenant-a-replacement", Name = "Tenant A replacement", TenantId = TenantA, Permissions = [] }
|
|
],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
var snapshot = await contributor.InspectAsync("agnostic-role");
|
|
var request = new RoleReferenceRemovalRequest("agnostic-role", Administrator(), snapshot.Version, snapshot.Dependencies)
|
|
{
|
|
SelectedReferences = snapshot.Dependencies
|
|
.Select(x => new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, x.OwnerId))
|
|
.ToArray(),
|
|
ReplacementRoleId = "tenant-a-replacement"
|
|
};
|
|
|
|
// Remediation is initiated in tenant A and would resolve the replacement role through tenant A's role
|
|
// authorization service alone, even though tenant B's connection is also in scope for this agnostic
|
|
// role. Admitting a tenant-A-only replacement would write a role into tenant B's policy that does not
|
|
// exist there, so it must be rejected rather than authorized in one tenant and applied to every tenant.
|
|
var validation = await contributor.ValidateRemovalAsync(request);
|
|
var forbidden = Assert.IsType<RoleReferenceRemovalValidationResult.Forbidden>(validation);
|
|
Assert.Equal("replacement_role_unavailable_or_unauthorized", forbidden.Code);
|
|
|
|
var result = await contributor.RemoveEditableReferencesAsync(request);
|
|
var failed = Assert.IsType<RoleReferenceRemovalResult.Failed>(result);
|
|
Assert.Equal("replacement_role_unavailable_or_unauthorized", failed.Code);
|
|
Assert.Empty(failed.ChangedOwnerIds);
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(ownConnection.Id), "agnostic-role");
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(otherTenantConnection.Id), "agnostic-role");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemediationOfAnAgnosticRoleAcceptsAnAgnosticReplacementRole()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("agnostic-role"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("agnostic-role"), TenantB);
|
|
var (contributor, store, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection, otherTenantConnection],
|
|
additionalRoles:
|
|
[
|
|
new Role { Id = "agnostic-role", Name = "Agnostic role", TenantId = Tenant.AgnosticTenantId, Permissions = [] },
|
|
new Role { Id = "agnostic-replacement", Name = "Agnostic replacement", TenantId = Tenant.AgnosticTenantId, Permissions = [] }
|
|
],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
var snapshot = await contributor.InspectAsync("agnostic-role");
|
|
var request = new RoleReferenceRemovalRequest("agnostic-role", Administrator(), snapshot.Version, snapshot.Dependencies)
|
|
{
|
|
SelectedReferences = snapshot.Dependencies
|
|
.Select(x => new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, x.OwnerId))
|
|
.ToArray(),
|
|
ReplacementRoleId = "agnostic-replacement"
|
|
};
|
|
|
|
// An agnostic replacement exists identically in every tenant, so it is safe to write into tenant B's
|
|
// policy even though remediation was authorized through tenant A's role services.
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Valid>(await contributor.ValidateRemovalAsync(request));
|
|
var result = Assert.IsType<RoleReferenceRemovalResult.Success>(await contributor.RemoveEditableReferencesAsync(request));
|
|
|
|
Assert.Equal(
|
|
[otherTenantConnection.Id, ownConnection.Id],
|
|
result.ChangedOwnerIds.Order(StringComparer.Ordinal).ToArray());
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(ownConnection.Id), "agnostic-replacement");
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(otherTenantConnection.Id), "agnostic-replacement");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task RemediationRemovesTheRoleFromAHostScopedConnectionForATenant()
|
|
{
|
|
var hostConnection = Connection("host-connection", CreateUserPolicy("workflow-user", "other-role"));
|
|
var (contributor, store, _) = await CreateContributorAsync(
|
|
[],
|
|
[hostConnection],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
var snapshot = await contributor.InspectAsync("workflow-user");
|
|
var request = new RoleReferenceRemovalRequest("workflow-user", Administrator(), snapshot.Version, snapshot.Dependencies);
|
|
|
|
Assert.IsType<RoleReferenceRemovalValidationResult.Valid>(await contributor.ValidateRemovalAsync(request));
|
|
var result = Assert.IsType<RoleReferenceRemovalResult.Success>(await contributor.RemoveEditableReferencesAsync(request));
|
|
|
|
Assert.Equal([hostConnection.Id], result.ChangedOwnerIds);
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(hostConnection.Id), "other-role");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ReplacementRoleIdThatResolvesToBothATenantRoleAndAnAgnosticRoleIsRejectedRatherThanThrowing()
|
|
{
|
|
var ownConnection = Connection("own-connection", CreateUserPolicy("agnostic-role"), TenantA);
|
|
var otherTenantConnection = Connection("other-tenant-connection", CreateUserPolicy("agnostic-role"), TenantB);
|
|
var (contributor, store, _) = await CreateContributorAsync(
|
|
[],
|
|
[ownConnection, otherTenantConnection],
|
|
additionalRoles:
|
|
[
|
|
new Role { Id = "agnostic-role", Name = "Agnostic role", TenantId = Tenant.AgnosticTenantId, Permissions = [] },
|
|
new Role { Id = "ambiguous-replacement", Name = "Ambiguous replacement (tenant A)", TenantId = TenantA, Permissions = [] },
|
|
new Role { Id = "ambiguous-replacement", Name = "Ambiguous replacement (agnostic)", TenantId = Tenant.AgnosticTenantId, Permissions = [] }
|
|
],
|
|
tenantAccessor: new TestTenantAccessor(TenantA));
|
|
var snapshot = await contributor.InspectAsync("agnostic-role");
|
|
var request = new RoleReferenceRemovalRequest("agnostic-role", Administrator(), snapshot.Version, snapshot.Dependencies)
|
|
{
|
|
SelectedReferences = snapshot.Dependencies
|
|
.Select(x => new RoleDeletionReferenceSelection(ExternalAuthenticationRoleDeletionDependencyContributor.SourceName, x.OwnerId))
|
|
.ToArray(),
|
|
ReplacementRoleId = "ambiguous-replacement"
|
|
};
|
|
|
|
// The replacement ID resolves to two roles in the in-memory store (a tenant-A role and an agnostic role
|
|
// sharing the same ID), which is exactly the collision ResolveRoleTenantIdAsync fails closed on for a
|
|
// deletion target. A replacement candidate is not the coordinator's own deletion target, so this must be
|
|
// reported as an ordinary validation failure rather than escape as an exception.
|
|
var validation = await contributor.ValidateRemovalAsync(request);
|
|
var forbidden = Assert.IsType<RoleReferenceRemovalValidationResult.Forbidden>(validation);
|
|
Assert.Equal("replacement_role_unavailable_or_unauthorized", forbidden.Code);
|
|
|
|
var result = await contributor.RemoveEditableReferencesAsync(request);
|
|
var failed = Assert.IsType<RoleReferenceRemovalResult.Failed>(result);
|
|
Assert.Equal("replacement_role_unavailable_or_unauthorized", failed.Code);
|
|
Assert.Empty(failed.ChangedOwnerIds);
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(ownConnection.Id), "agnostic-role");
|
|
AssertDefaultRoleIds(await store.FindByIdAsync(otherTenantConnection.Id), "agnostic-role");
|
|
}
|
|
|
|
private static Task<(ExternalAuthenticationRoleDeletionDependencyContributor Contributor, InMemoryIdentityProviderConnectionStore Store, InMemoryConnectionRegistryVersionStore Versions)> CreateContributorAsync(
|
|
IReadOnlyCollection<IdentityProviderConnection> configuredConnections,
|
|
params IdentityProviderConnection[] databaseConnections) =>
|
|
CreateContributorAsync(configuredConnections, databaseConnections, null);
|
|
|
|
private static async Task<(ExternalAuthenticationRoleDeletionDependencyContributor Contributor, InMemoryIdentityProviderConnectionStore Store, InMemoryConnectionRegistryVersionStore Versions)> CreateContributorAsync(
|
|
IReadOnlyCollection<IdentityProviderConnection> configuredConnections,
|
|
IdentityProviderConnection[] databaseConnections,
|
|
IReadOnlyCollection<Role>? additionalRoles = null,
|
|
ITenantAccessor? tenantAccessor = null,
|
|
Func<InMemoryIdentityProviderConnectionStore, IIdentityProviderConnectionStore>? decorateStore = null)
|
|
{
|
|
var store = new InMemoryIdentityProviderConnectionStore();
|
|
foreach (var connection in databaseConnections)
|
|
Assert.IsType<ConnectionMutationResult.Created>(await store.CreateAsync(connection));
|
|
|
|
var accessor = tenantAccessor ?? TestTenantAccessor.Default;
|
|
var roleStore = new MemoryRoleStore(new MemoryStore<Role>(), accessor);
|
|
await roleStore.SaveAsync(new Role { Id = "workflow-user", Name = "Workflow user", TenantId = accessor.TenantId, Permissions = [] });
|
|
await roleStore.SaveAsync(new Role { Id = "other-role", Name = "Other role", TenantId = accessor.TenantId, Permissions = [] });
|
|
foreach (var role in additionalRoles ?? [])
|
|
await roleStore.SaveAsync(role);
|
|
var versions = new InMemoryConnectionRegistryVersionStore();
|
|
var services = new ServiceCollection().BuildServiceProvider();
|
|
var contributor = new ExternalAuthenticationRoleDeletionDependencyContributor(
|
|
decorateStore?.Invoke(store) ?? store,
|
|
new MutableOptionsMonitor<ExternalAuthenticationOptions>(new ExternalAuthenticationOptions { ConfigurationConnections = configuredConnections.ToList() }),
|
|
[new RoleAuthorizationService(new StoreBasedRoleProvider(roleStore), new PermissionEvaluator())],
|
|
[roleStore],
|
|
versions,
|
|
new ConnectionRevisionCalculator(),
|
|
new ExternalAuthenticationSecurityNotifier(services),
|
|
new PermissionEvaluator(),
|
|
accessor);
|
|
return (contributor, store, versions);
|
|
}
|
|
|
|
private static IdentityProviderConnection Connection(string id, PolicySelection policy, string? tenantId = null) => new()
|
|
{
|
|
Id = id,
|
|
TenantId = tenantId ?? ConnectionScope.HostTenantId,
|
|
Key = id,
|
|
AdapterType = "oidc",
|
|
AdapterSettingsVersion = 1,
|
|
DisplayName = id,
|
|
IsEnabled = false,
|
|
UnlinkedPolicy = policy,
|
|
MaterialRevision = "test",
|
|
CreatedAt = DateTimeOffset.UnixEpoch,
|
|
UpdatedAt = DateTimeOffset.UnixEpoch
|
|
};
|
|
|
|
private static PolicySelection CreateUserPolicy(params string[] defaultRoleIds) => new(
|
|
CreateUserUnlinkedIdentityPolicy.PolicyType,
|
|
1,
|
|
JsonSerializer.SerializeToElement(new { defaultRoleIds }));
|
|
|
|
private static void AssertDefaultRoleIds(IdentityProviderConnection? connection, params string[] expectedRoleIds) =>
|
|
Assert.Equal(
|
|
expectedRoleIds,
|
|
Assert.IsType<IdentityProviderConnection>(connection).UnlinkedPolicy!.Settings.GetProperty("defaultRoleIds").EnumerateArray().Select(x => x.GetString()!).ToArray());
|
|
|
|
private const string TenantA = "tenant-a";
|
|
private const string TenantB = "tenant-b";
|
|
private const string ConnectionsUpdate = $"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Update}";
|
|
private const string PoliciesUpdate = $"{ExternalAuthenticationResourcePermissions.Policies}:{CoreVerbs.Update}";
|
|
private const string DefaultRolesUpdate = $"{ExternalAuthenticationResourcePermissions.PolicyDefaultRoles}:{CoreVerbs.Update}";
|
|
|
|
private static ClaimsPrincipal Administrator(string? omittedPermission = null)
|
|
{
|
|
var permissions = new[]
|
|
{
|
|
"identity/roles:delete",
|
|
ConnectionsUpdate,
|
|
PoliciesUpdate,
|
|
DefaultRolesUpdate
|
|
};
|
|
return new ClaimsPrincipal(new ClaimsIdentity(
|
|
permissions
|
|
.Where(x => !string.Equals(x, omittedPermission, StringComparison.Ordinal))
|
|
.Select(x => new Claim(PermissionNames.ClaimType, x))));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reassigns a connection to another tenant once the contributor has read it, which puts the connection
|
|
/// outside the role's tenant context between prevalidation and the remediation write.
|
|
/// </summary>
|
|
private sealed class ConnectionStoreThatMovesConnectionToAnotherTenant(
|
|
InMemoryIdentityProviderConnectionStore inner,
|
|
string connectionId,
|
|
string tenantId,
|
|
int lookupsBeforeMove) : IIdentityProviderConnectionStore
|
|
{
|
|
private int _lookups;
|
|
|
|
public ValueTask<Page<IdentityProviderConnection>> FindAsync(ConnectionFilter filter, CancellationToken cancellationToken = default) =>
|
|
inner.FindAsync(filter, cancellationToken);
|
|
|
|
public async ValueTask<IdentityProviderConnection?> FindByIdAsync(string id, CancellationToken cancellationToken = default)
|
|
{
|
|
var connection = await inner.FindByIdAsync(id, cancellationToken);
|
|
if (connection is not null && string.Equals(id, connectionId, StringComparison.Ordinal) && Interlocked.Increment(ref _lookups) > lookupsBeforeMove)
|
|
connection.TenantId = tenantId;
|
|
return connection;
|
|
}
|
|
|
|
public ValueTask<ConnectionMutationResult> CreateAsync(IdentityProviderConnection connection, CancellationToken cancellationToken = default) =>
|
|
inner.CreateAsync(connection, cancellationToken);
|
|
|
|
public ValueTask<ConnectionMutationResult> UpdateAsync(IdentityProviderConnection connection, long expectedRevision, CancellationToken cancellationToken = default) =>
|
|
inner.UpdateAsync(connection, expectedRevision, cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves roles by ID alone, regardless of the ambient tenant, standing in for the EF Core role store
|
|
/// with multitenancy disabled: it installs no tenant query filter and can resolve a tenant-owned role by
|
|
/// ID no matter which tenant is ambient. <see cref="MemoryRoleStore"/> cannot exercise that scenario
|
|
/// because it always filters by the ambient tenant itself.
|
|
/// </summary>
|
|
private sealed class RoleStoreWithoutAmbientTenantFilter(IReadOnlyCollection<Role> roles) : IRoleStore
|
|
{
|
|
public Task AddAsync(Role role, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
public Task DeleteAsync(RoleFilter filter, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
public Task SaveAsync(Role role, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
public Task<Role?> FindAsync(RoleFilter filter, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(roles.FirstOrDefault(x => x.Id == filter.Id));
|
|
|
|
public Task<IEnumerable<Role>> FindManyAsync(RoleFilter filter, CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(roles.Where(x => x.Id == filter.Id));
|
|
}
|
|
|
|
private sealed class RoleStoreThatRemovesReplacementAfterContributorValidation(
|
|
MemoryRoleStore inner,
|
|
string replacementRoleId) : IRoleStore
|
|
{
|
|
private int _replacementChecks;
|
|
|
|
public bool ReplacementRemoved { get; private set; }
|
|
|
|
public Task AddAsync(Role role, CancellationToken cancellationToken = default) => inner.AddAsync(role, cancellationToken);
|
|
|
|
public Task DeleteAsync(RoleFilter filter, CancellationToken cancellationToken = default) => inner.DeleteAsync(filter, cancellationToken);
|
|
|
|
public Task SaveAsync(Role role, CancellationToken cancellationToken = default) => inner.SaveAsync(role, cancellationToken);
|
|
|
|
public Task<Role?> FindAsync(RoleFilter filter, CancellationToken cancellationToken = default) => inner.FindAsync(filter, cancellationToken);
|
|
|
|
public async Task<IEnumerable<Role>> FindManyAsync(RoleFilter filter, CancellationToken cancellationToken = default)
|
|
{
|
|
var roles = (await inner.FindManyAsync(filter, cancellationToken)).ToArray();
|
|
if (!ReplacementRemoved && filter.Ids?.Contains(replacementRoleId) == true && Interlocked.Increment(ref _replacementChecks) == 2)
|
|
{
|
|
await inner.DeleteAsync(new() { Id = replacementRoleId }, cancellationToken);
|
|
ReplacementRemoved = true;
|
|
}
|
|
|
|
return roles;
|
|
}
|
|
}
|
|
}
|