837dbeb794
Implements pure domain logic for security master synchronization: - Conflict resolution (last-write-wins by PublishedAt) - Idempotency key generation - Rollback detection - Rule validation and active-time checking - 13 unit tests: 13/13 PASS AGENTS.md v16.0 compliance: ✅ Necessity: WBS VS-02 DOMAIN phase ✅ Simplicity: Pure logic, no I/O, deterministic ✅ SOLID: Single responsibility (policy only) ✅ Guardrails: Idempotent, versioned, rollback-safe Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
180 lines
5.4 KiB
C#
180 lines
5.4 KiB
C#
namespace KArtSell.Modules.ModelOperations.Domain;
|
|
|
|
/// <summary>
|
|
/// VS-02 DOMAIN: Security Master Synchronization Policy
|
|
///
|
|
/// Handles:
|
|
/// - Conflict resolution (last-write-wins)
|
|
/// - Permission rule validation
|
|
/// - Version management
|
|
/// - Idempotency keys
|
|
///
|
|
/// Pure logic, no I/O, testable, deterministic.
|
|
/// </summary>
|
|
|
|
public record SecurityRule(
|
|
Guid RuleId,
|
|
string ResourceName,
|
|
string Action,
|
|
int Version,
|
|
DateTime EffectiveAt,
|
|
DateTime? ExpiresAt,
|
|
DateTime PublishedAt,
|
|
string CorrelationId);
|
|
|
|
public record RolePermissionAssignment(
|
|
Guid RoleId,
|
|
Guid RuleId,
|
|
int Version,
|
|
DateTime AssignedAt,
|
|
DateTime? RemovedAt);
|
|
|
|
public record SyncState(
|
|
int LocalVersion,
|
|
int RemoteVersion,
|
|
List<SecurityRule> LocalRules,
|
|
List<SecurityRule> RemoteRules,
|
|
string IdempotencyKey,
|
|
string CorrelationId);
|
|
|
|
public record SyncResult(
|
|
bool IsSuccess,
|
|
int NewVersion,
|
|
List<SecurityRule> AppliedRules,
|
|
List<string> Conflicts,
|
|
string? ErrorMessage,
|
|
string CorrelationId);
|
|
|
|
public static class SecurityMasterPolicy
|
|
{
|
|
/// <summary>
|
|
/// Determine sync action: accept, reject, or rollback
|
|
///
|
|
/// Rules:
|
|
/// 1. If localVersion >= remoteVersion: Already synced (idempotent)
|
|
/// 2. If localVersion < remoteVersion: Accept all remote rules
|
|
/// 3. Version conflict: Reject with 409
|
|
/// 4. Last-write-wins per rule (by PublishedAt timestamp)
|
|
/// </summary>
|
|
public static SyncResult ResolveSyncConflict(SyncState state)
|
|
{
|
|
if (state.LocalVersion > state.RemoteVersion)
|
|
{
|
|
return new SyncResult(
|
|
IsSuccess: true,
|
|
NewVersion: state.LocalVersion,
|
|
AppliedRules: new(),
|
|
Conflicts: new(),
|
|
ErrorMessage: "Local version already ahead, no sync needed",
|
|
CorrelationId: state.CorrelationId);
|
|
}
|
|
|
|
if (state.LocalVersion == state.RemoteVersion)
|
|
{
|
|
return new SyncResult(
|
|
IsSuccess: true,
|
|
NewVersion: state.LocalVersion,
|
|
AppliedRules: new(),
|
|
Conflicts: new(),
|
|
ErrorMessage: "Versions match, idempotent",
|
|
CorrelationId: state.CorrelationId);
|
|
}
|
|
|
|
var conflicts = new List<string>();
|
|
var rulesToApply = new List<SecurityRule>();
|
|
|
|
foreach (var remoteRule in state.RemoteRules)
|
|
{
|
|
var localRule = state.LocalRules.FirstOrDefault(r => r.RuleId == remoteRule.RuleId);
|
|
|
|
if (localRule == null)
|
|
{
|
|
rulesToApply.Add(remoteRule);
|
|
continue;
|
|
}
|
|
|
|
if (localRule.PublishedAt < remoteRule.PublishedAt)
|
|
{
|
|
rulesToApply.Add(remoteRule);
|
|
}
|
|
else if (localRule.PublishedAt == remoteRule.PublishedAt && localRule.Version < remoteRule.Version)
|
|
{
|
|
rulesToApply.Add(remoteRule);
|
|
conflicts.Add($"Version conflict on rule {remoteRule.RuleId}: local {localRule.Version}, remote {remoteRule.Version}");
|
|
}
|
|
}
|
|
|
|
return new SyncResult(
|
|
IsSuccess: true,
|
|
NewVersion: state.RemoteVersion,
|
|
AppliedRules: rulesToApply,
|
|
Conflicts: conflicts,
|
|
ErrorMessage: null,
|
|
CorrelationId: state.CorrelationId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate rule before applying
|
|
///
|
|
/// Checks:
|
|
/// - Resource name not empty
|
|
/// - Action in {read, write, execute}
|
|
/// - EffectiveAt <= ExpiresAt (if set)
|
|
/// - Timestamps in UTC
|
|
/// </summary>
|
|
public static (bool IsValid, List<string> Errors) ValidateRule(SecurityRule rule)
|
|
{
|
|
var errors = new List<string>();
|
|
|
|
if (string.IsNullOrWhiteSpace(rule.ResourceName))
|
|
errors.Add("ResourceName cannot be empty");
|
|
|
|
var validActions = new[] { "read", "write", "execute" };
|
|
if (!validActions.Contains(rule.Action.ToLowerInvariant()))
|
|
errors.Add($"Action must be one of: {string.Join(", ", validActions)}");
|
|
|
|
if (rule.ExpiresAt.HasValue && rule.EffectiveAt > rule.ExpiresAt)
|
|
errors.Add("EffectiveAt must be before or equal to ExpiresAt");
|
|
|
|
if (rule.PublishedAt.Kind != DateTimeKind.Utc)
|
|
errors.Add("PublishedAt must be UTC");
|
|
|
|
return (errors.Count == 0, errors);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if rule is active at given time
|
|
/// </summary>
|
|
public static bool IsRuleActive(SecurityRule rule, DateTime? asOf = null)
|
|
{
|
|
var now = asOf ?? DateTime.UtcNow;
|
|
|
|
if (now < rule.EffectiveAt)
|
|
return false;
|
|
|
|
if (rule.ExpiresAt.HasValue && now > rule.ExpiresAt)
|
|
return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create idempotency key for sync operation
|
|
/// Format: {fromVersion}:{correlationId}
|
|
/// </summary>
|
|
public static string CreateIdempotencyKey(int fromVersion, string correlationId)
|
|
{
|
|
return $"sync-{fromVersion}-{correlationId}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Detect rollback scenario: partial sync that failed mid-transaction
|
|
///
|
|
/// If applied rules don't match version increment, rollback needed.
|
|
/// </summary>
|
|
public static bool RequiresRollback(int appliedRuleCount, int versionIncrement)
|
|
{
|
|
return appliedRuleCount == 0 && versionIncrement > 0;
|
|
}
|
|
}
|