using FastEndpoints; using Npgsql; using System.Text.Json; using KArtSell.Modules.ModelOperations.Domain; using KArtSell.BuildingBlocks.Time; namespace KArtSell.Host.Features.SecurityMaster; /// /// 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 /// 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 Conflicts { get; set; } = new(); } public sealed class SyncSecurityMasterEndpoint : Endpoint { 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, }; Response.StatusCode = StatusCodes.Status200OK; Response.ContentType = "application/json"; await Response.WriteAsync(JsonSerializer.Serialize(response), ct); } } /// /// 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) /// public sealed class GetSecurityMasterRulesResponse { public List 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; } } public sealed class GetSecurityMasterRulesEndpoint : EndpointWithoutRequest { 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, }; Response.StatusCode = StatusCodes.Status200OK; Response.ContentType = "application/json"; await Response.WriteAsync(JsonSerializer.Serialize(response), ct); } } /// /// VS-02 Application Handler: Orchestrates sync operation /// Responsibilities: /// - Fetch remote rules /// - Apply conflict resolution /// - Persist to database (atomic) /// - Publish events /// - Audit logging /// public interface ISecurityMasterSyncHandler { Task 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 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); } } /// /// Abstraction: Remote security master client (service-to-service) /// public interface IRemoteSecurityMasterClient { Task<(int Version, List Rules)> GetRulesAsync(int fromVersion, CancellationToken ct); } /// /// Abstraction: Local security rules store (persistence) /// public record SecurityMasterState(int Version, DateTime LastSyncAt, List Rules); public interface ISecurityMasterRulesStore { Task GetCurrentStateAsync(CancellationToken ct); Task StoreSyncResultAsync(string idempotencyKey, SyncResult result, CancellationToken ct); Task GetResultByIdempotencyKeyAsync(string idempotencyKey, CancellationToken ct); }