elsa-core/test/integration/Elsa.ExternalAuthentication.IntegrationTests/Connections/ConnectionManagementTests.cs
Sipke Schoorstra 80a5892ecd
fix(external-auth): keep the default-roles guard when an update omits the policy (#7996)
* fix(external-auth): keep the default-roles guard when an update omits the policy

A PUT that omitted unlinkedPolicy hit ValidatePolicyAsync's null-policy early
return before the PolicyDefaultRoles guard, so an actor holding only
connections:update could clear a stored create-user policy and silently drop
its default-role assignments (refs #7977, #7992). The candidate role set is
now computed before the early return -- empty when the policy is omitted --
so clearing, adding, or switching a policy all count as changing default
roles. The cheap permission check also runs before the registry-backed role
comparison, so the common permitted path skips building the registry.

Adds integration coverage for the omitted-policy transition in both
directions and for clearing a policy that assigns no roles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(external-auth): dispose the request message in PutConnectionAsync

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 11:45:44 +02:00

1169 lines
67 KiB
C#

using Elsa.Authorization;
using Elsa.Extensions;
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;
using Microsoft.Extensions.Options;
using NSubstitute;
using Elsa.ExternalAuthentication.IntegrationTests.Fixtures;
namespace Elsa.ExternalAuthentication.IntegrationTests.Connections;
[Collection(nameof(EndpointSecurityCollection))]
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!;
private TestAdapter _adapter = null!;
private TestRoleAuthorizationService _roleAuthorizationService = null!;
private TestManagedSecretBindingWriter _managedSecretWriter = null!;
private IExternalAuthenticationSessionStore _sessions = null!;
private INotificationSender _notifications = null!;
private bool _unsafePermissionGranted = true;
/// <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;
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();
// 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();
builder.Services.Configure<ExternalAuthenticationOptions>(options =>
{
options.EnableDatabaseConnections = true;
options.AllowedAdapterTypes = [];
options.AllowedUnlinkedIdentityPolicyTypes = [];
options.AllowedExternalUserMatcherTypes = ["allowed-matcher"];
options.AllowedPermissionGrantSourceTypes = [];
options.UnlinkedIdentityPolicy.AllowDatabaseConnectionOverride = true;
options.FinalLoginPathGuard.IsEnabled = false;
options.Redirects.ExternalCallbackBaseUri = new Uri("https://elsa.example/elsa/api/");
});
_store = new InMemoryIdentityProviderConnectionStore();
_registryVersions = new InMemoryConnectionRegistryVersionStore();
_observations = new InMemoryConnectionObservationStore();
_registry = new TestConnectionRegistry(_store);
builder.Services.AddSingleton<IIdentityProviderConnectionStore>(_store);
builder.Services.AddSingleton<IIdentityProviderConnectionRegistry>(_registry);
builder.Services.AddSingleton<FinalLoginPathGuard>();
builder.Services.AddSingleton<IConnectionRegistryVersionStore>(_registryVersions);
builder.Services.AddSingleton<IConnectionObservationStore>(_observations);
builder.Services.AddSingleton<ConnectionRevisionCalculator>();
_adapter = new TestAdapter();
builder.Services.AddSingleton<IExternalAuthenticationAdapterRegistry>(new TestAdapterRegistry(_adapter));
_settingsMigrations = new TestAdapterSettingsMigrationService();
builder.Services.AddSingleton<IAdapterSettingsMigrationService>(_settingsMigrations);
builder.Services.AddSingleton<IIdentityProviderConnectionValidityAssessor, IdentityProviderConnectionValidityAssessor>();
builder.Services.AddSingleton<IUnlinkedIdentityPolicyRegistry>(new TestUnlinkedIdentityPolicyRegistry());
builder.Services.AddSingleton<IExternalUserMatcherRegistry>(new TestExternalUserMatcherRegistry("allowed-matcher", "disallowed-matcher"));
builder.Services.AddScoped(_ => Substitute.For<IPermissionGrantSourceRegistry>());
builder.Services.AddSingleton<IPermissionDelegationAuthorizer>(Substitute.For<IPermissionDelegationAuthorizer>());
_roleAuthorizationService = new TestRoleAuthorizationService();
builder.Services.AddSingleton<IRoleAuthorizationService>(_roleAuthorizationService);
_notifications = Substitute.For<INotificationSender>();
builder.Services.AddSingleton(_notifications);
builder.Services.AddSingleton<ISystemClock, SystemClock>();
_sessions = Substitute.For<IExternalAuthenticationSessionStore>();
builder.Services.AddSingleton(_sessions);
_managedSecretWriter = new TestManagedSecretBindingWriter();
builder.Services.AddSingleton<IManagedSecretBindingWriter>(_managedSecretWriter);
builder.Services.AddSingleton<ISecretBindingResolver>(new TestSecretBindingResolver());
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) =>
{
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"));
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);
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);
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);
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);
}
var update = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{createdDocument.Id}") { Content = JsonContent.Create(CreateRequest("contoso", displayName: "Updated")) };
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);
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());
var stale = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{createdDocument.Id}") { Content = JsonContent.Create(CreateRequest("contoso", displayName: "Stale")) };
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);
}
[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());
}
[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());
}
[Fact]
public async Task ConfigurationConnectionIsReadOnlyAndBlocksSameScopeKeyCreation()
{
_registry.ConfigurationConnection = ConfigurationConnection("contoso");
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);
var secret = new HttpRequestMessage(HttpMethod.Put, "/external-authentication/connections/configuration-contoso/secret-bindings/clientSecret/managed") { Content = JsonContent.Create(new { resolverType = "test-managed", value = "secret" }) };
secret.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
Assert.Equal(HttpStatusCode.Forbidden, (await _client.SendAsync(secret)).StatusCode);
}
[Fact]
public async Task ShadowedDatabaseConnectionAdvertisesPromotionCapabilityOnlyWhenAllowedAndActive()
{
const string connectionId = "database-contoso";
_registry.ConfigurationConnection = ConfigurationConnection("contoso");
await _store.CreateAsync(DatabaseConnection(connectionId, ConnectionScope.HostTenantId, "contoso"));
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);
_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);
}
[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);
}
[Fact]
public async Task ConnectionsAreManagedHostWideRegardlessOfCurrentTenant()
{
var client = _client!;
foreach (var scope in new[] { new { kind = "default", tenantId = (string?)null }, new { kind = "tenant", tenantId = (string?)"tenant-b" } })
{
var response = await _client!.PostAsJsonAsync("/external-authentication/connections", CreateRequest("scope-" + scope.kind, scope));
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("host_scope_required", await response.Content.ReadAsStringAsync());
}
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")) };
update.Headers.TryAddWithoutValidation("If-Match", "\"1\"");
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(update)).StatusCode);
var secret = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{host.Id}/secret-bindings/clientSecret/managed") { Content = JsonContent.Create(new { resolverType = "test-managed", value = "secret" }) };
secret.Headers.TryAddWithoutValidation("If-Match", "\"2\"");
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(secret)).StatusCode);
await _store.CreateAsync(DatabaseConnection("legacy-tenant", "tenant-a", "legacy-tenant"));
Assert.Equal(HttpStatusCode.NotFound, (await client.GetAsync("/external-authentication/connections/legacy-tenant")).StatusCode);
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!;
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));
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);
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" });
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());
var secret = new HttpRequestMessage(HttpMethod.Put, $"/external-authentication/connections/{connection.Id}/secret-bindings/clientSecret/managed") { Content = JsonContent.Create(new { resolverType = "test-managed", value = "secret" }) };
secret.Headers.TryAddWithoutValidation("If-Match", "\"2\"");
Assert.Equal(HttpStatusCode.OK, (await client.SendAsync(secret)).StatusCode);
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);
}
[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);
}
[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());
}
[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);
}
[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>()
}));
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
{
key,
scope = scope ?? new { kind = "host" },
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 },
upstreamLogoutMode,
confirmUnsafeSettings,
overridesConfigurationConnection,
unlinkedPolicy
};
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" }
}));
private sealed class ConnectionDocument
{
public string Id { get; set; } = null!;
public string? CallbackUri { get; set; }
public string? PreviewCallbackUri { get; set; }
public bool EnabledIntent { get; set; }
public int AdapterSettingsVersion { get; set; }
public bool CanPromoteToConfigurationOverride { get; set; }
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!;
}
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);
}
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
};
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
};
private sealed class TestAdapterRegistry(IExternalAuthenticationAdapter registeredAdapter) : IExternalAuthenticationAdapterRegistry
{
public IReadOnlyCollection<ExternalAuthenticationAdapterDescriptor> ListDescriptors() => [registeredAdapter.Describe()];
public bool TryGet(string type, out IExternalAuthenticationAdapter adapter)
{
adapter = registeredAdapter;
return string.Equals(type, registeredAdapter.Type, StringComparison.Ordinal);
}
}
private sealed class TestAdapter : IExternalAuthenticationAdapter
{
public string Type => "test";
public bool RequiresClientSecret { get; set; }
public ExternalAuthenticationAdapterDescriptor Describe() => new(Type, "Test", "Test adapter", 2,
[
new SettingFieldDescriptor("clientSecret", "Client secret", "Secret", "secret", RequiresClientSecret, "secret", null, [], new SettingFieldValidation(), true, false, null, null, true),
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));
}
}
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;
}
}
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"));
}
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>();
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];
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
};
});
})
.ToArray();
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);
private static IdentityProviderConnectionReference ToReference(EffectiveIdentityProviderConnection connection) =>
new(connection.Connection.Id, connection.Connection.DisplayName, connection.Ownership);
}
}