48ae6e9f8d
ci / backend (push) Failing after 1s
ci / static (push) Failing after 8s
Build & Test with Secrets / build (push) Failing after 1s
ci / frontend (push) Failing after 22s
Build & Test with Secrets / security-scan (push) Failing after 7s
ci / publish (push) Has been skipped
Build & Test with Secrets / frontend (push) Failing after 1m8s
Build & Test with Secrets / notification (push) Failing after 1s
deploy / deploy (push) Successful in 1m32s
deploy / notify (push) Successful in 1s
SyncSecurityMasterEndpoint and GetSecurityMasterRulesEndpoint disabled until ISecurityMasterRulesStore and IRemoteSecurityMasterClient are implemented. DI registrations remain commented in Program.cs. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
319 lines
11 KiB
C#
319 lines
11 KiB
C#
using FastEndpoints;
|
|
using Npgsql;
|
|
using System.Text.Json;
|
|
using KArtSell.Modules.ModelOperations.Domain;
|
|
using KArtSell.BuildingBlocks.Time;
|
|
|
|
namespace KArtSell.Host.Features.SecurityMaster;
|
|
|
|
/// <summary>
|
|
/// VS-02 BE: Security Master Sync Endpoint
|
|
/// POST /api/security/master/sync
|
|
///
|
|
/// Synchronizes local security rules with remote master
|
|
/// - Last-write-wins conflict resolution
|
|
/// - Idempotent by version + correlationId
|
|
/// - Atomic transaction (all-or-nothing)
|
|
/// - Returns 200 if success, 409 if conflict, 503 if unavailable
|
|
/// </summary>
|
|
|
|
public sealed class SyncSecurityMasterRequest
|
|
{
|
|
public int FromVersion { get; set; }
|
|
}
|
|
|
|
public sealed class SyncSecurityMasterResponse
|
|
{
|
|
public int Version { get; set; }
|
|
public int RulesCount { get; set; }
|
|
public DateTime SyncedAt { get; set; }
|
|
public List<string> Conflicts { get; set; } = new();
|
|
}
|
|
|
|
// DISABLED: ISecurityMasterRulesStore implementation pending
|
|
// public sealed class SyncSecurityMasterEndpoint : Endpoint<SyncSecurityMasterRequest, SyncSecurityMasterResponse>
|
|
// {
|
|
// private readonly ISecurityMasterSyncHandler _handler;
|
|
//
|
|
// public SyncSecurityMasterEndpoint(ISecurityMasterSyncHandler handler)
|
|
// {
|
|
// _handler = handler;
|
|
// }
|
|
//
|
|
// public override void Configure()
|
|
// {
|
|
// Post("/api/security/master/sync");
|
|
// Roles("SecurityAdmin");
|
|
// AllowAnonymous(); // Override role check if needed for service-to-service
|
|
// }
|
|
//
|
|
// public override async Task HandleAsync(SyncSecurityMasterRequest req, CancellationToken ct)
|
|
// {
|
|
// var correlationId = HttpContext.TraceIdentifier;
|
|
// var idempotencyKey = SecurityMasterPolicy.CreateIdempotencyKey(req.FromVersion, correlationId);
|
|
//
|
|
// var result = await _handler.SyncAsync(
|
|
// fromVersion: req.FromVersion,
|
|
// idempotencyKey: idempotencyKey,
|
|
// correlationId: correlationId,
|
|
// cancellationToken: ct);
|
|
//
|
|
// if (!result.IsSuccess)
|
|
// {
|
|
// ThrowError($"Version conflict. Local: {req.FromVersion}, Remote: {result.NewVersion}");
|
|
// }
|
|
//
|
|
// var response = new SyncSecurityMasterResponse
|
|
// {
|
|
// Version = result.NewVersion,
|
|
// RulesCount = result.AppliedRules.Count,
|
|
// SyncedAt = DateTime.UtcNow,
|
|
// Conflicts = result.Conflicts,
|
|
// };
|
|
//
|
|
// HttpContext.Response.StatusCode = StatusCodes.Status200OK;
|
|
// HttpContext.Response.ContentType = "application/json";
|
|
// await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(response), ct);
|
|
// }
|
|
// }
|
|
|
|
/// <summary>
|
|
/// VS-02 BE: Get Security Rules Endpoint
|
|
/// GET /api/security/master/rules
|
|
///
|
|
/// Retrieves active security rules
|
|
/// - Returns 503 if data stale (>5 min)
|
|
/// - Cached response (100ms SLA)
|
|
/// </summary>
|
|
|
|
public sealed class GetSecurityMasterRulesResponse
|
|
{
|
|
public List<SecurityRuleDto> Rules { get; set; } = new();
|
|
public int Version { get; set; }
|
|
public DateTime LastSyncAt { get; set; }
|
|
}
|
|
|
|
public sealed class SecurityRuleDto
|
|
{
|
|
public Guid RuleId { get; set; }
|
|
public string ResourceName { get; set; } = "";
|
|
public string Action { get; set; } = "";
|
|
public int Version { get; set; }
|
|
public DateTime EffectiveAt { get; set; }
|
|
public DateTime? ExpiresAt { get; set; }
|
|
}
|
|
|
|
// DISABLED: ISecurityMasterRulesStore implementation pending
|
|
// public sealed class GetSecurityMasterRulesEndpoint : EndpointWithoutRequest<GetSecurityMasterRulesResponse>
|
|
// {
|
|
// private readonly ISecurityMasterRulesStore _store;
|
|
// private readonly IClock _clock;
|
|
//
|
|
// public GetSecurityMasterRulesEndpoint(ISecurityMasterRulesStore store, IClock clock)
|
|
// {
|
|
// _store = store;
|
|
// _clock = clock;
|
|
// }
|
|
//
|
|
// public override void Configure()
|
|
// {
|
|
// Get("/api/security/master/rules");
|
|
// AllowAnonymous();
|
|
// }
|
|
//
|
|
// public override async Task HandleAsync(CancellationToken ct)
|
|
// {
|
|
// var state = await _store.GetCurrentStateAsync(ct);
|
|
//
|
|
// var staleTreshold = _clock.UtcNow.AddMinutes(-5);
|
|
// if (state.LastSyncAt < staleTreshold)
|
|
// {
|
|
// ThrowError("Security rules data is stale");
|
|
// }
|
|
//
|
|
// var rules = state.Rules
|
|
// .Where(r => SecurityMasterPolicy.IsRuleActive(r, _clock.UtcNow.DateTime))
|
|
// .Select(r => new SecurityRuleDto
|
|
// {
|
|
// RuleId = r.RuleId,
|
|
// ResourceName = r.ResourceName,
|
|
// Action = r.Action,
|
|
// Version = r.Version,
|
|
// EffectiveAt = r.EffectiveAt,
|
|
// ExpiresAt = r.ExpiresAt,
|
|
// })
|
|
// .ToList();
|
|
//
|
|
// var response = new GetSecurityMasterRulesResponse
|
|
// {
|
|
// Rules = rules,
|
|
// Version = state.Version,
|
|
// LastSyncAt = state.LastSyncAt,
|
|
// };
|
|
//
|
|
// HttpContext.Response.StatusCode = StatusCodes.Status200OK;
|
|
// HttpContext.Response.ContentType = "application/json";
|
|
// await HttpContext.Response.WriteAsync(JsonSerializer.Serialize(response), ct);
|
|
// }
|
|
// }
|
|
|
|
/// <summary>
|
|
/// VS-02 Application Handler: Orchestrates sync operation
|
|
/// Responsibilities:
|
|
/// - Fetch remote rules
|
|
/// - Apply conflict resolution
|
|
/// - Persist to database (atomic)
|
|
/// - Publish events
|
|
/// - Audit logging
|
|
/// </summary>
|
|
|
|
public interface ISecurityMasterSyncHandler
|
|
{
|
|
Task<SyncResult> SyncAsync(
|
|
int fromVersion,
|
|
string idempotencyKey,
|
|
string correlationId,
|
|
CancellationToken cancellationToken);
|
|
}
|
|
|
|
public class SecurityMasterSyncHandler : ISecurityMasterSyncHandler
|
|
{
|
|
private readonly NpgsqlDataSource _dataSource;
|
|
private readonly IRemoteSecurityMasterClient _remoteClient;
|
|
private readonly ISecurityMasterRulesStore _store;
|
|
private readonly IClock _clock;
|
|
|
|
public SecurityMasterSyncHandler(
|
|
NpgsqlDataSource dataSource,
|
|
IRemoteSecurityMasterClient remoteClient,
|
|
ISecurityMasterRulesStore store,
|
|
IClock clock)
|
|
{
|
|
_dataSource = dataSource;
|
|
_remoteClient = remoteClient;
|
|
_store = store;
|
|
_clock = clock;
|
|
}
|
|
|
|
public async Task<SyncResult> SyncAsync(
|
|
int fromVersion,
|
|
string idempotencyKey,
|
|
string correlationId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// Check idempotency
|
|
var existing = await _store.GetResultByIdempotencyKeyAsync(idempotencyKey, cancellationToken);
|
|
if (existing != null)
|
|
{
|
|
return existing;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Fetch remote rules
|
|
var remoteState = await _remoteClient.GetRulesAsync(fromVersion, cancellationToken);
|
|
|
|
// Get local state
|
|
var localState = await _store.GetCurrentStateAsync(cancellationToken);
|
|
|
|
// Resolve conflicts
|
|
var syncState = new SyncState(
|
|
LocalVersion: localState.Version,
|
|
RemoteVersion: remoteState.Version,
|
|
LocalRules: localState.Rules.ToList(),
|
|
RemoteRules: remoteState.Rules.ToList(),
|
|
IdempotencyKey: idempotencyKey,
|
|
CorrelationId: correlationId);
|
|
|
|
var result = SecurityMasterPolicy.ResolveSyncConflict(syncState);
|
|
|
|
if (!result.IsSuccess)
|
|
{
|
|
return result;
|
|
}
|
|
|
|
// Apply changes (atomic transaction)
|
|
await using var transaction = await _dataSource.OpenConnectionAsync(cancellationToken);
|
|
await using var tx = await transaction.BeginTransactionAsync(cancellationToken);
|
|
|
|
try
|
|
{
|
|
foreach (var rule in result.AppliedRules)
|
|
{
|
|
await PersistRuleAsync(transaction, rule, cancellationToken);
|
|
}
|
|
|
|
// Store sync result (idempotency)
|
|
await _store.StoreSyncResultAsync(idempotencyKey, result, cancellationToken);
|
|
|
|
await tx.CommitAsync(cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
await tx.RollbackAsync(cancellationToken);
|
|
throw;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new SyncResult(
|
|
IsSuccess: false,
|
|
NewVersion: fromVersion,
|
|
AppliedRules: new(),
|
|
Conflicts: new() { ex.Message },
|
|
ErrorMessage: "Sync failed: " + ex.Message,
|
|
CorrelationId: correlationId);
|
|
}
|
|
}
|
|
|
|
private async Task PersistRuleAsync(Npgsql.NpgsqlConnection connection, SecurityRule rule, CancellationToken ct)
|
|
{
|
|
const string sql = """
|
|
INSERT INTO security_master.rules (rule_id, resource_name, action, version, effective_at, expires_at, published_at, correlation_id, revision)
|
|
VALUES (@ruleId, @resourceName, @action, @version, @effectiveAt, @expiresAt, @publishedAt, @correlationId, 1)
|
|
ON CONFLICT(rule_id) DO UPDATE SET
|
|
version = EXCLUDED.version,
|
|
published_at = EXCLUDED.published_at,
|
|
revision = security_master.rules.revision + 1
|
|
WHERE EXCLUDED.published_at > security_master.rules.published_at;
|
|
""";
|
|
|
|
await using var cmd = connection.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
cmd.Parameters.AddWithValue("@ruleId", rule.RuleId);
|
|
cmd.Parameters.AddWithValue("@resourceName", rule.ResourceName);
|
|
cmd.Parameters.AddWithValue("@action", rule.Action);
|
|
cmd.Parameters.AddWithValue("@version", rule.Version);
|
|
cmd.Parameters.AddWithValue("@effectiveAt", rule.EffectiveAt);
|
|
cmd.Parameters.AddWithValue("@expiresAt", rule.ExpiresAt ?? (object)DBNull.Value);
|
|
cmd.Parameters.AddWithValue("@publishedAt", rule.PublishedAt);
|
|
cmd.Parameters.AddWithValue("@correlationId", rule.CorrelationId);
|
|
|
|
await cmd.ExecuteNonQueryAsync(ct);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Abstraction: Remote security master client (service-to-service)
|
|
/// </summary>
|
|
|
|
public interface IRemoteSecurityMasterClient
|
|
{
|
|
Task<(int Version, List<SecurityRule> Rules)> GetRulesAsync(int fromVersion, CancellationToken ct);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Abstraction: Local security rules store (persistence)
|
|
/// </summary>
|
|
|
|
public record SecurityMasterState(int Version, DateTime LastSyncAt, List<SecurityRule> Rules);
|
|
|
|
public interface ISecurityMasterRulesStore
|
|
{
|
|
Task<SecurityMasterState> GetCurrentStateAsync(CancellationToken ct);
|
|
Task StoreSyncResultAsync(string idempotencyKey, SyncResult result, CancellationToken ct);
|
|
Task<SyncResult?> GetResultByIdempotencyKeyAsync(string idempotencyKey, CancellationToken ct);
|
|
}
|