Add LDAP Distinguished Name Helper and Credential Audit Context
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented LdapDistinguishedNameHelper for escaping RDN and filter values.
- Created AuthorityCredentialAuditContext and IAuthorityCredentialAuditContextAccessor for managing credential audit context.
- Developed StandardCredentialAuditLogger with tests for success, failure, and lockout events.
- Introduced AuthorityAuditSink for persisting audit records with structured logging.
- Added CryptoPro related classes for certificate resolution and signing operations.
This commit is contained in:
master
2025-11-09 12:21:38 +02:00
parent ba4c935182
commit 75c2bcafce
385 changed files with 7354 additions and 7344 deletions

View File

@@ -0,0 +1,193 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Plugin.Ldap.Claims;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Claims;
public class LdapClaimsEnricherTests
{
private const string PluginName = "ldap";
[Fact]
public async Task EnrichAsync_AddsRoles_FromStaticMapping()
{
var options = CreateOptions();
options.Claims.GroupToRoleMap["cn=stellaops-admins,ou=groups,dc=example,dc=internal"] = "operators";
var connection = new FakeLdapConnection
{
OnFindAsync = (_, _, attributes, _) =>
{
Assert.Contains("memberOf", attributes);
var attr = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
["memberOf"] = new[] { "cn=stellaops-admins,ou=groups,dc=example,dc=internal" }
};
return ValueTask.FromResult<LdapSearchEntry?>(new LdapSearchEntry("uid=j.doe,ou=people,dc=example,dc=internal", attr));
}
};
var enricher = CreateEnricher(options, connection, new FakeLdapClaimsCache());
var identity = new ClaimsIdentity();
var context = CreateContext("uid=j.doe,ou=people,dc=example,dc=internal");
await enricher.EnrichAsync(identity, context, CancellationToken.None);
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "operators");
}
[Fact]
public async Task EnrichAsync_AddsRoles_FromRegexMapping()
{
var options = CreateOptions();
options.Claims.RegexMappings.Add(new LdapRegexMappingOptions
{
Pattern = "^cn=stellaops-(?P<role>[a-z-]+),",
RoleFormat = "{role}"
});
options.Claims.Normalize();
var connection = new FakeLdapConnection
{
OnFindAsync = (_, _, _, _) =>
{
var attr = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
["memberOf"] = new[] { "cn=stellaops-incident,ou=groups,dc=example,dc=internal" }
};
return ValueTask.FromResult<LdapSearchEntry?>(new LdapSearchEntry("uid=ops,ou=people,dc=example,dc=internal", attr));
}
};
var enricher = CreateEnricher(options, connection, new FakeLdapClaimsCache());
var identity = new ClaimsIdentity();
var context = CreateContext("uid=ops,ou=people,dc=example,dc=internal");
await enricher.EnrichAsync(identity, context, CancellationToken.None);
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "incident");
}
[Fact]
public async Task EnrichAsync_AddsExtraAttributes()
{
var options = CreateOptions();
options.Claims.ExtraAttributes["displayName"] = "displayName";
options.Claims.ExtraAttributes["email"] = "mail";
options.Claims.Normalize();
var connection = new FakeLdapConnection
{
OnFindAsync = (_, _, _, _) =>
{
var attr = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
["displayName"] = new[] { "Alice Example" },
["mail"] = new[] { "alice@example.test" },
["memberOf"] = new[] { "cn=stellaops-admins,ou=groups,dc=example,dc=internal" }
};
return ValueTask.FromResult<LdapSearchEntry?>(new LdapSearchEntry("uid=alice,ou=people,dc=example,dc=internal", attr));
}
};
var cache = new FakeLdapClaimsCache();
var enricher = CreateEnricher(options, connection, cache);
var identity = new ClaimsIdentity();
var context = CreateContext("uid=alice,ou=people,dc=example,dc=internal");
await enricher.EnrichAsync(identity, context, CancellationToken.None);
Assert.Contains(identity.Claims, claim => claim.Type == "displayName" && claim.Value == "Alice Example");
Assert.Contains(identity.Claims, claim => claim.Type == "email" && claim.Value == "alice@example.test");
Assert.Equal(1, cache.SetCount);
}
[Fact]
public async Task EnrichAsync_UsesCacheWhenAvailable()
{
var options = CreateOptions();
var cache = new FakeLdapClaimsCache
{
Cached = new LdapCachedClaims(
new[] { "operators" },
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["displayName"] = "Cached User"
})
};
var connection = new FakeLdapConnection();
var enricher = CreateEnricher(options, connection, cache);
var identity = new ClaimsIdentity();
var context = CreateContext("uid=cached,ou=people,dc=example,dc=internal");
await enricher.EnrichAsync(identity, context, CancellationToken.None);
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "operators");
Assert.Contains(identity.Claims, claim => claim.Type == "displayName" && claim.Value == "Cached User");
Assert.Equal(0, connection.Operations.Count);
Assert.Equal(0, cache.SetCount);
}
private static LdapClaimsEnricher CreateEnricher(
LdapPluginOptions options,
FakeLdapConnection connection,
ILdapClaimsCache cache)
{
var monitor = new TestOptionsMonitor<LdapPluginOptions>(options);
return new LdapClaimsEnricher(
PluginName,
new FakeLdapConnectionFactory(connection),
monitor,
cache,
TimeProvider.System,
NullLogger<LdapClaimsEnricher>.Instance);
}
private static AuthorityClaimsEnrichmentContext CreateContext(string subjectId)
{
var manifest = new AuthorityPluginManifest(
PluginName,
PluginName,
true,
null,
null,
Array.Empty<string>(),
new Dictionary<string, string?>(),
"ldap.yaml");
var configuration = new ConfigurationBuilder().Build();
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var user = new AuthorityUserDescriptor(
subjectId,
"username",
"User",
false,
Array.Empty<string>(),
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
return new AuthorityClaimsEnrichmentContext(pluginContext, user, null);
}
private static LdapPluginOptions CreateOptions()
{
var options = new LdapPluginOptions();
options.Claims.GroupAttribute = "memberOf";
options.Claims.Normalize();
return options;
}
}

View File

@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.Claims;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Claims;
public sealed class MongoLdapClaimsCacheTests : IAsyncLifetime
{
private readonly MongoDbRunner runner;
private readonly IMongoDatabase database;
public MongoLdapClaimsCacheTests()
{
runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
database = client.GetDatabase("ldap-claims-cache-tests");
}
[Fact]
public async Task SetAndGet_RoundTripsClaims()
{
var cache = CreateCache(enabled: true);
var claims = new LdapCachedClaims(
new[] { "operators" },
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["displayName"] = "Alice Example"
});
await cache.SetAsync("uid=alice,ou=people,dc=example,dc=internal", claims, CancellationToken.None);
var fetched = await cache.GetAsync("uid=alice,ou=people,dc=example,dc=internal", CancellationToken.None);
Assert.NotNull(fetched);
Assert.Contains("operators", fetched!.Roles);
Assert.Equal("Alice Example", fetched.Attributes["displayName"]);
}
[Fact]
public async Task GetAsync_ReturnsNull_WhenExpired()
{
var timeProvider = new TestTimeProvider(new DateTimeOffset(2025, 11, 9, 6, 0, 0, TimeSpan.Zero));
var cache = CreateCache(enabled: true, ttlSeconds: 60, timeProvider: timeProvider);
await cache.SetAsync("uid=expired,ou=people,dc=example,dc=internal", new LdapCachedClaims(Array.Empty<string>(), new Dictionary<string, string>()), CancellationToken.None);
timeProvider.Advance(TimeSpan.FromMinutes(5));
var fetched = await cache.GetAsync("uid=expired,ou=people,dc=example,dc=internal", CancellationToken.None);
Assert.Null(fetched);
}
[Fact]
public async Task SetAsync_EnforcesCapacity()
{
var cache = CreateCache(enabled: true, ttlSeconds: 600, maxEntries: 1);
await cache.SetAsync("uid=first,ou=people,dc=example,dc=internal", new LdapCachedClaims(Array.Empty<string>(), new Dictionary<string, string>()), CancellationToken.None);
await cache.SetAsync("uid=second,ou=people,dc=example,dc=internal", new LdapCachedClaims(Array.Empty<string>(), new Dictionary<string, string>()), CancellationToken.None);
var first = await cache.GetAsync("uid=first,ou=people,dc=example,dc=internal", CancellationToken.None);
var second = await cache.GetAsync("uid=second,ou=people,dc=example,dc=internal", CancellationToken.None);
Assert.Null(first);
Assert.NotNull(second);
}
private MongoLdapClaimsCache CreateCache(bool enabled, int ttlSeconds = 600, int maxEntries = 5000, TimeProvider? timeProvider = null)
{
var options = new LdapClaimsCacheOptions
{
Enabled = enabled,
CollectionName = $"ldap_claims_cache_tests_{Guid.NewGuid():N}",
TtlSeconds = ttlSeconds,
MaxEntries = maxEntries
};
options.Normalize();
options.Validate("ldap");
return new MongoLdapClaimsCache(
"ldap",
database,
options,
timeProvider ?? TimeProvider.System,
NullLogger<MongoLdapClaimsCache>.Instance);
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
runner.Dispose();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Mongo2Go;
using MongoDB.Bson;
using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
public sealed class LdapClientProvisioningStoreTests : IAsyncLifetime
{
private readonly MongoDbRunner runner;
private readonly IMongoDatabase database;
private readonly TestTimeProvider timeProvider = new(new DateTimeOffset(2025, 11, 9, 8, 0, 0, TimeSpan.Zero));
public LdapClientProvisioningStoreTests()
{
runner = MongoDbRunner.Start(singleNodeReplSet: true);
var client = new MongoClient(runner.ConnectionString);
database = client.GetDatabase("ldap-client-prov-tests");
}
[Fact]
public async Task CreateOrUpdateAsync_WritesToMongoLdapAndAudit()
{
ClearAudit();
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection();
var options = CreateOptions();
var optionsMonitor = new TestOptionsMonitor<LdapPluginOptions>(options);
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
database,
timeProvider,
NullLogger<LdapClientProvisioningStore>.Instance);
var registration = new AuthorityClientRegistration(
clientId: "svc-bootstrap",
confidential: true,
displayName: "Bootstrap Client",
clientSecret: "SuperSecret1!",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "signer" });
var result = await store.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(clientStore.Documents.ContainsKey("svc-bootstrap"));
Assert.Contains(fakeConnection.Operations, op => op.StartsWith("bind:", StringComparison.OrdinalIgnoreCase));
Assert.Contains(fakeConnection.Operations, op => op.StartsWith("add:cn=svc-bootstrap", StringComparison.OrdinalIgnoreCase));
var auditCollection = database.GetCollection<BsonDocument>("ldap_client_provisioning_audit");
var auditRecords = await auditCollection.Find(Builders<BsonDocument>.Filter.Empty).ToListAsync();
Assert.Single(auditRecords);
Assert.Equal("svc-bootstrap", auditRecords[0]["clientId"].AsString);
Assert.Equal("upsert", auditRecords[0]["operation"].AsString);
}
[Fact]
public async Task DeleteAsync_RemovesClientAndLogsRevocation()
{
ClearAudit();
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection
{
OnDeleteAsync = (_, _) => ValueTask.FromResult(true)
};
var options = CreateOptions();
var optionsMonitor = new TestOptionsMonitor<LdapPluginOptions>(options);
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
database,
timeProvider,
NullLogger<LdapClientProvisioningStore>.Instance);
clientStore.Documents["svc-bootstrap"] = new AuthorityClientDocument
{
ClientId = "svc-bootstrap",
Plugin = "ldap",
ClientType = "confidential",
SecretHash = "hash"
};
var result = await store.DeleteAsync("svc-bootstrap", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.DoesNotContain("svc-bootstrap", clientStore.Documents.Keys);
Assert.Single(revocationStore.Upserts);
Assert.Contains(fakeConnection.Operations, op => op.StartsWith("delete:cn=svc-bootstrap", StringComparison.OrdinalIgnoreCase));
var auditCollection = database.GetCollection<BsonDocument>("ldap_client_provisioning_audit");
var auditRecords = await auditCollection.Find(Builders<BsonDocument>.Filter.Empty).ToListAsync();
Assert.Contains(auditRecords, doc => doc["operation"] == "delete");
}
[Fact]
public async Task CreateOrUpdateAsync_ReturnsFailure_WhenDisabled()
{
ClearAudit();
var clientStore = new TrackingClientStore();
var revocationStore = new TrackingRevocationStore();
var fakeConnection = new FakeLdapConnection();
var options = CreateOptions();
options.ClientProvisioning.Enabled = false;
var optionsMonitor = new TestOptionsMonitor<LdapPluginOptions>(options);
var store = new LdapClientProvisioningStore(
"ldap",
clientStore,
revocationStore,
new FakeLdapConnectionFactory(fakeConnection),
optionsMonitor,
database,
timeProvider,
NullLogger<LdapClientProvisioningStore>.Instance);
var registration = new AuthorityClientRegistration(
clientId: "svc-bootstrap",
confidential: true,
displayName: "Bootstrap Client",
clientSecret: "SuperSecret1!",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" });
var result = await store.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal("disabled", result.ErrorCode);
}
private LdapPluginOptions CreateOptions()
{
var temp = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(temp);
var options = new LdapPluginOptions
{
Connection = new LdapConnectionOptions
{
Host = "ldaps://ldap.example.internal",
BindDn = "cn=svc,dc=example,dc=internal",
BindPasswordSecret = "bind-secret",
UserDnFormat = "uid={username},ou=people,dc=example,dc=internal"
},
ClientProvisioning = new LdapClientProvisioningOptions
{
Enabled = true,
ContainerDn = "ou=service,dc=example,dc=internal",
SecretAttribute = "userPassword",
AuditMirror = new LdapClientProvisioningAuditOptions
{
Enabled = true,
CollectionName = "ldap_client_provisioning_audit"
}
}
};
options.Normalize(Path.Combine(temp, "ldap.yaml"));
options.Validate("ldap");
return options;
}
public Task InitializeAsync() => Task.CompletedTask;
public Task DisposeAsync()
{
runner.Dispose();
return Task.CompletedTask;
}
private void ClearAudit()
{
try
{
database.DropCollection("ldap_client_provisioning_audit");
}
catch (MongoCommandException)
{
// collection may not exist yet
}
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
{
public List<AuthorityRevocationDocument> Upserts { get; } = new();
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Upserts.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(true);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
}

View File

@@ -0,0 +1,28 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Plugin.Ldap.Claims;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
internal sealed class FakeLdapClaimsCache : ILdapClaimsCache
{
public LdapCachedClaims? Cached { get; set; }
public int GetCount { get; private set; }
public int SetCount { get; private set; }
public ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken)
{
GetCount++;
return ValueTask.FromResult(Cached);
}
public ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
{
SetCount++;
Cached = claims;
return ValueTask.CompletedTask;
}
}

View File

@@ -29,6 +29,12 @@ internal sealed class FakeLdapConnection : ILdapConnectionHandle
public Func<string, string, IReadOnlyCollection<string>, CancellationToken, ValueTask<LdapSearchEntry?>>? OnFindAsync { get; set; }
public Func<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>, CancellationToken, ValueTask>? OnAddAsync { get; set; }
public Func<string, IReadOnlyDictionary<string, IReadOnlyCollection<string>>, CancellationToken, ValueTask>? OnModifyAsync { get; set; }
public Func<string, CancellationToken, ValueTask<bool>>? OnDeleteAsync { get; set; }
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
public ValueTask BindAsync(string distinguishedName, string password, CancellationToken cancellationToken)
@@ -48,4 +54,31 @@ internal sealed class FakeLdapConnection : ILdapConnectionHandle
? ValueTask.FromResult<LdapSearchEntry?>(null)
: OnFindAsync(baseDn, filter, attributes, cancellationToken);
}
public ValueTask AddEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
operations.Add($"add:{distinguishedName}");
return OnAddAsync is null
? ValueTask.CompletedTask
: OnAddAsync(distinguishedName, attributes, cancellationToken);
}
public ValueTask ModifyEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
operations.Add($"modify:{distinguishedName}");
return OnModifyAsync is null
? ValueTask.CompletedTask
: OnModifyAsync(distinguishedName, attributes, cancellationToken);
}
public ValueTask<bool> DeleteEntryAsync(string distinguishedName, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
operations.Add($"delete:{distinguishedName}");
return OnDeleteAsync is null
? ValueTask.FromResult(true)
: OnDeleteAsync(distinguishedName, cancellationToken);
}
}

