feat: Implement PostgreSQL repositories for various entities
- 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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user