fix(external-authentication): scope role-deletion impact to the role's tenant (#8036)
* 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>
2026-09-07 05:34:44 +00:00
|
|
|
using Elsa.Common.Multitenancy;
|
fix(external-auth)!: wildcard-aware permission grant boundary, and startup smoke tests for both hosts (#7985)
* fix(external-auth)!: match permission grant boundaries as patterns
The deployment allow/deny boundary and the delegation authorizer compared
permission strings with ordinal equality, so under the {resource}:{verb}
vocabulary they could not see wildcards. A deny list naming
'workflows/*:delete' did not deny 'workflows/definitions:delete', and a grant
of 'workflows/*:delete' outflanked a deny naming that leaf.
The bypass was reachable. ElsaRolePermissionGrantSource passes a role's
permissions to the boundary verbatim, survivors land in the issued token as
permission claims, and PermissionEvaluator does expand wildcards there. So an
ordinary role plus a deny list was enough, on every external sign-in, with no
privileged actor involved. Restoring the ordinal boundary under the new tests
fails seven of them.
Deny is now matched in both directions, allow one-directionally, both through
PermissionMatcher. A grant that is not a well-formed permission is dropped
with a warning rather than carried into a token it cannot authorize anything
in.
Five non-endpoint checks -- delegation, role-reference removal, unsafe
settings confirmation, the recovery override and the boundary itself -- also
still compared against the legacy ExternalAuthenticationPermissions
constants. Those carry two colons, so Permission.TryParse rejects them and no
principal can hold one, while the migration guide tells operators to replace
exactly those strings. All five now route through IPermissionEvaluator, and
the module registers AddElsaAuthorization itself instead of depending on host
ordering.
Non-core verbs move to ExternalAuthenticationVerbs, declared beside the
resources they apply to so a delegation check cannot spell one differently
from the endpoint it guards.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: apply IDE code cleanup to the diagnostics and identity modules
Redundant namespace qualifiers and usings removed, and primary-constructor
and record syntax applied, across Elsa.Diagnostics.ConsoleLogs,
Elsa.Diagnostics.StructuredLogs, Elsa.Expressions.JavaScript and
Elsa.Identity. Produced by a solution-wide IDE cleanup that ran alongside the
authorization work; separated from it so the permission changes can be
reviewed on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(hosts): boot both hosts and assert their gated routes challenge
This repo runs two parallel feature systems, the classic Features/ path and
the CShells ShellFeatures/ path, and every module has to register in both.
Nothing exercised either. The unit and integration suites construct services
directly, so a module registered in one path and not the other, or a service
missing from one container, passes every test and fails only when a host
starts. Three bugs in #7980 were found by running these two hosts by hand,
two of them shell-versus-classic divergences.
Each host is booted through WebApplicationFactory, running its real Program
with full feature registration, and asked for a handful of routes it is
expected to serve behind a permission. A 404 means the module was never
registered, a 5xx means the endpoint was found but its dependencies could not
be constructed, and a 200 means no gate ran; only 401 passes. All routes are
reported together, so a feature system that stops registering a group of
modules reads as one failure rather than a queue of identical ones.
Removing AddExternalAuthenticationServices from the shell feature -- the
divergence this is built to catch -- fails the shell host on all five of its
routes while the classic host stays green.
The assertions go through HTTP rather than the container on purpose. The
hosts have different topologies: the classic host's root provider holds
everything and registers 125 routes, while CShells gives each shell its own
provider and mounts routes per shell, leaving 6 in the root. A container or
route-table assertion would have to encode that difference and would break
whenever CShells changed internally. Behaviour at the edge is host-agnostic,
and it is what actually has to match.
Each host gains a namespaced entry-point marker because both already declare
a Program in the global namespace, which a test project referencing both
cannot tell apart.
Coverage is off for this project: it references both hosts, so every module
either pulls in would enter its denominator without adding real coverage, and
coverlet cannot instrument a graph that size. TreatAsLocalProperty keeps CI's
/p:CollectCoverage=true from overriding that.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: fail closed on an unparseable grant boundary
Two findings from review, both real.
The grant boundary parsed its allow and deny lists and silently dropped what
would not parse. An allow list of nothing but malformed entries therefore
reduced to an empty set, and an empty allow list means unrestricted -- so a
typo turned the boundary off entirely and let external grant sources put
permissions straight into issued tokens. The deny side had the mirror of it:
a malformed entry quietly stopped denying what it named.
A boundary that does not parse now admits nothing, and
ExternalAuthenticationOptionsValidator rejects the configuration at startup,
so the mistake reaches an operator rather than a token. Failing startup is
what makes the runtime behaviour safe to be strict about: it cannot be hit by
someone mid-edit, only by validation having been bypassed.
ConnectionEndpointSupport.HasPermission was a sixth ad-hoc permission check,
missed when the other five were converted. It compared claim values against
the legacy ExternalAuthenticationPermissions constants at four call sites --
policy management on create and update, session revocation, and unsafe
settings confirmation -- and those constants carry two colons, so nothing can
hold one once a deployment follows the migration guide. It now routes through
IPermissionEvaluator like the rest, resolved from the request with a fallback
to the shared evaluator, the same way EndpointSecurity does it.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(external-auth): filter permission patterns with Where
Addresses a review nit on ValidatePermissionPatterns. Behaviour is unchanged:
a null list still iterates nothing, only malformed entries are reported, and
the message text is identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: apply the grant boundary to role permissions too
Token issuance concatenated the user's Elsa role permissions raw alongside
the boundary-filtered external grants. A permission the boundary had just
excluded during grant resolution therefore reappeared in the issued token
from the same roles, which made the deny list unenforceable for anything a
role carried and left ElsaRolePermissionGrantSource filtering nothing that
was not added back a moment later. The bypass did not even need that grant
source configured: role permissions reached the token regardless of which
sources a connection selected.
Both origins now pass the same boundary. Re-applying it at issuance also
picks up a boundary that changed since sign-in, since refreshing reissues.
This is a behaviour change for deployments that configured a boundary
expecting it to bound only claim-mapped permissions: an external login may
now carry fewer permissions than before. Deployments with no boundary
configured, the default, are unaffected -- every well-formed permission
passes. The migration guide describes both directions.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 03:25:29 +00:00
|
|
|
using Elsa.Extensions;
|
2026-07-24 16:59:17 +00:00
|
|
|
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;
|
2026-07-25 00:35:56 +00:00
|
|
|
using Elsa.Identity.Contracts;
|
2026-07-24 16:59:17 +00:00
|
|
|
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);
|
|
|
|
|
|
fix(external-auth)!: wildcard-aware permission grant boundary, and startup smoke tests for both hosts (#7985)
* fix(external-auth)!: match permission grant boundaries as patterns
The deployment allow/deny boundary and the delegation authorizer compared
permission strings with ordinal equality, so under the {resource}:{verb}
vocabulary they could not see wildcards. A deny list naming
'workflows/*:delete' did not deny 'workflows/definitions:delete', and a grant
of 'workflows/*:delete' outflanked a deny naming that leaf.
The bypass was reachable. ElsaRolePermissionGrantSource passes a role's
permissions to the boundary verbatim, survivors land in the issued token as
permission claims, and PermissionEvaluator does expand wildcards there. So an
ordinary role plus a deny list was enough, on every external sign-in, with no
privileged actor involved. Restoring the ordinal boundary under the new tests
fails seven of them.
Deny is now matched in both directions, allow one-directionally, both through
PermissionMatcher. A grant that is not a well-formed permission is dropped
with a warning rather than carried into a token it cannot authorize anything
in.
Five non-endpoint checks -- delegation, role-reference removal, unsafe
settings confirmation, the recovery override and the boundary itself -- also
still compared against the legacy ExternalAuthenticationPermissions
constants. Those carry two colons, so Permission.TryParse rejects them and no
principal can hold one, while the migration guide tells operators to replace
exactly those strings. All five now route through IPermissionEvaluator, and
the module registers AddElsaAuthorization itself instead of depending on host
ordering.
Non-core verbs move to ExternalAuthenticationVerbs, declared beside the
resources they apply to so a delegation check cannot spell one differently
from the endpoint it guards.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: apply IDE code cleanup to the diagnostics and identity modules
Redundant namespace qualifiers and usings removed, and primary-constructor
and record syntax applied, across Elsa.Diagnostics.ConsoleLogs,
Elsa.Diagnostics.StructuredLogs, Elsa.Expressions.JavaScript and
Elsa.Identity. Produced by a solution-wide IDE cleanup that ran alongside the
authorization work; separated from it so the permission changes can be
reviewed on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(hosts): boot both hosts and assert their gated routes challenge
This repo runs two parallel feature systems, the classic Features/ path and
the CShells ShellFeatures/ path, and every module has to register in both.
Nothing exercised either. The unit and integration suites construct services
directly, so a module registered in one path and not the other, or a service
missing from one container, passes every test and fails only when a host
starts. Three bugs in #7980 were found by running these two hosts by hand,
two of them shell-versus-classic divergences.
Each host is booted through WebApplicationFactory, running its real Program
with full feature registration, and asked for a handful of routes it is
expected to serve behind a permission. A 404 means the module was never
registered, a 5xx means the endpoint was found but its dependencies could not
be constructed, and a 200 means no gate ran; only 401 passes. All routes are
reported together, so a feature system that stops registering a group of
modules reads as one failure rather than a queue of identical ones.
Removing AddExternalAuthenticationServices from the shell feature -- the
divergence this is built to catch -- fails the shell host on all five of its
routes while the classic host stays green.
The assertions go through HTTP rather than the container on purpose. The
hosts have different topologies: the classic host's root provider holds
everything and registers 125 routes, while CShells gives each shell its own
provider and mounts routes per shell, leaving 6 in the root. A container or
route-table assertion would have to encode that difference and would break
whenever CShells changed internally. Behaviour at the edge is host-agnostic,
and it is what actually has to match.
Each host gains a namespaced entry-point marker because both already declare
a Program in the global namespace, which a test project referencing both
cannot tell apart.
Coverage is off for this project: it references both hosts, so every module
either pulls in would enter its denominator without adding real coverage, and
coverlet cannot instrument a graph that size. TreatAsLocalProperty keeps CI's
/p:CollectCoverage=true from overriding that.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: fail closed on an unparseable grant boundary
Two findings from review, both real.
The grant boundary parsed its allow and deny lists and silently dropped what
would not parse. An allow list of nothing but malformed entries therefore
reduced to an empty set, and an empty allow list means unrestricted -- so a
typo turned the boundary off entirely and let external grant sources put
permissions straight into issued tokens. The deny side had the mirror of it:
a malformed entry quietly stopped denying what it named.
A boundary that does not parse now admits nothing, and
ExternalAuthenticationOptionsValidator rejects the configuration at startup,
so the mistake reaches an operator rather than a token. Failing startup is
what makes the runtime behaviour safe to be strict about: it cannot be hit by
someone mid-edit, only by validation having been bypassed.
ConnectionEndpointSupport.HasPermission was a sixth ad-hoc permission check,
missed when the other five were converted. It compared claim values against
the legacy ExternalAuthenticationPermissions constants at four call sites --
policy management on create and update, session revocation, and unsafe
settings confirmation -- and those constants carry two colons, so nothing can
hold one once a deployment follows the migration guide. It now routes through
IPermissionEvaluator like the rest, resolved from the request with a fallback
to the shared evaluator, the same way EndpointSecurity does it.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(external-auth): filter permission patterns with Where
Addresses a review nit on ValidatePermissionPatterns. Behaviour is unchanged:
a null list still iterates nothing, only malformed entries are reported, and
the message text is identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: apply the grant boundary to role permissions too
Token issuance concatenated the user's Elsa role permissions raw alongside
the boundary-filtered external grants. A permission the boundary had just
excluded during grant resolution therefore reappeared in the issued token
from the same roles, which made the deny list unenforceable for anything a
role carried and left ElsaRolePermissionGrantSource filtering nothing that
was not added back a moment later. The bypass did not even need that grant
source configured: role permissions reached the token regardless of which
sources a connection selected.
Both origins now pass the same boundary. Re-applying it at issuance also
picks up a boundary that changed since sign-in, since refreshing reissues.
This is a behaviour change for deployments that configured a boundary
expecting it to bound only claim-mapped permissions: an external login may
now carry fewer permissions than before. Deployments with no boundary
configured, the default, are unaffected -- every well-formed permission
passes. The migration guide describes both directions.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 03:25:29 +00:00
|
|
|
// 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();
|
|
|
|
|
|
refactor(auth)!: retire the legacy permission constants and duplicate descriptor types (#7987)
* refactor(auth)!: retire the legacy permission constants and duplicate descriptors
Completes the cutover started in #7980. Seven `<Module>Permissions` classes
holding `verb:resource` strings are removed: AIPermissions, ConsoleLogs,
Dashboard, ExternalAuthentication, OpenTelemetry, Secrets and StructuredLogs.
AIPermissions was not in #7982's list, which was written before the cutover
finished; it is dead by the same measure as the rest.
Removed rather than marked obsolete, which #7982 asked to be an explicit
decision. Every string these classes held carries two colons, so it does not
parse under the new grammar and authorizes nothing. Keeping them obsolete
would leave code that compiles, still reads as a permission check, and
silently grants no access -- a warning that is easy to suppress in front of a
runtime failure that is invisible. A compile error names the call site and
can be fixed against the migration guide's mapping table. Classes their own
modules still reference, WorkflowPermissions and IdentityPermissions among
them, are untouched.
External Authentication's parallel descriptor system is collapsed onto the
core types: its own PermissionDescriptor record, its IPermissionDescriptorProvider
and IPermissionDescriptorRegistry, and DefaultPermissionDescriptorRegistry.
That was not only tidiness. The module's registry was fed exclusively by its
legacy names, so after the cutover every well-formed grant failed the
`unknown_permission_descriptor` check and the warning fired constantly for
correct configuration. The resolver now consults the core catalog, which is
keyed by resource and lists the verbs each accepts, and a wildcard is treated
as advertised because it names a pattern rather than a resource to look up.
The descriptor endpoint serves the core catalog too: choosing what an
external mapping may confer means choosing from everything Elsa declares.
The module contributes its resource descriptors explicitly rather than
relying on the host's assembly scan, for the same reason it registers
AddElsaAuthorization itself.
The two naming tests now pin the new resource name instead of the legacy
string. The convention worth holding was always that the module is called
'diagnostics/console-logs', not that a retired constant kept its old value.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(client): match the permission descriptor client model to the catalog
Moving the descriptor endpoint onto the core catalog changed its shape from a
single permission string to a resource plus the verbs that resource accepts,
and the Refit client model kept the old one. It still deserialized and still
compiled, handing callers a blank Name and no way to reach the verbs -- the
data went missing without anything failing.
The client model now mirrors the served descriptor, and a contract test
compares the two property sets so the next divergence is a test failure
rather than an empty field. NonCoreVerbs is excluded: the server derives it
from SupportedVerbs, so a client holding the verbs can compute it.
Found by review, not by the suites: nothing here throws.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 04:04:32 +00:00
|
|
|
// 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>();
|
|
|
|
|
|
2026-07-24 16:59:17 +00:00
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, RejectUnlinkedIdentityPolicy.PolicyType);
|
|
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, CreateUserUnlinkedIdentityPolicy.PolicyType);
|
2026-07-25 00:35:56 +00:00
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.UnlinkedIdentityPolicy, MatchExternalUserUnlinkedIdentityPolicy.PolicyType);
|
2026-07-24 16:59:17 +00:00
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, ElsaRolePermissionGrantSource.SourceType);
|
|
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, ClaimMappingPermissionGrantSource.SourceType);
|
|
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, GroupMappingPermissionGrantSource.SourceType);
|
|
|
|
|
services.AddExternalAuthenticationExtension(ExternalAuthenticationExtensionKind.PermissionGrantSource, ClaimPassThroughPermissionGrantSource.SourceType);
|
2026-08-27 09:45:54 +00:00
|
|
|
// 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();
|
2026-07-24 16:59:17 +00:00
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IValidateOptions<ExternalAuthenticationOptions>, ExternalAuthenticationOptionsValidator>());
|
|
|
|
|
services.AddDataProtection();
|
|
|
|
|
services.AddRateLimiter(_ => { });
|
|
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<RateLimiterOptions>, ConfigureExternalAuthenticationRateLimiterOptions>());
|
|
|
|
|
|
fix(external-authentication): scope role-deletion impact to the role's tenant (#8036)
* 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>
2026-09-07 05:34:44 +00:00
|
|
|
// 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>();
|
|
|
|
|
|
2026-07-24 16:59:17 +00:00
|
|
|
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>();
|
2026-08-02 00:47:28 +00:00
|
|
|
services.TryAddSingleton<IIdentityProviderConnectionValidityAssessor, IdentityProviderConnectionValidityAssessor>();
|
2026-07-24 16:59:17 +00:00
|
|
|
services.TryAddSingleton<ExtensionDescriptorValidator>();
|
|
|
|
|
services.TryAddSingleton<IExternalAuthenticationAdapterRegistry, DefaultExternalAuthenticationAdapterRegistry>();
|
|
|
|
|
services.TryAddSingleton<IUnlinkedIdentityPolicyRegistry, DefaultUnlinkedIdentityPolicyRegistry>();
|
2026-07-25 00:35:56 +00:00
|
|
|
services.TryAddSingleton<IExternalUserMatcherRegistry, DefaultExternalUserMatcherRegistry>();
|
2026-07-24 16:59:17 +00:00
|
|
|
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>();
|
2026-07-25 00:35:56 +00:00
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISecretBindingResolver, ConfigurationSecretBindingResolver>());
|
2026-07-24 16:59:17 +00:00
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IUnlinkedIdentityPolicy, RejectUnlinkedIdentityPolicy>());
|
|
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IUnlinkedIdentityPolicy, CreateUserUnlinkedIdentityPolicy>());
|
2026-07-25 00:35:56 +00:00
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Singleton<IUnlinkedIdentityPolicy, MatchExternalUserUnlinkedIdentityPolicy>());
|
2026-07-24 16:59:17 +00:00
|
|
|
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>();
|
2026-07-25 00:35:56 +00:00
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IRoleDeletionDependencyContributor, ExternalAuthenticationRoleDeletionDependencyContributor>());
|
2026-08-02 01:35:39 +00:00
|
|
|
services.TryAddEnumerable(ServiceDescriptor.Scoped<IUserDeletionDependencyContributor, ExternalAuthenticationUserDeletionDependencyContributor>());
|
2026-07-24 16:59:17 +00:00
|
|
|
|
|
|
|
|
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 =>
|
fix(external-auth)!: wildcard-aware permission grant boundary, and startup smoke tests for both hosts (#7985)
* fix(external-auth)!: match permission grant boundaries as patterns
The deployment allow/deny boundary and the delegation authorizer compared
permission strings with ordinal equality, so under the {resource}:{verb}
vocabulary they could not see wildcards. A deny list naming
'workflows/*:delete' did not deny 'workflows/definitions:delete', and a grant
of 'workflows/*:delete' outflanked a deny naming that leaf.
The bypass was reachable. ElsaRolePermissionGrantSource passes a role's
permissions to the boundary verbatim, survivors land in the issued token as
permission claims, and PermissionEvaluator does expand wildcards there. So an
ordinary role plus a deny list was enough, on every external sign-in, with no
privileged actor involved. Restoring the ordinal boundary under the new tests
fails seven of them.
Deny is now matched in both directions, allow one-directionally, both through
PermissionMatcher. A grant that is not a well-formed permission is dropped
with a warning rather than carried into a token it cannot authorize anything
in.
Five non-endpoint checks -- delegation, role-reference removal, unsafe
settings confirmation, the recovery override and the boundary itself -- also
still compared against the legacy ExternalAuthenticationPermissions
constants. Those carry two colons, so Permission.TryParse rejects them and no
principal can hold one, while the migration guide tells operators to replace
exactly those strings. All five now route through IPermissionEvaluator, and
the module registers AddElsaAuthorization itself instead of depending on host
ordering.
Non-core verbs move to ExternalAuthenticationVerbs, declared beside the
resources they apply to so a delegation check cannot spell one differently
from the endpoint it guards.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style: apply IDE code cleanup to the diagnostics and identity modules
Redundant namespace qualifiers and usings removed, and primary-constructor
and record syntax applied, across Elsa.Diagnostics.ConsoleLogs,
Elsa.Diagnostics.StructuredLogs, Elsa.Expressions.JavaScript and
Elsa.Identity. Produced by a solution-wide IDE cleanup that ran alongside the
authorization work; separated from it so the permission changes can be
reviewed on their own.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(hosts): boot both hosts and assert their gated routes challenge
This repo runs two parallel feature systems, the classic Features/ path and
the CShells ShellFeatures/ path, and every module has to register in both.
Nothing exercised either. The unit and integration suites construct services
directly, so a module registered in one path and not the other, or a service
missing from one container, passes every test and fails only when a host
starts. Three bugs in #7980 were found by running these two hosts by hand,
two of them shell-versus-classic divergences.
Each host is booted through WebApplicationFactory, running its real Program
with full feature registration, and asked for a handful of routes it is
expected to serve behind a permission. A 404 means the module was never
registered, a 5xx means the endpoint was found but its dependencies could not
be constructed, and a 200 means no gate ran; only 401 passes. All routes are
reported together, so a feature system that stops registering a group of
modules reads as one failure rather than a queue of identical ones.
Removing AddExternalAuthenticationServices from the shell feature -- the
divergence this is built to catch -- fails the shell host on all five of its
routes while the classic host stays green.
The assertions go through HTTP rather than the container on purpose. The
hosts have different topologies: the classic host's root provider holds
everything and registers 125 routes, while CShells gives each shell its own
provider and mounts routes per shell, leaving 6 in the root. A container or
route-table assertion would have to encode that difference and would break
whenever CShells changed internally. Behaviour at the edge is host-agnostic,
and it is what actually has to match.
Each host gains a namespaced entry-point marker because both already declare
a Program in the global namespace, which a test project referencing both
cannot tell apart.
Coverage is off for this project: it references both hosts, so every module
either pulls in would enter its denominator without adding real coverage, and
coverlet cannot instrument a graph that size. TreatAsLocalProperty keeps CI's
/p:CollectCoverage=true from overriding that.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: fail closed on an unparseable grant boundary
Two findings from review, both real.
The grant boundary parsed its allow and deny lists and silently dropped what
would not parse. An allow list of nothing but malformed entries therefore
reduced to an empty set, and an empty allow list means unrestricted -- so a
typo turned the boundary off entirely and let external grant sources put
permissions straight into issued tokens. The deny side had the mirror of it:
a malformed entry quietly stopped denying what it named.
A boundary that does not parse now admits nothing, and
ExternalAuthenticationOptionsValidator rejects the configuration at startup,
so the mistake reaches an operator rather than a token. Failing startup is
what makes the runtime behaviour safe to be strict about: it cannot be hit by
someone mid-edit, only by validation having been bypassed.
ConnectionEndpointSupport.HasPermission was a sixth ad-hoc permission check,
missed when the other five were converted. It compared claim values against
the legacy ExternalAuthenticationPermissions constants at four call sites --
policy management on create and update, session revocation, and unsafe
settings confirmation -- and those constants carry two colons, so nothing can
hold one once a deployment follows the migration guide. It now routes through
IPermissionEvaluator like the rest, resolved from the request with a fallback
to the shared evaluator, the same way EndpointSecurity does it.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* style(external-auth): filter permission patterns with Where
Addresses a review nit on ValidatePermissionPatterns. Behaviour is unchanged:
a null list still iterates nothing, only malformed entries are reported, and
the message text is identical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth)!: apply the grant boundary to role permissions too
Token issuance concatenated the user's Elsa role permissions raw alongside
the boundary-filtered external grants. A permission the boundary had just
excluded during grant resolution therefore reappeared in the issued token
from the same roles, which made the deny list unenforceable for anything a
role carried and left ElsaRolePermissionGrantSource filtering nothing that
was not added back a moment later. The bypass did not even need that grant
source configured: role permissions reached the token regardless of which
sources a connection selected.
Both origins now pass the same boundary. Re-applying it at issuance also
picks up a boundary that changed since sign-in, since refreshing reissues.
This is a behaviour change for deployments that configured a boundary
expecting it to bound only claim-mapped permissions: an external login may
now carry fewer permissions than before. Deployments with no boundary
configured, the default, are unaffected -- every well-formed permission
passes. The migration guide describes both directions.
Refs #7982
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 03:25:29 +00:00
|
|
|
options.Registrations.Add(new(kind, type)));
|
2026-07-24 16:59:17 +00:00
|
|
|
return services;
|
|
|
|
|
}
|
|
|
|
|
}
|