View File

@@ -283,6 +283,83 @@ public class LdapPluginOptionsTests : IDisposable
Assert.Equal("TLS_AES_256_GCM_SHA384", Assert.Single(options.Security.AllowedCipherSuites));
}
[Fact]
public void Normalize_TrimsClaimsConfiguration()
{
var options = ValidOptions();
options.Claims.GroupAttribute = " memberOf ";
options.Claims.GroupToRoleMap = new Dictionary<string, string>
{
{ " cn=stellaops-admins,ou=groups,dc=example,dc=internal ", " operators " }
};
options.Claims.RegexMappings.Add(new LdapRegexMappingOptions
{
Pattern = " ^cn=stellaops-(?P<role>[a-z-]+),ou=groups,dc=example,dc=internal$ ",
RoleFormat = " {role} "
});
options.Claims.ExtraAttributes = new Dictionary<string, string>
{
{ " displayName ", " displayName " }
};
options.Claims.Cache.Enabled = true;
options.Claims.Cache.CollectionName = " cache_collection ";
options.Claims.Cache.TtlSeconds = 0;
options.Claims.Cache.MaxEntries = -1;
options.Normalize(Path.Combine(tempRoot, "ldap.yaml"));
Assert.Equal("memberOf", options.Claims.GroupAttribute);
Assert.Equal("operators", options.Claims.GroupToRoleMap["cn=stellaops-admins,ou=groups,dc=example,dc=internal"]);
Assert.Equal("{role}", options.Claims.RegexMappings[0].RoleFormat);
Assert.Equal("displayName", options.Claims.ExtraAttributes["displayName"]);
Assert.Equal("cache_collection", options.Claims.Cache.CollectionName);
Assert.Equal(600, options.Claims.Cache.TtlSeconds);
Assert.Equal(0, options.Claims.Cache.MaxEntries);
}
[Fact]
public void Validate_AllowsClaimsCacheWithoutExplicitCollection()
{
var options = ValidOptions();
options.Claims.Cache.Enabled = true;
options.Claims.Cache.CollectionName = " ";
options.Normalize(Path.Combine(tempRoot, "ldap.yaml"));
options.Validate("corp-ldap");
Assert.Equal("ldap_claims_cache_corp-ldap", options.Claims.Cache.ResolveCollectionName("corp-ldap"));
}
[Fact]
public void Normalize_ClientProvisioningOptions()
{
var options = ValidOptions();
options.ClientProvisioning.Enabled = true;
options.ClientProvisioning.ContainerDn = " ou=service,dc=example,dc=internal ";
options.ClientProvisioning.SecretAttribute = " userPassword ";
options.ClientProvisioning.AuditMirror.CollectionName = " audit_log ";
options.Normalize(Path.Combine(tempRoot, "ldap.yaml"));
Assert.Equal("ou=service,dc=example,dc=internal", options.ClientProvisioning.ContainerDn);
Assert.Equal("userPassword", options.ClientProvisioning.SecretAttribute);
Assert.Equal("audit_log", options.ClientProvisioning.AuditMirror.CollectionName);
}
[Fact]
public void Validate_Throws_WhenClientProvisioningMissingContainer()
{
var options = ValidOptions();
options.ClientProvisioning.Enabled = true;
options.ClientProvisioning.ContainerDn = " ";
options.ClientProvisioning.SecretAttribute = "userPassword";
options.Normalize(Path.Combine(tempRoot, "ldap.yaml"));
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("corp-ldap"));
Assert.Contains("clientProvisioning.containerDn", ex.Message, StringComparison.OrdinalIgnoreCase);
}
private static LdapPluginOptions ValidOptions()
{
return new LdapPluginOptions

View File

@@ -10,6 +10,10 @@
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugin.Ldap\\StellaOps.Authority.Plugin.Ldap.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,31 @@
using System;
using Microsoft.Extensions.Options;
namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
internal sealed class TestOptionsMonitor<T> : IOptionsMonitor<T>
{
private T currentValue;
public TestOptionsMonitor(T value)
{
currentValue = value;
}
public T CurrentValue => currentValue;
public T Get(string? name) => currentValue;
public IDisposable OnChange(Action<T, string> listener) => NullDisposable.Instance;
public void Update(T value) => currentValue = value;
private sealed class NullDisposable : IDisposable
{
public static readonly NullDisposable Instance = new();
public void Dispose()
{
}
}
}

View File

@@ -0,0 +1,20 @@
using System;
namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
internal sealed class TestTimeProvider : TimeProvider
{
private DateTimeOffset current;
public TestTimeProvider(DateTimeOffset start)
{
current = start;
}
public override DateTimeOffset GetUtcNow() => current;
public void Advance(TimeSpan delta)
{
current += delta;
}
}

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Plugin.Ldap.Bootstrap;
internal sealed class LdapBootstrapAuditDocument
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("plugin")]
public string Plugin { get; set; } = string.Empty;
[BsonElement("username")]
public string Username { get; set; } = string.Empty;
[BsonElement("dn")]
public string DistinguishedName { get; set; } = string.Empty;
[BsonElement("operation")]
public string Operation { get; set; } = string.Empty;
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[BsonElement("metadata")]
public Dictionary<string, string?> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Authority.Plugin.Ldap.Claims;
internal interface ILdapClaimsCache
{
ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken);
ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken);
}
internal sealed record LdapCachedClaims(IReadOnlyList<string> Roles, IReadOnlyDictionary<string, string> Attributes);
internal sealed class DisabledLdapClaimsCache : ILdapClaimsCache
{
public static DisabledLdapClaimsCache Instance { get; } = new();
private DisabledLdapClaimsCache()
{
}
public ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken)
=> ValueTask.FromResult<LdapCachedClaims?>(null);
public ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}

View File

@@ -1,15 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugin.Ldap.Claims;
internal sealed class LdapClaimsEnricher : IClaimsEnricher
{
public ValueTask EnrichAsync(
private static readonly Regex PlaceholderRegex = new("{(?<name>[^}]+)}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly string pluginName;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly ILdapClaimsCache claimsCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<LdapClaimsEnricher> logger;
public LdapClaimsEnricher(
string pluginName,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
ILdapClaimsCache claimsCache,
TimeProvider timeProvider,
ILogger<LdapClaimsEnricher> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.claimsCache = claimsCache ?? throw new ArgumentNullException(nameof(claimsCache));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask EnrichAsync(
ClaimsIdentity identity,
AuthorityClaimsEnrichmentContext context,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
{
ArgumentNullException.ThrowIfNull(identity);
ArgumentNullException.ThrowIfNull(context);
var user = context.User;
if (user?.SubjectId is null)
{
return;
}
var options = optionsMonitor.Get(pluginName).Claims;
if (!HasWork(options))
{
return;
}
var cached = await claimsCache.GetAsync(user.SubjectId, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
ApplyRoleClaims(identity, cached.Roles);
ApplyAttributeClaims(identity, cached.Attributes);
return;
}
var attributes = BuildAttributeProjection(options);
if (attributes.Count == 0)
{
return;
}
try
{
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
var entry = await connection
.FindEntryAsync(user.SubjectId, "(objectClass=*)", attributes, cancellationToken)
.ConfigureAwait(false);
if (entry is null)
{
logger.LogWarning("LDAP claims enrichment could not locate subject {Subject} for plugin {Plugin}.", user.SubjectId, pluginName);
return;
}
var roles = ResolveRoles(options, entry.Attributes);
var extraClaims = ResolveExtraAttributes(options, entry.Attributes);
ApplyRoleClaims(identity, roles);
ApplyAttributeClaims(identity, extraClaims);
if (roles.Count > 0 || extraClaims.Count > 0)
{
await claimsCache.SetAsync(
user.SubjectId,
new LdapCachedClaims(roles, extraClaims),
cancellationToken).ConfigureAwait(false);
}
}
catch (LdapTransientException ex)
{
logger.LogWarning(ex, "LDAP claims enrichment transient failure for plugin {Plugin}.", pluginName);
}
catch (LdapOperationException ex)
{
logger.LogWarning(ex, "LDAP claims enrichment failed for plugin {Plugin}.", pluginName);
}
}
private static bool HasWork(LdapClaimsOptions options)
=> !string.IsNullOrWhiteSpace(options.GroupAttribute)
|| options.GroupToRoleMap.Count > 0
|| options.RegexMappings.Count > 0
|| options.ExtraAttributes.Count > 0;
private static IReadOnlyCollection<string> BuildAttributeProjection(LdapClaimsOptions options)
{
var attributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(options.GroupAttribute))
{
attributes.Add(options.GroupAttribute!);
}
foreach (var attribute in options.ExtraAttributes.Values)
{
if (!string.IsNullOrWhiteSpace(attribute))
{
attributes.Add(attribute);
}
}
return attributes;
}
private static IReadOnlyList<string> ResolveRoles(
LdapClaimsOptions options,
IReadOnlyDictionary<string, IReadOnlyList<string>> attributes)
{
var roles = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(options.GroupAttribute) &&
attributes.TryGetValue(options.GroupAttribute!, out var groupValues))
{
foreach (var group in groupValues)
{
var normalized = group?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
if (options.GroupToRoleMap.TryGetValue(normalized, out var mappedRole))
{
AddRole(roles, mappedRole);
}
foreach (var regex in options.RegexMappings)
{
var match = Regex.Match(normalized, regex.Pattern!, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
if (!match.Success)
{
continue;
}
var formatted = PlaceholderRegex.Replace(regex.RoleFormat!, placeholder =>
{
var groupName = placeholder.Groups["name"].Value;
return match.Groups[groupName]?.Value ?? string.Empty;
});
AddRole(roles, formatted);
}
}
}
return roles.ToArray();
}
private static void AddRole(ISet<string> roles, string? value)
{
var normalized = value?.Trim();
if (!string.IsNullOrWhiteSpace(normalized))
{
roles.Add(normalized);
}
}
private static IReadOnlyDictionary<string, string> ResolveExtraAttributes(
LdapClaimsOptions options,
IReadOnlyDictionary<string, IReadOnlyList<string>> attributes)
{
if (options.ExtraAttributes.Count == 0)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var result = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var mapping in options.ExtraAttributes)
{
if (string.IsNullOrWhiteSpace(mapping.Key) || string.IsNullOrWhiteSpace(mapping.Value))
{
continue;
}
if (!attributes.TryGetValue(mapping.Value, out var values) || values.Count == 0)
{
continue;
}
var attributeValue = values[0];
if (!string.IsNullOrWhiteSpace(attributeValue))
{
result[mapping.Key] = attributeValue;
}
}
return result;
}
private static void ApplyRoleClaims(ClaimsIdentity identity, IEnumerable<string> roles)
{
foreach (var role in roles)
{
if (!identity.HasClaim(ClaimTypes.Role, role))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
}
}
private static void ApplyAttributeClaims(ClaimsIdentity identity, IReadOnlyDictionary<string, string> attributes)
{
foreach (var pair in attributes)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
if (!identity.HasClaim(pair.Key, pair.Value))
{
identity.AddClaim(new Claim(pair.Key, pair.Value));
}
}
}
}

View File

@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace StellaOps.Authority.Plugin.Ldap.Claims;
internal sealed class MongoLdapClaimsCache : ILdapClaimsCache
{
private readonly string pluginName;
private readonly IMongoCollection<LdapClaimsCacheDocument> collection;
private readonly LdapClaimsCacheOptions options;
private readonly TimeProvider timeProvider;
private readonly ILogger<MongoLdapClaimsCache> logger;
private readonly TimeSpan entryLifetime;
public MongoLdapClaimsCache(
string pluginName,
IMongoDatabase database,
LdapClaimsCacheOptions cacheOptions,
TimeProvider timeProvider,
ILogger<MongoLdapClaimsCache> logger)
{
ArgumentNullException.ThrowIfNull(pluginName);
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(cacheOptions);
this.pluginName = pluginName;
options = cacheOptions;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
entryLifetime = TimeSpan.FromSeconds(cacheOptions.TtlSeconds);
var collectionName = cacheOptions.ResolveCollectionName(pluginName);
collection = database.GetCollection<LdapClaimsCacheDocument>(collectionName);
EnsureIndexes();
}
public async ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
var document = await collection
.Find(doc => doc.Id == BuildDocumentId(subjectId))
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (document is null)
{
return null;
}
if (document.ExpiresAt <= timeProvider.GetUtcNow())
{
await collection.DeleteOneAsync(doc => doc.Id == document.Id, cancellationToken).ConfigureAwait(false);
return null;
}
IReadOnlyList<string> roles = document.Roles is { Count: > 0 }
? document.Roles.AsReadOnly()
: Array.Empty<string>();
var attributes = document.Attributes is { Count: > 0 }
? new Dictionary<string, string>(document.Attributes, StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new LdapCachedClaims(roles, attributes);
}
public async ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
ArgumentNullException.ThrowIfNull(claims);
if (options.MaxEntries > 0)
{
await EnforceCapacityAsync(options.MaxEntries, cancellationToken).ConfigureAwait(false);
}
var now = timeProvider.GetUtcNow();
var document = new LdapClaimsCacheDocument
{
Id = BuildDocumentId(subjectId),
Plugin = pluginName,
SubjectId = subjectId,
CachedAt = now,
ExpiresAt = now + entryLifetime,
Roles = claims.Roles?.ToList() ?? new List<string>(),
Attributes = claims.Attributes?.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase) ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
};
await collection.ReplaceOneAsync(
existing => existing.Id == document.Id,
document,
new ReplaceOptions { IsUpsert = true },
cancellationToken).ConfigureAwait(false);
}
private string BuildDocumentId(string subjectId)
=> $"{pluginName}:{subjectId}".ToLowerInvariant();
private async Task EnforceCapacityAsync(int maxEntries, CancellationToken cancellationToken)
{
var total = await collection.CountDocumentsAsync(
Builders<LdapClaimsCacheDocument>.Filter.Empty,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (total < maxEntries)
{
return;
}
var surplus = (int)(total - maxEntries + 1);
var ids = await collection
.Find(Builders<LdapClaimsCacheDocument>.Filter.Empty)
.SortBy(doc => doc.CachedAt)
.Limit(surplus)
.Project(doc => doc.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (ids.Count == 0)
{
return;
}
var deleteFilter = Builders<LdapClaimsCacheDocument>.Filter.In(doc => doc.Id, ids);
await collection.DeleteManyAsync(deleteFilter, cancellationToken).ConfigureAwait(false);
}
private void EnsureIndexes()
{
var expiresIndex = Builders<LdapClaimsCacheDocument>.IndexKeys.Ascending(doc => doc.ExpiresAt);
var indexModel = new CreateIndexModel<LdapClaimsCacheDocument>(
expiresIndex,
new CreateIndexOptions
{
Name = "idx_expires_at",
ExpireAfter = TimeSpan.Zero
});
try
{
collection.Indexes.CreateOne(indexModel);
}
catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(ex, "LDAP claims cache index already exists for plugin {Plugin}.", pluginName);
}
}
}
internal sealed class LdapClaimsCacheDocument
{
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("plugin")]
public string Plugin { get; set; } = string.Empty;
[BsonElement("subjectId")]
public string SubjectId { get; set; } = string.Empty;
[BsonElement("roles")]
public List<string> Roles { get; set; } = new();
[BsonElement("attributes")]
public Dictionary<string, string> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("cachedAt")]
public DateTimeOffset CachedAt { get; set; }
[BsonElement("expiresAt")]
public DateTimeOffset ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Concurrent;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal sealed record LdapCapabilitySnapshot(bool ClientProvisioningWritable, bool BootstrapWritable);
internal static class LdapCapabilitySnapshotCache
{
private static readonly ConcurrentDictionary<string, LdapCapabilitySnapshot> Cache = new(StringComparer.OrdinalIgnoreCase);
public static LdapCapabilitySnapshot GetOrAdd(string pluginName, Func<LdapCapabilitySnapshot> factory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
ArgumentNullException.ThrowIfNull(factory);
return Cache.GetOrAdd(pluginName, _ => factory());
}
}

