save progress

This commit is contained in:
StellaOps Bot
2026-01-02 21:06:27 +02:00
parent f46bde5575
commit 3f197814c5
441 changed files with 21545 additions and 4306 deletions

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
</ItemGroup>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0086-M | DONE | Maintainability audit for StellaOps.Authority.Core. |
| AUDIT-0086-T | DONE | Test coverage audit for StellaOps.Authority.Core. |
| AUDIT-0086-A | TODO | Pending approval for changes. |
| AUDIT-0086-A | DONE | Deterministic builder defaults, replay verifier handling, and tests. |

View File

@@ -75,7 +75,7 @@ public sealed class NullVerdictManifestSigner : IVerdictManifestSigner
public Task<SignatureVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default)
=> Task.FromResult(new SignatureVerificationResult
{
Valid = true,
Valid = false,
Error = "Signing disabled",
});
}

View File

@@ -161,7 +161,7 @@ public static class VerdictManifestSerializer
};
/// <summary>
/// Serialize manifest to canonical JSON (sorted keys, no indentation).
/// Serialize manifest to deterministic JSON (stable naming policy, no indentation).
/// </summary>
public static string Serialize(VerdictManifest manifest)
{

View File

@@ -14,17 +14,25 @@ public sealed class VerdictManifestBuilder
private VerdictResult? _result;
private string? _policyHash;
private string? _latticeVersion;
private DateTimeOffset _evaluatedAt = DateTimeOffset.UtcNow;
private DateTimeOffset _evaluatedAt;
private readonly Func<string> _idGenerator;
private readonly TimeProvider _timeProvider;
public VerdictManifestBuilder()
: this(() => Guid.NewGuid().ToString("n"))
: this(() => Guid.NewGuid().ToString("n"), TimeProvider.System)
{
}
public VerdictManifestBuilder(Func<string> idGenerator)
: this(idGenerator, TimeProvider.System)
{
}
public VerdictManifestBuilder(Func<string> idGenerator, TimeProvider timeProvider)
{
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_evaluatedAt = _timeProvider.GetUtcNow();
}
public VerdictManifestBuilder WithTenant(string tenant)
@@ -74,7 +82,7 @@ public sealed class VerdictManifestBuilder
VulnFeedSnapshotIds = SortedImmutable(vulnFeedSnapshotIds),
VexDocumentDigests = SortedImmutable(vexDocumentDigests),
ReachabilityGraphIds = SortedImmutable(reachabilityGraphIds ?? Enumerable.Empty<string>()),
ClockCutoff = clockCutoff ?? DateTimeOffset.UtcNow,
ClockCutoff = clockCutoff ?? _timeProvider.GetUtcNow(),
};
return this;
}

View File

@@ -100,14 +100,8 @@ public sealed class VerdictReplayVerifier : IVerdictReplayVerifier
{
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
// We need to find the manifest - this requires a search across tenants
// In practice, the caller should provide the tenant or the manifest directly
return new ReplayVerificationResult
{
Success = false,
OriginalManifest = null!,
Error = "Use VerifyAsync(VerdictManifest) overload with the full manifest.",
};
throw new InvalidOperationException(
"Verdict replay requires a full manifest or tenant context; use VerifyAsync(VerdictManifest) instead.");
}
public async Task<ReplayVerificationResult> VerifyAsync(VerdictManifest manifest, CancellationToken ct = default)

View File

