feat: Complete VS-02 DOMAIN - SecurityMaster sync policy (Batch 1 - 3/7)
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>
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user