View File

@@ -0,0 +1,467 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
{
private static readonly IReadOnlyCollection<string> DefaultObjectClasses = new[]
{
"top",
"person",
"organizationalPerson",
"inetOrgPerson"
};
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly IMongoDatabase mongoDatabase;
private readonly TimeProvider clock;
private readonly ILogger<LdapClientProvisioningStore> logger;
public LdapClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
IMongoDatabase mongoDatabase,
TimeProvider clock,
ILogger<LdapClientProvisioningStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.mongoDatabase = mongoDatabase ?? throw new ArgumentNullException(nameof(mongoDatabase));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private bool ProvisioningEnabled => GetProvisioningOptions().Enabled;
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
if (!ProvisioningEnabled)
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("disabled", "Client provisioning is disabled for this plugin.");
}
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
ApplyRegistration(document, registration);
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
var options = GetProvisioningOptions();
try
{
await SyncLdapAsync(registration, options, cancellationToken).ConfigureAwait(false);
await WriteAuditRecordAsync("upsert", document, options, cancellationToken).ConfigureAwait(false);
}
catch (LdapInsufficientAccessException ex)
{
logger.LogError(ex, "LDAP provisioning denied for client {ClientId}.", registration.ClientId);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("ldap_permission_denied", ex.Message);
}
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
{
logger.LogError(ex, "LDAP provisioning failed for client {ClientId}.", registration.ClientId);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("ldap_error", ex.Message);
}
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return document is null ? null : ToDescriptor(document);
}
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
if (!ProvisioningEnabled)
{
return AuthorityPluginOperationResult.Failure("disabled", "Client provisioning is disabled for this plugin.");
}
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var options = GetProvisioningOptions();
var distinguishedName = BuildDistinguishedName(clientId, options);
try
{
await RemoveLdapEntryAsync(distinguishedName, cancellationToken).ConfigureAwait(false);
await WriteAuditRecordAsync("delete", new AuthorityClientDocument
{
ClientId = clientId,
Properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
Plugin = pluginName,
SenderConstraint = null
}, options, cancellationToken).ConfigureAwait(false);
}
catch (LdapInsufficientAccessException ex)
{
logger.LogWarning(ex, "LDAP delete denied for client {ClientId}. Continuing with revocation.", clientId);
}
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
{
logger.LogWarning(ex, "LDAP delete failed for client {ClientId}. Continuing with revocation.", clientId);
}
await RecordRevocationAsync(clientId, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult.Success();
}
private async Task SyncLdapAsync(
AuthorityClientRegistration registration,
LdapClientProvisioningOptions options,
CancellationToken cancellationToken)
{
var connectionOptions = optionsMonitor.Get(pluginName).Connection;
var bindSecret = LdapSecretResolver.Resolve(connectionOptions.BindPasswordSecret);
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
await connection.BindAsync(connectionOptions.BindDn!, bindSecret, cancellationToken).ConfigureAwait(false);
var distinguishedName = BuildDistinguishedName(registration.ClientId, options);
var attributes = BuildAttributes(registration, options);
var filter = $"({options.RdnAttribute}={LdapDistinguishedNameHelper.EscapeFilterValue(registration.ClientId)})";
var existing = await connection.FindEntryAsync(options.ContainerDn!, filter, Array.Empty<string>(), cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
}
else
{
await connection.ModifyEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
}
}
private async Task RemoveLdapEntryAsync(string distinguishedName, CancellationToken cancellationToken)
{
var connectionOptions = optionsMonitor.Get(pluginName).Connection;
var bindSecret = LdapSecretResolver.Resolve(connectionOptions.BindPasswordSecret);
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
await connection.BindAsync(connectionOptions.BindDn!, bindSecret, cancellationToken).ConfigureAwait(false);
await connection.DeleteEntryAsync(distinguishedName, cancellationToken).ConfigureAwait(false);
}
private async Task WriteAuditRecordAsync(
string operation,
AuthorityClientDocument document,
LdapClientProvisioningOptions options,
CancellationToken cancellationToken)
{
if (!options.AuditMirror.Enabled)
{
return;
}
var collectionName = options.ResolveAuditCollectionName(pluginName);
var collection = mongoDatabase.GetCollection<LdapClientProvisioningAuditDocument>(collectionName);
var record = new LdapClientProvisioningAuditDocument
{
Plugin = pluginName,
ClientId = document.ClientId,
DistinguishedName = BuildDistinguishedName(document.ClientId, options),
Operation = operation,
SecretHash = document.SecretHash,
Tenant = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) ? tenant : null,
Project = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var project) ? project : null,
Timestamp = clock.GetUtcNow(),
Metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["senderConstraint"] = document.SenderConstraint,
["plugin"] = pluginName
}
};
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private async Task RecordRevocationAsync(string clientId, CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if metadata write fails.
}
}
private void ApplyRegistration(AuthorityClientDocument document, AuthorityClientRegistration registration)
{
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
var tenant = NormalizeTenant(registration.Tenant);
if (!string.IsNullOrWhiteSpace(tenant))
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = tenant;
}
document.Properties[AuthorityClientMetadataKeys.Project] = registration.Project ?? StellaOpsTenancyDefaults.AnyProject;
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (!string.IsNullOrWhiteSpace(normalizedConstraint))
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
document.CertificateBindings = registration.CertificateBindings.Count == 0
? new List<AuthorityClientCertificateBinding>()
: registration.CertificateBindings.Select(binding => MapCertificateBinding(binding, clock.GetUtcNow())).ToList();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
audiences,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
{
return string.Empty;
}
return string.Join(
" ",
values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal));
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()
: registration.SubjectAlternativeNames
.Select(name => name.Trim())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AuthorityClientCertificateBinding
{
Thumbprint = registration.Thumbprint,
SerialNumber = registration.SerialNumber,
Subject = registration.Subject,
Issuer = registration.Issuer,
SubjectAlternativeNames = subjectAlternativeNames,
NotBefore = registration.NotBefore,
NotAfter = registration.NotAfter,
Label = registration.Label,
CreatedAt = now,
UpdatedAt = now
};
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim() switch
{
{ Length: 0 } => null,
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
_ => null
};
}
private IReadOnlyDictionary<string, IReadOnlyCollection<string>> BuildAttributes(
AuthorityClientRegistration registration,
LdapClientProvisioningOptions options)
{
var displayName = string.IsNullOrWhiteSpace(registration.DisplayName) ? registration.ClientId : registration.DisplayName!.Trim();
var attributes = new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase)
{
["objectClass"] = DefaultObjectClasses.ToArray(),
[options.RdnAttribute] = new[] { registration.ClientId },
["sn"] = new[] { registration.ClientId },
["displayName"] = new[] { displayName },
["description"] = new[] { $"StellaOps client {registration.ClientId}" }
};
if (!string.IsNullOrWhiteSpace(options.SecretAttribute) &&
registration.Confidential &&
!string.IsNullOrWhiteSpace(registration.ClientSecret))
{
attributes[options.SecretAttribute!] = new[] { registration.ClientSecret! };
}
return attributes;
}
private static string BuildDistinguishedName(string clientId, LdapClientProvisioningOptions options)
{
var escapedValue = LdapDistinguishedNameHelper.EscapeRdnValue(clientId);
return $"{options.RdnAttribute}={escapedValue},{options.ContainerDn}";
}
private LdapClientProvisioningOptions GetProvisioningOptions()
=> optionsMonitor.Get(pluginName).ClientProvisioning;
}
internal sealed class LdapClientProvisioningAuditDocument
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("plugin")]
public string Plugin { get; set; } = string.Empty;
[BsonElement("clientId")]
public string ClientId { get; set; } = string.Empty;
[BsonElement("dn")]
public string DistinguishedName { get; set; } = string.Empty;
[BsonElement("operation")]
public string Operation { get; set; } = string.Empty;
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("project")]
[BsonIgnoreIfNull]
public string? Project { get; set; }
[BsonElement("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[BsonElement("metadata")]
public Dictionary<string, string?> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal static class LdapDistinguishedNameHelper
{
public static string EscapeRdnValue(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var chars = value.ToCharArray();
var needsEscaping = chars[0] == ' ' || chars[0] == '#'
|| chars[^1] == ' '
|| HasSpecial(chars);
if (!needsEscaping)
{
return value;
}
var buffer = new List<char>(value.Length * 2);
for (var i = 0; i < chars.Length; i++)
{
var c = chars[i];
var escape = c is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '=';
if ((i == 0 && (c == ' ' || c == '#')) || (i == chars.Length - 1 && c == ' '))
{
escape = true;
}
if (escape)
{
buffer.Add('\\');
}
buffer.Add(c);
}
return new string(buffer.ToArray());
}
public static string EscapeFilterValue(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
return value
.Replace("\\", "\\5c", StringComparison.Ordinal)
.Replace("*", "\\2a", StringComparison.Ordinal)
.Replace("(", "\\28", StringComparison.Ordinal)
.Replace(")", "\\29", StringComparison.Ordinal)
.Replace("\0", "\\00", StringComparison.Ordinal);
}
private static bool HasSpecial(ReadOnlySpan<char> chars)
{
foreach (var c in chars)
{
if (c is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '=')
{
return true;
}
}
return false;
}
}

View File

@@ -170,6 +170,9 @@ internal sealed class DirectoryServicesLdapConnectionHandle : ILdapConnectionHan
private readonly ILogger logger;
private readonly LdapMetrics metrics;
private const int InvalidCredentialsResultCode = 49;
private const int NoSuchObjectResultCode = 32;
private const int AlreadyExistsResultCode = 68;
private const int InsufficientAccessRightsResultCode = 50;
private const int ServerDownResultCode = 81;
private const int TimeLimitExceededResultCode = 3;
private const int BusyResultCode = 51;
@@ -260,6 +263,115 @@ internal sealed class DirectoryServicesLdapConnectionHandle : ILdapConnectionHan
}
}
public ValueTask AddEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var request = new AddRequest(distinguishedName);
foreach (var attribute in attributes)
{
if (attribute.Value is null || attribute.Value.Count == 0)
{
continue;
}
request.Attributes.Add(new DirectoryAttribute(attribute.Key, attribute.Value.ToArray()));
}
connection.SendRequest(request);
return ValueTask.CompletedTask;
}
catch (DirectoryOperationException ex) when (ex.Response.ResultCode == ResultCode.EntryAlreadyExists)
{
throw new LdapOperationException($"LDAP entry '{distinguishedName}' already exists.", ex);
}
catch (LdapException ex) when (ex.ErrorCode == AlreadyExistsResultCode)
{
throw new LdapOperationException($"LDAP entry '{distinguishedName}' already exists.", ex);
}
catch (LdapException ex) when (ex.ErrorCode == InsufficientAccessRightsResultCode)
{
throw new LdapInsufficientAccessException($"LDAP bind user lacks permissions to add '{distinguishedName}'.", ex);
}
catch (LdapException ex)
{
throw new LdapOperationException($"LDAP add failure ({FormatResult(ex.ErrorCode)}).", ex);
}
}
public ValueTask ModifyEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var request = new ModifyRequest(distinguishedName);
foreach (var attribute in attributes)
{
var modification = new DirectoryAttributeModification
{
Name = attribute.Key
};
if (attribute.Value is null || attribute.Value.Count == 0)
{
modification.Operation = DirectoryAttributeOperation.Delete;
}
else
{
modification.Operation = DirectoryAttributeOperation.Replace;
foreach (var value in attribute.Value)
{
modification.Add(value);
}
}
request.Modifications.Add(modification);
}
connection.SendRequest(request);
return ValueTask.CompletedTask;
}
catch (LdapException ex) when (ex.ErrorCode == NoSuchObjectResultCode)
{
throw new LdapOperationException($"LDAP entry '{distinguishedName}' was not found.", ex);
}
catch (LdapException ex) when (ex.ErrorCode == InsufficientAccessRightsResultCode)
{
throw new LdapInsufficientAccessException($"LDAP bind user lacks permissions to modify '{distinguishedName}'.", ex);
}
catch (LdapException ex)
{
throw new LdapOperationException($"LDAP modify failure ({FormatResult(ex.ErrorCode)}).", ex);
}
}
public ValueTask<bool> DeleteEntryAsync(string distinguishedName, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var request = new DeleteRequest(distinguishedName);
connection.SendRequest(request);
return ValueTask.FromResult(true);
}
catch (LdapException ex) when (ex.ErrorCode == NoSuchObjectResultCode)
{
return ValueTask.FromResult(false);
}
catch (LdapException ex) when (ex.ErrorCode == InsufficientAccessRightsResultCode)
{
throw new LdapInsufficientAccessException($"LDAP bind user lacks permissions to delete '{distinguishedName}'.", ex);
}
catch (LdapException ex)
{
throw new LdapOperationException($"LDAP delete failure ({FormatResult(ex.ErrorCode)}).", ex);
}
}
private static bool IsInvalidCredentials(LdapException ex)
=> ex.ErrorCode == InvalidCredentialsResultCode;
@@ -276,6 +388,9 @@ internal sealed class DirectoryServicesLdapConnectionHandle : ILdapConnectionHan
ServerDownResultCode => "ServerDown (81)",
TimeLimitExceededResultCode => "TimeLimitExceeded (3)",
BusyResultCode => "Busy (51)",
AlreadyExistsResultCode => "EntryAlreadyExists (68)",
InsufficientAccessRightsResultCode => "InsufficientAccess (50)",
NoSuchObjectResultCode => "NoSuchObject (32)",
UnavailableResultCode => "Unavailable (52)",
_ => errorCode.ToString(CultureInfo.InvariantCulture)
};

View File

@@ -15,6 +15,12 @@ internal interface ILdapConnectionHandle : IAsyncDisposable
ValueTask BindAsync(string distinguishedName, string password, CancellationToken cancellationToken);
ValueTask<LdapSearchEntry?> FindEntryAsync(string baseDn, string filter, IReadOnlyCollection<string> attributes, CancellationToken cancellationToken);
ValueTask AddEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken);
ValueTask ModifyEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken);
ValueTask<bool> DeleteEntryAsync(string distinguishedName, CancellationToken cancellationToken);
}
internal sealed record LdapSearchEntry(string DistinguishedName, IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);

View File

@@ -25,3 +25,11 @@ internal class LdapOperationException : Exception
{
}
}
internal sealed class LdapInsufficientAccessException : LdapOperationException
{
public LdapInsufficientAccessException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}

View File

