Add LDAP Distinguished Name Helper and Credential Audit Context
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,3 +25,11 @@ internal class LdapOperationException : Exception
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LdapInsufficientAccessException : LdapOperationException
|
||||
{
|
||||
public LdapInsufficientAccessException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 | PLG1–PLG3 | 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): Wave 0A 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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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 (120 s OpTok, 300 s 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 120 s, 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).
|
||||
Reference in New Issue
Block a user