@@ -79,5 +79,13 @@ public static class AuthorityPersistenceExtensions
services.AddScoped<IOfflineKitAuditRepository>(sp => sp.GetRequiredService<OfflineKitAuditRepository>());
services.AddScoped<IOfflineKitAuditEmitter, OfflineKitAuditEmitter>();
services.AddScoped<RevocationExportStateRepository>();
services.AddScoped<IBootstrapInviteRepository>(sp => sp.GetRequiredService<BootstrapInviteRepository>());
services.AddScoped<IServiceAccountRepository>(sp => sp.GetRequiredService<ServiceAccountRepository>());
services.AddScoped<IClientRepository>(sp => sp.GetRequiredService<ClientRepository>());
services.AddScoped<IRevocationRepository>(sp => sp.GetRequiredService<RevocationRepository>());
services.AddScoped<ILoginAttemptRepository>(sp => sp.GetRequiredService<LoginAttemptRepository>());
services.AddScoped<IOidcTokenRepository>(sp => sp.GetRequiredService<OidcTokenRepository>());
services.AddScoped<IAirgapAuditRepository>(sp => sp.GetRequiredService<AirgapAuditRepository>());
}
}

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for airgap audit records.
/// </summary>
public sealed class AirgapAuditRepository : RepositoryBase<AuthorityDataSource>
public sealed class AirgapAuditRepository : RepositoryBase<AuthorityDataSource>, IAirgapAuditRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for bootstrap invites.
/// </summary>
public sealed class BootstrapInviteRepository : RepositoryBase<AuthorityDataSource>
public sealed class BootstrapInviteRepository : RepositoryBase<AuthorityDataSource>, IBootstrapInviteRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for OAuth/OpenID clients.
/// </summary>
public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>
public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>, IClientRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -0,0 +1,9 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IAirgapAuditRepository
{
Task InsertAsync(AirgapAuditEntity entity, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AirgapAuditEntity>> ListAsync(int limit, int offset, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IBootstrapInviteRepository
{
Task<BootstrapInviteEntity?> FindByTokenAsync(string token, CancellationToken cancellationToken = default);
Task InsertAsync(BootstrapInviteEntity entity, CancellationToken cancellationToken = default);
Task<bool> TryReserveAsync(string token, string expectedType, DateTimeOffset now, string? reservedBy, CancellationToken cancellationToken = default);
Task<bool> ReleaseAsync(string token, CancellationToken cancellationToken = default);
Task<bool> ConsumeAsync(string token, string? consumedBy, DateTimeOffset consumedAt, CancellationToken cancellationToken = default);
Task<IReadOnlyList<BootstrapInviteEntity>> ExpireAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IClientRepository
{
Task<ClientEntity?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken = default);
Task UpsertAsync(ClientEntity entity, CancellationToken cancellationToken = default);
Task<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,9 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface ILoginAttemptRepository
{
Task InsertAsync(LoginAttemptEntity entity, CancellationToken cancellationToken = default);
Task<IReadOnlyList<LoginAttemptEntity>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,25 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IOidcTokenRepository
{
Task<OidcTokenEntity?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken = default);
Task<OidcTokenEntity?> FindByReferenceIdAsync(string referenceId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListBySubjectAsync(string subjectId, int limit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListByClientAsync(string clientId, int limit, int offset, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListByScopeAsync(string tenant, string scope, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListRevokedAsync(string? tenant, int limit, CancellationToken cancellationToken = default);
Task<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default);
Task<IReadOnlyList<OidcTokenEntity>> ListAsync(int limit, CancellationToken cancellationToken = default);
Task UpsertAsync(OidcTokenEntity entity, CancellationToken cancellationToken = default);
Task<bool> RevokeAsync(string tokenId, CancellationToken cancellationToken = default);
Task<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken = default);
Task<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken = default);
Task<OidcRefreshTokenEntity?> FindRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default);
Task<OidcRefreshTokenEntity?> FindRefreshTokenByHandleAsync(string handle, CancellationToken cancellationToken = default);
Task UpsertRefreshTokenAsync(OidcRefreshTokenEntity entity, CancellationToken cancellationToken = default);
Task<bool> ConsumeRefreshTokenAsync(string tokenId, CancellationToken cancellationToken = default);
Task<int> RevokeRefreshTokensBySubjectAsync(string subjectId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IRevocationRepository
{
Task UpsertAsync(RevocationEntity entity, CancellationToken cancellationToken = default);
Task<IReadOnlyList<RevocationEntity>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken = default);
Task RemoveAsync(string category, string revocationId, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,11 @@
using StellaOps.Authority.Persistence.Postgres.Models;
namespace StellaOps.Authority.Persistence.Postgres.Repositories;
public interface IServiceAccountRepository
{
Task<ServiceAccountEntity?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken = default);
Task<IReadOnlyList<ServiceAccountEntity>> ListAsync(string? tenant, CancellationToken cancellationToken = default);
Task UpsertAsync(ServiceAccountEntity entity, CancellationToken cancellationToken = default);
Task<bool> DeleteAsync(string accountId, CancellationToken cancellationToken = default);
}

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for login attempts.
/// </summary>
public sealed class LoginAttemptRepository : RepositoryBase<AuthorityDataSource>
public sealed class LoginAttemptRepository : RepositoryBase<AuthorityDataSource>, ILoginAttemptRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for OpenIddict tokens and refresh tokens.
/// </summary>
public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>
public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>, IOidcTokenRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);
@@ -69,6 +69,130 @@ public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListByClientAsync(string clientId, int limit, int offset, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE client_id = @client_id
ORDER BY created_at DESC, id DESC
LIMIT @limit OFFSET @offset
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "client_id", clientId);
AddParameter(cmd, "limit", limit);
AddParameter(cmd, "offset", offset);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListByScopeAsync(string tenant, string scope, DateTimeOffset? issuedAfter, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = @tenant
AND position(' ' || @scope || ' ' IN ' ' || COALESCE(properties->>'scope', '') || ' ') > 0
AND (@issued_after IS NULL OR created_at >= @issued_after)
ORDER BY created_at DESC, id DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "scope", scope);
AddParameter(cmd, "issued_after", issuedAfter);
AddParameter(cmd, "limit", limit);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListRevokedAsync(string? tenant, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE lower(COALESCE(properties->>'status', 'valid')) = 'revoked'
AND (@tenant IS NULL OR (properties->>'tenant') = @tenant)
ORDER BY token_id ASC, id ASC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "limit", limit);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT COUNT(*)
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = @tenant
AND (@service_account_id IS NULL OR (properties->>'service_account_id') = @service_account_id)
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
AND (expires_at IS NULL OR expires_at > @now)
""";
var count = await ExecuteScalarAsync<long>(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "service_account_id", serviceAccountId);
AddParameter(cmd, "now", now);
},
cancellationToken: cancellationToken).ConfigureAwait(false);
return count ?? 0;
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, token_id, subject_id, client_id, token_type, reference_id, created_at, expires_at, redeemed_at, payload, properties
FROM authority.oidc_tokens
WHERE (properties->>'tenant') = @tenant
AND (@service_account_id IS NULL OR (properties->>'service_account_id') = @service_account_id)
AND lower(COALESCE(properties->>'status', 'valid')) <> 'revoked'
AND (expires_at IS NULL OR expires_at > @now)
ORDER BY created_at DESC, id DESC
LIMIT @limit
""";
return await QueryAsync(
tenantId: string.Empty,
sql: sql,
configureCommand: cmd =>
{
AddParameter(cmd, "tenant", tenant);
AddParameter(cmd, "service_account_id", serviceAccountId);
AddParameter(cmd, "now", now);
AddParameter(cmd, "limit", limit);
},
mapRow: MapToken,
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListAsync(int limit, CancellationToken cancellationToken = default)
{
const string sql = """

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for revocations.
/// </summary>
public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>
public sealed class RevocationRepository : RepositoryBase<AuthorityDataSource>, IRevocationRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -9,7 +9,7 @@ namespace StellaOps.Authority.Persistence.Postgres.Repositories;
/// <summary>
/// PostgreSQL repository for service accounts.
/// </summary>
public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSource>
public sealed class ServiceAccountRepository : RepositoryBase<AuthorityDataSource>, IServiceAccountRepository
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.General);

View File

@@ -79,5 +79,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<IOfflineKitAuditRepository>(sp => sp.GetRequiredService<OfflineKitAuditRepository>());
services.AddScoped<IOfflineKitAuditEmitter, OfflineKitAuditEmitter>();
services.AddScoped<RevocationExportStateRepository>();
services.AddScoped<IBootstrapInviteRepository>(sp => sp.GetRequiredService<BootstrapInviteRepository>());
services.AddScoped<IServiceAccountRepository>(sp => sp.GetRequiredService<ServiceAccountRepository>());
services.AddScoped<IClientRepository>(sp => sp.GetRequiredService<ClientRepository>());
services.AddScoped<IRevocationRepository>(sp => sp.GetRequiredService<RevocationRepository>());
services.AddScoped<ILoginAttemptRepository>(sp => sp.GetRequiredService<LoginAttemptRepository>());
services.AddScoped<IOidcTokenRepository>(sp => sp.GetRequiredService<OidcTokenRepository>());
services.AddScoped<IAirgapAuditRepository>(sp => sp.GetRequiredService<AirgapAuditRepository>());
}
}