@@ -6,7 +6,9 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
@@ -24,6 +26,8 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
private readonly ILdapConnectionFactory connectionFactory;
private readonly ILogger<LdapCredentialStore> logger;
private readonly LdapMetrics metrics;
private readonly IMongoDatabase mongoDatabase;
private readonly TimeProvider timeProvider;
private readonly Func<TimeSpan, CancellationToken, Task> delayAsync;
public LdapCredentialStore(
@@ -32,6 +36,8 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
ILdapConnectionFactory connectionFactory,
ILogger<LdapCredentialStore> logger,
LdapMetrics metrics,
IMongoDatabase mongoDatabase,
TimeProvider timeProvider,
Func<TimeSpan, CancellationToken, Task>? delayAsync = null)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
@@ -39,6 +45,8 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
this.mongoDatabase = mongoDatabase ?? throw new ArgumentNullException(nameof(mongoDatabase));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.delayAsync = delayAsync ?? ((delay, token) => Task.Delay(delay, token));
}
@@ -185,13 +193,49 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
}
}
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
AuthorityUserRegistration registration,
CancellationToken cancellationToken)
{
return ValueTask.FromResult(AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
"not_supported",
"LDAP identity provider does not support provisioning users."));
ArgumentNullException.ThrowIfNull(registration);
var pluginOptions = optionsMonitor.Get(pluginName);
var bootstrapOptions = pluginOptions.Bootstrap;
if (!bootstrapOptions.Enabled)
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
"not_supported",
"LDAP identity provider does not support bootstrap provisioning.");
}
if (string.IsNullOrWhiteSpace(registration.Username) || string.IsNullOrWhiteSpace(registration.Password))
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
"invalid_request",
"Bootstrap provisioning requires a username and password.");
}
try
{
var descriptor = await ProvisionBootstrapUserAsync(registration, pluginOptions, bootstrapOptions, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(descriptor);
}
catch (LdapInsufficientAccessException ex)
{
logger.LogError(ex, "LDAP bootstrap provisioning denied for user {Username}.", registration.Username);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("ldap_permission_denied", ex.Message);
}
catch (LdapTransientException ex)
{
logger.LogWarning(ex, "LDAP bootstrap provisioning transient failure for user {Username}.", registration.Username);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("ldap_transient_error", ex.Message);
}
catch (LdapOperationException ex)
{
logger.LogError(ex, "LDAP bootstrap provisioning failed for user {Username}.", registration.Username);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("ldap_error", ex.Message);
}
}
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)

View File

@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Claims;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Credentials;
@@ -18,9 +19,10 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
private readonly LdapClaimsEnricher claimsEnricher;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly LdapClientProvisioningStore clientProvisioningStore;
private readonly ILogger<LdapIdentityProviderPlugin> logger;
private readonly AuthorityIdentityProviderCapabilities capabilities = new(true, false, false);
private readonly AuthorityIdentityProviderCapabilities capabilities;
private readonly bool supportsClientProvisioning;
public LdapIdentityProviderPlugin(
AuthorityPluginContext pluginContext,
@@ -28,6 +30,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
LdapClaimsEnricher claimsEnricher,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
LdapClientProvisioningStore clientProvisioningStore,
ILogger<LdapIdentityProviderPlugin> logger)
{
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
@@ -35,7 +38,32 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
this.claimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.clientProvisioningStore = clientProvisioningStore ?? throw new ArgumentNullException(nameof(clientProvisioningStore));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
var provisioningOptions = optionsMonitor.Get(pluginContext.Manifest.Name).ClientProvisioning;
supportsClientProvisioning = manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled;
if (manifestCapabilities.SupportsClientProvisioning && !provisioningOptions.Enabled)
{
this.logger.LogWarning(
"LDAP plugin '{PluginName}' manifest declares clientProvisioning, but configuration disabled it. Capability will be advertised as false.",
pluginContext.Manifest.Name);
}
if (manifestCapabilities.SupportsBootstrap)
{
this.logger.LogInformation(
"LDAP plugin '{PluginName}' manifest declares bootstrap capability, but it is not implemented yet. Capability will be advertised as false.",
pluginContext.Manifest.Name);
}
capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: manifestCapabilities.SupportsMfa,
SupportsClientProvisioning: supportsClientProvisioning,
SupportsBootstrap: false);
}
public string Name => pluginContext.Manifest.Name;
@@ -48,7 +76,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
public IClaimsEnricher ClaimsEnricher => claimsEnricher;
public IClientProvisioningStore? ClientProvisioning => null;
public IClientProvisioningStore? ClientProvisioning => supportsClientProvisioning ? clientProvisioningStore : null;
public AuthorityIdentityProviderCapabilities Capabilities => capabilities;

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Authority.Plugin.Ldap;
@@ -13,6 +14,12 @@ internal sealed class LdapPluginOptions
public LdapQueryOptions Queries { get; set; } = new();
public LdapClaimsOptions Claims { get; set; } = new();
public LdapClientProvisioningOptions ClientProvisioning { get; set; } = new();
public LdapBootstrapOptions Bootstrap { get; set; } = new();
public void Normalize(string configPath)
{
ArgumentNullException.ThrowIfNull(configPath);
@@ -20,6 +27,9 @@ internal sealed class LdapPluginOptions
Connection.Normalize(configPath);
Security.Normalize();
Queries.Normalize();
Claims.Normalize();
ClientProvisioning.Normalize();
Bootstrap.Normalize();
}
public void Validate(string pluginName)
@@ -29,6 +39,9 @@ internal sealed class LdapPluginOptions
Connection.Validate(pluginName);
Security.Validate(pluginName);
Queries.Validate(pluginName);
Claims.Validate(pluginName);
ClientProvisioning.Validate(pluginName);
Bootstrap.Validate(pluginName);
EnsureSecurityRequirements(pluginName);
}
@@ -364,3 +377,354 @@ internal sealed class LdapQueryOptions
}
}
}
internal sealed class LdapClaimsOptions
{
public string? GroupAttribute { get; set; } = "memberOf";
public Dictionary<string, string> GroupToRoleMap { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<LdapRegexMappingOptions> RegexMappings { get; set; } = new();
public Dictionary<string, string> ExtraAttributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public LdapClaimsCacheOptions Cache { get; set; } = new();
public void Normalize()
{
GroupAttribute = Normalize(GroupAttribute);
GroupToRoleMap = GroupToRoleMap?
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(pair => pair.Key.Trim(), pair => pair.Value.Trim(), StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
RegexMappings = RegexMappings?
.Where(mapping => mapping is not null)
.Select(mapping =>
{
mapping!.Normalize();
return mapping;
})
.ToList() ?? new List<LdapRegexMappingOptions>();
ExtraAttributes = ExtraAttributes?
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(pair => pair.Key.Trim(), pair => pair.Value.Trim(), StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Cache ??= new LdapClaimsCacheOptions();
Cache.Normalize();
}
public void Validate(string pluginName)
{
if (string.IsNullOrWhiteSpace(GroupAttribute) &&
ExtraAttributes.Count == 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' must configure claims.groupAttribute or claims.extraAttributes.");
}
for (var index = 0; index < RegexMappings.Count; index++)
{
var mapping = RegexMappings[index];
mapping.Validate(pluginName, index);
}
Cache.Validate(pluginName);
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal sealed class LdapClientProvisioningOptions
{
private const string DefaultRdnAttribute = "cn";
public bool Enabled { get; set; }
public string? ContainerDn { get; set; }
public string RdnAttribute { get; set; } = DefaultRdnAttribute;
public string? SecretAttribute { get; set; } = "userPassword";
public LdapClientProvisioningAuditOptions AuditMirror { get; set; } = new();
public void Normalize()
{
ContainerDn = Normalize(ContainerDn);
RdnAttribute = string.IsNullOrWhiteSpace(RdnAttribute) ? DefaultRdnAttribute : RdnAttribute.Trim();
SecretAttribute = string.IsNullOrWhiteSpace(SecretAttribute) ? null : SecretAttribute.Trim();
AuditMirror ??= new LdapClientProvisioningAuditOptions();
AuditMirror.Normalize();
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ContainerDn))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.containerDn when enabled.");
}
if (string.IsNullOrWhiteSpace(RdnAttribute))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.rdnAttribute when enabled.");
}
if (string.IsNullOrWhiteSpace(SecretAttribute))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.secretAttribute when enabled.");
}
AuditMirror.Validate(pluginName);
}
public string ResolveAuditCollectionName(string pluginName)
=> AuditMirror.ResolveCollectionName(pluginName);
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal sealed class LdapClientProvisioningAuditOptions
{
public bool Enabled { get; set; } = true;
public string? CollectionName { get; set; }
public void Normalize()
{
CollectionName = string.IsNullOrWhiteSpace(CollectionName) ? null : CollectionName.Trim();
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
var collection = ResolveCollectionName(pluginName);
if (string.IsNullOrWhiteSpace(collection))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.auditMirror.collectionName when enabled.");
}
}
public string ResolveCollectionName(string pluginName)
{
if (!string.IsNullOrWhiteSpace(CollectionName))
{
return CollectionName!;
}
var normalized = pluginName
.Replace(':', '_')
.Replace('/', '_')
.Replace('\\', '_');
return $"ldap_client_provisioning_{normalized}".ToLowerInvariant();
}
}
internal sealed class LdapRegexMappingOptions
{
private static readonly Regex PythonNamedGroupRegex = new(@"\(\?P<(?<name>[^>]+)>", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public string? Pattern { get; set; }
public string? RoleFormat { get; set; }
public void Normalize()
{
Pattern = string.IsNullOrWhiteSpace(Pattern) ? string.Empty : NormalizePattern(Pattern.Trim());
RoleFormat = string.IsNullOrWhiteSpace(RoleFormat) ? "{role}" : RoleFormat.Trim();
}
private static string NormalizePattern(string pattern)
{
if (pattern.Length == 0)
{
return pattern;
}
return PythonNamedGroupRegex.Replace(pattern, match => $"(?<{match.Groups["name"].Value}>");
}
public void Validate(string pluginName, int index)
{
if (string.IsNullOrWhiteSpace(Pattern))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.regexMappings[{index}].pattern to be specified.");
}
try
{
_ = new Regex(Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' claims.regexMappings[{index}].pattern is invalid: {ex.Message}", ex);
}
if (string.IsNullOrWhiteSpace(RoleFormat))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.regexMappings[{index}].roleFormat to be specified.");
}
}
}
internal sealed class LdapClaimsCacheOptions
{
public bool Enabled { get; set; }
public string? CollectionName { get; set; }
public int TtlSeconds { get; set; } = 600;
public int MaxEntries { get; set; } = 5000;
public void Normalize()
{
CollectionName = string.IsNullOrWhiteSpace(CollectionName) ? null : CollectionName.Trim();
if (TtlSeconds <= 0)
{
TtlSeconds = 600;
}
if (MaxEntries < 0)
{
MaxEntries = 0;
}
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
if (TtlSeconds <= 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.cache.ttlSeconds to be greater than zero when enabled.");
}
var collectionName = ResolveCollectionName(pluginName);
if (string.IsNullOrWhiteSpace(collectionName))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.cache.collectionName when cache is enabled.");
}
}
public string ResolveCollectionName(string pluginName)
{
if (!string.IsNullOrWhiteSpace(CollectionName))
{
return CollectionName!;
}
var normalized = pluginName
.Replace(':', '_')
.Replace('/', '_')
.Replace('\\', '_');
return $"ldap_claims_cache_{normalized}".ToLowerInvariant();
}
}
internal sealed class LdapBootstrapOptions
{
private static readonly string[] DefaultObjectClasses = new[]
{
"top",
"person",
"organizationalPerson",
"inetOrgPerson"
};
public bool Enabled { get; set; }
public string? ContainerDn { get; set; }
public string RdnAttribute { get; set; } = "uid";
public string UsernameAttribute { get; set; } = "uid";
public string DisplayNameAttribute { get; set; } = "displayName";
public string GivenNameAttribute { get; set; } = "givenName";
public string SurnameAttribute { get; set; } = "sn";
public string? EmailAttribute { get; set; } = "mail";
public string SecretAttribute { get; set; } = "userPassword";
public string[] ObjectClasses { get; set; } = DefaultObjectClasses;
public Dictionary<string, string> StaticAttributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public LdapClientProvisioningAuditOptions AuditMirror { get; set; } = new();
public void Normalize()
{
ContainerDn = Normalize(ContainerDn);
RdnAttribute = Normalize(RdnAttribute) ?? "uid";
UsernameAttribute = Normalize(UsernameAttribute) ?? "uid";
DisplayNameAttribute = Normalize(DisplayNameAttribute) ?? "displayName";
GivenNameAttribute = Normalize(GivenNameAttribute) ?? "givenName";
SurnameAttribute = Normalize(SurnameAttribute) ?? "sn";
EmailAttribute = Normalize(EmailAttribute);
SecretAttribute = Normalize(SecretAttribute) ?? "userPassword";
ObjectClasses = ObjectClasses?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? DefaultObjectClasses;
StaticAttributes = StaticAttributes?
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(pair => pair.Key.Trim(), pair => pair.Value.Trim(), StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
AuditMirror ??= new LdapClientProvisioningAuditOptions();
AuditMirror.Normalize();
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ContainerDn))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires bootstrap.containerDn when bootstrap is enabled.");
}
if (ObjectClasses.Length == 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires bootstrap.objectClasses to contain at least one value when enabled.");
}
if (string.IsNullOrWhiteSpace(SecretAttribute))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires bootstrap.secretAttribute when enabled.");
}
AuditMirror.Validate(pluginName);
}
public string ResolveAuditCollectionName(string pluginName)
=> AuditMirror.ResolveCollectionName($"{pluginName}_bootstrap");
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -1,12 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Claims;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Credentials;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Ldap;
@@ -22,6 +26,8 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = pluginManifest.Name;
var configPath = pluginManifest.ConfigPath;
context.Services.TryAddSingleton(TimeProvider.System);
context.Services.AddOptions<LdapPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
.PostConfigure(options =>
@@ -46,7 +52,43 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<ILogger<LdapCredentialStore>>(),
sp.GetRequiredService<LdapMetrics>()));
context.Services.AddScoped<LdapClaimsEnricher>();
context.Services.AddScoped(sp => new LdapClientProvisioningStore(
pluginName,
sp.GetRequiredService<IAuthorityClientStore>(),
sp.GetRequiredService<IAuthorityRevocationStore>(),
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<IMongoDatabase>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILogger<LdapClientProvisioningStore>>()));
context.Services.AddSingleton<ILdapClaimsCache>(sp =>
{
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cacheOptions = pluginOptions.Claims.Cache;
if (!cacheOptions.Enabled)
{
return DisabledLdapClaimsCache.Instance;
}
return new MongoLdapClaimsCache(
pluginName,
sp.GetRequiredService<IMongoDatabase>(),
cacheOptions,
ResolveTimeProvider(sp),
sp.GetRequiredService<ILogger<MongoLdapClaimsCache>>());
});
context.Services.AddScoped(sp => new LdapClaimsEnricher(
pluginName,
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<ILdapClaimsCache>(),
ResolveTimeProvider(sp),
sp.GetRequiredService<ILogger<LdapClaimsEnricher>>()));
context.Services.AddScoped<IClaimsEnricher>(sp => sp.GetRequiredService<LdapClaimsEnricher>());
context.Services.AddScoped<IUserCredentialStore>(sp => sp.GetRequiredService<LdapCredentialStore>());
@@ -57,6 +99,12 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<LdapClaimsEnricher>(),
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<LdapClientProvisioningStore>(),
sp.GetRequiredService<ILogger<LdapIdentityProviderPlugin>>()));
context.Services.AddScoped<IClientProvisioningStore>(sp => sp.GetRequiredService<LdapClientProvisioningStore>());
}
private static TimeProvider ResolveTimeProvider(IServiceProvider services)
=> services.GetService<TimeProvider>() ?? TimeProvider.System;
}

View File

