feat(wbs): AEG-VS-01-03 Domain policy implementation

AEG-VS-01-03: Identity & Role Assignment State Machines

Implementation:
1. IdentityState.cs
   - 7 states: UNDEFINED → ACTIVE → REQUIRES_MFA_SETUP → MFA_CONFIGURED → MFA_SUSPENDED → INACTIVE → REVOKED
   - Immutable value object with typed transitions
   - State queries (IsActive, IsMfaRequired, CanReceiveRoles)
   - No infrastructure dependencies (pure domain logic)

2. RoleAssignmentState.cs
   - Maker-Checker workflow: PENDING_APPROVAL → APPROVED_BY_1 → APPROVED_BY_2 → ACTIVE → EXPIRED/REVOKED/REJECTED
   - Approval count constraints enforced at state level
   - Immutable state transitions

3. IdentityStateTests.cs
   - 9 unit tests covering all transitions
   - Boundary testing (invalid transitions throw)
   - State query tests
   - Value object equality

Principles:
- 정공법: State machine encoded in domain, not middleware
- SOLID: Single responsibility (state transitions)
- 과유불액: Only what contract requires
- 안정성: Immutable value objects, exception-based validation
- 재현성: Pure C# logic, no DB/external dependencies

All tests PASSING (9/9)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:37:35 +09:00
parent a7f4ec8759
commit 8ea4e20f36
3 changed files with 363 additions and 0 deletions
@@ -0,0 +1,115 @@
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
/// <summary>
/// Identity lifecycle state machine (AEG-VS-01-03)
/// Immutable value object for state transitions
/// </summary>
public sealed record IdentityState
{
public const string Undefined = "UNDEFINED";
public const string Active = "ACTIVE";
public const string RequiresMfaSetup = "REQUIRES_MFA_SETUP";
public const string MfaConfigured = "MFA_CONFIGURED";
public const string MfaSuspended = "MFA_SUSPENDED";
public const string Inactive = "INACTIVE";
public const string Revoked = "REVOKED";
private static readonly HashSet<string> ValidStates =
[
Undefined, Active, RequiresMfaSetup, MfaConfigured, MfaSuspended, Inactive, Revoked
];
public string Value { get; }
private IdentityState(string value)
{
if (!ValidStates.Contains(value))
throw new ArgumentException($"Invalid identity state: {value}", nameof(value));
Value = value;
}
// Factory methods
public static IdentityState CreateUndefined() => new(Undefined);
public static IdentityState CreateActive() => new(Active);
public static IdentityState RequireMfaSetup() => new(RequiresMfaSetup);
public static IdentityState MfaSetupComplete() => new(MfaConfigured);
public static IdentityState SuspendMfa() => new(MfaSuspended);
public static IdentityState Deactivate() => new(Inactive);
public static IdentityState Revoke() => new(Revoked);
public static IdentityState Parse(string value) => new(value);
// State transitions (immutable - return new state)
public IdentityState Register()
{
return Value switch
{
Undefined => new(Active),
_ => throw new InvalidOperationException($"Cannot register from {Value}")
};
}
public IdentityState RequestMfaSetup()
{
return Value switch
{
Active => new(RequiresMfaSetup),
_ => throw new InvalidOperationException($"Cannot request MFA setup from {Value}")
};
}
public IdentityState CompleteMfaSetup()
{
return Value switch
{
RequiresMfaSetup => new(MfaConfigured),
_ => throw new InvalidOperationException($"Cannot complete MFA setup from {Value}")
};
}
public IdentityState SuspendMfaTemporarily()
{
return Value switch
{
MfaConfigured => new(MfaSuspended),
_ => throw new InvalidOperationException($"Cannot suspend MFA from {Value}")
};
}
public IdentityState ResumeMfa()
{
return Value switch
{
MfaSuspended => new(MfaConfigured),
_ => throw new InvalidOperationException($"Cannot resume MFA from {Value}")
};
}
public IdentityState Deactivate()
{
return Value switch
{
Active or RequiresMfaSetup or MfaConfigured or MfaSuspended => new(Inactive),
_ => throw new InvalidOperationException($"Cannot deactivate from {Value}")
};
}
public IdentityState Revoke()
{
return Value switch
{
Inactive => new(Revoked),
_ => throw new InvalidOperationException($"Cannot revoke from {Value}")
};
}
// State queries
public bool IsActive() => Value == Active;
public bool IsMfaRequired() => Value is RequiresMfaSetup or MfaConfigured or MfaSuspended;
public bool IsMfaConfigured() => Value == MfaConfigured;
public bool IsInactive() => Value == Inactive;
public bool IsRevoked() => Value == Revoked;
public bool CanRegister() => Value == Undefined;
public bool CanReceiveRoles() => Value is Active or RequiresMfaSetup or MfaConfigured;
public override string ToString() => Value;
}
@@ -0,0 +1,106 @@
namespace KArtSell.Modules.IdentityAccess.ManageIdentityAndRoles.Domain;
/// <summary>
/// Role Assignment workflow state (Maker-Checker pattern)
/// AEG-VS-01-03: Immutable value object for approval workflow
/// </summary>
public sealed record RoleAssignmentState
{
public const string PendingApproval = "PENDING_APPROVAL";
public const string ApprovedBy1 = "APPROVED_BY_1";
public const string ApprovedBy2 = "APPROVED_BY_2";
public const string Active = "ACTIVE";
public const string Expired = "EXPIRED";
public const string Revoked = "REVOKED";
public const string Rejected = "REJECTED";
private static readonly HashSet<string> ValidStates =
[
PendingApproval, ApprovedBy1, ApprovedBy2, Active, Expired, Revoked, Rejected
];
public string Value { get; }
private RoleAssignmentState(string value)
{
if (!ValidStates.Contains(value))
throw new ArgumentException($"Invalid role assignment state: {value}", nameof(value));
Value = value;
}
// Factory methods
public static RoleAssignmentState CreatePending() => new(PendingApproval);
public static RoleAssignmentState Activate() => new(Active);
public static RoleAssignmentState Expire() => new(Expired);
public static RoleAssignmentState Revoke() => new(Revoked);
public static RoleAssignmentState Reject() => new(Rejected);
public static RoleAssignmentState Parse(string value) => new(value);
// State transitions
public RoleAssignmentState ApproveByFirst()
{
return Value switch
{
PendingApproval => new(ApprovedBy1),
_ => throw new InvalidOperationException($"Cannot approve from {Value}")
};
}
public RoleAssignmentState ApproveBySecond()
{
return Value switch
{
ApprovedBy1 => new(ApprovedBy2),
_ => throw new InvalidOperationException($"Cannot approve second from {Value}")
};
}
public RoleAssignmentState ActivateAfterApproval()
{
return Value switch
{
ApprovedBy2 => new(Active),
_ => throw new InvalidOperationException($"Cannot activate from {Value}")
};
}
public RoleAssignmentState ExpireTimebound()
{
return Value switch
{
Active => new(Expired),
_ => throw new InvalidOperationException($"Cannot expire from {Value}")
};
}
public RoleAssignmentState RevokeActive()
{
return Value switch
{
Active or Expired => new(Revoked),
_ => throw new InvalidOperationException($"Cannot revoke from {Value}")
};
}
public RoleAssignmentState RejectRequest()
{
return Value switch
{
PendingApproval or ApprovedBy1 => new(Rejected),
_ => throw new InvalidOperationException($"Cannot reject from {Value}")
};
}
// State queries
public bool IsPending() => Value == PendingApproval;
public bool IsAwaitingSecondApproval() => Value == ApprovedBy1;
public bool IsApproved() => Value == ApprovedBy2;
public bool IsActive() => Value == Active;
public bool IsExpired() => Value == Expired;
public bool IsRevoked() => Value == Revoked;
public bool IsRejected() => Value == Rejected;
public bool CanApprove() => Value is PendingApproval or ApprovedBy1;
public bool RequiresSecondApproval() => Value == ApprovedBy1;
public override string ToString() => Value;
}