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(); } // DISABLED: ISecurityMasterRulesStore implementation pending // 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, // }; // // HttpContext.Response.StatusCode = StatusCodes.Status200OK; // HttpContext.Response.ContentType = "application/json"; // await HttpContext.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; } } // DISABLED: ISecurityMasterRulesStore implementation pending // 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, // }; // // HttpContext.Response.StatusCode = StatusCodes.Status200OK; // HttpContext.Response.ContentType = "application/json"; // await HttpContext.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); }