Skip to content

Commit 22bc531

Browse files
committed
Allow promoting shadowed authentication connections
1 parent 238080c commit 22bc531

6 files changed

Lines changed: 166 additions & 16 deletions

File tree

specs/012-external-authentication/contracts/rest-api.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@ Requires `external-authentication:connections:read`. Maximum `pageSize` is 100.
313313
"key": "contoso",
314314
"source": "database",
315315
"overridesConfigurationConnection": true,
316+
"canCreateOverride": false,
317+
"canPromoteToConfigurationOverride": false,
316318
"adapterType": "openid-connect",
317319
"callbackUri": "https://elsa.example/elsa/api/external-authentication/callback/contoso",
318320
"previewCallbackUri": "https://elsa.example/elsa/api/external-authentication/previews/callback/01JZCONNECTION",
@@ -381,6 +383,8 @@ POST /external-authentication/connections
381383

382384
Requires `external-authentication:connections:create`. Studio starts with a complete editable copy of the configuration-owned connection, preserves its immutable logical `key`, and submits the ordinary create document with `"overridesConfigurationConnection": true`. The server creates a distinct database record with `source=database`; subsequent saves send the whole document to the ordinary update endpoint. No inherited field markers or partial patch semantics exist. A disabled database override continues shadowing the configuration-owned connection. Archiving it reveals configuration; restoring it resumes shadowing in disabled state.
383385

386+
Connection responses expose `canCreateOverride` for configuration-owned connections when deployment policy permits creating a database override. They expose `canPromoteToConfigurationOverride` for an unarchived, shadowed database-owned connection when the same policy permits promotion. Clients promote that existing record by updating its ordinary document with `"overridesConfigurationConnection": true`; this preserves its ID, secret bindings, and enabled lifecycle instead of creating another database record. A promotion that would remove the final normal sign-in path returns `409 conflict` with `error="conflict"` and `details.code="final_login_path_guard"`.
387+
384388
### Detail and Update
385389

