elsa-core/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Sessions/ExternalRefreshPermissionTests.cs

217 lines
12 KiB
C#
Raw Normal View History

2026-07-24 16:59:17 +00:00
using System.Security.Cryptography;
using Elsa.Common;
using Elsa.Common.Multitenancy;
2026-07-24 16:59:17 +00:00
using Elsa.ExternalAuthentication.Contracts;
using Elsa.ExternalAuthentication.IntegrationTests.Broker;
using Elsa.ExternalAuthentication.Models;
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.ExternalAuthentication.Options;
2026-07-24 16:59:17 +00:00
using Elsa.ExternalAuthentication.Services;
using Elsa.ExternalAuthentication.Stores.InMemory;
using Elsa.Identity.Contracts;
using Elsa.Identity.Entities;
using Elsa.Identity.Models;
using NSubstitute;
namespace Elsa.ExternalAuthentication.IntegrationTests.Sessions;
public class ExternalRefreshPermissionTests
{
[Fact]
public async Task BrokerSnapshotsResolvedExternalGrantsWithProvenance()
{
var identity = new ExternalIdentity("https://issuer.example", "subject-a", new Dictionary<string, IReadOnlyCollection<string>>());
var adapter = new BrokerSecurityTests.RecordingAdapter
{
AuthenticationResult = new ExternalAuthenticationResult(identity, new Dictionary<string, IReadOnlyCollection<string>> { ["groups"] = ["operators"] }, [])
};
var identityResolver = Substitute.For<IExternalIdentityResolver>();
identityResolver.ResolveAsync(Arg.Any<ExternalIdentityResolutionContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new ExternalIdentityResolution("user-a", false)));
var permissionResolver = Substitute.For<IPermissionGrantResolver>();
var expectedGrant = new PermissionGrant("workflows:manage", "group-mapping", "groups:operators");
permissionResolver.ResolveAsync(Arg.Any<PermissionGrantResolutionContext>(), Arg.Any<CancellationToken>())
.Returns(ValueTask.FromResult(new PermissionGrantResult([expectedGrant], [])));
var sessions = Substitute.For<IExternalAuthenticationSessionStore>();
ExternalAuthenticationSession? savedSession = null;
sessions.SaveAsync(Arg.Do<ExternalAuthenticationSession>(session => savedSession = session), Arg.Any<CancellationToken>())
.Returns(ValueTask.CompletedTask);
var broker = BrokerSecurityTests.CreateBroker(
adapter,
identityResolver: identityResolver,
permissionGrantResolver: permissionResolver,
sessionStore: sessions);
var request = new BrokerAuthorizationRequest(
"studio",
new Uri("https://studio.example/authentication/external/callback"),
"code",
"challenge",
"S256",
"/workflows",
"contoso");
await broker.InitiateExternalAsync(request, "tenant-a");
var result = await broker.CompleteCallbackAsync(
"contoso",
2026-07-24 16:59:17 +00:00
adapter.CorrelationState!,
new Dictionary<string, IReadOnlyCollection<string>> { ["state"] = [adapter.CorrelationState!] });
Assert.Null(result.Error);
Assert.NotNull(savedSession);
Assert.Equal([expectedGrant], savedSession.ExternalGrants);
await permissionResolver.Received(1).ResolveAsync(
Arg.Is<PermissionGrantResolutionContext>(context =>
context.TargetTenantId == "tenant-a" &&
context.UserId == "user-a" &&
context.Identity == identity &&
context.ProjectedClaims.ContainsKey("groups")),
Arg.Any<CancellationToken>());
}
[Fact]
public async Task RefreshRetainsBoundedExternalSnapshotAndReevaluatesCurrentElsaRoles()
{
var clock = new TestClock();
var sessionStore = new InMemoryExternalAuthenticationSessionStore(clock);
var connection = new IdentityProviderConnection
{
Id = "connection-a",
TenantId = "tenant-a",
Key = "contoso",
AdapterType = "openid-connect",
DisplayName = "Contoso",
IsEnabled = true,
MaterialRevision = "revision-a"
};
var effective = new EffectiveIdentityProviderConnection(
connection,
ConnectionSourceOwnership.Configuration,
new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"),
ConnectionValidity.Valid,
false,
"configuration");
var registry = Substitute.For<IIdentityProviderConnectionRegistry>();
registry.FindByKeyAsync("tenant-a", "contoso", Arg.Any<CancellationToken>())
2026-07-24 16:59:17 +00:00
.Returns(ValueTask.FromResult<EffectiveIdentityProviderConnection?>(effective));
var user = new User { Id = "user-a", Name = "alice", TenantId = "tenant-a", Roles = ["role-a"] };
var role = new Role { Id = "role-a", Name = "Operators", TenantId = "tenant-a", Permissions = ["*"] };
var tenantAccessor = new DefaultTenantAccessor();
2026-07-24 16:59:17 +00:00
var users = Substitute.For<IUserProvider>();
users.FindAsync(Arg.Any<UserFilter>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(tenantAccessor.TenantId == "tenant-a" ? user : null));
2026-07-24 16:59:17 +00:00
var roles = Substitute.For<IRoleProvider>();
roles.FindManyAsync(Arg.Any<RoleFilter>(), Arg.Any<CancellationToken>())
.Returns(_ => ValueTask.FromResult<IEnumerable<Role>>(tenantAccessor.TenantId == "tenant-a" ? [role] : []));
2026-07-24 16:59:17 +00:00
var issuanceContexts = new List<TokenIssuanceContext>();
var tokenService = Substitute.For<IElsaTokenService>();
tokenService.IssueAccessTokenAsync(
Arg.Do<TokenIssuanceContext>(context => issuanceContexts.Add(context)),
Arg.Any<CancellationToken>())
.Returns(_ => ValueTask.FromResult(new IssuedAccessToken($"access-{issuanceContexts.Count}", clock.UtcNow.AddHours(1))));
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
var issuer = new DefaultExternalAuthenticationTokenIssuer(sessionStore, registry, [], users, roles, tokenService, tenantAccessor, clock, Microsoft.Extensions.Options.Options.Create(new ExternalAuthenticationOptions()));
2026-07-24 16:59:17 +00:00
var externalGrant = new PermissionGrant("reports:view", "claim-mapping", "department:engineering");
var session = new ExternalAuthenticationSession
{
Id = "session-a",
AuthenticationClientId = "studio",
TenantId = "tenant-a",
UserId = "user-a",
ConnectionKey = "contoso",
2026-07-24 16:59:17 +00:00
ConnectionMaterialRevision = "revision-a",
SecretGenerationFingerprint = Convert.ToHexString(SHA256.HashData([])),
Issuer = "https://issuer.example",
SubjectHash = "subject-hash",
ExternalGrants = [externalGrant],
StartedAt = clock.UtcNow,
LastRefreshedAt = clock.UtcNow,
ExpiresAt = clock.UtcNow.AddHours(8),
RefreshExpiresAt = clock.UtcNow.AddHours(8)
};
using (tenantAccessor.PushContext(new Tenant { Id = "tenant-b", Name = "Tenant B" }))
{
var initial = await issuer.IssueAsync(session);
role.Permissions = ["workflows:manage"];
using var refreshToken = new SensitiveString(initial.RefreshToken);
await issuer.RefreshAsync("studio", refreshToken);
Assert.Equal("tenant-b", tenantAccessor.TenantId);
}
2026-07-24 16:59:17 +00:00
Assert.Equal(2, issuanceContexts.Count);
Assert.Equal(["*", "reports:view"], issuanceContexts[0].Permissions);
2026-07-24 16:59:17 +00:00
Assert.Equal(["workflows:manage", "reports:view"], issuanceContexts[1].Permissions);
Assert.DoesNotContain("*", issuanceContexts[1].Permissions);
2026-07-24 16:59:17 +00:00
Assert.Equal("session-a", issuanceContexts[1].ExternalAuthenticationSessionId);
}
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
[Fact]
public async Task DeploymentDenyBoundaryAppliesToRoleDerivedPermissionsAtIssuance()
{
// A role permission excluded by the boundary during grant resolution used to reappear here, because
// issuance concatenated the same roles' permissions raw. That made the deny list unenforceable for
// anything a role carried, whether or not the connection selected the elsa-roles grant source.
var options = new ExternalAuthenticationOptions();
options.PermissionGrants.DeniedPermissions = ["workflows/*:delete"];
var contexts = await IssueWithAsync(options, rolePermissions: ["workflows/definitions:delete", "workflows/definitions:view"]);
// The denied role permission is gone; the role's other permission and the undenied external grant stay.
Assert.DoesNotContain("workflows/definitions:delete", contexts.Single().Permissions);
Assert.Equal(["workflows/definitions:view", "reports:view"], contexts.Single().Permissions);
}
[Fact]
public async Task RolePermissionsAreUnaffectedWhenNoBoundaryIsConfigured()
{
// The default is both lists empty, and that must stay a no-op: an external login should not quietly
// hand back less than the user's roles grant just because issuance now consults the boundary.
var contexts = await IssueWithAsync(new ExternalAuthenticationOptions(), rolePermissions: ["workflows/definitions:delete", "*"]);
Assert.Equal(["workflows/definitions:delete", "*", "reports:view"], contexts.Single().Permissions);
}
private static async Task<IReadOnlyList<TokenIssuanceContext>> IssueWithAsync(ExternalAuthenticationOptions options, string[] rolePermissions)
{
var clock = new TestClock();
var sessionStore = new InMemoryExternalAuthenticationSessionStore(clock);
var connection = new IdentityProviderConnection
{
Id = "connection-a", TenantId = "tenant-a", Key = "contoso", AdapterType = "oidc",
AdapterSettingsVersion = 1, DisplayName = "Contoso", MaterialRevision = "revision-a"
};
var effective = new EffectiveIdentityProviderConnection(connection, ConnectionSourceOwnership.Configuration, new ConnectionScope(ConnectionScopeKind.Tenant, "tenant-a"), ConnectionValidity.Valid, false, "configuration");
var registry = Substitute.For<IIdentityProviderConnectionRegistry>();
registry.FindByKeyAsync("tenant-a", "contoso", Arg.Any<CancellationToken>()).Returns(ValueTask.FromResult<EffectiveIdentityProviderConnection?>(effective));
var user = new User { Id = "user-a", Name = "alice", TenantId = "tenant-a", Roles = ["role-a"] };
var role = new Role { Id = "role-a", Name = "Operators", TenantId = "tenant-a", Permissions = rolePermissions };
var tenantAccessor = new DefaultTenantAccessor();
var users = Substitute.For<IUserProvider>();
users.FindAsync(Arg.Any<UserFilter>(), Arg.Any<CancellationToken>()).Returns(_ => Task.FromResult<User?>(user));
var roles = Substitute.For<IRoleProvider>();
roles.FindManyAsync(Arg.Any<RoleFilter>(), Arg.Any<CancellationToken>()).Returns(_ => ValueTask.FromResult<IEnumerable<Role>>([role]));
var contexts = new List<TokenIssuanceContext>();
var tokenService = Substitute.For<IElsaTokenService>();
tokenService.IssueAccessTokenAsync(Arg.Do<TokenIssuanceContext>(contexts.Add), Arg.Any<CancellationToken>())
.Returns(_ => ValueTask.FromResult(new IssuedAccessToken($"access-{contexts.Count}", clock.UtcNow.AddHours(1))));
var issuer = new DefaultExternalAuthenticationTokenIssuer(sessionStore, registry, [], users, roles, tokenService, tenantAccessor, clock, Microsoft.Extensions.Options.Options.Create(options));
await issuer.IssueAsync(new ExternalAuthenticationSession
{
Id = "session-b", AuthenticationClientId = "studio", TenantId = "tenant-a", UserId = "user-a",
ConnectionKey = "contoso", ConnectionMaterialRevision = "revision-a",
SecretGenerationFingerprint = Convert.ToHexString(SHA256.HashData([])),
Issuer = "https://issuer.example", SubjectHash = "subject-hash",
ExternalGrants = [new PermissionGrant("reports:view", "claim-mapping", "department:engineering")],
StartedAt = clock.UtcNow, LastRefreshedAt = clock.UtcNow,
ExpiresAt = clock.UtcNow.AddHours(8), RefreshExpiresAt = clock.UtcNow.AddHours(8)
});
return contexts;
}
2026-07-24 16:59:17 +00:00
private sealed class TestClock : ISystemClock
{
public DateTimeOffset UtcNow => DateTimeOffset.Parse("2026-07-24T12:00:00Z");
}
}