* 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>
1094 lines
63 KiB
C#
1094 lines
63 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 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);
|
|
}
|
|
}
|