@@ -13,10 +13,12 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.0" />
<PackageReference Include="MongoDB.Driver" Version="3.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests.Security;
public class StandardCredentialAuditLoggerTests
{
[Fact]
public async Task RecordAsync_EmitsSuccessEvent_WithContext()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
"corr-success",
"client-app",
"tenant-alpha",
"203.0.113.10",
"198.51.100.5",
"TestAgent/1.0"));
var timestamp = DateTimeOffset.Parse("2025-11-08T12:00:00Z");
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(timestamp),
NullLogger<StandardCredentialAuditLogger>.Instance);
await sut.RecordAsync(
"standard",
"alice",
subjectId: "subject-1",
success: true,
failureCode: null,
reason: null,
properties: Array.Empty<AuthEventProperty>(),
CancellationToken.None);
var record = Assert.Single(sink.Records);
Assert.Equal("authority.plugin.standard.password_verification", record.EventType);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
Assert.Equal(timestamp, record.OccurredAt);
Assert.Equal("corr-success", record.CorrelationId);
Assert.Equal("subject-1", record.Subject?.SubjectId.Value);
Assert.Equal("alice", record.Subject?.Username.Value);
Assert.Equal("client-app", record.Client?.ClientId.Value);
Assert.Equal("standard", record.Client?.Provider.Value);
Assert.Equal("tenant-alpha", record.Tenant.Value);
Assert.Equal("203.0.113.10", record.Network?.RemoteAddress.Value);
Assert.Equal("198.51.100.5", record.Network?.ForwardedFor.Value);
Assert.Equal("TestAgent/1.0", record.Network?.UserAgent.Value);
}
[Fact]
public async Task RecordAsync_EmitsFailureEvent_WithProperties()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
"corr-failure",
null,
null,
null,
null,
null));
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T13:00:00Z")),
NullLogger<StandardCredentialAuditLogger>.Instance);
var properties = new[]
{
new AuthEventProperty
{
Name = "plugin.failed_attempts",
Value = ClassifiedString.Public("2")
}
};
await sut.RecordAsync(
"standard",
"bob",
subjectId: null,
success: false,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
reason: "Invalid credentials.",
properties,
CancellationToken.None);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("Invalid credentials.", record.Reason);
Assert.Collection(
record.Properties,
property =>
{
Assert.Equal("plugin.failed_attempts", property.Name);
Assert.Equal("2", property.Value.Value);
},
property =>
{
Assert.Equal("plugin.failure_code", property.Name);
Assert.Equal(nameof(AuthorityCredentialFailureCode.InvalidCredentials), property.Value.Value);
});
}
[Fact]
public async Task RecordAsync_EmitsLockoutEvent_WithClassification()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
"corr-lockout",
"client-app",
"tenant-beta",
null,
null,
null));
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T14:30:00Z")),
NullLogger<StandardCredentialAuditLogger>.Instance);
var properties = new List<AuthEventProperty>
{
new()
{
Name = "plugin.lockout_until",
Value = ClassifiedString.Personal("2025-11-08T15:00:00Z")
}
};
await sut.RecordAsync(
"standard",
"carol",
subjectId: "subject-3",
success: false,
failureCode: AuthorityCredentialFailureCode.LockedOut,
reason: "Account locked.",
properties,
CancellationToken.None);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.LockedOut, record.Outcome);
Assert.Equal("Account locked.", record.Reason);
Assert.Equal("subject-3", record.Subject?.SubjectId.Value);
Assert.Equal("tenant-beta", record.Tenant.Value);
Assert.Collection(
record.Properties,
property =>
{
Assert.Equal("plugin.lockout_until", property.Name);
Assert.Equal("2025-11-08T15:00:00Z", property.Value.Value);
Assert.Equal(AuthEventDataClassification.Personal, property.Value.Classification);
},
property =>
{
Assert.Equal("plugin.failure_code", property.Name);
Assert.Equal(nameof(AuthorityCredentialFailureCode.LockedOut), property.Value.Value);
});
}
[Fact]
public async Task RecordAsync_AddsFailureCode_WhenPropertiesNull()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T15:45:00Z")),
NullLogger<StandardCredentialAuditLogger>.Instance);
await sut.RecordAsync(
"standard",
"dave",
subjectId: "subject-4",
success: false,
failureCode: AuthorityCredentialFailureCode.RequiresMfa,
reason: "MFA required.",
properties: null,
CancellationToken.None);
var record = Assert.Single(sink.Records);
var property = Assert.Single(record.Properties);
Assert.Equal("plugin.failure_code", property.Name);
Assert.Equal(nameof(AuthorityCredentialFailureCode.RequiresMfa), property.Value.Value);
}
private sealed class TestAuthEventSink : IAuthEventSink
{
public List<AuthEventRecord> Records { get; } = new();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
Records.Add(record);
return ValueTask.CompletedTask;
}
}
private sealed class TestCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
{
private readonly AsyncLocal<AuthorityCredentialAuditContext?> current = new();
public AuthorityCredentialAuditContext? Current => current.Value;
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
{
var previous = current.Value;
current.Value = context;
return new Scope(this, previous);
}
private sealed class Scope : IDisposable
{
private readonly TestCredentialAuditContextAccessor accessor;
private readonly AuthorityCredentialAuditContext? previous;
private bool disposed;
public Scope(TestCredentialAuditContextAccessor accessor, AuthorityCredentialAuditContext? previous)
{
this.accessor = accessor;
this.previous = previous;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
accessor.current.Value = previous;
}
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset timestamp;
public FixedTimeProvider(DateTimeOffset timestamp)
{
this.timestamp = timestamp;
}
public override DateTimeOffset GetUtcNow() => timestamp;
}
}

View File

@@ -45,9 +45,8 @@ public class StandardClientProvisioningStoreTests
Assert.Contains("scopea", descriptor.AllowedScopes);
}
[Fact]
[Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant()
[Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
@@ -72,9 +71,8 @@ public class StandardClientProvisioningStoreTests
Assert.NotNull(descriptor);
Assert.Equal("tenant-alpha", descriptor!.Tenant);
}
public async Task CreateOrUpdateAsync_StoresAudiences()
[Fact]
public async Task CreateOrUpdateAsync_StoresAudiences()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();

View File

@@ -13,9 +13,10 @@ using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
@@ -54,36 +55,8 @@ public class StandardPluginRegistrarTests
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
{
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
{
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton(TimeProvider.System);
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -140,12 +113,10 @@ public class StandardPluginRegistrarTests
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -178,13 +149,8 @@ public class StandardPluginRegistrarTests
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -223,12 +189,9 @@ public class StandardPluginRegistrarTests
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
@@ -267,10 +230,8 @@ public class StandardPluginRegistrarTests
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
services.AddSingleton(TimeProvider.System);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -347,11 +308,11 @@ internal sealed class StubRevocationStore : IAuthorityRevocationStore
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
@@ -363,6 +324,93 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
internal sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
{
private readonly List<AuthorityLoginAttemptDocument> documents = new();
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
documents.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(documents);
}
internal sealed class TestAuthorityCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
{
private readonly AsyncLocal<AuthorityCredentialAuditContext?> current = new();
public AuthorityCredentialAuditContext? Current => current.Value;
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
{
var previous = current.Value;
current.Value = context;
return new Scope(this, previous);
}
private sealed class Scope : IDisposable
{
private readonly TestAuthorityCredentialAuditContextAccessor accessor;
private readonly AuthorityCredentialAuditContext? previous;
private bool disposed;
public Scope(TestAuthorityCredentialAuditContextAccessor accessor, AuthorityCredentialAuditContext? previous)
{
this.accessor = accessor;
this.previous = previous;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
accessor.current.Value = previous;
}
}
}
internal sealed class TestAuthEventSink : IAuthEventSink
{
public List<AuthEventRecord> Records { get; } = new();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
Records.Add(record);
return ValueTask.CompletedTask;
}
}
internal static class StandardPluginRegistrarTestHelpers
{
public static ServiceCollection CreateServiceCollection(
IMongoDatabase database,
IAuthEventSink? authEventSink = null,
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
{
ArgumentNullException.ThrowIfNull(database);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityLoginAttemptStore>(new InMemoryLoginAttemptStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityCredentialAuditContextAccessor>(
auditContextAccessor ?? new TestAuthorityCredentialAuditContextAccessor());
services.AddSingleton<IAuthEventSink>(authEventSink ?? new TestAuthEventSink());
return services;
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -10,6 +11,7 @@ using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
@@ -113,12 +115,27 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.NotNull(second.RetryAfter);
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
var secondRetryProperty = second.AuditProperties.Single(property => property.Name == "plugin.retry_after_seconds");
var expectedSecondRetry = Math.Ceiling(second.RetryAfter!.Value.TotalSeconds).ToString(CultureInfo.InvariantCulture);
Assert.Equal(expectedSecondRetry, secondRetryProperty.Value.Value);
Assert.Equal(2, auditLogger.Events.Count);
var third = await store.VerifyPasswordAsync("bob", "nope", CancellationToken.None);
Assert.False(third.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, third.FailureCode);
Assert.NotNull(third.RetryAfter);
var thirdRetryProperty = third.AuditProperties.Single(property => property.Name == "plugin.retry_after_seconds");
var expectedThirdRetry = Math.Ceiling(third.RetryAfter!.Value.TotalSeconds).ToString(CultureInfo.InvariantCulture);
Assert.Equal(expectedThirdRetry, thirdRetryProperty.Value.Value);
Assert.Equal(3, auditLogger.Events.Count);
Assert.False(auditLogger.Events[0].Success);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, auditLogger.Events[0].FailureCode);
Assert.False(auditLogger.Events[1].Success);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, auditLogger.Events[1].FailureCode);
var lastAudit = auditLogger.Events[^1];
Assert.False(lastAudit.Success);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, lastAudit.FailureCode);
Assert.Contains(lastAudit.Properties, property => property.Name == "plugin.retry_after_seconds");
}
[Fact]
@@ -206,12 +223,22 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken)
{
events.Add(new AuditEntry(normalizedUsername, success, failureCode, reason));
events.Add(new AuditEntry(
normalizedUsername,
success,
failureCode,
reason,
properties ?? Array.Empty<AuthEventProperty>()));
return ValueTask.CompletedTask;
}
internal sealed record AuditEntry(string Username, bool Success, AuthorityCredentialFailureCode? FailureCode, string? Reason);
internal sealed record AuditEntry(
string Username,
bool Success,
AuthorityCredentialFailureCode? FailureCode,
string? Reason,
IReadOnlyList<AuthEventProperty> Properties);
}

View File

@@ -1,8 +1,8 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using Microsoft.Extensions.Logging;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Security;
@@ -16,7 +16,7 @@ internal interface IStandardCredentialAuditLogger
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken);
}
@@ -24,16 +24,19 @@ internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLo
{
private const string EventType = "authority.plugin.standard.password_verification";
private readonly IAuthorityLoginAttemptStore loginAttemptStore;
private readonly IAuthEventSink eventSink;
private readonly IAuthorityCredentialAuditContextAccessor contextAccessor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StandardCredentialAuditLogger> logger;
public StandardCredentialAuditLogger(
IAuthorityLoginAttemptStore loginAttemptStore,
IAuthEventSink eventSink,
IAuthorityCredentialAuditContextAccessor contextAccessor,
TimeProvider timeProvider,
ILogger<StandardCredentialAuditLogger> logger)
{
this.loginAttemptStore = loginAttemptStore ?? throw new ArgumentNullException(nameof(loginAttemptStore));
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
this.contextAccessor = contextAccessor ?? throw new ArgumentNullException(nameof(contextAccessor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -45,29 +48,29 @@ internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLo
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken)
{
try
{
var document = new AuthorityLoginAttemptDocument
var context = contextAccessor.Current;
var outcome = NormalizeOutcome(success, failureCode);
var mergedProperties = MergeProperties(properties, failureCode);
var record = new AuthEventRecord
{
EventType = EventType,
Outcome = NormalizeOutcome(success, failureCode),
SubjectId = Normalize(subjectId),
Username = Normalize(normalizedUsername),
Plugin = pluginName,
Successful = success,
OccurredAt = timeProvider.GetUtcNow(),
CorrelationId = context?.CorrelationId,
Outcome = outcome,
Reason = Normalize(reason),
OccurredAt = timeProvider.GetUtcNow()
Subject = BuildSubject(subjectId, normalizedUsername, pluginName),
Client = BuildClient(context?.ClientId, pluginName),
Tenant = ClassifiedString.Personal(context?.Tenant),
Network = BuildNetwork(context),
Properties = mergedProperties
};
if (properties.Count > 0)
{
document.Properties = ConvertProperties(properties);
}
await loginAttemptStore.InsertAsync(document, cancellationToken).ConfigureAwait(false);
await eventSink.WriteAsync(record, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -75,58 +78,101 @@ internal sealed class StandardCredentialAuditLogger : IStandardCredentialAuditLo
}
}
private static string NormalizeOutcome(bool success, AuthorityCredentialFailureCode? failureCode)
private static AuthEventSubject? BuildSubject(string? subjectId, string? username, string pluginName)
{
if (string.IsNullOrWhiteSpace(subjectId) && string.IsNullOrWhiteSpace(username))
{
return null;
}
return new AuthEventSubject
{
SubjectId = ClassifiedString.Personal(Normalize(subjectId)),
Username = ClassifiedString.Personal(Normalize(username)),
Realm = ClassifiedString.Public(pluginName)
};
}
private static AuthEventClient? BuildClient(string? clientId, string pluginName)
{
if (string.IsNullOrWhiteSpace(clientId))
{
return new AuthEventClient
{
ClientId = ClassifiedString.Empty,
Name = ClassifiedString.Empty,
Provider = ClassifiedString.Public(pluginName)
};
}
return new AuthEventClient
{
ClientId = ClassifiedString.Personal(clientId),
Name = ClassifiedString.Empty,
Provider = ClassifiedString.Public(pluginName)
};
}
private static AuthEventNetwork? BuildNetwork(AuthorityCredentialAuditContext? context)
{
if (context is null)
{
return null;
}
if (string.IsNullOrWhiteSpace(context.RemoteAddress) &&
string.IsNullOrWhiteSpace(context.ForwardedFor) &&
string.IsNullOrWhiteSpace(context.UserAgent))
{
return null;
}
return new AuthEventNetwork
{
RemoteAddress = ClassifiedString.Personal(context.RemoteAddress),
ForwardedFor = ClassifiedString.Personal(context.ForwardedFor),
UserAgent = ClassifiedString.Personal(context.UserAgent)
};
}
private static IReadOnlyList<AuthEventProperty> MergeProperties(
IReadOnlyList<AuthEventProperty>? properties,
AuthorityCredentialFailureCode? failureCode)
{
var source = properties ?? Array.Empty<AuthEventProperty>();
if (failureCode is null || source.Any(property => property.Name == "plugin.failure_code"))
{
return source;
}
var merged = new List<AuthEventProperty>(source.Count + 1);
merged.AddRange(source);
merged.Add(new AuthEventProperty
{
Name = "plugin.failure_code",
Value = ClassifiedString.Public(failureCode.ToString())
});
return merged;
}
private static AuthEventOutcome NormalizeOutcome(bool success, AuthorityCredentialFailureCode? failureCode)
{
if (success)
{
return "success";
return AuthEventOutcome.Success;
}
return failureCode switch
{
AuthorityCredentialFailureCode.LockedOut => "locked_out",
AuthorityCredentialFailureCode.RequiresMfa => "requires_mfa",
AuthorityCredentialFailureCode.RequiresPasswordReset => "requires_password_reset",
AuthorityCredentialFailureCode.PasswordExpired => "password_expired",
_ => "failure"
AuthorityCredentialFailureCode.LockedOut => AuthEventOutcome.LockedOut,
AuthorityCredentialFailureCode.RequiresMfa => AuthEventOutcome.RequiresMfa,
AuthorityCredentialFailureCode.RequiresPasswordReset => AuthEventOutcome.RequiresFreshAuth,
AuthorityCredentialFailureCode.PasswordExpired => AuthEventOutcome.RequiresFreshAuth,
_ => AuthEventOutcome.Failure
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static List<AuthorityLoginAttemptPropertyDocument> ConvertProperties(
IReadOnlyList<AuthEventProperty> properties)
{
if (properties.Count == 0)
{
return new List<AuthorityLoginAttemptPropertyDocument>();
}
var documents = new List<AuthorityLoginAttemptPropertyDocument>(properties.Count);
foreach (var property in properties)
{
if (property is null || string.IsNullOrWhiteSpace(property.Name) || !property.Value.HasValue)
{
continue;
}
documents.Add(new AuthorityLoginAttemptPropertyDocument
{
Name = property.Name,
Value = property.Value.Value,
Classification = NormalizeClassification(property.Value.Classification)
});
}
return documents;
}
private static string NormalizeClassification(AuthEventDataClassification classification)
=> classification switch
{
AuthEventDataClassification.Personal => "personal",
AuthEventDataClassification.Sensitive => "sensitive",
_ => "none"
};
}

View File

@@ -83,6 +83,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
Name = "plugin.lockout_until",
Value = ClassifiedString.Public(lockoutEnd.ToString("O", CultureInfo.InvariantCulture))
});
AddRetryAfterProperty(auditProperties, retryAfter);
await RecordAuditAsync(
normalized,
@@ -170,6 +171,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
});
}
AddRetryAfterProperty(auditProperties, retry);
await RecordAuditAsync(
normalized,
user.SubjectId,
@@ -428,4 +431,24 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
auditProperties,
cancellationToken).ConfigureAwait(false);
}
private static void AddRetryAfterProperty(ICollection<AuthEventProperty> properties, TimeSpan? retryAfter)
{
if (retryAfter is null || retryAfter <= TimeSpan.Zero)
{
return;
}
var seconds = Math.Ceiling(retryAfter.Value.TotalSeconds);
if (seconds <= 0)
{
return;
}
properties.Add(new AuthEventProperty
{
Name = "plugin.retry_after_seconds",
Value = ClassifiedString.Public(seconds.ToString(CultureInfo.InvariantCulture))
});
}
}

