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
|
|
|
using Elsa.Authorization;
|
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 System.Net;
|
|
|
|
|
using System.Net.Http.Json;
|
|
|
|
|
using System.Security.Claims;
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
using Elsa.Common;
|
|
|
|
|
using Elsa.Common.Multitenancy;
|
|
|
|
|
using Elsa.Common.Services;
|
|
|
|
|
using Elsa.ExternalAuthentication.Contracts;
|
|
|
|
|
using Elsa.ExternalAuthentication.Features;
|
|
|
|
|
using Elsa.ExternalAuthentication.Models;
|
|
|
|
|
using Elsa.ExternalAuthentication.Notifications;
|
|
|
|
|
using Elsa.ExternalAuthentication.Options;
|
|
|
|
|
using Elsa.ExternalAuthentication.Permissions;
|
|
|
|
|
using Elsa.ExternalAuthentication.Services;
|
|
|
|
|
using Elsa.ExternalAuthentication.Stores.InMemory;
|
|
|
|
|
using Elsa.Identity.Contracts;
|
|
|
|
|
using Elsa.Mediator.Contracts;
|
|
|
|
|
using FastEndpoints;
|
|
|
|
|
using Microsoft.AspNetCore.Builder;
|
|
|
|
|
using Microsoft.AspNetCore.TestHost;
|
|
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
2026-07-29 09:56:35 +00:00
|
|
|
using Microsoft.Extensions.Options;
|
2026-07-24 16:59:17 +00:00
|
|
|
using NSubstitute;
|
fix: stop two silent serialization and test-isolation traps (#7969)
* fix: stop two silent serialization and test-isolation traps
Two follow-ups from #7957.
ExternalAuthentication tests: the same process-global
EndpointSecurityOptions.SecurityIsEnabled race the shells API tests had,
across the six classes in that assembly that build an endpoint host —
five setting it to false and IdentityLinkAuthorizationTests to true.
Unlike the shells case these all call UseAuthorization(), so it does not
surface as a missing-middleware error: anonymous endpoints answer
401/403, and the authorization test's endpoints come back AllowAnonymous
and stop enforcing what it asserts. A module initializer cannot fix it
since the assembly genuinely needs both values, so the six now share one
collection with DisableParallelization. They are also the only six that
build a host, so nothing else can observe a leaked value.
Unaliased payloads: a payload whose type has no registered serialization
alias is written without a _type discriminator and read back as an
ExpandoObject whose keys carry the state serializer's camel-case naming
policy, so a consumer that published Status finds status. The
degradation is deliberate — the alias registry is an allow-list that
keeps arbitrary CLR type names out of deserialization — but it was
silent. It is now reported once per type, naming the type and both
lossless alternatives, and PublishEvent.Payload documents them. Measured
across the integration suite, only genuine user payload types reach this
path, so the warning does not fire for Elsa's own types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: check the log level before claiming the once-per-type warning slot
WarnAboutUnaliasedType claimed a type's single report via TryAdd before
LogWarning applied its level filter, so a type first serialized while
Warning was disabled spent its slot on a call that logged nothing and
then stayed silent forever, including after the level was raised at
runtime. Check IsEnabled first, so the slot is only consumed by a report
that is actually emitted.
The regression test needs the capture to be the only logging provider:
IsEnabled on the composite logger is an OR across providers, so the test
builder's own xunit provider would otherwise keep Warning enabled
regardless of what the test asked for.
Reported by Greptile on #7969.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:36:11 +00:00
|
|
|
using Elsa.ExternalAuthentication.IntegrationTests.Fixtures;
|
2026-07-24 16:59:17 +00:00
|
|
|
|
|
|
|
|
namespace Elsa.ExternalAuthentication.IntegrationTests.Connections;
|
|
|
|
|
|
fix: stop two silent serialization and test-isolation traps (#7969)
* fix: stop two silent serialization and test-isolation traps
Two follow-ups from #7957.
ExternalAuthentication tests: the same process-global
EndpointSecurityOptions.SecurityIsEnabled race the shells API tests had,
across the six classes in that assembly that build an endpoint host —
five setting it to false and IdentityLinkAuthorizationTests to true.
Unlike the shells case these all call UseAuthorization(), so it does not
surface as a missing-middleware error: anonymous endpoints answer
401/403, and the authorization test's endpoints come back AllowAnonymous
and stop enforcing what it asserts. A module initializer cannot fix it
since the assembly genuinely needs both values, so the six now share one
collection with DisableParallelization. They are also the only six that
build a host, so nothing else can observe a leaked value.
Unaliased payloads: a payload whose type has no registered serialization
alias is written without a _type discriminator and read back as an
ExpandoObject whose keys carry the state serializer's camel-case naming
policy, so a consumer that published Status finds status. The
degradation is deliberate — the alias registry is an allow-list that
keeps arbitrary CLR type names out of deserialization — but it was
silent. It is now reported once per type, naming the type and both
lossless alternatives, and PublishEvent.Payload documents them. Measured
across the integration suite, only genuine user payload types reach this
path, so the warning does not fire for Elsa's own types.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: check the log level before claiming the once-per-type warning slot
WarnAboutUnaliasedType claimed a type's single report via TryAdd before
LogWarning applied its level filter, so a type first serialized while
Warning was disabled spent its slot on a call that logged nothing and
then stayed silent forever, including after the level was raised at
runtime. Check IsEnabled first, so the slot is only consumed by a report
that is actually emitted.
The regression test needs the capture to be the only logging provider:
IsEnabled on the composite logger is an OR across providers, so the test
builder's own xunit provider would otherwise keep Warning enabled
regardless of what the test asked for.
Reported by Greptile on #7969.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:36:11 +00:00
|
|
|
[Collection(nameof(EndpointSecurityCollection))]
|
2026-07-24 16:59:17 +00:00
|
|
|
public class ConnectionManagementTests : IAsyncLifetime
|
|
|
|
|
{
|
|
|
|
|
private WebApplication? _app;
|
|
|
|
|
private HttpClient? _client;
|
|
|
|
|
private bool _wasSecurityEnabled;
|
|
|
|
|
private TestConnectionRegistry _registry = null!;
|
|
|
|
|
private InMemoryIdentityProviderConnectionStore _store = null!;
|
|
|
|
|
private InMemoryConnectionRegistryVersionStore _registryVersions = null!;
|
|
|
|
|
private InMemoryConnectionObservationStore _observations = null!;
|
|
|
|
|
private TestAdapterSettingsMigrationService _settingsMigrations = null!;
|
2026-07-25 01:48:50 +00:00
|
|
|
private TestAdapter _adapter = null!;
|
|
|
|
|
private TestRoleAuthorizationService _roleAuthorizationService = null!;
|
|
|
|
|
private TestManagedSecretBindingWriter _managedSecretWriter = null!;
|
|
|
|
|
private IExternalAuthenticationSessionStore _sessions = null!;
|
2026-07-24 16:59:17 +00:00
|
|
|
private INotificationSender _notifications = null!;
|
|
|
|
|
private bool _unsafePermissionGranted = true;
|
feat(external-auth)!: require a permission to author policy default roles (#7992)
* feat(external-auth)!: require a permission to author policy default roles
Setting the defaultRoleIds of an unlinked-identity policy was guarded only by
the subset rule -- you could not grant roles carrying permissions you did not
hold -- so any actor able to edit a connection could decide what auto-created
users receive. The permission named for that decision,
external-authentication/policies/default-roles:update, was enforced in one
place: removing policy references while deleting a role.
The asymmetry is what makes this look like a check that was never wired
rather than a deliberate carve-out. Its sibling, policies:update, is already
enforced on the write path at both the create and update sites, through the
same RequiresPolicyManagement condition that covers the very policy the roles
live inside.
Demonstrated rather than argued: with the guard stubbed out, a caller holding
only connections:create and policies:update creates a connection whose policy
assigns "workflow-user", and the response is 201. The subset rule does not
object, because it answers a different question -- it prevents escalation, not
delegation of the decision.
The two checks are now reported independently for that reason. The permission
asks whether this actor may decide default roles at all; the subset rule asks
whether these particular roles stay inside what they already hold. It applies
only when roles are actually being set, so clearing the list, or a policy that
assigns none, needs nothing extra.
Breaking for roles holding the legacy policies:manage but not roles:assign
that set default roles today. Anyone who held roles:assign already maps to the
new permission and is unaffected. Documented in the migration guide.
Closes #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): gate default roles on the set changing, not on it existing
Review reproduced the over-reach through the real endpoints: validation runs
on every update, on enabling a connection, and on read-only validate, so
keying the permission off default roles being present meant that once anyone
set them, an administrator without the permission could no longer edit an
unrelated field on that connection, enable it, or validate it.
The permission now applies when the set changes -- adding, removing, or
clearing all count as deciding what auto-created users receive; leaving a
stored set alone does not. Order is not treated as meaningful, so reordering
is not a change.
The test that was supposed to cover this asserted only that a message was
absent, which passes for any failure response and made it vacuous exactly
when it mattered: it passed with the over-reach still in place, because the
request was failing 405 on the wrong verb. It now uses PUT and asserts
success, and reverting the fix makes it fail with the 400 review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): treat abandoning a create-user policy as a role change
The permission check sat inside the create-user branch, so it only ran when
the candidate policy still created users. Switching a stored fallback to one
that does not -- 'reject', or match-user with a different noMatchAction --
skipped it entirely and dropped the policy's automatic role assignments
without the permission that governs them. Review reproduced it.
The effective default roles of a policy that does not create users are none,
so computing that first and comparing outside the branch makes abandonment a
change like any other. The subset rule stays inside the branch, because it
only has something to say about roles actually being assigned.
The new test expresses abandonment through noMatchAction rather than the
policy type, since the fixture's registry only knows match-user. Re-scoping
the check to create-user candidates makes it fail with OK instead of the
expected BadRequest, which is the bypass.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): take the default-role baseline from the registry
A configuration-owned connection has no database row, so comparing against
the store alone made its configured default roles look newly assigned on
every validation. Validation needs only connections:view, so a caller with
exactly that could not validate such a connection at all -- review
reproduced it.
The baseline now comes from the registry, which answers for both ownerships
and is the question actually being asked: what does this connection assign
today. The store remains a fallback for a record the registry does not know.
The new test gives the fixture's configuration connection an unlinked policy
with default roles and validates it as a view-only caller. Reverting to the
store-only baseline makes it fail with the permission error, which is the
symptom review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:31:44 +00:00
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Overrides the acting principal's permissions for one test.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The default is all-or-nothing, which cannot express "may manage policies but may not decide default
|
|
|
|
|
/// roles" -- the separation of duties #7977 is about. A test that needs that distinction sets this.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private string[]? _permissions;
|
2026-07-24 16:59:17 +00:00
|
|
|
private string _tenantId = "tenant-a";
|
|
|
|
|
|
|
|
|
|
public async Task InitializeAsync()
|
|
|
|
|
{
|
|
|
|
|
_wasSecurityEnabled = EndpointSecurityOptions.SecurityIsEnabled;
|
|
|
|
|
EndpointSecurityOptions.SecurityIsEnabled = false;
|
|
|
|
|
var builder = WebApplication.CreateSlimBuilder();
|
|
|
|
|
builder.WebHost.UseTestServer();
|
|
|
|
|
builder.Services.AddFastEndpoints(options =>
|
|
|
|
|
{
|
|
|
|
|
options.Assemblies = [typeof(ExternalAuthenticationFeature).Assembly];
|
|
|
|
|
options.Filter = endpoint => endpoint.Namespace == "Elsa.ExternalAuthentication.Endpoints.Connections";
|
|
|
|
|
});
|
|
|
|
|
builder.Services.AddAuthorization();
|
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
|
|
|
// This fixture wires the module's services by hand rather than through
|
|
|
|
|
// AddExternalAuthenticationServices, so it has to register the permission evaluator the way a host does.
|
|
|
|
|
builder.Services.AddElsaAuthorization();
|
2026-07-24 16:59:17 +00:00
|
|
|
builder.Services.Configure<ExternalAuthenticationOptions>(options =>
|
|
|
|
|
{
|
|
|
|
|
options.EnableDatabaseConnections = true;
|
|
|
|
|
options.AllowedAdapterTypes = [];
|
|
|
|
|
options.AllowedUnlinkedIdentityPolicyTypes = [];
|
2026-07-25 01:48:50 +00:00
|
|
|
options.AllowedExternalUserMatcherTypes = ["allowed-matcher"];
|
2026-07-24 16:59:17 +00:00
|
|
|
options.AllowedPermissionGrantSourceTypes = [];
|
2026-07-25 01:48:50 +00:00
|
|
|
options.UnlinkedIdentityPolicy.AllowDatabaseConnectionOverride = true;
|
2026-07-29 09:56:35 +00:00
|
|
|
options.FinalLoginPathGuard.IsEnabled = false;
|
2026-07-25 01:48:50 +00:00
|
|
|
options.Redirects.ExternalCallbackBaseUri = new Uri("https://elsa.example/elsa/api/");
|
2026-07-24 16:59:17 +00:00
|
|
|
});
|
|
|
|
|
_store = new InMemoryIdentityProviderConnectionStore();
|
|
|
|
|
_registryVersions = new InMemoryConnectionRegistryVersionStore();
|
|
|
|
|
_observations = new InMemoryConnectionObservationStore();
|
|
|
|
|
_registry = new TestConnectionRegistry(_store);
|
|
|
|
|
builder.Services.AddSingleton<IIdentityProviderConnectionStore>(_store);
|
|
|
|
|
builder.Services.AddSingleton<IIdentityProviderConnectionRegistry>(_registry);
|
2026-07-29 09:56:35 +00:00
|
|
|
builder.Services.AddSingleton<FinalLoginPathGuard>();
|
2026-07-24 16:59:17 +00:00
|
|
|
builder.Services.AddSingleton<IConnectionRegistryVersionStore>(_registryVersions);
|
|
|
|
|
builder.Services.AddSingleton<IConnectionObservationStore>(_observations);
|
|
|
|
|
builder.Services.AddSingleton<ConnectionRevisionCalculator>();
|
2026-07-25 01:48:50 +00:00
|
|
|
_adapter = new TestAdapter();
|
|
|
|
|
builder.Services.AddSingleton<IExternalAuthenticationAdapterRegistry>(new TestAdapterRegistry(_adapter));
|
2026-07-24 16:59:17 +00:00
|
|
|
_settingsMigrations = new TestAdapterSettingsMigrationService();
|
|
|
|
|
builder.Services.AddSingleton<IAdapterSettingsMigrationService>(_settingsMigrations);
|
2026-08-02 00:47:28 +00:00
|
|
|
builder.Services.AddSingleton<IIdentityProviderConnectionValidityAssessor, IdentityProviderConnectionValidityAssessor>();
|
2026-07-25 01:48:50 +00:00
|
|
|
builder.Services.AddSingleton<IUnlinkedIdentityPolicyRegistry>(new TestUnlinkedIdentityPolicyRegistry());
|
|
|
|
|
builder.Services.AddSingleton<IExternalUserMatcherRegistry>(new TestExternalUserMatcherRegistry("allowed-matcher", "disallowed-matcher"));
|
2026-07-24 16:59:17 +00:00
|
|
|
builder.Services.AddScoped(_ => Substitute.For<IPermissionGrantSourceRegistry>());
|
|
|
|
|
builder.Services.AddSingleton<IPermissionDelegationAuthorizer>(Substitute.For<IPermissionDelegationAuthorizer>());
|
2026-07-25 01:48:50 +00:00
|
|
|
_roleAuthorizationService = new TestRoleAuthorizationService();
|
|
|
|
|
builder.Services.AddSingleton<IRoleAuthorizationService>(_roleAuthorizationService);
|
2026-07-24 16:59:17 +00:00
|
|
|
_notifications = Substitute.For<INotificationSender>();
|
|
|
|
|
builder.Services.AddSingleton(_notifications);
|
|
|
|
|
builder.Services.AddSingleton<ISystemClock, SystemClock>();
|
2026-07-25 01:48:50 +00:00
|
|
|
_sessions = Substitute.For<IExternalAuthenticationSessionStore>();
|
|
|
|
|
builder.Services.AddSingleton(_sessions);
|
|
|
|
|
_managedSecretWriter = new TestManagedSecretBindingWriter();
|
|
|
|
|
builder.Services.AddSingleton<IManagedSecretBindingWriter>(_managedSecretWriter);
|
2026-07-29 09:56:35 +00:00
|
|
|
builder.Services.AddSingleton<ISecretBindingResolver>(new TestSecretBindingResolver());
|
2026-07-24 16:59:17 +00:00
|
|
|
var tenant = Substitute.For<ITenantAccessor>();
|
|
|
|
|
tenant.TenantId.Returns(_ => _tenantId);
|
|
|
|
|
builder.Services.AddSingleton(tenant);
|
|
|
|
|
builder.Services.AddScoped<IdentityProviderConnectionManagementService>();
|
|
|
|
|
_app = builder.Build();
|
|
|
|
|
_app.Use(async (context, next) =>
|
|
|
|
|
{
|
feat(external-auth)!: require a permission to author policy default roles (#7992)
* feat(external-auth)!: require a permission to author policy default roles
Setting the defaultRoleIds of an unlinked-identity policy was guarded only by
the subset rule -- you could not grant roles carrying permissions you did not
hold -- so any actor able to edit a connection could decide what auto-created
users receive. The permission named for that decision,
external-authentication/policies/default-roles:update, was enforced in one
place: removing policy references while deleting a role.
The asymmetry is what makes this look like a check that was never wired
rather than a deliberate carve-out. Its sibling, policies:update, is already
enforced on the write path at both the create and update sites, through the
same RequiresPolicyManagement condition that covers the very policy the roles
live inside.
Demonstrated rather than argued: with the guard stubbed out, a caller holding
only connections:create and policies:update creates a connection whose policy
assigns "workflow-user", and the response is 201. The subset rule does not
object, because it answers a different question -- it prevents escalation, not
delegation of the decision.
The two checks are now reported independently for that reason. The permission
asks whether this actor may decide default roles at all; the subset rule asks
whether these particular roles stay inside what they already hold. It applies
only when roles are actually being set, so clearing the list, or a policy that
assigns none, needs nothing extra.
Breaking for roles holding the legacy policies:manage but not roles:assign
that set default roles today. Anyone who held roles:assign already maps to the
new permission and is unaffected. Documented in the migration guide.
Closes #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): gate default roles on the set changing, not on it existing
Review reproduced the over-reach through the real endpoints: validation runs
on every update, on enabling a connection, and on read-only validate, so
keying the permission off default roles being present meant that once anyone
set them, an administrator without the permission could no longer edit an
unrelated field on that connection, enable it, or validate it.
The permission now applies when the set changes -- adding, removing, or
clearing all count as deciding what auto-created users receive; leaving a
stored set alone does not. Order is not treated as meaningful, so reordering
is not a change.
The test that was supposed to cover this asserted only that a message was
absent, which passes for any failure response and made it vacuous exactly
when it mattered: it passed with the over-reach still in place, because the
request was failing 405 on the wrong verb. It now uses PUT and asserts
success, and reverting the fix makes it fail with the 400 review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): treat abandoning a create-user policy as a role change
The permission check sat inside the create-user branch, so it only ran when
the candidate policy still created users. Switching a stored fallback to one
that does not -- 'reject', or match-user with a different noMatchAction --
skipped it entirely and dropped the policy's automatic role assignments
without the permission that governs them. Review reproduced it.
The effective default roles of a policy that does not create users are none,
so computing that first and comparing outside the branch makes abandonment a
change like any other. The subset rule stays inside the branch, because it
only has something to say about roles actually being assigned.
The new test expresses abandonment through noMatchAction rather than the
policy type, since the fixture's registry only knows match-user. Re-scoping
the check to create-user candidates makes it fail with OK instead of the
expected BadRequest, which is the bypass.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): take the default-role baseline from the registry
A configuration-owned connection has no database row, so comparing against
the store alone made its configured default roles look newly assigned on
every validation. Validation needs only connections:view, so a caller with
exactly that could not validate such a connection at all -- review
reproduced it.
The baseline now comes from the registry, which answers for both ownerships
and is the question actually being asked: what does this connection assign
today. The store remains a fallback for a record the registry does not know.
The new test gives the fixture's configuration connection an unlinked policy
with default roles and validates it as a view-only caller. Reverting to the
store-only baseline makes it fail with the permission error, which is the
symptom review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:31:44 +00:00
|
|
|
var granted = _permissions ?? [_unsafePermissionGranted ? PermissionNames.All : $"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Update}"];
|
|
|
|
|
context.User = new ClaimsPrincipal(new ClaimsIdentity(granted.Select(x => new Claim(PermissionNames.ClaimType, x)), "test"));
|
2026-07-24 16:59:17 +00:00
|
|
|
await next(context);
|
|
|
|
|
});
|
|
|
|
|
_app.UseAuthorization();
|
|
|
|
|
_app.UseFastEndpoints();
|
|
|
|
|
await _app.StartAsync();
|
|
|
|
|
_client = _app.GetTestClient();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task DisposeAsync()
|
|
|
|
|
{
|
|
|
|
|
EndpointSecurityOptions.SecurityIsEnabled = _wasSecurityEnabled;
|
|
|
|
|
_client?.Dispose();
|
|
|
|
|
if (_app is not null)
|
|
|
|
|
{
|
|
|
|
|
await _app.StopAsync();
|
|
|
|
|
await _app.DisposeAsync();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task DatabaseConnectionLifecycleUsesEtagsAndPreservesItsIdentity()
|
|
|
|
|
{
|
|
|
|
|
var create = await _client!.PostAsJsonAsync("/external-authentication/connections", CreateRequest("contoso"));
|
|
|
|
|
var created = await create.Content.ReadFromJsonAsync<ConnectionDocument>();
|
|
|
|
|
|
|
|
|
|
Assert.True(create.StatusCode == HttpStatusCode.Created, await create.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.Equal("\"1\"", create.Headers.ETag?.Tag);
|
|
|
|
|
var createdDocument = Assert.IsType<ConnectionDocument>(created);
|
2026-07-25 01:48:50 +00:00
|
|
|
Assert.Equal("https://elsa.example/elsa/api/external-authentication/callback/contoso", createdDocument.CallbackUri);
|
|
|
|
|
Assert.Equal($"https://elsa.example/elsa/api/external-authentication/previews/callback/{createdDocument.Id}", createdDocument.PreviewCallbackUri);
|
2026-07-24 16:59:17 +00:00
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
var immutableKeyUpdate = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{createdDocument.Id}") { Content = JsonContent.Create(CreateRequest("contoso-renamed", displayName: "Updated")) };
|
|
|
|
|
immutableKeyUpdate.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
var immutableKeyResponse = await _client!.SendAsync(immutableKeyUpdate);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, immutableKeyResponse.StatusCode);
|
2026-07-29 18:01:16 +00:00
|
|
|
var immutableKeyContent = await immutableKeyResponse.Content.ReadAsStringAsync();
|
|
|
|
|
Assert.Contains("connection_key_immutable", immutableKeyContent);
|
|
|
|
|
using (var errorDocument = JsonDocument.Parse(immutableKeyContent))
|
|
|
|
|
{
|
|
|
|
|
var correlationId = errorDocument.RootElement.GetProperty("correlationId").GetString();
|
|
|
|
|
Assert.Matches("^[A-Za-z0-9_-]{1,128}$", correlationId);
|
|
|
|
|
}
|
2026-07-25 01:48:50 +00:00
|
|
|
|
|
|
|
|
var update = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{createdDocument.Id}") { Content = JsonContent.Create(CreateRequest("contoso", displayName: "Updated")) };
|
2026-07-24 16:59:17 +00:00
|
|
|
update.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
var updated = await _client!.SendAsync(update);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, updated.StatusCode);
|
|
|
|
|
Assert.Equal("\"2\"", updated.Headers.ETag?.Tag);
|
|
|
|
|
|
2026-07-29 18:01:16 +00:00
|
|
|
var validate = await _client.PostAsync($"/external-authentication/connections/{createdDocument.Id}/validate", null);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, validate.StatusCode);
|
|
|
|
|
Assert.Contains("\"valid\":true", await validate.Content.ReadAsStringAsync());
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
var stale = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{createdDocument.Id}") { Content = JsonContent.Create(CreateRequest("contoso", displayName: "Stale")) };
|
2026-07-24 16:59:17 +00:00
|
|
|
stale.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.PreconditionFailed, (await _client.SendAsync(stale)).StatusCode);
|
|
|
|
|
|
|
|
|
|
var enable = new HttpRequestMessage(HttpMethod.Post, $"/external-authentication/connections/{createdDocument.Id}/enable");
|
|
|
|
|
enable.Headers.TryAddWithoutValidation("If-Match", "\"2\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await _client.SendAsync(enable)).StatusCode);
|
|
|
|
|
|
|
|
|
|
var archive = new HttpRequestMessage(HttpMethod.Delete, $"/external-authentication/connections/{createdDocument.Id}");
|
|
|
|
|
archive.Headers.TryAddWithoutValidation("If-Match", "\"3\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await _client.SendAsync(archive)).StatusCode);
|
|
|
|
|
|
|
|
|
|
var restore = new HttpRequestMessage(HttpMethod.Post, $"/external-authentication/connections/{createdDocument.Id}/restore");
|
|
|
|
|
restore.Headers.TryAddWithoutValidation("If-Match", "\"4\"");
|
|
|
|
|
var restored = await _client.SendAsync(restore);
|
|
|
|
|
var restoredDocument = await restored.Content.ReadFromJsonAsync<ConnectionDocument>();
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, restored.StatusCode);
|
|
|
|
|
var restoredConnection = Assert.IsType<ConnectionDocument>(restoredDocument);
|
|
|
|
|
Assert.Equal(createdDocument.Id, restoredConnection.Id);
|
|
|
|
|
Assert.False(restoredConnection.EnabledIntent);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 23:43:30 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task ValidateRequiresCompleteConfigurationAndReturnsMissingSecretDetails()
|
|
|
|
|
{
|
|
|
|
|
_adapter.RequiresClientSecret = true;
|
|
|
|
|
var create = await _client!.PostAsJsonAsync("/external-authentication/connections", CreateRequest("missing-secret"));
|
|
|
|
|
var connection = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
|
|
|
|
|
var validate = await _client!.PostAsync($"/external-authentication/connections/{connection.Id}/validate", null);
|
|
|
|
|
var validation = JsonDocument.Parse(await validate.Content.ReadAsStringAsync()).RootElement;
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, validate.StatusCode);
|
|
|
|
|
Assert.False(validation.GetProperty("valid").GetBoolean());
|
|
|
|
|
var error = Assert.Single(validation.GetProperty("errors").EnumerateArray());
|
|
|
|
|
Assert.Equal("secretBindings.clientSecret", error.GetProperty("field").GetString());
|
|
|
|
|
Assert.Equal("required", error.GetProperty("code").GetString());
|
|
|
|
|
Assert.Equal("A required secret binding is missing.", error.GetProperty("message").GetString());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 08:07:29 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task ConnectionResponseEmitsCanonicalUpstreamLogoutModeString()
|
|
|
|
|
{
|
|
|
|
|
var response = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("user-choice-logout", upstreamLogoutMode: "user-choice"));
|
|
|
|
|
var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
|
|
|
|
Assert.Equal(JsonValueKind.String, body.RootElement.GetProperty("upstreamLogoutMode").ValueKind);
|
|
|
|
|
Assert.Equal("user-choice", body.RootElement.GetProperty("upstreamLogoutMode").GetString());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 16:59:17 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task ConfigurationConnectionIsReadOnlyAndBlocksSameScopeKeyCreation()
|
|
|
|
|
{
|
2026-07-29 09:56:35 +00:00
|
|
|
_registry.ConfigurationConnection = ConfigurationConnection("contoso");
|
2026-07-24 16:59:17 +00:00
|
|
|
|
|
|
|
|
var create = await _client!.PostAsJsonAsync("/external-authentication/connections", CreateRequest("contoso"));
|
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, create.StatusCode);
|
|
|
|
|
|
|
|
|
|
var update = new HttpRequestMessage(HttpMethod.Put, "/external-authentication/connections/configuration-contoso") { Content = JsonContent.Create(CreateRequest("contoso")) };
|
|
|
|
|
update.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client!.SendAsync(update)).StatusCode);
|
|
|
|
|
|
|
|
|
|
var lifecycle = new HttpRequestMessage(HttpMethod.Post, "/external-authentication/connections/configuration-contoso/disable");
|
|
|
|
|
lifecycle.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(lifecycle)).StatusCode);
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
var secret = new HttpRequestMessage(HttpMethod.Put, "/external-authentication/connections/configuration-contoso/secret-bindings/clientSecret/managed") { Content = JsonContent.Create(new { resolverType = "test-managed", value = "secret" }) };
|
2026-07-24 16:59:17 +00:00
|
|
|
secret.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(secret)).StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 09:56:35 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task ShadowedDatabaseConnectionAdvertisesPromotionCapabilityOnlyWhenAllowedAndActive()
|
|
|
|
|
{
|
|
|
|
|
const string connectionId = "database-contoso";
|
|
|
|
|
_registry.ConfigurationConnection = ConfigurationConnection("contoso");
|
|
|
|
|
await _store.CreateAsync(DatabaseConnection(connectionId, ConnectionScope.HostTenantId, "contoso"));
|
|
|
|
|
|
2026-07-30 23:00:36 +00:00
|
|
|
var shadowedDatabase = await GetConnectionResponseAsync(connectionId);
|
|
|
|
|
Assert.False(shadowedDatabase.CanPromoteToConfigurationOverride);
|
|
|
|
|
Assert.Equal("configuration-contoso", shadowedDatabase.ShadowedBy?.Id);
|
|
|
|
|
Assert.Equal(connectionId, Assert.Single((await GetConnectionResponseAsync("configuration-contoso")).Shadows).Id);
|
2026-07-29 09:56:35 +00:00
|
|
|
|
|
|
|
|
_app!.Services.GetRequiredService<IOptions<ExternalAuthenticationOptions>>().Value.AllowConfigurationConnectionOverrides = true;
|
|
|
|
|
Assert.True((await GetConnectionResponseAsync(connectionId)).CanPromoteToConfigurationOverride);
|
|
|
|
|
|
|
|
|
|
var connection = Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connectionId));
|
|
|
|
|
connection.OverridesConfigurationConnection = true;
|
|
|
|
|
await _store.UpdateAsync(connection, connection.Revision);
|
|
|
|
|
Assert.False((await GetConnectionResponseAsync(connectionId)).CanPromoteToConfigurationOverride);
|
|
|
|
|
|
|
|
|
|
connection = Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connectionId));
|
|
|
|
|
connection.ArchivedAt = DateTimeOffset.UtcNow;
|
|
|
|
|
await _store.UpdateAsync(connection, connection.Revision);
|
|
|
|
|
Assert.False((await GetConnectionResponseAsync(connectionId)).CanPromoteToConfigurationOverride);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PromotingShadowedConnectionUpdatesTheExistingRecordAndPreservesLifecycleAndSecretBindings()
|
|
|
|
|
{
|
|
|
|
|
const string connectionId = "database-contoso";
|
|
|
|
|
_registry.ConfigurationConnection = ConfigurationConnection("contoso", isEnabled: true);
|
|
|
|
|
var databaseConnection = DatabaseConnection(connectionId, ConnectionScope.HostTenantId, "contoso");
|
|
|
|
|
databaseConnection.IsEnabled = true;
|
|
|
|
|
databaseConnection.SecretBindings["clientSecret"] = new SecretBinding("test-managed", "preserved-secret");
|
|
|
|
|
await _store.CreateAsync(databaseConnection);
|
|
|
|
|
|
|
|
|
|
var denied = await UpdateConnectionAsync(connectionId, 1, CreateRequest("contoso", overridesConfigurationConnection: true));
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, denied.StatusCode);
|
|
|
|
|
Assert.False(Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connectionId)).OverridesConfigurationConnection);
|
|
|
|
|
|
|
|
|
|
_app!.Services.GetRequiredService<IOptions<ExternalAuthenticationOptions>>().Value.AllowConfigurationConnectionOverrides = true;
|
|
|
|
|
var promoted = await UpdateConnectionAsync(connectionId, 1, CreateRequest("contoso", overridesConfigurationConnection: true));
|
|
|
|
|
var promotedDocument = Assert.IsType<ConnectionDocument>(await promoted.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, promoted.StatusCode);
|
|
|
|
|
Assert.Equal(connectionId, promotedDocument.Id);
|
|
|
|
|
Assert.True(promotedDocument.EnabledIntent);
|
|
|
|
|
var persisted = Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connectionId));
|
|
|
|
|
Assert.True(persisted.OverridesConfigurationConnection);
|
|
|
|
|
Assert.True(persisted.IsEnabled);
|
|
|
|
|
Assert.Equal("preserved-secret", persisted.SecretBindings["clientSecret"].Reference);
|
|
|
|
|
|
|
|
|
|
var effective = await _registry.GetAsync(_tenantId);
|
|
|
|
|
Assert.True(effective.Connections.Single(x => x.Connection.Id == "configuration-contoso").IsShadowed);
|
|
|
|
|
Assert.False(effective.Connections.Single(x => x.Connection.Id == connectionId).IsShadowed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task PromotionOfDisabledShadowedConnectionIsBlockedWhenItWouldRemoveTheFinalLoginPath()
|
|
|
|
|
{
|
|
|
|
|
const string connectionId = "database-contoso";
|
|
|
|
|
_registry.ConfigurationConnection = ConfigurationConnection("contoso", isEnabled: true);
|
|
|
|
|
await _store.CreateAsync(DatabaseConnection(connectionId, ConnectionScope.HostTenantId, "contoso"));
|
|
|
|
|
var options = _app!.Services.GetRequiredService<IOptions<ExternalAuthenticationOptions>>().Value;
|
|
|
|
|
options.AllowConfigurationConnectionOverrides = true;
|
|
|
|
|
options.LocalLogin.IsEnabled = false;
|
|
|
|
|
options.FinalLoginPathGuard.IsEnabled = true;
|
|
|
|
|
options.FinalLoginPathGuard.RequireRecoveryMethod = true;
|
|
|
|
|
options.FinalLoginPathGuard.HasBreakGlassAuthentication = false;
|
|
|
|
|
|
|
|
|
|
var promotion = await UpdateConnectionAsync(connectionId, 1, CreateRequest("contoso", overridesConfigurationConnection: true));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, promotion.StatusCode);
|
|
|
|
|
Assert.Contains("final_login_path_guard", await promotion.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.False(Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connectionId)).OverridesConfigurationConnection);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task ConnectionResponsesRedactDescriptorDeclaredSecretsInSettings()
|
|
|
|
|
{
|
|
|
|
|
var connection = DatabaseConnection("legacy-secret", ConnectionScope.HostTenantId, "legacy-secret");
|
|
|
|
|
connection.AdapterSettings = JsonDocument.Parse("{\"valid\":true,\"clientSecret\":\"must-not-leave-the-server\"}").RootElement.Clone();
|
|
|
|
|
await _store.CreateAsync(connection);
|
|
|
|
|
|
|
|
|
|
var response = await _client!.GetAsync("/external-authentication/connections/legacy-secret");
|
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
|
Assert.DoesNotContain("must-not-leave-the-server", body, StringComparison.Ordinal);
|
|
|
|
|
Assert.Contains("[REDACTED]", body, StringComparison.Ordinal);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ConnectionResponsesOmitSettingsWhenAdapterIsUnavailable()
|
|
|
|
|
{
|
|
|
|
|
var connection = DatabaseConnection("removed-adapter", ConnectionScope.HostTenantId, "removed-adapter");
|
|
|
|
|
connection.AdapterType = "removed";
|
|
|
|
|
connection.AdapterSettings = JsonDocument.Parse("{\"clientSecret\":\"must-not-leave-the-server\",\"issuer\":\"https://issuer.example\"}").RootElement.Clone();
|
|
|
|
|
await _store.CreateAsync(connection);
|
|
|
|
|
|
|
|
|
|
var response = await _client!.GetAsync("/external-authentication/connections/removed-adapter");
|
|
|
|
|
var body = await response.Content.ReadAsStringAsync();
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
|
Assert.DoesNotContain("must-not-leave-the-server", body, StringComparison.Ordinal);
|
|
|
|
|
Assert.DoesNotContain("issuer.example", body, StringComparison.Ordinal);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 16:59:17 +00:00
|
|
|
[Fact]
|
2026-07-25 00:35:56 +00:00
|
|
|
public async Task ConnectionsAreManagedHostWideRegardlessOfCurrentTenant()
|
2026-07-24 16:59:17 +00:00
|
|
|
{
|
|
|
|
|
var client = _client!;
|
2026-07-25 00:35:56 +00:00
|
|
|
foreach (var scope in new[] { new { kind = "default", tenantId = (string?)null }, new { kind = "tenant", tenantId = (string?)"tenant-b" } })
|
2026-07-24 16:59:17 +00:00
|
|
|
{
|
|
|
|
|
var response = await _client!.PostAsJsonAsync("/external-authentication/connections", CreateRequest("scope-" + scope.kind, scope));
|
2026-07-25 00:35:56 +00:00
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
|
Assert.Contains("host_scope_required", await response.Content.ReadAsStringAsync());
|
2026-07-24 16:59:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 00:35:56 +00:00
|
|
|
var create = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("host-connection"));
|
|
|
|
|
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
|
|
|
|
var host = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
|
|
|
|
|
var update = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{host.Id}") { Content = JsonContent.Create(CreateRequest("host-connection", displayName: "Updated")) };
|
2026-07-24 16:59:17 +00:00
|
|
|
update.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
2026-07-25 00:35:56 +00:00
|
|
|
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(update)).StatusCode);
|
2026-07-24 16:59:17 +00:00
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
var secret = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{host.Id}/secret-bindings/clientSecret/managed") { Content = JsonContent.Create(new { resolverType = "test-managed", value = "secret" }) };
|
2026-07-25 00:35:56 +00:00
|
|
|
secret.Headers.TryAddWithoutValidation("If-Match", "\"2\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(secret)).StatusCode);
|
2026-07-24 16:59:17 +00:00
|
|
|
|
2026-07-25 00:35:56 +00:00
|
|
|
await _store.CreateAsync(DatabaseConnection("legacy-tenant", "tenant-a", "legacy-tenant"));
|
|
|
|
|
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/external-authentication/connections/legacy-tenant")).StatusCode);
|
2026-07-24 16:59:17 +00:00
|
|
|
|
|
|
|
|
await _store.CreateAsync(DatabaseConnection("tenant-inherited-key", "tenant-a", "tenant-inherited-key"));
|
|
|
|
|
var hostCollision = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("tenant-inherited-key", new { kind = "host", tenantId = (string?)null }));
|
|
|
|
|
Assert.Equal(HttpStatusCode.Conflict, hostCollision.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ListSupportsDeterministicPagingFiltersAndStaleObservations()
|
|
|
|
|
{
|
|
|
|
|
var client = _client!;
|
2026-07-25 00:35:56 +00:00
|
|
|
await _store.CreateAsync(DatabaseConnection("list-a", ConnectionScope.HostTenantId, "alpha", 1));
|
|
|
|
|
await _store.CreateAsync(DatabaseConnection("list-b", ConnectionScope.HostTenantId, "bravo", 2));
|
|
|
|
|
await _store.CreateAsync(DatabaseConnection("list-c", ConnectionScope.HostTenantId, "charlie", 3));
|
|
|
|
|
await _store.CreateAsync(DatabaseConnection("legacy-tenant", "tenant-b", "not-enumerable", 4));
|
2026-07-24 16:59:17 +00:00
|
|
|
await _observations.SaveLatestAsync(new ConnectionObservation("list-a", "old-material", DateTimeOffset.UtcNow, ConnectionObservationStatus.Succeeded, "connectivity", TimeSpan.Zero, "OK", [], "test"));
|
|
|
|
|
|
|
|
|
|
var first = await client.GetFromJsonAsync<ListDocument>("/external-authentication/connections?source=database&valid=true&shadowed=false&pageSize=1");
|
|
|
|
|
var firstPage = Assert.IsType<ListDocument>(first);
|
|
|
|
|
var firstConnection = Assert.Single(firstPage.Items);
|
|
|
|
|
Assert.Equal("alpha", firstConnection.Key);
|
|
|
|
|
Assert.True(firstConnection.LatestObservation!.IsStale);
|
|
|
|
|
Assert.NotNull(firstPage.NextCursor);
|
|
|
|
|
|
|
|
|
|
var detail = await client.GetFromJsonAsync<ListConnectionDocument>("/external-authentication/connections/list-a");
|
|
|
|
|
Assert.True(Assert.IsType<ListConnectionDocument>(detail).LatestObservation!.IsStale);
|
|
|
|
|
|
|
|
|
|
var second = await client.GetFromJsonAsync<ListDocument>($"/external-authentication/connections?source=database&valid=true&shadowed=false&pageSize=1&cursor={Uri.EscapeDataString(firstPage.NextCursor!)}");
|
|
|
|
|
var secondPage = Assert.IsType<ListDocument>(second);
|
|
|
|
|
Assert.Equal("bravo", Assert.Single(secondPage.Items).Key);
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/external-authentication/connections?source=unknown")).StatusCode);
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/external-authentication/connections?cursor=not-a-cursor")).StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task DraftMayBeIncompleteButEnableRequiresAdapterValidationAndMigration()
|
|
|
|
|
{
|
|
|
|
|
var client = _client!;
|
|
|
|
|
var versionBefore = await _registryVersions.GetVersionAsync();
|
|
|
|
|
var create = await _client!.PostAsJsonAsync("/external-authentication/connections", CreateRequest("draft", settings: new { }));
|
|
|
|
|
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
|
|
|
|
var draft = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
Assert.Equal(2, draft.AdapterSettingsVersion);
|
|
|
|
|
Assert.False(await _registryVersions.IsCurrentAsync(versionBefore));
|
|
|
|
|
|
|
|
|
|
var enable = new HttpRequestMessage(HttpMethod.Post, $"/external-authentication/connections/{draft.Id}/enable");
|
|
|
|
|
enable.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.SendAsync(enable)).StatusCode);
|
|
|
|
|
|
2026-07-25 00:35:56 +00:00
|
|
|
var future = await client.PostAsJsonAsync("/external-authentication/connections", new { key = "future", scope = new { kind = "host" }, adapterType = "test", adapterSettingsVersion = 3, adapterSettings = new { valid = true }, displayName = "Future", claimProjection = new { }, upstreamLogoutMode = "disabled" });
|
2026-07-24 16:59:17 +00:00
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, future.StatusCode);
|
|
|
|
|
Assert.Contains("migration_unavailable", await future.Content.ReadAsStringAsync());
|
|
|
|
|
|
|
|
|
|
var secretInSettings = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("secret-in-settings", settings: new { valid = true, clientSecret = "not-allowed" }));
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, secretInSettings.StatusCode);
|
|
|
|
|
Assert.Contains("secret_binding_required", await secretInSettings.Content.ReadAsStringAsync());
|
|
|
|
|
|
|
|
|
|
_settingsMigrations.CanMigrateVersionOne = false;
|
|
|
|
|
var missing = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("missing-migration"));
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, missing.StatusCode);
|
|
|
|
|
Assert.Contains("migration_unavailable", await missing.Content.ReadAsStringAsync());
|
|
|
|
|
|
|
|
|
|
_settingsMigrations.CanMigrateVersionOne = true;
|
|
|
|
|
var uppercaseKey = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("UpperCase"));
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, uppercaseKey.StatusCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ExistingUnsafeSettingsRemainManageableWithoutUnsafeConfirmation()
|
|
|
|
|
{
|
|
|
|
|
var client = _client!;
|
|
|
|
|
var create = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("unsafe", settings: new { valid = true, unsafeMode = true }, confirmUnsafeSettings: true));
|
|
|
|
|
Assert.Equal(HttpStatusCode.Created, create.StatusCode);
|
|
|
|
|
var connection = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
|
|
|
|
|
_unsafePermissionGranted = false;
|
|
|
|
|
var safeSettingsUpdate = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}") { Content = JsonContent.Create(CreateRequest("unsafe", settings: new { valid = true, unsafeMode = true, label = "changed" })) };
|
|
|
|
|
safeSettingsUpdate.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(safeSettingsUpdate)).StatusCode);
|
|
|
|
|
|
|
|
|
|
var validate = await client.PostAsync($"/external-authentication/connections/{connection.Id}/validate", null);
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, validate.StatusCode);
|
|
|
|
|
Assert.Contains("\"valid\":true", await validate.Content.ReadAsStringAsync());
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
var secret = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}/secret-bindings/clientSecret/managed") { Content = JsonContent.Create(new { resolverType = "test-managed", value = "secret" }) };
|
2026-07-24 16:59:17 +00:00
|
|
|
secret.Headers.TryAddWithoutValidation("If-Match", "\"2\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(secret)).StatusCode);
|
2026-07-25 01:48:50 +00:00
|
|
|
await _notifications.Received().SendAsync(Arg.Is<IdentityProviderConnectionSecretBindingChanged>(x => x.FieldName == "clientSecret" && x.ResolverType == "test-managed" && !x.IsConfigured), Arg.Any<CancellationToken>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ManagedSecretReplacementCleansUpStagedMaterialWhenConnectionCasLoses()
|
|
|
|
|
{
|
|
|
|
|
var client = _client!;
|
|
|
|
|
var create = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("managed-secret-race"));
|
|
|
|
|
var connection = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
_managedSecretWriter.BeforeReturn = async () =>
|
|
|
|
|
{
|
|
|
|
|
var concurrent = Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connection.Id));
|
|
|
|
|
concurrent.DisplayName = "Concurrent update";
|
|
|
|
|
Assert.IsType<ConnectionMutationResult.Updated>(await _store.UpdateAsync(concurrent, concurrent.Revision));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var replace = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}/secret-bindings/clientSecret/managed")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(new { resolverType = "test-managed", value = "replacement" })
|
|
|
|
|
};
|
|
|
|
|
replace.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.PreconditionFailed, (await client.SendAsync(replace)).StatusCode);
|
|
|
|
|
Assert.Single(_managedSecretWriter.RemovedReferences);
|
|
|
|
|
Assert.Empty(Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connection.Id)).SecretBindings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ManagedSecretReplacementCleansUpStagedMaterialWhenValidationThrows()
|
|
|
|
|
{
|
|
|
|
|
var create = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("managed-secret-exception", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
var connection = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
_managedSecretWriter.BeforeReturn = () =>
|
|
|
|
|
{
|
|
|
|
|
_roleAuthorizationService.ThrowOnAssignRoles = true;
|
|
|
|
|
return Task.CompletedTask;
|
|
|
|
|
};
|
|
|
|
|
var replace = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}/secret-bindings/clientSecret/managed")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(new { resolverType = "test-managed", value = "replacement" })
|
|
|
|
|
};
|
|
|
|
|
replace.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
|
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => _client!.SendAsync(replace));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(new[] { "staged-1" }, _managedSecretWriter.RemovedReferences);
|
|
|
|
|
Assert.Empty(Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connection.Id)).SecretBindings);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task DisablingWithSessionRevocationRequiresPermissionAndEmitsAggregateNotification()
|
|
|
|
|
{
|
|
|
|
|
var connection = DatabaseConnection("disable-with-revoke", ConnectionScope.HostTenantId, "disable-with-revoke");
|
|
|
|
|
connection.IsEnabled = true;
|
|
|
|
|
await _store.CreateAsync(connection);
|
|
|
|
|
_sessions.RevokeActiveForConnectionAsync("disable-with-revoke", "connection_disabled", Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()).Returns(2);
|
|
|
|
|
_unsafePermissionGranted = false;
|
|
|
|
|
|
|
|
|
|
var forbidden = new HttpRequestMessage(HttpMethod.Post, "/external-authentication/connections/disable-with-revoke/disable?revokeActiveSessions=true");
|
|
|
|
|
forbidden.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.Forbidden, (await _client!.SendAsync(forbidden)).StatusCode);
|
|
|
|
|
await _sessions.DidNotReceive().RevokeActiveForConnectionAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>());
|
|
|
|
|
|
|
|
|
|
_unsafePermissionGranted = true;
|
|
|
|
|
var allowed = new HttpRequestMessage(HttpMethod.Post, "/external-authentication/connections/disable-with-revoke/disable?revokeActiveSessions=true");
|
|
|
|
|
allowed.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await _client.SendAsync(allowed)).StatusCode);
|
|
|
|
|
await _notifications.Received().SendAsync(
|
|
|
|
|
Arg.Is<ExternalAuthenticationConnectionSessionsRevoked>(x => x.SessionCount == 2 && x.Reason == "connection_disabled"),
|
|
|
|
|
Arg.Any<CancellationToken>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ManagedSecretReplacementRemainsPublishedWhenPostCommitNotificationFails()
|
|
|
|
|
{
|
|
|
|
|
var client = _client!;
|
|
|
|
|
var create = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("managed-secret-notification"));
|
|
|
|
|
var connection = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
_notifications
|
|
|
|
|
.SendAsync(Arg.Any<INotification>(), Arg.Any<CancellationToken>())
|
|
|
|
|
.Returns(_ => Task.FromException(new InvalidOperationException("Notification failure")));
|
|
|
|
|
|
|
|
|
|
var replace = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}/secret-bindings/clientSecret/managed")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(new { resolverType = "test-managed", value = "replacement" })
|
|
|
|
|
};
|
|
|
|
|
replace.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(replace)).StatusCode);
|
|
|
|
|
var persisted = Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connection.Id));
|
|
|
|
|
Assert.Equal("staged-1", persisted.SecretBindings["clientSecret"].Reference);
|
|
|
|
|
Assert.Empty(_managedSecretWriter.RemovedReferences);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ManagedSecretWriterMustStageAReferenceDistinctFromTheLiveBinding()
|
|
|
|
|
{
|
|
|
|
|
var client = _client!;
|
|
|
|
|
var create = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("managed-secret-distinct"));
|
|
|
|
|
var connection = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
var first = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}/secret-bindings/clientSecret/managed")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(new { resolverType = "test-managed", value = "first" })
|
|
|
|
|
};
|
|
|
|
|
first.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(first)).StatusCode);
|
|
|
|
|
|
|
|
|
|
_managedSecretWriter.ReferenceToReturn = "staged-1";
|
|
|
|
|
var invalid = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}/secret-bindings/clientSecret/managed")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(new { resolverType = "test-managed", value = "second" })
|
|
|
|
|
};
|
|
|
|
|
invalid.Headers.TryAddWithoutValidation("If-Match", "\"2\"");
|
|
|
|
|
|
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(() => client.SendAsync(invalid));
|
|
|
|
|
Assert.Equal("staged-1", Assert.IsType<IdentityProviderConnection>(await _store.FindByIdAsync(connection.Id)).SecretBindings["clientSecret"].Reference);
|
|
|
|
|
Assert.Empty(_managedSecretWriter.RemovedReferences);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task GeneralConnectionPayloadCannotInjectOrClearSecretBindings()
|
|
|
|
|
{
|
|
|
|
|
var client = _client!;
|
|
|
|
|
var injectedCreate = await client.PostAsJsonAsync("/external-authentication/connections", new
|
|
|
|
|
{
|
|
|
|
|
key = "injected-secret",
|
|
|
|
|
scope = new { kind = "host" },
|
|
|
|
|
adapterType = "test",
|
|
|
|
|
adapterSettingsVersion = 1,
|
|
|
|
|
adapterSettings = new { valid = true },
|
|
|
|
|
displayName = "Injected",
|
|
|
|
|
secretBindings = new { clientSecret = new { resolverType = "configuration", reference = "ConnectionStrings:Production" } },
|
|
|
|
|
claimProjection = new { },
|
|
|
|
|
upstreamLogoutMode = "disabled"
|
|
|
|
|
});
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, injectedCreate.StatusCode);
|
|
|
|
|
Assert.Contains("secret_bindings_mutation_not_allowed", await injectedCreate.Content.ReadAsStringAsync());
|
|
|
|
|
|
|
|
|
|
var create = await client.PostAsJsonAsync("/external-authentication/connections", CreateRequest("cannot-clear-secret"));
|
|
|
|
|
var connection = Assert.IsType<ConnectionDocument>(await create.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
var clear = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(new
|
|
|
|
|
{
|
|
|
|
|
key = "cannot-clear-secret",
|
|
|
|
|
scope = new { kind = "host" },
|
|
|
|
|
adapterType = "test",
|
|
|
|
|
adapterSettingsVersion = 2,
|
|
|
|
|
adapterSettings = new { valid = true },
|
|
|
|
|
displayName = "Cannot clear",
|
|
|
|
|
secretBindings = new { },
|
|
|
|
|
claimProjection = new { },
|
|
|
|
|
upstreamLogoutMode = "disabled"
|
|
|
|
|
})
|
|
|
|
|
};
|
|
|
|
|
clear.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, (await client.SendAsync(clear)).StatusCode);
|
2026-07-24 16:59:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task MatcherPolicyRejectsAMatcherDisallowedByDeployment()
|
|
|
|
|
{
|
|
|
|
|
var response = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("disallowed-matcher", unlinkedPolicy: CreateMatcherPolicy("disallowed-matcher", "reject")));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
|
Assert.Contains("validation_failed", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task MatcherCreateUserFallbackRequiresRoleDelegation()
|
|
|
|
|
{
|
|
|
|
|
_roleAuthorizationService.CanAssignRoles = false;
|
|
|
|
|
|
|
|
|
|
var response = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("matcher-roles", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
|
Assert.Contains("validation_failed", await response.Content.ReadAsStringAsync());
|
|
|
|
|
Assert.Equal(new[] { "workflow-user" }, _roleAuthorizationService.LastRequestedRoleIds);
|
|
|
|
|
}
|
|
|
|
|
|
feat(external-auth)!: require a permission to author policy default roles (#7992)
* feat(external-auth)!: require a permission to author policy default roles
Setting the defaultRoleIds of an unlinked-identity policy was guarded only by
the subset rule -- you could not grant roles carrying permissions you did not
hold -- so any actor able to edit a connection could decide what auto-created
users receive. The permission named for that decision,
external-authentication/policies/default-roles:update, was enforced in one
place: removing policy references while deleting a role.
The asymmetry is what makes this look like a check that was never wired
rather than a deliberate carve-out. Its sibling, policies:update, is already
enforced on the write path at both the create and update sites, through the
same RequiresPolicyManagement condition that covers the very policy the roles
live inside.
Demonstrated rather than argued: with the guard stubbed out, a caller holding
only connections:create and policies:update creates a connection whose policy
assigns "workflow-user", and the response is 201. The subset rule does not
object, because it answers a different question -- it prevents escalation, not
delegation of the decision.
The two checks are now reported independently for that reason. The permission
asks whether this actor may decide default roles at all; the subset rule asks
whether these particular roles stay inside what they already hold. It applies
only when roles are actually being set, so clearing the list, or a policy that
assigns none, needs nothing extra.
Breaking for roles holding the legacy policies:manage but not roles:assign
that set default roles today. Anyone who held roles:assign already maps to the
new permission and is unaffected. Documented in the migration guide.
Closes #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): gate default roles on the set changing, not on it existing
Review reproduced the over-reach through the real endpoints: validation runs
on every update, on enabling a connection, and on read-only validate, so
keying the permission off default roles being present meant that once anyone
set them, an administrator without the permission could no longer edit an
unrelated field on that connection, enable it, or validate it.
The permission now applies when the set changes -- adding, removing, or
clearing all count as deciding what auto-created users receive; leaving a
stored set alone does not. Order is not treated as meaningful, so reordering
is not a change.
The test that was supposed to cover this asserted only that a message was
absent, which passes for any failure response and made it vacuous exactly
when it mattered: it passed with the over-reach still in place, because the
request was failing 405 on the wrong verb. It now uses PUT and asserts
success, and reverting the fix makes it fail with the 400 review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): treat abandoning a create-user policy as a role change
The permission check sat inside the create-user branch, so it only ran when
the candidate policy still created users. Switching a stored fallback to one
that does not -- 'reject', or match-user with a different noMatchAction --
skipped it entirely and dropped the policy's automatic role assignments
without the permission that governs them. Review reproduced it.
The effective default roles of a policy that does not create users are none,
so computing that first and comparing outside the branch makes abandonment a
change like any other. The subset rule stays inside the branch, because it
only has something to say about roles actually being assigned.
The new test expresses abandonment through noMatchAction rather than the
policy type, since the fixture's registry only knows match-user. Re-scoping
the check to create-user candidates makes it fail with OK instead of the
expected BadRequest, which is the bypass.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): take the default-role baseline from the registry
A configuration-owned connection has no database row, so comparing against
the store alone made its configured default roles look newly assigned on
every validation. Validation needs only connections:view, so a caller with
exactly that could not validate such a connection at all -- review
reproduced it.
The baseline now comes from the registry, which answers for both ownerships
and is the question actually being asked: what does this connection assign
today. The store remains a fallback for a record the registry does not know.
The new test gives the fixture's configuration connection an unlinked policy
with default roles and validates it as a view-only caller. Reverting to the
store-only baseline makes it fail with the permission error, which is the
symptom review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:31:44 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task SettingDefaultRolesRequiresThePolicyDefaultRolesPermission()
|
|
|
|
|
{
|
|
|
|
|
// The actor may create connections and manage policies, but not decide what auto-created users get.
|
|
|
|
|
// Before #7977 that was inexpressible: policies:update guarded the policy while the roles inside it
|
|
|
|
|
// were guarded only by the subset rule, so any connection administrator could set them.
|
|
|
|
|
_permissions =
|
|
|
|
|
[
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Create}",
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Policies}:{CoreVerbs.Update}"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
var response = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("roles-guard", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
|
Assert.Contains("policy default roles update permission", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task HoldingThePolicyDefaultRolesPermissionClearsThatObjection()
|
|
|
|
|
{
|
|
|
|
|
_permissions =
|
|
|
|
|
[
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Create}",
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Policies}:{CoreVerbs.Update}",
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.PolicyDefaultRoles}:{CoreVerbs.Update}"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
var response = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("roles-allowed", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
|
|
|
|
|
// The subset rule is a separate question and still applies; only this objection must be gone.
|
|
|
|
|
Assert.DoesNotContain("policy default roles update permission", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task LeavingStoredDefaultRolesAloneNeedsNoPermission()
|
|
|
|
|
{
|
|
|
|
|
// Validation runs on every update, on enabling a connection, and on read-only validate. Keying the
|
|
|
|
|
// permission off the roles being present rather than changing meant that once anyone set default
|
|
|
|
|
// roles, an administrator without it could no longer edit an unrelated field on that connection.
|
|
|
|
|
var created = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("roles-untouched", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
|
|
|
var id = (await created.Content.ReadFromJsonAsync<ConnectionDocument>())!.Id;
|
|
|
|
|
var revision = created.Headers.ETag!.Tag;
|
|
|
|
|
|
|
|
|
|
// Now act as someone who may edit connections and policies, but not decide default roles.
|
|
|
|
|
_permissions =
|
|
|
|
|
[
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Update}",
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Policies}:{CoreVerbs.Update}"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{id}")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(CreateRequest("roles-untouched", displayName: "Renamed", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")))
|
|
|
|
|
};
|
|
|
|
|
request.Headers.TryAddWithoutValidation("If-Match", revision);
|
|
|
|
|
|
|
|
|
|
var response = await _client.SendAsync(request);
|
|
|
|
|
|
|
|
|
|
// Asserting the status, not just the absence of a message: DoesNotContain alone passes for any
|
|
|
|
|
// failure response, which would make this test vacuous exactly when it matters.
|
|
|
|
|
Assert.True(response.IsSuccessStatusCode, $"expected success, got {(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task AbandoningACreateUserPolicyStillCountsAsChangingDefaultRoles()
|
|
|
|
|
{
|
|
|
|
|
// Turning off a stored create-user fallback removes its automatic role assignments. That is a
|
|
|
|
|
// decision about what auto-created users receive, so it needs the same permission as editing the
|
|
|
|
|
// list -- checking only create-user candidates would have let it through unguarded. Expressed here by
|
|
|
|
|
// changing noMatchAction rather than the policy type, because the test registry only knows match-user.
|
|
|
|
|
var created = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("roles-abandoned", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
|
|
|
var id = (await created.Content.ReadFromJsonAsync<ConnectionDocument>())!.Id;
|
|
|
|
|
var revision = created.Headers.ETag!.Tag;
|
|
|
|
|
|
|
|
|
|
_permissions =
|
|
|
|
|
[
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Update}",
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Policies}:{CoreVerbs.Update}"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
var request = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{id}")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(CreateRequest("roles-abandoned", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "reject")))
|
|
|
|
|
};
|
|
|
|
|
request.Headers.TryAddWithoutValidation("If-Match", revision);
|
|
|
|
|
|
|
|
|
|
var response = await _client.SendAsync(request);
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
|
Assert.Contains("policy default roles update permission", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 09:45:44 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task OmittingAStoredCreateUserPolicyStillCountsAsChangingDefaultRoles()
|
|
|
|
|
{
|
|
|
|
|
// The abandonment guard above works by switching noMatchAction, but a PUT can drop the stored
|
|
|
|
|
// fallback more quietly: omit unlinkedPolicy altogether. Normalization does not carry the stored
|
|
|
|
|
// policy forward, so a null candidate clears it -- and its role assignments with it. That is the
|
|
|
|
|
// same decision as switching to 'reject', so it needs the same permission.
|
|
|
|
|
var (id, revision) = await CreateConnectionAsync(
|
|
|
|
|
CreateRequest("roles-omitted", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
|
|
|
|
|
_permissions = UpdateWithoutDefaultRolesPermission;
|
|
|
|
|
|
|
|
|
|
var response = await PutConnectionAsync(id, revision, CreateRequest("roles-omitted"));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
|
Assert.Contains("policy default roles update permission", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task IntroducingACreateUserPolicyOnAPolicylessConnectionRequiresThePermission()
|
|
|
|
|
{
|
|
|
|
|
// The reverse transition: the stored connection has no policy, so the baseline role set is empty,
|
|
|
|
|
// and an update that introduces a create-user fallback with roles is deciding what auto-created
|
|
|
|
|
// users receive.
|
|
|
|
|
var (id, revision) = await CreateConnectionAsync(CreateRequest("roles-introduced"));
|
|
|
|
|
|
|
|
|
|
_permissions = UpdateWithoutDefaultRolesPermission;
|
|
|
|
|
|
|
|
|
|
var response = await PutConnectionAsync(id, revision,
|
|
|
|
|
CreateRequest("roles-introduced", unlinkedPolicy: CreateMatcherPolicy("allowed-matcher", "create-user")));
|
|
|
|
|
|
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
|
|
|
Assert.Contains("policy default roles update permission", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task ClearingAPolicyThatAssignsNoRolesNeedsNoPermission()
|
|
|
|
|
{
|
|
|
|
|
// Clearing a create-user fallback whose role list is already empty changes nothing about what
|
|
|
|
|
// auto-created users receive, so the guard must stay quiet -- it keys off the effective set
|
|
|
|
|
// changing, not off the policy disappearing.
|
|
|
|
|
var (id, revision) = await CreateConnectionAsync(
|
|
|
|
|
CreateRequest("no-roles-cleared", unlinkedPolicy: CreateMatcherPolicyWithoutDefaultRoles("allowed-matcher", "create-user")));
|
|
|
|
|
|
|
|
|
|
_permissions = UpdateWithoutDefaultRolesPermission;
|
|
|
|
|
|
|
|
|
|
var response = await PutConnectionAsync(id, revision, CreateRequest("no-roles-cleared"));
|
|
|
|
|
|
|
|
|
|
Assert.True(response.IsSuccessStatusCode, $"expected success, got {(int)response.StatusCode}: {await response.Content.ReadAsStringAsync()}");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>May edit connections and policies, but not decide default roles -- the #7977 separation.</summary>
|
|
|
|
|
private static readonly string[] UpdateWithoutDefaultRolesPermission =
|
|
|
|
|
[
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Update}",
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Policies}:{CoreVerbs.Update}"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
private async Task<(string Id, string Revision)> CreateConnectionAsync(object request)
|
|
|
|
|
{
|
|
|
|
|
var created = await _client!.PostAsJsonAsync("/external-authentication/connections", request);
|
|
|
|
|
Assert.Equal(HttpStatusCode.Created, created.StatusCode);
|
|
|
|
|
return ((await created.Content.ReadFromJsonAsync<ConnectionDocument>())!.Id, created.Headers.ETag!.Tag);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<HttpResponseMessage> PutConnectionAsync(string id, string revision, object request)
|
|
|
|
|
{
|
|
|
|
|
using var message = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{id}")
|
|
|
|
|
{
|
|
|
|
|
Content = JsonContent.Create(request)
|
|
|
|
|
};
|
|
|
|
|
message.Headers.TryAddWithoutValidation("If-Match", revision);
|
|
|
|
|
return await _client!.SendAsync(message);
|
|
|
|
|
}
|
|
|
|
|
|
feat(external-auth)!: require a permission to author policy default roles (#7992)
* feat(external-auth)!: require a permission to author policy default roles
Setting the defaultRoleIds of an unlinked-identity policy was guarded only by
the subset rule -- you could not grant roles carrying permissions you did not
hold -- so any actor able to edit a connection could decide what auto-created
users receive. The permission named for that decision,
external-authentication/policies/default-roles:update, was enforced in one
place: removing policy references while deleting a role.
The asymmetry is what makes this look like a check that was never wired
rather than a deliberate carve-out. Its sibling, policies:update, is already
enforced on the write path at both the create and update sites, through the
same RequiresPolicyManagement condition that covers the very policy the roles
live inside.
Demonstrated rather than argued: with the guard stubbed out, a caller holding
only connections:create and policies:update creates a connection whose policy
assigns "workflow-user", and the response is 201. The subset rule does not
object, because it answers a different question -- it prevents escalation, not
delegation of the decision.
The two checks are now reported independently for that reason. The permission
asks whether this actor may decide default roles at all; the subset rule asks
whether these particular roles stay inside what they already hold. It applies
only when roles are actually being set, so clearing the list, or a policy that
assigns none, needs nothing extra.
Breaking for roles holding the legacy policies:manage but not roles:assign
that set default roles today. Anyone who held roles:assign already maps to the
new permission and is unaffected. Documented in the migration guide.
Closes #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): gate default roles on the set changing, not on it existing
Review reproduced the over-reach through the real endpoints: validation runs
on every update, on enabling a connection, and on read-only validate, so
keying the permission off default roles being present meant that once anyone
set them, an administrator without the permission could no longer edit an
unrelated field on that connection, enable it, or validate it.
The permission now applies when the set changes -- adding, removing, or
clearing all count as deciding what auto-created users receive; leaving a
stored set alone does not. Order is not treated as meaningful, so reordering
is not a change.
The test that was supposed to cover this asserted only that a message was
absent, which passes for any failure response and made it vacuous exactly
when it mattered: it passed with the over-reach still in place, because the
request was failing 405 on the wrong verb. It now uses PUT and asserts
success, and reverting the fix makes it fail with the 400 review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): treat abandoning a create-user policy as a role change
The permission check sat inside the create-user branch, so it only ran when
the candidate policy still created users. Switching a stored fallback to one
that does not -- 'reject', or match-user with a different noMatchAction --
skipped it entirely and dropped the policy's automatic role assignments
without the permission that governs them. Review reproduced it.
The effective default roles of a policy that does not create users are none,
so computing that first and comparing outside the branch makes abandonment a
change like any other. The subset rule stays inside the branch, because it
only has something to say about roles actually being assigned.
The new test expresses abandonment through noMatchAction rather than the
policy type, since the fixture's registry only knows match-user. Re-scoping
the check to create-user candidates makes it fail with OK instead of the
expected BadRequest, which is the bypass.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(external-auth): take the default-role baseline from the registry
A configuration-owned connection has no database row, so comparing against
the store alone made its configured default roles look newly assigned on
every validation. Validation needs only connections:view, so a caller with
exactly that could not validate such a connection at all -- review
reproduced it.
The baseline now comes from the registry, which answers for both ownerships
and is the question actually being asked: what does this connection assign
today. The store remains a fallback for a record the registry does not know.
The new test gives the fixture's configuration connection an unlinked policy
with default roles and validates it as a view-only caller. Reverting to the
store-only baseline makes it fail with the permission error, which is the
symptom review described.
Refs #7977
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 01:31:44 +00:00
|
|
|
[Fact]
|
|
|
|
|
public async Task ValidatingAConfigurationOwnedConnectionDoesNotReadItsRolesAsNew()
|
|
|
|
|
{
|
|
|
|
|
// A configuration-owned connection has no database row, so taking the baseline from the database
|
|
|
|
|
// store alone made its configured roles look newly assigned every time. Validation only needs
|
|
|
|
|
// connections:view, so a caller with exactly that could not validate one at all.
|
|
|
|
|
var configuration = ConfigurationConnection("config-roles", isEnabled: true);
|
|
|
|
|
configuration.UnlinkedPolicy = CreateMatcherPolicy("allowed-matcher", "create-user");
|
|
|
|
|
_registry.ConfigurationConnection = configuration;
|
|
|
|
|
|
|
|
|
|
_permissions = [$"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.View}"];
|
|
|
|
|
|
|
|
|
|
var response = await _client!.PostAsync($"/external-authentication/connections/{configuration.Id}/validate", null);
|
|
|
|
|
|
|
|
|
|
Assert.DoesNotContain("policy default roles update permission", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[Fact]
|
|
|
|
|
public async Task APolicyThatSetsNoDefaultRolesNeedsNoExtraPermission()
|
|
|
|
|
{
|
|
|
|
|
// Creating with none decides nothing, so it needs nothing. Changing a stored set -- including
|
|
|
|
|
// clearing it -- is deciding, and is covered by the permission.
|
|
|
|
|
_permissions =
|
|
|
|
|
[
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Connections}:{CoreVerbs.Create}",
|
|
|
|
|
$"{ExternalAuthenticationResourcePermissions.Policies}:{CoreVerbs.Update}"
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
var response = await _client!.PostAsJsonAsync(
|
|
|
|
|
"/external-authentication/connections",
|
|
|
|
|
CreateRequest("roles-empty", unlinkedPolicy: CreateMatcherPolicyWithoutDefaultRoles("allowed-matcher", "create-user")));
|
|
|
|
|
|
|
|
|
|
Assert.DoesNotContain("policy default roles update permission", await response.Content.ReadAsStringAsync());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static PolicySelection CreateMatcherPolicyWithoutDefaultRoles(string matcherType, string noMatchAction) => new(
|
|
|
|
|
"match-user",
|
|
|
|
|
1,
|
|
|
|
|
JsonSerializer.SerializeToElement(new
|
|
|
|
|
{
|
|
|
|
|
matcher = new { type = matcherType, settingsVersion = 1, settings = new { } },
|
|
|
|
|
noMatchAction,
|
|
|
|
|
defaultRoleIds = Array.Empty<string>()
|
|
|
|
|
}));
|
|
|
|
|
|
2026-07-29 09:56:35 +00:00
|
|
|
private static object CreateRequest(string key, object? scope = null, string displayName = "Contoso", object? settings = null, bool confirmUnsafeSettings = false, object? unlinkedPolicy = null, string upstreamLogoutMode = "disabled", bool overridesConfigurationConnection = false) => new
|
2026-07-24 16:59:17 +00:00
|
|
|
{
|
|
|
|
|
key,
|
2026-07-25 00:35:56 +00:00
|
|
|
scope = scope ?? new { kind = "host" },
|
2026-07-24 16:59:17 +00:00
|
|
|
adapterType = "test",
|
|
|
|
|
adapterSettingsVersion = 1,
|
|
|
|
|
adapterSettings = settings ?? new { valid = true },
|
|
|
|
|
displayName,
|
|
|
|
|
order = 10,
|
|
|
|
|
claimProjection = new { allowedClaimTypes = Array.Empty<string>(), redactedClaimTypes = Array.Empty<string>(), maximumClaimCount = 0, maximumValueLength = 0, maximumTotalBytes = 0 },
|
2026-07-27 08:07:29 +00:00
|
|
|
upstreamLogoutMode,
|
2026-07-25 01:48:50 +00:00
|
|
|
confirmUnsafeSettings,
|
2026-07-29 09:56:35 +00:00
|
|
|
overridesConfigurationConnection,
|
2026-07-25 01:48:50 +00:00
|
|
|
unlinkedPolicy
|
2026-07-24 16:59:17 +00:00
|
|
|
};
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
private static PolicySelection CreateMatcherPolicy(string matcherType, string noMatchAction) => new(
|
|
|
|
|
"match-user",
|
|
|
|
|
1,
|
|
|
|
|
JsonSerializer.SerializeToElement(new
|
|
|
|
|
{
|
|
|
|
|
matcher = new { type = matcherType, settingsVersion = 1, settings = new { } },
|
|
|
|
|
noMatchAction,
|
|
|
|
|
defaultRoleIds = new[] { "workflow-user" }
|
|
|
|
|
}));
|
|
|
|
|
|
2026-07-24 16:59:17 +00:00
|
|
|
private sealed class ConnectionDocument
|
|
|
|
|
{
|
|
|
|
|
public string Id { get; set; } = null!;
|
2026-07-25 01:48:50 +00:00
|
|
|
public string? CallbackUri { get; set; }
|
|
|
|
|
public string? PreviewCallbackUri { get; set; }
|
2026-07-24 16:59:17 +00:00
|
|
|
public bool EnabledIntent { get; set; }
|
|
|
|
|
public int AdapterSettingsVersion { get; set; }
|
2026-07-29 09:56:35 +00:00
|
|
|
public bool CanPromoteToConfigurationOverride { get; set; }
|
2026-07-30 23:00:36 +00:00
|
|
|
public ConnectionReferenceDocument? ShadowedBy { get; set; }
|
|
|
|
|
public ICollection<ConnectionReferenceDocument> Shadows { get; set; } = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class ConnectionReferenceDocument
|
|
|
|
|
{
|
|
|
|
|
public string Id { get; set; } = null!;
|
|
|
|
|
public string DisplayName { get; set; } = null!;
|
|
|
|
|
public string Source { get; set; } = null!;
|
2026-07-29 09:56:35 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<ConnectionDocument> GetConnectionResponseAsync(string connectionId)
|
|
|
|
|
{
|
|
|
|
|
var response = await _client!.GetAsync($"/external-authentication/connections/{connectionId}");
|
|
|
|
|
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
|
|
|
|
return Assert.IsType<ConnectionDocument>(await response.Content.ReadFromJsonAsync<ConnectionDocument>());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async Task<HttpResponseMessage> UpdateConnectionAsync(string connectionId, long revision, object request)
|
|
|
|
|
{
|
|
|
|
|
var update = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connectionId}") { Content = JsonContent.Create(request) };
|
|
|
|
|
update.Headers.TryAddWithoutValidation("If-Match", $"\"{revision}\"");
|
|
|
|
|
return await _client!.SendAsync(update);
|
2026-07-24 16:59:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class ListDocument
|
|
|
|
|
{
|
|
|
|
|
public List<ListConnectionDocument> Items { get; set; } = [];
|
|
|
|
|
public string? NextCursor { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class ListConnectionDocument
|
|
|
|
|
{
|
|
|
|
|
public string Key { get; set; } = null!;
|
|
|
|
|
public ObservationDocument? LatestObservation { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class ObservationDocument
|
|
|
|
|
{
|
|
|
|
|
public bool IsStale { get; set; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static IdentityProviderConnection DatabaseConnection(string id, string tenantId, string key, int order = 0) => new()
|
|
|
|
|
{
|
|
|
|
|
Id = id,
|
|
|
|
|
TenantId = tenantId,
|
|
|
|
|
Key = key,
|
|
|
|
|
AdapterType = "test",
|
|
|
|
|
AdapterSettingsVersion = 2,
|
|
|
|
|
AdapterSettings = JsonDocument.Parse("{\"valid\":true}").RootElement.Clone(),
|
|
|
|
|
DisplayName = key,
|
|
|
|
|
DisplayOrder = order,
|
|
|
|
|
ClaimProjection = ClaimProjection.Empty,
|
|
|
|
|
MaterialRevision = "material-" + id,
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-29 09:56:35 +00:00
|
|
|
private static IdentityProviderConnection ConfigurationConnection(string key, bool isEnabled = false) => new()
|
|
|
|
|
{
|
|
|
|
|
Id = "configuration-" + key,
|
|
|
|
|
TenantId = ConnectionScope.HostTenantId,
|
|
|
|
|
Key = key,
|
|
|
|
|
AdapterType = "test",
|
|
|
|
|
AdapterSettingsVersion = 1,
|
|
|
|
|
AdapterSettings = JsonDocument.Parse("{}").RootElement.Clone(),
|
|
|
|
|
DisplayName = "Configuration " + key,
|
|
|
|
|
IsEnabled = isEnabled,
|
|
|
|
|
ClaimProjection = ClaimProjection.Empty,
|
|
|
|
|
MaterialRevision = "m-configuration-" + key,
|
|
|
|
|
Revision = 1
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
private sealed class TestAdapterRegistry(IExternalAuthenticationAdapter registeredAdapter) : IExternalAuthenticationAdapterRegistry
|
2026-07-24 16:59:17 +00:00
|
|
|
{
|
2026-07-25 01:48:50 +00:00
|
|
|
public IReadOnlyCollection<ExternalAuthenticationAdapterDescriptor> ListDescriptors() => [registeredAdapter.Describe()];
|
2026-07-24 16:59:17 +00:00
|
|
|
public bool TryGet(string type, out IExternalAuthenticationAdapter adapter)
|
|
|
|
|
{
|
2026-07-25 01:48:50 +00:00
|
|
|
adapter = registeredAdapter;
|
|
|
|
|
return string.Equals(type, registeredAdapter.Type, StringComparison.Ordinal);
|
2026-07-24 16:59:17 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestAdapter : IExternalAuthenticationAdapter
|
|
|
|
|
{
|
|
|
|
|
public string Type => "test";
|
2026-08-01 23:43:30 +00:00
|
|
|
public bool RequiresClientSecret { get; set; }
|
2026-07-24 16:59:17 +00:00
|
|
|
public ExternalAuthenticationAdapterDescriptor Describe() => new(Type, "Test", "Test adapter", 2,
|
|
|
|
|
[
|
2026-08-01 23:43:30 +00:00
|
|
|
new SettingFieldDescriptor("clientSecret", "Client secret", "Secret", "secret", RequiresClientSecret, "secret", null, [], new SettingFieldValidation(), true, false, null, null, true),
|
2026-07-24 16:59:17 +00:00
|
|
|
new SettingFieldDescriptor("unsafeMode", "Unsafe mode", "Unsafe", "boolean", false, "toggle", null, [], new SettingFieldValidation(), false, true, null, null, false)
|
|
|
|
|
], new(false, false, false), null);
|
|
|
|
|
public ValueTask<ConnectionValidationResult> ValidateAsync(ConnectionValidationContext context, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var settings = context.Connection.Connection.AdapterSettings;
|
|
|
|
|
var valid = settings.ValueKind == JsonValueKind.Object && settings.TryGetProperty("valid", out var value) && value.ValueKind == JsonValueKind.True;
|
|
|
|
|
return ValueTask.FromResult(valid
|
|
|
|
|
? new ConnectionValidationResult(true, [], [])
|
|
|
|
|
: new ConnectionValidationResult(false, [new ConnectionValidationError("adapterSettings.valid", "required", "The test adapter requires valid=true.")], []));
|
|
|
|
|
}
|
|
|
|
|
public ValueTask<ExternalAuthorizationRequest> CreateAuthorizationRequestAsync(ExternalAuthorizationContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
|
public ValueTask<ExternalAuthenticationResult> AuthenticateCallbackAsync(ExternalCallbackContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
|
public ValueTask<ConnectionTestResult> TestAsync(ConnectionTestContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
|
public ValueTask<ExternalLogoutRequest?> CreateLogoutRequestAsync(ExternalLogoutContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestAdapterSettingsMigrationService : IAdapterSettingsMigrationService
|
|
|
|
|
{
|
|
|
|
|
public bool CanMigrateVersionOne { get; set; } = true;
|
|
|
|
|
|
|
|
|
|
public ValueTask<AdapterSettingsMigrationResult> MigrateAsync(string adapterType, int settingsVersion, JsonElement settings, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (!string.Equals(adapterType, "test", StringComparison.Ordinal) || settingsVersion is < 1 or > 2 || (settingsVersion == 1 && !CanMigrateVersionOne))
|
|
|
|
|
throw new InvalidOperationException("No compatible settings migration is available.");
|
|
|
|
|
|
|
|
|
|
return ValueTask.FromResult(new AdapterSettingsMigrationResult(2, settings.Clone(), settingsVersion == 1));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-25 01:48:50 +00:00
|
|
|
private sealed class TestUnlinkedIdentityPolicyRegistry : IUnlinkedIdentityPolicyRegistry
|
|
|
|
|
{
|
|
|
|
|
private readonly IUnlinkedIdentityPolicy _matchUser = new TestUnlinkedIdentityPolicy("match-user");
|
|
|
|
|
|
|
|
|
|
public IReadOnlyCollection<UnlinkedIdentityPolicyDescriptor> ListDescriptors() => [];
|
|
|
|
|
public bool TryGet(string type, out IUnlinkedIdentityPolicy policy)
|
|
|
|
|
{
|
|
|
|
|
policy = _matchUser;
|
|
|
|
|
return string.Equals(type, policy.Type, StringComparison.Ordinal);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestUnlinkedIdentityPolicy(string type) : IUnlinkedIdentityPolicy
|
|
|
|
|
{
|
|
|
|
|
public string Type => type;
|
|
|
|
|
public UnlinkedIdentityPolicyDescriptor Describe() => new(Type, Type, Type, 1, [], null);
|
|
|
|
|
public ValueTask<UnlinkedIdentityDecision> EvaluateAsync(UnlinkedIdentityContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestExternalUserMatcherRegistry : IExternalUserMatcherRegistry
|
|
|
|
|
{
|
|
|
|
|
private readonly IReadOnlyDictionary<string, IExternalUserMatcher> _items;
|
|
|
|
|
|
|
|
|
|
public TestExternalUserMatcherRegistry(params string[] types) => _items = types
|
|
|
|
|
.Select(type => (IExternalUserMatcher)new TestExternalUserMatcher(type))
|
|
|
|
|
.ToDictionary(x => x.Type, StringComparer.Ordinal);
|
|
|
|
|
|
|
|
|
|
public IReadOnlyCollection<ExternalUserMatcherDescriptor> ListDescriptors() => _items.Values.Select(x => x.Describe()).ToArray();
|
|
|
|
|
public bool TryGet(string type, out IExternalUserMatcher matcher) => _items.TryGetValue(type, out matcher!);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestExternalUserMatcher(string type) : IExternalUserMatcher
|
|
|
|
|
{
|
|
|
|
|
public string Type => type;
|
|
|
|
|
public ExternalUserMatcherDescriptor Describe() => new(Type, Type, Type, 1, [], null);
|
|
|
|
|
public ValueTask<ExternalUserMatchResult> MatchAsync(ExternalUserMatcherContext context, CancellationToken cancellationToken = default) => throw new NotSupportedException();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestRoleAuthorizationService : IRoleAuthorizationService
|
|
|
|
|
{
|
|
|
|
|
public bool CanAssignRoles { get; set; } = true;
|
|
|
|
|
public bool ThrowOnAssignRoles { get; set; }
|
|
|
|
|
public IReadOnlyCollection<string> LastRequestedRoleIds { get; private set; } = [];
|
|
|
|
|
|
|
|
|
|
public Task<bool> CanAssignRolesAsync(ClaimsPrincipal user, IEnumerable<string>? roleIds, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (ThrowOnAssignRoles)
|
|
|
|
|
throw new InvalidOperationException("Test role authorization failure.");
|
|
|
|
|
LastRequestedRoleIds = (roleIds ?? []).ToArray();
|
|
|
|
|
return Task.FromResult(CanAssignRoles);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool CanCreateRoleWithPermissions(ClaimsPrincipal user, IEnumerable<string>? permissions) => true;
|
|
|
|
|
public bool CanMutateRole(ClaimsPrincipal user, Elsa.Identity.Entities.Role role, IEnumerable<string>? replacementPermissions = null) => true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private sealed class TestManagedSecretBindingWriter : IManagedSecretBindingWriter
|
|
|
|
|
{
|
|
|
|
|
private int _sequence;
|
|
|
|
|
|
|
|
|
|
public string ResolverType => "test-managed";
|
|
|
|
|
public string DisplayName => "Test managed secrets";
|
|
|
|
|
public Func<Task>? BeforeReturn { get; set; }
|
|
|
|
|
public string? ReferenceToReturn { get; set; }
|
|
|
|
|
public List<string> RemovedReferences { get; } = [];
|
|
|
|
|
|
|
|
|
|
public async ValueTask<SecretBinding> StageAsync(ManagedSecretBindingWriteRequest request, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
if (BeforeReturn is not null)
|
|
|
|
|
await BeforeReturn();
|
|
|
|
|
var reference = ReferenceToReturn ?? $"staged-{Interlocked.Increment(ref _sequence)}";
|
|
|
|
|
return new SecretBinding(ResolverType, reference, Ownership: SecretBindingOwnership.Managed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public ValueTask RemoveAsync(SecretBinding binding, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
RemovedReferences.Add(binding.Reference);
|
|
|
|
|
return ValueTask.CompletedTask;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 09:56:35 +00:00
|
|
|
private sealed class TestSecretBindingResolver : ISecretBindingResolver
|
|
|
|
|
{
|
|
|
|
|
public string Type => "test-managed";
|
|
|
|
|
public ValueTask<SecretBindingState> GetStateAsync(SecretBinding binding, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var isConfigured = string.Equals(binding.Reference, "preserved-secret", StringComparison.Ordinal);
|
|
|
|
|
return ValueTask.FromResult(new SecretBindingState(isConfigured, isConfigured));
|
|
|
|
|
}
|
|
|
|
|
public ValueTask<ResolvedSecretBinding> ResolveAsync(SecretBinding binding, CancellationToken cancellationToken = default) => ValueTask.FromResult(new ResolvedSecretBinding(new SensitiveString("secret"), "test"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-24 16:59:17 +00:00
|
|
|
private sealed class TestConnectionRegistry(IIdentityProviderConnectionStore store) : IIdentityProviderConnectionRegistry
|
|
|
|
|
{
|
|
|
|
|
public IdentityProviderConnection? ConfigurationConnection { get; set; }
|
|
|
|
|
|
|
|
|
|
public async ValueTask<EffectiveConnectionRegistry> GetAsync(string targetTenantId, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
var rows = await store.FindAsync(new ConnectionFilter(), cancellationToken);
|
|
|
|
|
var database = rows.Items.Where(x => x.TenantId == targetTenantId || x.TenantId == ConnectionScope.HostTenantId)
|
|
|
|
|
.Select(x => new EffectiveIdentityProviderConnection(x, ConnectionSourceOwnership.Database, ToScope(x.TenantId), ConnectionValidity.Unknown, false, "database"));
|
|
|
|
|
IEnumerable<EffectiveIdentityProviderConnection> configuration = ConfigurationConnection is not null && (ConfigurationConnection.TenantId == targetTenantId || ConfigurationConnection.TenantId == ConnectionScope.HostTenantId)
|
|
|
|
|
? [new EffectiveIdentityProviderConnection(ConfigurationConnection, ConnectionSourceOwnership.Configuration, ToScope(ConfigurationConnection.TenantId), ConnectionValidity.Unknown, false, "configuration")]
|
|
|
|
|
: Array.Empty<EffectiveIdentityProviderConnection>();
|
2026-07-29 09:56:35 +00:00
|
|
|
var candidates = configuration.Concat(database).ToArray();
|
|
|
|
|
var connections = candidates
|
|
|
|
|
.GroupBy(x => ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), StringComparer.Ordinal)
|
|
|
|
|
.SelectMany(group =>
|
|
|
|
|
{
|
|
|
|
|
var candidatesForKey = group.ToArray();
|
|
|
|
|
var preferred = candidatesForKey.FirstOrDefault(x => x.Ownership == ConnectionSourceOwnership.Database && x.Connection.OverridesConfigurationConnection && !x.Connection.ArchivedAt.HasValue)
|
|
|
|
|
?? candidatesForKey.FirstOrDefault(x => x.Ownership == ConnectionSourceOwnership.Configuration)
|
|
|
|
|
?? candidatesForKey[0];
|
2026-07-30 23:00:36 +00:00
|
|
|
var preferredReference = ToReference(preferred);
|
|
|
|
|
var shadowedReferences = candidatesForKey
|
|
|
|
|
.Where(candidate => !ReferenceEquals(candidate, preferred))
|
|
|
|
|
.Select(ToReference)
|
|
|
|
|
.ToArray();
|
|
|
|
|
return candidatesForKey.Select(candidate =>
|
|
|
|
|
{
|
|
|
|
|
var isShadowed = !ReferenceEquals(candidate, preferred);
|
|
|
|
|
return candidate with
|
|
|
|
|
{
|
|
|
|
|
IsShadowed = isShadowed,
|
|
|
|
|
ShadowedBy = isShadowed ? preferredReference : null,
|
|
|
|
|
Shadows = isShadowed ? [] : shadowedReferences
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-07-29 09:56:35 +00:00
|
|
|
})
|
|
|
|
|
.ToArray();
|
2026-07-24 16:59:17 +00:00
|
|
|
return new EffectiveConnectionRegistry(connections, [], "test");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async ValueTask<EffectiveIdentityProviderConnection?> FindByKeyAsync(string targetTenantId, string key, CancellationToken cancellationToken = default) => (await GetAsync(targetTenantId, cancellationToken)).Connections.FirstOrDefault(x => string.Equals(x.Connection.Key, key, StringComparison.Ordinal));
|
|
|
|
|
public async ValueTask<EffectiveIdentityProviderConnection?> FindByIdAsync(string targetTenantId, string connectionId, CancellationToken cancellationToken = default) => (await GetAsync(targetTenantId, cancellationToken)).Connections.FirstOrDefault(x => string.Equals(x.Connection.Id, connectionId, StringComparison.Ordinal));
|
|
|
|
|
private static ConnectionScope ToScope(string tenantId) => tenantId == ConnectionScope.HostTenantId ? ConnectionScope.Host : tenantId.Length == 0 ? ConnectionScope.DefaultTenant : new ConnectionScope(ConnectionScopeKind.Tenant, tenantId);
|
2026-07-30 23:00:36 +00:00
|
|
|
private static IdentityProviderConnectionReference ToReference(EffectiveIdentityProviderConnection connection) =>
|
|
|
|
|
new(connection.Connection.Id, connection.Connection.DisplayName, connection.Ownership);
|
2026-07-24 16:59:17 +00:00
|
|
|
}
|
|
|
|
|
}
|