386390
```http

src/clients/Elsa.Api.Client/Resources/ExternalAuthentication/Connections/Models/ExternalAuthenticationConnection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public sealed class ExternalAuthenticationConnection
2323
public bool IsPreferred { get; set; }
2424
public bool OverridesConfigurationConnection { get; set; }
2525
public bool CanCreateOverride { get; set; }
26+
public bool CanPromoteToConfigurationOverride { get; set; }
2627
public bool EnabledIntent { get; set; }
2728
public bool EffectivelyEnabled { get; set; }
2829
public string Validity { get; set; } = "";

src/modules/Elsa.ExternalAuthentication/Endpoints/Connections/ConnectionManagementModels.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ internal sealed class ConnectionResponse
108108
public bool IsPreferred { get; init; }
109109
public bool OverridesConfigurationConnection { get; init; }
110110
public bool CanCreateOverride { get; init; }
111+
public bool CanPromoteToConfigurationOverride { get; init; }
111112
public bool EnabledIntent { get; init; }
112113
public bool EffectivelyEnabled { get; init; }
113114
public string Validity { get; init; } = null!;
@@ -157,6 +158,7 @@ public static async ValueTask<ConnectionResponse> FromAsync(EffectiveIdentityPro
157158
IsPreferred = effective.Connection.IsPreferred,
158159
OverridesConfigurationConnection = effective.Connection.OverridesConfigurationConnection,
159160
CanCreateOverride = effective.Ownership == ConnectionSourceOwnership.Configuration && management.CanCreateConfigurationOverride(),
161+
CanPromoteToConfigurationOverride = management.CanPromoteToConfigurationOverride(effective),
160162
EnabledIntent = effective.Connection.IsEnabled,
161163
EffectivelyEnabled = effective.Connection.IsEnabled && !effective.Connection.ArchivedAt.HasValue && !effective.IsShadowed && effective.Validity != ConnectionValidity.Invalid,
162164
Validity = effective.Validity.ToString().ToLowerInvariant(),

src/modules/Elsa.ExternalAuthentication/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Use `BindExternalAuthenticationOptions` for `IConfiguration` binding so the arbi
2828
- `ExternalAuthentication:Connections` defines immutable, configuration-owned connections.
2929
- Database-owned connections are optional and controlled by `EnableDatabaseConnections`.
3030
- Configuration takes precedence over a database connection with the same effective key and scope. Studio shows the database row as shadowed instead of silently overwriting it.
31+
- When `AllowConfigurationConnectionOverrides` is enabled, an administrator can promote an unarchived shadowed database connection into an explicit override, preserving that record, its secret bindings, and its lifecycle. A promotion that would remove the final normal sign-in path is rejected by the final-login-path guard.
3132
- Authentication Clients, extension allowlists, permission boundaries, egress policy, and final-login recovery policy remain deployment-owned.
3233

3334
An empty `AllowedAdapterTypes` collection permits every installed adapter. The built-in policy allowlist contains `reject` and `create-user`; the built-in grant-source allowlist contains `elsa-roles`, `claim-mapping`, `group-mapping`, and `claim-pass-through`.

src/modules/Elsa.ExternalAuthentication/Services/IdentityProviderConnectionManagementService.cs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,32 @@ public async ValueTask<ConnectionValidationResult> ValidateAsync(IdentityProvide
260260

261261
public bool CanCreateConfigurationOverride() => options.Value.AllowConfigurationConnectionOverrides;
262262

263+
public bool CanPromoteToConfigurationOverride(EffectiveIdentityProviderConnection connection) =>
264+
connection.Ownership == ConnectionSourceOwnership.Database &&
265+
connection.IsShadowed &&
266+
!connection.Connection.ArchivedAt.HasValue &&
267+
options.Value.AllowConfigurationConnectionOverrides;
268+
263269
private async ValueTask<bool> IsBlockedByFinalLoginPathGuardAsync(IdentityProviderConnection existing, IdentityProviderConnection candidate, string targetTenantId, ClaimsPrincipal actor, bool confirmedOverride, CancellationToken cancellationToken)
264270
{
265271
var guard = services.GetService<FinalLoginPathGuard>();
266-
return guard is not null && await guard.AuthorizeAsync(existing, candidate, targetTenantId, actor, confirmedOverride, cancellationToken) == FinalLoginPathGuardResult.Denied;
272+
if (guard is null)
273+
return false;
274+
275+
var guardExisting = existing;
276+
if (!existing.OverridesConfigurationConnection && candidate.OverridesConfigurationConnection)
277+
{
278+
var normalizedKey = ConnectionRevisionCalculator.NormalizeKey(candidate.Key);
279+
var effective = await registry.GetAsync(targetTenantId, cancellationToken);
280+
var displacedConfigurationConnection = effective.Connections.FirstOrDefault(x =>
281+
x.Ownership == ConnectionSourceOwnership.Configuration &&
282+
!x.IsShadowed &&
283+
string.Equals(ConnectionRevisionCalculator.NormalizeKey(x.Connection.Key), normalizedKey, StringComparison.Ordinal));
284+
if (displacedConfigurationConnection is not null)
285+
guardExisting = displacedConfigurationConnection.Connection;
286+
}
287+
288+
return await guard.AuthorizeAsync(guardExisting, candidate, targetTenantId, actor, confirmedOverride, cancellationToken) == FinalLoginPathGuardResult.Denied;
267289
}
268290

269291
private async ValueTask<ManagementConnectionMutationResult> ProcessMutationAsync(ConnectionMutationResult result, ClaimsPrincipal actor, string operation, ConnectionLifecycle? previousLifecycle, CancellationToken cancellationToken, IdentityProviderConnection? previousConnection = null)
@@ -479,6 +501,7 @@ private void NormalizeForUpdate(IdentityProviderConnection candidate, IdentityPr
479501
{
480502
candidate.Key = candidate.Key?.Trim() ?? string.Empty;
481503
candidate.TenantId = ConnectionScope.HostTenantId;
504+
candidate.IsEnabled = existing.IsEnabled;
482505
candidate.UpdatedAt = clock.UtcNow;
483506
candidate.SecretBindings ??= new Dictionary<string, SecretBinding>(StringComparer.Ordinal);
484507
candidate.PermissionGrantSources ??= [];

0 commit comments

Comments
 (0)