View File

@@ -1,29 +0,0 @@
# Team 8 / Plugin Standard Backlog (UTC 2025-10-10)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| SEC2.PLG | DOING (2025-11-08) | Security Guild, Storage Guild | SEC2.A (audit contract) | Emit audit events from password verification outcomes and persist via `IAuthorityLoginAttemptStore`. | ✅ Serilog events enriched with subject/client/IP/outcome; ✅ Mongo records written per attempt; ✅ Tests assert success/lockout/failure cases. |
| SEC3.PLG | BLOCKED (2025-10-21) | Security Guild, BE-Auth Plugin | CORE8, SEC3.A (rate limiter) | Ensure lockout responses and rate-limit metadata flow through plugin logs/events (include retry-after). <br>⛔ Pending AUTH-DPOP-11-001 / AUTH-MTLS-11-002; PLUGIN-DI-08-001 is done, limiter telemetry just awaits the updated Authority surface. | ✅ Audit record includes retry-after; ✅ Tests confirm lockout + limiter interplay. |
| SEC5.PLG | BLOCKED (2025-10-21) | Security Guild | SEC5.A (threat model) | Address plugin-specific mitigations (bootstrap user handling, password policy docs) in threat model backlog. <br>⛔ Final documentation now hinges on AUTH-DPOP-11-001 / AUTH-MTLS-11-002; scoped DI work is complete. | ✅ Threat model lists plugin attack surfaces; ✅ Mitigation items filed. |
| PLG4-6.CAPABILITIES | DONE (2025-11-08) | BE-Auth Plugin, Docs Guild | PLG1PLG3 | Finalise capability metadata exposure, config validation, and developer guide updates; remaining action is Docs polish/diagram export. | ✅ Capability metadata + validation merged; ✅ Plugin guide updated with final copy & diagrams; ✅ Release notes mention new toggles. |
| PLG7.RFC | DONE (2025-11-03) | BE-Auth Plugin, Security Guild | PLG4 | Socialize LDAP plugin RFC (`docs/rfcs/authority-plugin-ldap.md`) and capture guild feedback. | ✅ Guild review sign-off recorded; ✅ Follow-up issues filed in module boards. |
| PLG7.IMPL-001 | DONE (2025-11-03) | BE-Auth Plugin | PLG7.RFC | Scaffold `StellaOps.Authority.Plugin.Ldap` + tests, bind configuration (client certificate, trust-store, insecure toggle) with validation and docs samples. | ✅ Project + test harness build; ✅ Configuration bound & validated; ✅ Sample config updated. |
| PLG7.IMPL-002 | DONE (2025-11-04) | BE-Auth Plugin, Security Guild | PLG7.IMPL-001 | Implement LDAP credential store with TLS/mutual TLS enforcement, deterministic retry/backoff, and structured logging/metrics. | ✅ Credential store passes integration tests (OpenLDAP + mtls); ✅ Metrics/logs emitted; ✅ Error mapping documented.<br>2025-11-04: DirectoryServices factory now enforces TLS/mTLS options, credential store retries use deterministic backoff with metrics, audit logging includes failure codes, and unit suite (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests`) remains green. |
| PLG7.IMPL-003 | TODO | BE-Auth Plugin | PLG7.IMPL-001 | Deliver claims enricher with DN-to-role dictionary and regex mapping plus Mongo cache, including determinism + eviction tests. | ✅ Regex mapping deterministic; ✅ Cache TTL + invalidation tested; ✅ Claims doc updated. |
| PLG7.IMPL-004 | TODO | BE-Auth Plugin, DevOps Guild | PLG7.IMPL-002 | Implement client provisioning store with LDAP write toggles, Mongo audit mirror, bootstrap validation, and health reporting. | ✅ Audit mirror records persisted; ✅ Bootstrap validation logs capability summary; ✅ Health checks cover LDAP + audit mirror. |
| PLG7.IMPL-005 | TODO | BE-Auth Plugin, Docs Guild | PLG7.IMPL-001..004 | Update developer guide, samples, and release notes for LDAP plugin (mutual TLS, regex mapping, audit mirror) and ensure Offline Kit coverage. | ✅ Docs merged; ✅ Release notes drafted; ✅ Offline kit config templates updated. |
| PLG6.DIAGRAM | DONE (2025-11-03) | Docs Guild | PLG6.DOC | Export final sequence/component diagrams for the developer guide and add offline-friendly assets under `docs/assets/authority`. | ✅ Mermaid sources committed; ✅ Rendered SVG/PNG linked from Section 2 + Section 9; ✅ Docs build preview shared with Plugin + Docs guilds. |
> 2025-11-03: Task moved to DOING drafting component + sequence diagrams and prepping offline-friendly exports for the developer guide.
> 2025-11-03: Task marked DONE added component topology + bootstrap sequence diagrams (Mermaid + SVG) and refreshed developer guide references for offline kits.
> 2025-11-03: LDAP plugin RFC accepted; review notes in `docs/notes/2025-11-03-authority-plugin-ldap-review.md`. Follow-up implementation items PLG7.IMPL-001..005 added per review outcomes.
> 2025-11-03: PLG7.IMPL-001 completed created `StellaOps.Authority.Plugin.Ldap` + tests projects, implemented configuration normalization/validation (client certificate, trust store, insecure toggle) with coverage (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj`), and refreshed `etc/authority.plugins/ldap.yaml`.
> 2025-11-04: PLG7.IMPL-002 progress StartTLS initialization now uses `StartTransportLayerSecurity(null)` and LDAP result-code handling normalized for `System.DirectoryServices.Protocols` 8.0 (invalid credentials + transient cases). Plugin tests rerun via `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj` (green).
> 2025-11-04: PLG7.IMPL-002 progress enforced TLS/client certificate validation, added structured audit properties and retry logging for credential lookups, warned on unsupported cipher lists, updated sample config, and reran `dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Ldap.Tests/StellaOps.Authority.Plugin.Ldap.Tests.csproj --no-restore`.
> 2025-11-08: PLG4-6.CAPABILITIES completed added the `bootstrap` capability flag, extended registries/logs/docs, and gated bootstrap APIs on the new capability (`dotnet test` suites for plugins + Authority core all green).
> 2025-11-08: SEC2.PLG resumed Standard plugin now records password verification outcomes via `StandardCredentialAuditLogger`, persisting events to `IAuthorityLoginAttemptStore`; unit tests cover success/lockout/failure flows (`dotnet test src/Authority/StellaOps.Authority/StellaOps.Authority.Plugin.Standard.Tests/StellaOps.Authority.Plugin.Standard.Tests.csproj --no-build`).
> Update statuses to DOING/DONE/BLOCKED as you make progress. Always run `dotnet test` for touched projects before marking DONE.
> Remark (2025-10-13, PLG6.DOC/PLG6.DIAGRAM): Security Guild delivered `docs/security/rate-limits.md`; Docs team can lift Section 3 (tuning table + alerts) into the developer guide diagrams when rendering assets.
> Check-in (2025-10-19): Wave0A dependencies (AUTH-DPOP-11-001, AUTH-MTLS-11-002, PLUGIN-DI-08-001) still open, so SEC2/SEC3/SEC5 remain in progress without new scope until upstream limiter updates land.

View File

@@ -0,0 +1,30 @@
using System;
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Ambient metadata describing the client/tenant/network responsible for a credential verification attempt.
/// </summary>
public sealed record AuthorityCredentialAuditContext(
string? CorrelationId,
string? ClientId,
string? Tenant,
string? RemoteAddress,
string? ForwardedFor,
string? UserAgent);
/// <summary>
/// Provides access to the current <see cref="AuthorityCredentialAuditContext"/>.
/// </summary>
public interface IAuthorityCredentialAuditContextAccessor
{
/// <summary>
/// Gets the current credential audit context for the executing scope, if any.
/// </summary>
AuthorityCredentialAuditContext? Current { get; }
/// <summary>
/// Pushes a new credential audit context for the lifetime of a scope.
/// </summary>
IDisposable BeginScope(AuthorityCredentialAuditContext context);
}

View File

@@ -0,0 +1,198 @@
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using StellaOps.Authority.Audit;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Tests.Audit;
public class AuthorityAuditSinkTests
{
[Fact]
public async Task WriteAsync_PersistsDocumentWithExpectedMetadata()
{
var store = new TestAuthorityLoginAttemptStore();
var logger = new TestLogger<AuthorityAuditSink>();
var sink = new AuthorityAuditSink(store, logger);
var record = new AuthEventRecord
{
EventType = "authority.password.grant",
OccurredAt = new DateTimeOffset(2025, 11, 8, 15, 0, 0, TimeSpan.Zero),
CorrelationId = "corr-123",
Outcome = AuthEventOutcome.Success,
Reason = "issued",
Subject = new AuthEventSubject
{
SubjectId = ClassifiedString.Personal("sub-1"),
Username = ClassifiedString.Personal("alice"),
DisplayName = ClassifiedString.Personal("Alice Example"),
Realm = ClassifiedString.Public("standard"),
Attributes = new[]
{
new AuthEventProperty
{
Name = "subject.email",
Value = ClassifiedString.Personal("alice@example.test")
}
}
},
Client = new AuthEventClient
{
ClientId = ClassifiedString.Personal("cli-web"),
Name = ClassifiedString.Public("Console"),
Provider = ClassifiedString.Public("standard")
},
Tenant = ClassifiedString.Public("tenant-a"),
Scopes = new[] { "openid", "profile", "profile" },
Network = new AuthEventNetwork
{
RemoteAddress = ClassifiedString.Personal("10.0.0.5"),
ForwardedFor = ClassifiedString.Personal("203.0.113.5"),
UserAgent = ClassifiedString.Personal("Mozilla/5.0")
},
Properties = new[]
{
new AuthEventProperty
{
Name = "plugin.failed_attempts",
Value = ClassifiedString.Public("0")
}
}
};
await sink.WriteAsync(record, CancellationToken.None);
var document = Assert.Single(store.Documents);
Assert.Equal("authority.password.grant", document.EventType);
Assert.Equal("success", document.Outcome);
Assert.Equal("sub-1", document.SubjectId);
Assert.Equal("alice", document.Username);
Assert.Equal("cli-web", document.ClientId);
Assert.Equal("tenant-a", document.Tenant);
Assert.Equal("standard", document.Plugin);
Assert.Equal("10.0.0.5", document.RemoteAddress);
Assert.Equal(record.OccurredAt, document.OccurredAt);
Assert.Equal(new[] { "openid", "profile" }, document.Scopes);
var property = Assert.Single(document.Properties);
Assert.Equal("plugin.failed_attempts", property.Name);
Assert.Equal("0", property.Value);
Assert.Equal("none", property.Classification);
var logEntry = Assert.Single(logger.Entries);
Assert.Equal(LogLevel.Information, logEntry.Level);
Assert.Contains("Authority audit event authority.password.grant emitted with outcome success.", logEntry.Message);
}
[Fact]
public async Task WriteAsync_EmitsStructuredScope()
{
var store = new TestAuthorityLoginAttemptStore();
var logger = new TestLogger<AuthorityAuditSink>();
var sink = new AuthorityAuditSink(store, logger);
var record = new AuthEventRecord
{
EventType = "authority.password.grant",
OccurredAt = DateTimeOffset.UtcNow,
Outcome = AuthEventOutcome.Failure,
Reason = "invalid",
Subject = new AuthEventSubject
{
SubjectId = ClassifiedString.Personal("sub-2"),
Username = ClassifiedString.Personal("bob")
},
Client = new AuthEventClient
{
ClientId = ClassifiedString.Personal("cli-api"),
Provider = ClassifiedString.Public("standard")
},
Tenant = ClassifiedString.Public("tenant-b"),
Network = new AuthEventNetwork
{
RemoteAddress = ClassifiedString.Personal("192.0.2.10")
},
Properties = new[]
{
new AuthEventProperty
{
Name = "failure.code",
Value = ClassifiedString.Public("InvalidCredentials")
}
}
};
await sink.WriteAsync(record, CancellationToken.None);
var scope = Assert.Single(logger.Scopes);
var scopeDictionary = scope.ToDictionary(entry => entry.Key, entry => entry.Value);
Assert.Equal("authority.password.grant", scopeDictionary["audit.event_type"]);
Assert.Equal("failure", scopeDictionary["audit.outcome"]);
Assert.Equal("tenant-b", GetClassifiedValue(scopeDictionary["audit.tenant"]));
Assert.Equal("sub-2", GetClassifiedValue(scopeDictionary["audit.subject.id"]));
Assert.Equal("cli-api", GetClassifiedValue(scopeDictionary["audit.client.id"]));
Assert.Equal("192.0.2.10", GetClassifiedValue(scopeDictionary["audit.network.remote"]));
Assert.Equal("InvalidCredentials", GetClassifiedValue(scopeDictionary["audit.property.failure.code"]));
}
private static string? GetClassifiedValue(object? entry)
{
if (entry is null)
{
return null;
}
var valueProperty = entry.GetType().GetProperty("value");
return valueProperty?.GetValue(entry) as string;
}
private sealed class TestAuthorityLoginAttemptStore : IAuthorityLoginAttemptStore
{
public List<AuthorityLoginAttemptDocument> Documents { get; } = new();
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(Array.Empty<AuthorityLoginAttemptDocument>());
}
private sealed class TestLogger<T> : ILogger<T>
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public List<IReadOnlyCollection<KeyValuePair<string, object?>>> Scopes { get; } = new();
public IDisposable BeginScope<TState>(TState state)
{
if (state is IReadOnlyCollection<KeyValuePair<string, object?>> scope)
{
var copy = scope.ToArray();
Scopes.Add(copy);
}
return new Scope();
}
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
private sealed class Scope : IDisposable
{
public void Dispose()
{
}
}
}
}

View File

