* 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>
137 lines
10 KiB
C#
137 lines
10 KiB
C#
using Elsa.Common.Multitenancy;
|
|
using Elsa.Extensions;
|
|
using Elsa.ExternalAuthentication.Contracts;
|
|
using Elsa.ExternalAuthentication.Options;
|
|
using Elsa.ExternalAuthentication.Permissions;
|
|
using Elsa.ExternalAuthentication.Policies;
|
|
using Elsa.ExternalAuthentication.Providers;
|
|
using Elsa.ExternalAuthentication.Services;
|
|
using Elsa.ExternalAuthentication.Stores.InMemory;
|
|
using Elsa.ExternalAuthentication.Validation;
|
|
using Elsa.Identity.Contracts;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
|
using Microsoft.Extensions.DependencyInjection.Extensions;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Microsoft.Extensions.DependencyInjection;
|
|
|
|
public static class ServiceCollectionExtensions
|
|
{
|
|
/// <summary>Adds the explicit, non-readiness External Authentication health bridge.</summary>
|
|
public static IHealthChecksBuilder AddExternalAuthenticationHealthCheck(this IServiceCollection services, string name = "external-authentication", IEnumerable<string>? tags = null) =>
|
|
services.AddHealthChecks().AddCheck<ExternalAuthenticationHealthCheck>(name, HealthStatus.Degraded, tags ?? ["external-authentication", "optional"]);
|
|
|
|
/// <summary>
|
|
/// Adds the protocol-neutral External Authentication foundation and its single-node defaults.
|
|
/// Hosts requiring durable, multi-node state may replace the store registrations.
|
|
/// </summary>
|
|
public static IServiceCollection AddExternalAuthenticationServices(this IServiceCollection services, Action<ExternalAuthenticationOptions>? configureOptions = null)
|
|
{
|
|
var options = services.AddOptions<ExternalAuthenticationOptions>().ValidateOnStart();
|
|
if (configureOptions != null)
|
|
options.Configure(configureOptions);
|
|
|
|
// The module evaluates permissions outside endpoint authorization -- delegation, the grant boundary,
|
|
// and the recovery override -- so it depends on the evaluator whether or not a host wired one up.
|
|
// The call is TryAdd-based and idempotent, so a host that already registered one keeps it.
|
|
services.AddElsaAuthorization();
|
|
|
|
// Contributed explicitly rather than left to the host's assembly scan, so the module's resources reach
|
|
// the catalog on any host that registers its services, the same reason AddElsaAuthorization is called
|
|
// here. Registration is TryAddEnumerable-backed, so a host that also scans this assembly gets one copy.
|
|
services.AddPermissionDescriptors<ExternalAuthenticationResourcePermissionsDescriptorProvider>();
|
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, RejectUnlinkedIdentityPolicy.PolicyType);
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, CreateUserUnlinkedIdentityPolicy.PolicyType);
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, MatchExternalUserUnlinkedIdentityPolicy.PolicyType);
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, ElsaRolePermissionGrantSource.SourceType);
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, ClaimMappingPermissionGrantSource.SourceType);
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, GroupMappingPermissionGrantSource.SourceType);
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, ClaimPassThroughPermissionGrantSource.SourceType);
|
|
// The validator warns about grant-boundary configuration, and ValidateOnStart resolves it on any
|
|
// IOptions access, so a logger has to be resolvable even on a bare service collection. AddLogging is
|
|
// TryAdd-based, so a host that already configured logging keeps its own.
|
|
services.AddLogging();
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<ExternalAuthenticationOptions>, ExternalAuthenticationOptionsValidator>());
|
|
services.AddDataProtection();
|
|
services.AddRateLimiter(_ => { });
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<RateLimiterOptions>, ConfigureExternalAuthenticationRateLimiterOptions>());
|
|
|
|
// The module reads the ambient tenant outside the multitenancy feature -- connection scoping and the
|
|
// role-deletion contributor's tenant boundary -- so it depends on an accessor whether or not a host
|
|
// enabled multitenancy. TryAdd keeps a host's own registration.
|
|
services.TryAddSingleton<ITenantAccessor, DefaultTenantAccessor>();
|
|
|
|
services.TryAddSingleton<ConnectionRevisionCalculator>();
|
|
services.TryAddSingleton<FinalLoginPathGuard>();
|
|
services.TryAddSingleton<ExternalAuthenticationSecurityNotifier>();
|
|
services.TryAddScoped<ConnectionTestService>();
|
|
services.TryAddScoped<PreviewSignInService>();
|
|
services.TryAddSingleton<ExternalAuthenticationHealthCheck>();
|
|
services.TryAddSingleton<IOutboundDnsResolver, SystemOutboundDnsResolver>();
|
|
services.TryAddSingleton<OutboundDestinationValidator>();
|
|
services.TryAddSingleton<IValidatedAddressConnector, SocketValidatedAddressConnector>();
|
|
services.TryAddSingleton<ValidatedOutboundConnectionFactory>();
|
|
services.TryAddSingleton<IProviderHttpClientFactory, ProviderHttpClientFactory>();
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IIdentityProviderConnectionSource, ConfigurationIdentityProviderConnectionSource>());
|
|
services.TryAddSingleton<IIdentityProviderConnectionStore, InMemoryIdentityProviderConnectionStore>();
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IIdentityProviderConnectionSource, DatabaseIdentityProviderConnectionSource>());
|
|
services.TryAddSingleton<IIdentityProviderConnectionRegistry, DefaultIdentityProviderConnectionRegistry>();
|
|
services.TryAddSingleton<IIdentityProviderConnectionValidityAssessor, IdentityProviderConnectionValidityAssessor>();
|
|
services.TryAddSingleton<ExtensionDescriptorValidator>();
|
|
services.TryAddSingleton<IExternalAuthenticationAdapterRegistry, DefaultExternalAuthenticationAdapterRegistry>();
|
|
services.TryAddSingleton<IUnlinkedIdentityPolicyRegistry, DefaultUnlinkedIdentityPolicyRegistry>();
|
|
services.TryAddSingleton<IExternalUserMatcherRegistry, DefaultExternalUserMatcherRegistry>();
|
|
services.TryAddScoped<IPermissionGrantSourceRegistry, DefaultPermissionGrantSourceRegistry>();
|
|
services.TryAddSingleton<IAdapterSettingsMigrationService, AdapterSettingsMigrationService>();
|
|
|
|
services.TryAddSingleton<IExternalAuthenticationStateStore, InMemoryExternalAuthenticationStateStore>();
|
|
services.TryAddSingleton<IExternalAuthenticationHandleHasher, HmacExternalAuthenticationHandleHasher>();
|
|
services.TryAddSingleton<IAuthorizationGrantStore, InMemoryAuthorizationGrantStore>();
|
|
services.TryAddSingleton<IExternalAuthenticationSessionStore, InMemoryExternalAuthenticationSessionStore>();
|
|
services.TryAddSingleton<IPreviewResultStore, InMemoryPreviewResultStore>();
|
|
services.TryAddSingleton<IConnectionObservationStore, InMemoryConnectionObservationStore>();
|
|
services.TryAddSingleton<IConnectionRegistryVersionStore, InMemoryConnectionRegistryVersionStore>();
|
|
services.TryAddSingleton<InMemoryExternalIdentityProvisionerState>();
|
|
services.TryAddScoped<InMemoryExternalIdentityProvisioner>();
|
|
services.TryAddScoped<IExternalIdentityProvisioner>(serviceProvider => serviceProvider.GetRequiredService<InMemoryExternalIdentityProvisioner>());
|
|
services.TryAddScoped<IExternalIdentityLinkManagementStore>(serviceProvider => serviceProvider.GetRequiredService<InMemoryExternalIdentityProvisioner>());
|
|
services.TryAddScoped<ExternalIdentityLinkManagementService>();
|
|
services.TryAddScoped<IExternalIdentityResolver, DefaultExternalIdentityResolver>();
|
|
services.TryAddScoped<IPermissionGrantResolver, DefaultPermissionGrantResolver>();
|
|
services.TryAddScoped<IPermissionDelegationAuthorizer, DefaultPermissionDelegationAuthorizer>();
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretBindingResolver, ConfigurationSecretBindingResolver>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IUnlinkedIdentityPolicy, RejectUnlinkedIdentityPolicy>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IUnlinkedIdentityPolicy, CreateUserUnlinkedIdentityPolicy>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IUnlinkedIdentityPolicy, MatchExternalUserUnlinkedIdentityPolicy>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IPermissionGrantSource, ElsaRolePermissionGrantSource>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IPermissionGrantSource, ClaimMappingPermissionGrantSource>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IPermissionGrantSource, GroupMappingPermissionGrantSource>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IPermissionGrantSource, ClaimPassThroughPermissionGrantSource>());
|
|
services.TryAddScoped<IExternalAuthenticationTokenIssuer, DefaultExternalAuthenticationTokenIssuer>();
|
|
services.TryAddScoped<IExternalAuthenticationBroker, ExternalAuthenticationBroker>();
|
|
services.TryAddScoped<IdentityProviderConnectionManagementService>();
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IRoleDeletionDependencyContributor, ExternalAuthenticationRoleDeletionDependencyContributor>());
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IUserDeletionDependencyContributor, ExternalAuthenticationUserDeletionDependencyContributor>());
|
|
|
|
return services;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Registers the stable identifier of a trusted deployment-installed extension
|
|
/// for startup selection validation.
|
|
/// </summary>
|
|
public static IServiceCollection AddExternalAuthenticationExtension(
|
|
this IServiceCollection services,
|
|
ExternalAuthenticationExtensionKind kind,
|
|
string type)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(type);
|
|
services.Configure<ExternalAuthenticationExtensionOptions>(options =>
|
|
options.Registrations.Add(new(kind, type)));
|
|
return services;
|
|
}
|
|
}
|