feat: Implement PostgreSQL repositories for various entities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Added BootstrapInviteRepository for managing bootstrap invites.
- Added ClientRepository for handling OAuth/OpenID clients.
- Introduced LoginAttemptRepository for logging login attempts.
- Created OidcTokenRepository for managing OpenIddict tokens and refresh tokens.
- Implemented RevocationExportStateRepository for persisting revocation export state.
- Added RevocationRepository for managing revocations.
- Introduced ServiceAccountRepository for handling service accounts.
This commit is contained in:
master
2025-12-11 17:48:25 +02:00
parent 1995883476
commit ab22181e8b
82 changed files with 5153 additions and 2261 deletions

View File

@@ -15,4 +15,15 @@ public sealed record AirGapState
public TimeAnchor TimeAnchor { get; init; } = TimeAnchor.Unknown;
public DateTimeOffset LastTransitionAt { get; init; } = DateTimeOffset.MinValue;
public StalenessBudget StalenessBudget { get; init; } = StalenessBudget.Default;
/// <summary>
/// Drift baseline in seconds (difference between wall clock and anchor time at seal).
/// </summary>
public long DriftBaselineSeconds { get; init; } = 0;
/// <summary>
/// Per-content staleness budgets (advisories, vex, policy).
/// </summary>
public IReadOnlyDictionary<string, StalenessBudget> ContentBudgets { get; init; } =
new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -71,8 +71,10 @@ internal static class AirGapEndpoints
var budget = request.StalenessBudget ?? StalenessBudget.Default;
var now = timeProvider.GetUtcNow();
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, cancellationToken);
var status = new AirGapStatus(state, stalenessCalculator.Evaluate(anchor, budget, now), now);
var state = await service.SealAsync(tenantId, request.PolicyHash!, anchor, budget, now, request.ContentBudgets, cancellationToken);
var staleness = stalenessCalculator.Evaluate(anchor, budget, now);
var contentStaleness = stalenessCalculator.EvaluateContent(anchor, state.ContentBudgets, now);
var status = new AirGapStatus(state, staleness, contentStaleness, now);
telemetry.RecordSeal(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}
@@ -86,8 +88,10 @@ internal static class AirGapEndpoints
CancellationToken cancellationToken)
{
var tenantId = ResolveTenant(httpContext);
var state = await service.UnsealAsync(tenantId, timeProvider.GetUtcNow(), cancellationToken);
var status = new AirGapStatus(state, StalenessEvaluation.Unknown, timeProvider.GetUtcNow());
var now = timeProvider.GetUtcNow();
var state = await service.UnsealAsync(tenantId, now, cancellationToken);
var emptyContentStaleness = new Dictionary<string, StalenessEvaluation>(StringComparer.OrdinalIgnoreCase);
var status = new AirGapStatus(state, StalenessEvaluation.Unknown, emptyContentStaleness, now);
telemetry.RecordUnseal(tenantId, status);
return Results.Ok(AirGapStatusResponse.FromStatus(status));
}

View File

@@ -11,7 +11,9 @@ public sealed record AirGapStatusResponse(
TimeAnchor TimeAnchor,
StalenessEvaluation Staleness,
long DriftSeconds,
long DriftBaselineSeconds,
long SecondsRemaining,
IReadOnlyDictionary<string, ContentStalenessEntry> ContentStaleness,
DateTimeOffset LastTransitionAt,
DateTimeOffset EvaluatedAt)
{
@@ -23,7 +25,30 @@ public sealed record AirGapStatusResponse(
status.State.TimeAnchor,
status.Staleness,
status.Staleness.AgeSeconds,
status.State.DriftBaselineSeconds,
status.Staleness.SecondsRemaining,
BuildContentStaleness(status.ContentStaleness),
status.State.LastTransitionAt,
status.EvaluatedAt);
private static IReadOnlyDictionary<string, ContentStalenessEntry> BuildContentStaleness(
IReadOnlyDictionary<string, StalenessEvaluation> evaluations)
{
var result = new Dictionary<string, ContentStalenessEntry>(StringComparer.OrdinalIgnoreCase);
foreach (var kvp in evaluations)
{
result[kvp.Key] = ContentStalenessEntry.FromEvaluation(kvp.Value);
}
return result;
}
}
public sealed record ContentStalenessEntry(
long AgeSeconds,
long SecondsRemaining,
bool IsWarning,
bool IsBreach)
{
public static ContentStalenessEntry FromEvaluation(StalenessEvaluation eval) =>
new(eval.AgeSeconds, eval.SecondsRemaining, eval.IsWarning, eval.IsBreach);
}

View File

@@ -11,4 +11,10 @@ public sealed class SealRequest
public TimeAnchor? TimeAnchor { get; set; }
public StalenessBudget? StalenessBudget { get; set; }
/// <summary>
/// Optional per-content staleness budgets (advisories, vex, policy).
/// Falls back to StalenessBudget when not provided.
/// </summary>
public Dictionary<string, StalenessBudget>? ContentBudgets { get; set; }
}

View File

@@ -22,11 +22,20 @@ public sealed class AirGapStateService
TimeAnchor timeAnchor,
StalenessBudget budget,
DateTimeOffset nowUtc,
IReadOnlyDictionary<string, StalenessBudget>? contentBudgets = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(policyHash);
budget.Validate();
// Compute drift baseline: difference between wall clock and anchor time at seal
var driftBaseline = timeAnchor.AnchorTime > DateTimeOffset.MinValue
? (long)(nowUtc - timeAnchor.AnchorTime).TotalSeconds
: 0;
// Build content budgets with defaults for common keys
var resolvedContentBudgets = BuildContentBudgets(contentBudgets, budget);
var newState = new AirGapState
{
TenantId = tenantId,
@@ -34,7 +43,9 @@ public sealed class AirGapStateService
PolicyHash = policyHash,
TimeAnchor = timeAnchor,
StalenessBudget = budget,
LastTransitionAt = nowUtc
LastTransitionAt = nowUtc,
DriftBaselineSeconds = driftBaseline,
ContentBudgets = resolvedContentBudgets
};
await _store.SetAsync(newState, cancellationToken);
@@ -63,8 +74,39 @@ public sealed class AirGapStateService
{
var state = await _store.GetAsync(tenantId, cancellationToken);
var staleness = _stalenessCalculator.Evaluate(state.TimeAnchor, state.StalenessBudget, nowUtc);
return new AirGapStatus(state, staleness, nowUtc);
var contentStaleness = _stalenessCalculator.EvaluateContent(state.TimeAnchor, state.ContentBudgets, nowUtc);
return new AirGapStatus(state, staleness, contentStaleness, nowUtc);
}
private static IReadOnlyDictionary<string, StalenessBudget> BuildContentBudgets(
IReadOnlyDictionary<string, StalenessBudget>? provided,
StalenessBudget fallback)
{
var result = new Dictionary<string, StalenessBudget>(StringComparer.OrdinalIgnoreCase);
if (provided != null)
{
foreach (var kvp in provided)
{
result[kvp.Key] = kvp.Value;
}
}
// Ensure common keys exist with fallback
foreach (var key in new[] { "advisories", "vex", "policy" })
{
if (!result.ContainsKey(key))
{
result[key] = fallback;
}
}
return result;
}
}
public sealed record AirGapStatus(AirGapState State, StalenessEvaluation Staleness, DateTimeOffset EvaluatedAt);
public sealed record AirGapStatus(
AirGapState State,
StalenessEvaluation Staleness,
IReadOnlyDictionary<string, StalenessEvaluation> ContentStaleness,
DateTimeOffset EvaluatedAt);