@@ -26,6 +26,7 @@ using OpenIddict.Extensions;
using OpenIddict.Server;
using OpenIddict.Server.AspNetCore;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.OpenIddict;
using StellaOps.Authority.OpenIddict.Handlers;
using StellaOps.Authority.Plugins.Abstractions;
@@ -241,6 +242,84 @@ public class ClientCredentialsHandlersTests
Assert.Equal(AuthorityTokenKinds.ServiceAccount, context.Transaction.Properties[AuthorityOpenIddictConstants.TokenKindProperty]);
}
[Fact]
public async Task ValidateClientCredentials_Rejects_WhenSealedEvidenceMissing()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var sealedValidator = new TestSealedModeEvidenceValidator
{
Result = AuthoritySealedModeValidationResult.Failure("missing", "Sealed evidence missing.", null)
};
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance,
sealedValidator);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("failure:missing", context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]);
}
[Fact]
public async Task ValidateClientCredentials_SetsSealedStatus_WhenEvidenceConfirmed()
{
var clientDocument = CreateClient(
secret: "s3cr3t!",
allowedGrantTypes: "client_credentials",
allowedScopes: "jobs:read");
clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true";
var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument));
var sealedValidator = new TestSealedModeEvidenceValidator
{
Result = AuthoritySealedModeValidationResult.Success(DateTimeOffset.Parse("2025-11-08T12:00:00Z", CultureInfo.InvariantCulture), null)
};
var handler = new ValidateClientCredentialsHandler(
new TestClientStore(clientDocument),
registry,
TestInstruments.ActivitySource,
new TestAuthEventSink(),
new TestRateLimiterMetadataAccessor(),
new TestServiceAccountStore(),
new TestTokenStore(),
TimeProvider.System,
new NoopCertificateValidator(),
new HttpContextAccessor(),
TestHelpers.CreateAuthorityOptions(),
NullLogger<ValidateClientCredentialsHandler>.Instance,
sealedValidator);
var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:read");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction);
await handler.HandleAsync(context);
Assert.False(context.IsRejected);
var sealedStatus = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]);
Assert.StartsWith("confirmed:", sealedStatus, StringComparison.Ordinal);
}
[Fact]
public async Task ValidateClientCredentials_RejectsWhenServiceAccountQuotaExceeded()
{
@@ -4013,6 +4092,14 @@ public class AuthorityClientCertificateValidatorTests
}
}
internal sealed class TestSealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator
{
public AuthoritySealedModeValidationResult Result { get; set; } = AuthoritySealedModeValidationResult.Success(null, null);
public ValueTask<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(Result);
}
internal sealed class TestClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
@@ -4652,6 +4739,46 @@ public class ObservabilityIncidentTokenHandlerTests
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidGrant, context.Error);
}
[Fact]
public async Task ValidateRefreshTokenHandler_Rejects_WhenSealedEvidenceMissing()
{
var clientDocument = CreateClient();
clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true";
var clientStore = new TestClientStore(clientDocument);
var sealedValidator = new TestSealedModeEvidenceValidator
{
Result = AuthoritySealedModeValidationResult.Failure("missing", "Sealed evidence missing.", null)
};
var handler = new ValidateRefreshTokenGrantHandler(
clientStore,
new NoopCertificateValidator(),
NullLogger<ValidateRefreshTokenGrantHandler>.Instance,
sealedValidator);
var transaction = new OpenIddictServerTransaction
{
EndpointType = OpenIddictServerEndpointType.Token,
Options = new OpenIddictServerOptions(),
Request = new OpenIddictRequest
{
GrantType = OpenIddictConstants.GrantTypes.RefreshToken
}
};
var principal = CreatePrincipal(clientDocument.ClientId, "refresh-token", "standard");
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)
{
Principal = principal
};
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("failure:missing", context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]);
}
[Fact]
public async Task ValidateAccessTokenHandler_Rejects_WhenMtlsCertificateMissing()
{

View File

@@ -60,6 +60,38 @@ public class PasswordGrantHandlersTests
Assert.Equal("tenant-alpha", metadata?.Tenant);
}
[Fact]
public async Task ValidatePasswordGrant_Rejects_WhenSealedEvidenceMissing()
{
var sink = new TestAuthEventSink();
var metadataAccessor = new TestRateLimiterMetadataAccessor();
var registry = CreateRegistry(new SuccessCredentialStore());
var clientDocument = CreateClientDocument();
clientDocument.Properties[AuthorityClientMetadataKeys.RequiresAirGapSealConfirmation] = "true";
var clientStore = new StubClientStore(clientDocument);
var sealedValidator = new TestSealedModeEvidenceValidator
{
Result = AuthoritySealedModeValidationResult.Failure("missing", "Sealed evidence missing.", null)
};
var handler = new ValidatePasswordGrantHandler(
registry,
TestActivitySource,
sink,
metadataAccessor,
clientStore,
TimeProvider.System,
NullLogger<ValidatePasswordGrantHandler>.Instance,
sealedValidator);
var context = new OpenIddictServerEvents.ValidateTokenRequestContext(CreatePasswordTransaction("alice", "Password1!"));
await handler.HandleAsync(context);
Assert.True(context.IsRejected);
Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error);
Assert.Equal("failure:missing", context.Transaction.Properties[AuthorityOpenIddictConstants.SealedModeStatusProperty]);
}
[Fact]
public async Task ValidateDpopProofHandler_RejectsPasswordGrant_WhenProofMissing()
{
@@ -809,7 +841,15 @@ public class PasswordGrantHandlersTests
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
private sealed class StubClientStore : IAuthorityClientStore
private sealed class TestSealedModeEvidenceValidator : IAuthoritySealedModeEvidenceValidator
{
public AuthoritySealedModeValidationResult Result { get; set; } = AuthoritySealedModeValidationResult.Success(null, null);
public ValueTask<AuthoritySealedModeValidationResult> ValidateAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(Result);
}
private sealed class StubClientStore : IAuthorityClientStore
{
private AuthorityClientDocument? document;

View File

@@ -0,0 +1,58 @@
using System;
using System.Threading;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Audit;
internal sealed class AuthorityCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
{
private readonly AsyncLocal<Scope?> currentScope = new();
public AuthorityCredentialAuditContext? Current => currentScope.Value?.Context;
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
{
ArgumentNullException.ThrowIfNull(context);
var parent = currentScope.Value;
var scope = new Scope(this, context, parent);
currentScope.Value = scope;
return scope;
}
private void EndScope(Scope scope)
{
if (currentScope.Value == scope)
{
currentScope.Value = scope.Parent;
}
}
private sealed class Scope : IDisposable
{
private readonly AuthorityCredentialAuditContextAccessor accessor;
private bool disposed;
public Scope(AuthorityCredentialAuditContextAccessor accessor, AuthorityCredentialAuditContext context, Scope? parent)
{
this.accessor = accessor;
Context = context;
Parent = parent;
}
public AuthorityCredentialAuditContext Context { get; }
public Scope? Parent { get; }
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
accessor.EndScope(this);
}
}
}

View File

@@ -31,6 +31,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
private readonly TimeProvider timeProvider;
private readonly ILogger<ValidatePasswordGrantHandler> logger;
private readonly IAuthoritySealedModeEvidenceValidator sealedModeEvidenceValidator;
private readonly IAuthorityCredentialAuditContextAccessor auditContextAccessor;
public ValidatePasswordGrantHandler(
IAuthorityIdentityProviderRegistry registry,
@@ -40,7 +41,8 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
IAuthorityClientStore clientStore,
TimeProvider timeProvider,
ILogger<ValidatePasswordGrantHandler> logger,
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null)
IAuthoritySealedModeEvidenceValidator? sealedModeEvidenceValidator = null,
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
{
this.registry = registry ?? throw new ArgumentNullException(nameof(registry));
this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource));
@@ -50,6 +52,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.sealedModeEvidenceValidator = sealedModeEvidenceValidator ?? NoopAuthoritySealedModeEvidenceValidator.Instance;
this.auditContextAccessor = auditContextAccessor ?? throw new ArgumentNullException(nameof(auditContextAccessor));
}
public async ValueTask HandleAsync(OpenIddictServerEvents.ValidateTokenRequestContext context)
@@ -66,7 +69,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op
activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.Password);
activity?.SetTag("authority.username", context.Request.Username ?? string.Empty);
PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction);
var correlationId = PasswordGrantAuditHelper.EnsureCorrelationId(context.Transaction);
var metadata = metadataAccessor.GetMetadata();
var clientId = context.ClientId ?? context.Request.ClientId;
@@ -1056,10 +1059,18 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open
return;
}
var verification = await provider.Credentials.VerifyPasswordAsync(
username,
password,
context.CancellationToken).ConfigureAwait(false);
using var credentialAuditScope = auditContextAccessor.BeginScope(new AuthorityCredentialAuditContext(
correlationId,
clientId,
tenant,
metadata?.RemoteIp,
metadata?.ForwardedFor,
metadata?.UserAgent));
var verification = await provider.Credentials.VerifyPasswordAsync(
username,
password,
context.CancellationToken).ConfigureAwait(false);
if (!verification.Succeeded || verification.User is null)
{

View File

@@ -11,6 +11,7 @@ using OpenIddict.Server;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Security;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.OpenIddict.Handlers;

View File

@@ -140,6 +140,7 @@ builder.Services.TryAddSingleton<IAuthorityRateLimiterMetadataAccessor, Authorit
builder.Services.TryAddSingleton<IAuthorityRateLimiterPartitionKeyResolver, DefaultAuthorityRateLimiterPartitionKeyResolver>();
builder.Services.AddSingleton<IAuthorityClientCertificateValidator, AuthorityClientCertificateValidator>();
builder.Services.TryAddSingleton<IAuthorityAirgapAuditService, AuthorityAirgapAuditService>();
builder.Services.TryAddScoped<IAuthorityCredentialAuditContextAccessor, AuthorityCredentialAuditContextAccessor>();
builder.Services.TryAddSingleton<IAuthoritySealedModeEvidenceValidator, AuthoritySealedModeEvidenceValidator>();
builder.Services.AddSingleton<AuthorityOpenApiDocumentProvider>();
builder.Services.TryAddSingleton<IConsoleWorkspaceService, ConsoleWorkspaceSampleService>();

View File

@@ -1,184 +0,0 @@
# Authority Host Task Board — Epic 1: Aggregation-Only Contract
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003.
> 2025-10-27: Client credential ingestion scopes now require tenant assignment; access token validation backfills tenants and rejects cross-tenant mismatches with tests.
> 2025-10-27: `dotnet test` blocked — Concelier build fails (`AdvisoryObservationQueryService` returns `ImmutableHashSet<string?>`), preventing Authority test suite run; waiting on Concelier fix before rerun.
> 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates).
> 2025-10-27: Scope catalogue aligned with `advisory:ingest/advisory:read/vex:ingest/vex:read`, `aoc:verify` pairing documented, console/CLI references refreshed, and `etc/authority.yaml.sample` updated to require read scopes for verification clients.
> 2025-10-31: Client credentials and password grants now reject advisory/vex read or signals scopes without `aoc:verify`, enforce tenant assignment for `aoc:verify`, tag violations via `authority.aoc_scope_violation`, extend tests, and refresh scope catalogue docs/sample roles.
| AUTH-CRYPTO-90-001 | DOING (2025-11-08) | Authority Core & Security Guild | SEC-CRYPTO-90-003, SEC-CRYPTO-90-004 | Migrate signing/key-loading paths (`KmsAuthoritySigningKeySource`, `FileAuthoritySigningKeySource`, `AuthorityJwksService`, secret hashers) to `ICryptoProviderRegistry` so regional bundles can pick `ru.cryptopro.csp` / `ru.pkcs11` providers as defined in `docs/security/crypto-routing-audit-2025-11-07.md`. | All signing + hashing code paths resolve registry providers; Authority config exposes provider selection; JWKS output references sovereign keys; regression tests updated. |
## Link-Not-Merge v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-29: Rejected legacy `concelier.merge` scope during client credential validation, removed it from known scope catalog, blocked discovery/issuance, added regression tests, and refreshed scope documentation.
## Policy Engine v2
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass).
> 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example.
## Graph Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
## Policy Engine + Editor v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-DPOP-11-001 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-AOC-19-002 | Enforce DPoP sender constraints for all Authority token flows (nonce store selection, algorithm allowlist, `cnf.jkt` persistence, structured telemetry). | `/token` enforces configured DPoP policies (nonce, allowed algorithms); cnf claims verified in integration tests; docs/runbooks updated with configuration guidance. |
> 2025-11-08: DPoP validation now executes for every `/token` grant (client credentials, password, device, refresh); interactive handlers apply shared sender-constraint claims so tokens emit `cnf.jkt` + telemetry, and docs describe the expanded coverage.
> 2025-11-07: Joint Authority/DevOps stand-up committed to shipping nonce store + telemetry updates by 2025-11-10; config samples and integration tests being updated in tandem.
| AUTH-MTLS-11-002 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-DPOP-11-001 | Add mTLS-bound access token issuance/validation (client certificate thumbprints, JWKS rotation hooks) for high-assurance tenants and services. | mTLS certificate binding validated end-to-end; audit logs capture cert hashes; docs describe bootstrap/rotation steps. |
> 2025-11-08: Refresh tokens now require the bound certificate, certificate thumbprints propagate through token issuance via `AuthoritySenderConstraintHelper`, and JWKS/docs updated to cover the expanded sender constraint surface.
> 2025-11-08: Wiring cert thumbprint persistence + audit hooks now that DPoP nonce enforcement is in place; targeting shared delivery window with DEVOPS-AIRGAP-57-002.
> 2025-11-07: Same stand-up aligned on 2025-11-10 target for mTLS enforcement + JWKS rotation docs so plugin mitigations can unblock.
| AUTH-POLICY-23-001 | DONE (2025-10-27) | Authority Core & Docs Guild | AUTH-AOC-19-002 | Introduce fine-grained policy scopes (`policy:read`, `policy:author`, `policy:review`, `policy:simulate`, `findings:read`) for CLI/service identities; refresh discovery metadata, issuer templates, and offline defaults. | Scope catalogue and sample configs updated; `policy-cli` seed credentials rotated; docs recorded migration steps. |
| AUTH-POLICY-23-002 | DONE (2025-11-08) | Authority Core & Security Guild | AUTH-POLICY-23-001 | Implement optional two-person rule for activation: require two distinct `policy:activate` approvals when configured; emit audit logs. | Activation endpoint enforces rule; audit logs contain approver IDs; tests cover 2-person path. |
> 2025-11-08: Policy Engine enforces pending_second_approval when dual-control toggles demand it, activation auditor emits structured `policy.activation.*` scopes, and tests cover settings/audits.
> Blocked: Policy Engine/Studio have not yet exposed activation workflow endpoints or approval payloads needed to enforce dual-control (`WEB-POLICY-23-002`, `POLICY-ENGINE-23-002`). Revisit once activation contract lands.
| AUTH-POLICY-23-003 | DONE (2025-11-08) | Authority Core & Docs Guild | AUTH-POLICY-23-001 | Update documentation and sample configs for policy roles, approval workflow, and signing requirements. | Docs updated with reviewer checklist; configuration examples validated. |
> 2025-11-08: Docs refreshed for dual-control activation (console workflow, compliance checklist, sample YAML) and linked to new Policy Engine activation options.
> 2025-11-07: Scope migration landed (AUTH-POLICY-23-001); dual-approval + documentation tasks now waiting on pairing.
> 2025-10-27: Added `policy-cli` defaults to Authority config/secrets, refreshed CLI/CI documentation with the new scope bundle, recorded release migration guidance, and introduced `scripts/verify-policy-scopes.py` to guard against regressions.
## Graph & Vuln Explorer v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-27: Paused work after exploratory spike (scope enforcement still outstanding); no functional changes merged.
## Orchestrator Dashboard
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-31: Picked up during Console/Orchestrator alignment; focusing on scope catalog + tenant enforcement first.
> 2025-10-31: `orch:read` added to scope catalogue and Authority runtime, Console defaults include the scope, `Orch.Viewer` role documented, and client-credential tests enforce tenant requirements.
> 2025-10-27: Added `orch:operate` scope, enforced `operator_reason`/`operator_ticket` on token issuance, updated Authority configs/docs, and captured audit metadata for control actions.
> 2025-10-28: Policy gateway + scanner now pass the expanded token client signature (`null` metadata by default), test stubs capture the optional parameters, and Policy Gateway/Scanner suites are green after fixing the Concelier storage build break.
> 2025-10-28: Authority password-grant tests now hit the new constructors but still need updates to drop obsolete `IOptions` arguments before the suite can pass.
| AUTH-ORCH-34-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-ORCH-33-001 | Introduce `Orch.Admin` role with quota/backfill scopes, enforce audit reason on quota changes, and update offline defaults/docs. | Admin role available; quotas/backfills require scope + reason; tests confirm tenant isolation; documentation updated. |
> 2025-11-02: `orch:backfill` scope added with mandatory `backfill_reason`/`backfill_ticket`, client-credential validation and resource authorization paths emit audit fields, CLI picks up new configuration/env vars, and Authority docs/config samples updated for `Orch.Admin`.
## StellaOps Console (Sprint 23)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-29: Authorization code flow enabled with PKCE requirement, console client seeded in `authority.yaml.sample`, discovery docs updated, and console runbook guidance added.
> 2025-10-31: Added `/console/tenants`, `/console/profile`, `/console/token/introspect` endpoints with tenant header filter, scope enforcement (`ui.read`, `authority:tenants.read`), and structured audit events. Console test harness covers success/mismatch cases.
> 2025-10-28: `docs/security/console-security.md` drafted with PKCE + DPoP (120s OpTok, 300s fresh-auth) and scope table. Authority Core to confirm `/fresh-auth` semantics, token lifetimes, and scope bundles align before closing task.
> 2025-10-31: Security guide expanded for `/console` endpoints & orchestrator scope, sample YAML annotated, ops runbook updated, and release note `docs/updates/2025-10-31-console-security-refresh.md` published.
> 2025-10-31: Default access-token lifetime reduced to 120s, console tests updated with dual auth schemes, docs/config/ops notes refreshed, release note logged.
## Policy Studio (Sprint 27)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-31: Added Policy Studio scope family (`policy:author/review/operate/audit`), updated OpenAPI + discovery headers, enforced tenant requirements in grant handlers, seeded new roles in Authority config/offline kit docs, and refreshed CLI/Console documentation + tests to validate the new catalogue.
| AUTH-POLICY-27-002 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-POLICY-27-001, REGISTRY-API-27-007 | Provide attestation signing service bindings (OIDC token exchange, cosign integration) and enforce publish/promote scope checks, fresh-auth requirements, and audit logging. | Publish/promote requests require fresh auth + correct scopes; attestations signed with validated identity; audit logs enriched with digest + tenant; integration tests pass. |
> Docs dependency: `DOCS-POLICY-27-009` awaiting signing guidance from this work.
> 2025-11-02: Added `policy:publish`/`policy:promote` scopes with interactive-only enforcement, metadata parameters (`policy_reason`, `policy_ticket`, `policy_digest`), fresh-auth token validation, audit augmentations, and updated config/docs references.
| AUTH-POLICY-27-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-POLICY-27-001, AUTH-POLICY-27-002 | Update Authority configuration/docs for Policy Studio roles, signing policies, approval workflows, and CLI integration; include compliance checklist. | Docs merged; samples validated; governance checklist appended; release notes updated. |
> 2025-11-04: Policy Studio roles/scopes documented across `docs/11_AUTHORITY.md`, sample configs, and OpenAPI; compliance checklist appended and Authority tests rerun to validate fresh-auth + scope enforcement.
## Exceptions v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-29: Added exception scopes + routing template options, enforced MFA requirement in password grant handlers, updated configuration samples.
> 2025-10-31: Authority scopes/routing docs updated (`docs/security/authority-scopes.md`, `docs/11_AUTHORITY.md`, `docs/policy/exception-effects.md`), monitoring guide covers new MFA audit events, and `etc/authority.yaml.sample` now demonstrates exception clients/templates.
## Reachability v1
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-29: Signals scopes added with tenant + aoc:verify enforcement; sensors guided via SignalsUploader template; tests cover gating.
## Vulnerability Explorer (Sprint 29)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-VULN-29-001 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-POLICY-27-001 | Define Vuln Explorer scopes/roles (`vuln:view`, `vuln:investigate`, `vuln:operate`, `vuln:audit`) with ABAC attributes (env, owner, business_tier) and update discovery metadata/offline kit defaults. | Roles/scopes published; issuer templates updated; integration tests cover ABAC filters; docs refreshed. |
| AUTH-VULN-29-002 | DONE (2025-11-03) | Authority Core & Security Guild | AUTH-VULN-29-001, LEDGER-29-002 | Enforce CSRF/anti-forgery tokens for workflow actions, sign attachment tokens, and record audit logs with ledger event hashes. | Workflow calls require valid tokens; audit logs include ledger references; security tests cover token expiry/abuse. |
| AUTH-VULN-29-003 | DONE (2025-11-04) | Authority Core & Docs Guild | AUTH-VULN-29-001..002 | Update security docs/config samples for Vuln Explorer roles, ABAC policies, attachment signing, and ledger verification guidance. | Docs merged with compliance checklist; configuration examples validated; release notes updated. |
> 2025-11-03: Vuln workflow CSRF + attachment token services live with audit enrichment and negative-path tests. Awaiting completion of full Authority suite run after repository-wide build finishes.
> 2025-11-04: Verified Vuln Explorer RBAC/ABAC coverage in Authority docs/security guides, attachment token guidance, and offline samples; Authority tests rerun confirming ledger-token + anti-forgery behaviours.
## Advisory AI (Sprint 31)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AIAI-31-001 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-VULN-29-001 | Define Advisory AI scopes (`advisory-ai:view`, `advisory-ai:operate`, `advisory-ai:admin`) and remote inference toggles; update discovery metadata/offline defaults. | Scopes/flags published; integration tests cover RBAC + opt-in settings; docs updated. |
| AUTH-AIAI-31-002 | DONE (2025-11-01) | Authority Core & Security Guild | AUTH-AIAI-31-001, AIAI-31-006 | Enforce anonymized prompt logging, tenant consent for remote inference, and audit logging of assistant tasks. | Logging/audit flows verified; privacy review passed; docs updated. |
## Export Center
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
## Notifications Studio
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-NOTIFY-38-001 | DONE (2025-11-01) | Authority Core & Security Guild | — | Define `Notify.Viewer`, `Notify.Operator`, `Notify.Admin` scopes/roles, update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit defaults refreshed. |
| AUTH-NOTIFY-40-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-38-001, WEB-NOTIFY-40-001 | Implement signed ack token key rotation, webhook allowlists, admin-only escalation settings, and audit logging of ack actions. | Ack tokens signed/rotated; webhook allowlists enforced; admin enforcement validated; audit logs capture ack/resolution. |
> 2025-11-02: `/notify/ack-tokens/rotate` exposed (notify.admin), emits `notify.ack.key_rotated|notify.ack.key_rotation_failed`, and DSSE rotation tests cover allowlist + scope enforcement.
| AUTH-NOTIFY-42-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-NOTIFY-40-001 | Investigate ack token rotation 500 errors (test Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure still failing). Capture logs, identify root cause, and patch handler. | Failure mode understood; fix merged; regression test passes. |
> 2025-11-02: Aliased `StellaOpsBearer` to the test auth handler, corrected bootstrap `/notifications/ack-tokens/rotate` defaults, and validated `Rotate_ReturnsBadRequest_WhenKeyIdMissing_AndAuditsFailure` via targeted `dotnet test`.
## CLI Parity & Task Packs
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-PACKS-41-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AOC-19-001 | Define CLI SSO profiles and pack scopes (`Packs.Read`, `Packs.Write`, `Packs.Run`, `Packs.Approve`), update discovery metadata, offline defaults, and issuer templates. | Scopes available; metadata updated; tests ensure enforcement; offline kit templates refreshed. |
> 2025-11-02: Added Pack scope policies, Authority role defaults, and CLI profile guidance covering operator/publisher/approver flows.
> 2025-11-02: Shared OpenSSL 1.1 shim feeds Authority & Signals Mongo2Go harnesses so pack scope coverage keeps running on OpenSSL 3 hosts (AUTH-PACKS-41-001).
> 2025-11-04: Discovery metadata/OpenAPI advertise packs scopes, configs/offline kit templates bundle new roles, and Authority tests re-run to validate tenant gating for `packs.*`.
| AUTH-PACKS-43-001 | BLOCKED (2025-10-27) | Authority Core & Security Guild | AUTH-PACKS-41-001, TASKRUN-42-001, ORCH-SVC-42-101 | Enforce pack signing policies, approval RBAC checks, CLI CI token scopes, and audit logging for approvals. | Signing policies enforced; approvals require correct roles; CI token scope tests pass; audit logs recorded. |
> Blocked: ORCH-SVC-42-101 (Orchestrator log streaming/approvals API) still TODO. AUTH-PACKS-41-001 + TASKRUN-42-001 are DONE (2025-11-04); resume once Orchestrator publishes contracts.
## Authority-Backed Scopes & Tenancy (Epic 14)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-28: Tidied advisory raw idempotency migration to avoid LINQ-on-`BsonValue` (explicit array copy) while continuing duplicate guardrail validation; scoped scanner/policy token call sites updated to honor new metadata parameter.
| AUTH-TEN-49-001 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-TEN-47-001 | Implement service accounts & delegation tokens (`act` chain), per-tenant quotas, audit stream of auth decisions, and revocation APIs. | Service tokens minted with scopes/TTL; delegation logged; quotas configurable; audit stream live; docs updated. |
> 2025-11-02: Authority bootstrap test harness now seeds service accounts via AuthorityDelegation options; `/internal/service-accounts` endpoints validated with targeted vstest run.
> 2025-11-02: Added Mongo service-account store, seeded options/collection initializers, token persistence metadata (`tokenKind`, `serviceAccountId`, `actorChain`), and docs/config samples. Introduced quota checks + tests covering service account issuance and persistence.
> 2025-11-02: Documented bootstrap service-account admin APIs in `docs/11_AUTHORITY.md`, noting API key requirements and stable upsert behaviour.
> 2025-11-03: Seeded explicit enabled service-account fixtures for integration tests and reran `StellaOps.Authority.Tests` to greenlight `/internal/service-accounts` listing + revocation scenarios.
> 2025-11-04: Confirmed service-account docs/config examples, quota tuning, and audit stream wiring; Authority suite re-executed to cover issuance/listing/revocation flows.
## Observability & Forensics (Epic 15)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-OBS-50-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-AOC-19-001 | Introduce scopes `obs:read`, `timeline:read`, `timeline:write`, `evidence:create`, `evidence:read`, `evidence:hold`, `attest:read`, and `obs:incident` (all tenant-scoped). Update discovery metadata, offline defaults, and scope grammar docs. | Scopes exposed via metadata; issuer templates updated; offline kit seeded; integration tests cover new scopes. |
| AUTH-OBS-52-001 | DONE (2025-11-02) | Authority Core & Security Guild | AUTH-OBS-50-001, TIMELINE-OBS-52-003, EVID-OBS-53-003 | Configure resource server policies for Timeline Indexer, Evidence Locker, Exporter, and Observability APIs enforcing new scopes + tenant claims. Emit audit events including scope usage and trace IDs. | Policies deployed; unauthorized access blocked; audit logs prove scope usage; contract tests updated. |
| AUTH-OBS-55-001 | DONE (2025-11-02) | Authority Core & Security Guild, Ops Guild | AUTH-OBS-50-001, WEB-OBS-55-001 | Harden incident mode authorization: require `obs:incident` scope + fresh auth, log activation reason, and expose verification endpoint for auditors. Update docs/runbooks. | Incident activate/deactivate requires scope; audit entries logged; docs updated with imposed rule reminder. |
## Air-Gapped Mode (Epic 16)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| AUTH-AIRGAP-56-001 | DONE (2025-11-04) | Authority Core & Security Guild | AIRGAP-CTL-56-001 | Provision new scopes (`airgap:seal`, `airgap:import`, `airgap:status:read`) in configuration metadata, offline kit defaults, and issuer templates. | Scopes exposed in discovery docs; offline kit updated; integration tests cover issuance. |
| AUTH-AIRGAP-56-002 | DONE (2025-11-04) | Authority Core & Security Guild | AUTH-AIRGAP-56-001, AIRGAP-IMP-58-001 | Audit import actions with actor, tenant, bundle ID, and trace ID; expose `/authority/audit/airgap` endpoint. | Audit records persisted; endpoint paginates results; tests cover RBAC + filtering. |
> 2025-11-04: Airgap scope constants are wired through discovery metadata, `etc/authority.yaml.sample`, and offline kit docs; scope issuance tests executed via `dotnet test`.
> 2025-11-04: `/authority/audit/airgap` API persists tenant-scoped audit entries with pagination and authorization guards validated by the Authority integration suite (187 tests).
| AUTH-AIRGAP-57-001 | DOING (2025-11-08) | Authority Core & Security Guild, DevOps Guild | AUTH-AIRGAP-56-001, DEVOPS-AIRGAP-57-002 | Enforce sealed-mode CI gating by refusing token issuance when declared sealed install lacks sealing confirmation. | Implement Authority-side sealed-mode checks once DevOps publishes sealed CI artefacts + contract (target 2025-11-10). |
> 2025-11-08: Picked up in tandem with DEVOPS-AIRGAP-57-002 — validating sealed confirmation payload + wiring Authority gating tests against ops/devops/sealed-mode-ci artefacts.
> 2025-11-08: `/token`/`/introspect` now reject mTLS-bound tokens without the recorded certificate; `authority_mtls_mismatch_total` metric + docs updated for plugin consumers.
> 2025-11-08: DevOps sealed-mode CI now emits `authority-sealed-ci.json`; ingest that contract next to unblock enforcement switch.
## SDKs & OpenAPI (Epic 17)
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
> 2025-10-28: Auth OpenAPI authored at `src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml` covering `/token`, `/introspect`, `/revoke`, `/jwks`, scope catalog, and error envelopes; parsed via PyYAML sanity check and referenced in Epic 17 docs.
> 2025-10-28: Added `/.well-known/openapi` endpoint wiring cached spec metadata, YAML/JSON negotiation, HTTP cache headers, and tests verifying ETag + Accept handling. Authority spec (`src/Api/StellaOps.Api.OpenApi/authority/openapi.yaml`) now includes grant/scope extensions.
| AUTH-OAS-62-001 | DONE (2025-11-02) | Authority Core & Security Guild, SDK Generator Guild | AUTH-OAS-61-001, SDKGEN-63-001 | Provide SDK helpers for OAuth2/PAT flows, tenancy override header; add integration tests. | SDKs expose auth helpers; tests cover token issuance; docs updated. |
> 2025-11-02: `AddStellaOpsApiAuthentication` shipped (OAuth2 + PAT), tenant header injection added, and client tests updated for caching behaviour.
| AUTH-OAS-63-001 | DONE (2025-11-02) | Authority Core & Security Guild, API Governance Guild | APIGOV-63-001 | Emit deprecation headers and notifications for legacy auth endpoints. | Headers emitted; notifications verified; migration guide published. |
> 2025-11-02: AUTH-OAS-63-001 completed — legacy OAuth shims emit Deprecation/Sunset/Warning headers, audit events captured, and migration guide published (Authority Core & Security Guild, API Governance Guild).