Skip to content

Commit b4294be

Browse files
beyondnetPeruclaude
andcommitted
feat(ums): DDD domain resource hierarchy + auth graph preview via same pipeline
DDD Domain Resources: - Add DomainMethod type to DomainResourceType (Aggregate | Entity | DomainMethod) - Add ParentResourceId to DomainResource enabling Aggregate → child Entity hierarchy - Enforce DDD rules in SystemSuite.AddDomainResource: DomainMethod requires parent, parent must exist and cannot itself be a DomainMethod - Migration: AddDomainResourceParentAndDomainMethodType - Frontend tree rebuilt to nest child Entities under parent Aggregates, DomainMethods shown as leaf nodes with FunctionSquare icon (orange) - GraphQL query and Zod schemas updated to include parentResourceId - Filter option "Métodos" added to both DomainResourcesPanel and ProfileDomainResourcesPanel Auth Graph Preview: - New endpoint GET /profiles/{id}/auth-graph/preview?format= calls IAuthorizationGraphBuilder.BuildAsync — the same pipeline as POST /api/v1/client/authenticate (no duplicate logic) - Credential validation is the only step omitted; tenant parameters, scopes, format resolution and audit still apply - Audits with EventType="Graph.Preview.Internal" - M3AuthorizationGraphPanel updated to call the new endpoint and display preview metadata: previewMode, tenant, authMethod, format, requestId Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent e53b630 commit b4294be

28 files changed

Lines changed: 3766 additions & 93 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Ums.Domain.Authorization.Graph;
2+
3+
namespace Ums.Application.Authorization.Graph;
4+
5+
/// <summary>
6+
/// Internal preview command: generates the same auth graph that a client system
7+
/// receives from POST /api/v1/client/authenticate, without requiring external
8+
/// credentials. Skips credential validation only; all graph rules, tenant
9+
/// parameters, scopes, and format resolution still apply.
10+
///
11+
/// Must only be invoked from the UMS admin UI under an authenticated session.
12+
/// </summary>
13+
public sealed record PreviewProfileAuthGraphCommand(
14+
Guid ProfileId,
15+
string AdminUserId, // UMS admin user requesting the preview (for audit)
16+
string ClientIp) : ICommand<PreviewProfileAuthGraphResult>;
17+
18+
/// <summary>Result returned to the Presentation layer for format override and HTTP response.</summary>
19+
public sealed record PreviewProfileAuthGraphResult(
20+
AuthorizationGraph Graph, // full graph object for Presentation-layer re-serialization
21+
string SerializedGraph, // pre-serialized in tenant default format
22+
string GraphFormat, // default format used: "JSON"|"XML"|"YAML"|"CSV"
23+
string RequestId, // correlates with the audit record
24+
Guid ProfileId,
25+
Guid UserId,
26+
Guid TenantId,
27+
string TenantCode,
28+
string AuthMethodUsed); // "Local" | "IDP"
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
using Ums.Application.Authorization.Graph.Serializers;
2+
using Ums.Domain.Authorization;
3+
using Ums.Domain.Authorization.Graph;
4+
using Ums.Domain.Identity;
5+
using Ums.Domain.Identity.Auth;
6+
7+
namespace Ums.Application.Authorization.Graph;
8+
9+
/// <summary>
10+
/// Generates the same AuthorizationGraph produced by POST /api/v1/client/authenticate
11+
/// without requiring external credentials. Credential validation is the only step
12+
/// omitted — all graph rules, tenant parameters, scopes, format resolution, and
13+
/// audit recording still execute through the same pipeline.
14+
///
15+
/// Flow (mirrors AuthenticateUserCommandHandler.BuildResultAsync):
16+
/// 1. Load Profile → User (target of the graph)
17+
/// 2. Load Tenant; resolve the tenant's configured auth method for context accuracy
18+
/// 3. IAuthorizationGraphBuilder.BuildAsync(user, tenantId, authMethod)
19+
/// 4. Serialize with default serializer + record the default format
20+
/// 5. Audit with EventType = "Graph.Preview.Internal"
21+
/// 6. Return graph + serialized + format so the Presentation layer can
22+
/// override format via Shell.Factory (identical to ClientAuthEndpoints pattern)
23+
/// </summary>
24+
public sealed class PreviewProfileAuthGraphCommandHandler(
25+
IProfileRepository profileRepo,
26+
IUserAccountRepository userRepo,
27+
ITenantRepository tenantRepo,
28+
IAuthorizationGraphBuilder graphBuilder,
29+
IAuthGraphFormatProvider formatProvider,
30+
IAuthMethodResolver methodResolver,
31+
IAuthorizationGraphSerializer defaultSerializer,
32+
IAuthAuditService auditService)
33+
: ICommandHandler<PreviewProfileAuthGraphCommand, PreviewProfileAuthGraphResult>
34+
{
35+
public async Task<Result<PreviewProfileAuthGraphResult>> Handle(
36+
PreviewProfileAuthGraphCommand command,
37+
CancellationToken cancellationToken)
38+
{
39+
var requestId = Guid.NewGuid().ToString();
40+
41+
// ── 1. Profile ────────────────────────────────────────────────────────
42+
var profile = await profileRepo.GetByIdAsync(command.ProfileId, cancellationToken);
43+
if (profile is null)
44+
return Result<PreviewProfileAuthGraphResult>.Failure("Profile not found.");
45+
46+
var tenantId = profile.Props.TenantId.GetValue();
47+
var userId = profile.Props.UserId.GetValue();
48+
49+
// ── 2. Tenant ─────────────────────────────────────────────────────────
50+
var tenant = await tenantRepo.GetByIdAsync(tenantId, cancellationToken);
51+
if (tenant is null)
52+
return Result<PreviewProfileAuthGraphResult>.Failure("Tenant not found.");
53+
54+
// ── 3. User (the profile owner whose graph we are previewing) ──────────
55+
var user = await userRepo.GetByIdAsync(userId, cancellationToken);
56+
if (user is null)
57+
return Result<PreviewProfileAuthGraphResult>.Failure(
58+
"User account not found for this profile.");
59+
60+
// ── 4. Resolve tenant's configured auth method (for graph context node)
61+
// Preview bypasses credential validation but keeps the method context so
62+
// the graph's Authentication node reflects the real configuration.
63+
var methodResult = await methodResolver.ResolveAsync(
64+
tenantId,
65+
AuthAccessScope.ExternalApi, // same scope as external clients
66+
cancellationToken);
67+
68+
var authMethod = methodResult.IsSuccess
69+
? methodResult.Value
70+
: AuthMethod.Local(); // safe fallback if resolution fails
71+
72+
// ── 5. Build graph — same service as POST /client/authenticate ─────────
73+
var graphResult = await graphBuilder.BuildAsync(
74+
user, tenantId, authMethod, cancellationToken);
75+
76+
if (graphResult.IsFailure)
77+
{
78+
await RecordAuditAsync(
79+
command, tenantId, userId, tenant.Props.Code.GetValue(),
80+
authMethod.Type.ToString(), false, requestId,
81+
graphResult.Error, cancellationToken);
82+
83+
return Result<PreviewProfileAuthGraphResult>.Failure(graphResult.Error);
84+
}
85+
86+
var graph = graphResult.Value;
87+
88+
// ── 6. Serialize with default serializer (Presentation layer can override)
89+
var defaultFormat = await formatProvider.GetDefaultFormatAsync(tenantId, cancellationToken);
90+
var serialized = defaultSerializer.Serialize(graph);
91+
92+
// ── 7. Audit — internal preview event ─────────────────────────────────
93+
await RecordAuditAsync(
94+
command, tenantId, userId, tenant.Props.Code.GetValue(),
95+
authMethod.Type.ToString(), true, requestId,
96+
null, cancellationToken);
97+
98+
return Result<PreviewProfileAuthGraphResult>.Success(new PreviewProfileAuthGraphResult(
99+
Graph: graph,
100+
SerializedGraph: serialized,
101+
GraphFormat: defaultFormat,
102+
RequestId: requestId,
103+
ProfileId: command.ProfileId,
104+
UserId: userId,
105+
TenantId: tenantId,
106+
TenantCode: tenant.Props.Code.GetValue(),
107+
AuthMethodUsed: authMethod.Type.ToString()));
108+
}
109+
110+
private Task RecordAuditAsync(
111+
PreviewProfileAuthGraphCommand command,
112+
Guid tenantId,
113+
Guid profileUserId,
114+
string tenantCode,
115+
string authMethod,
116+
bool succeeded,
117+
string requestId,
118+
string? failureReason,
119+
CancellationToken cancellationToken)
120+
{
121+
var evt = new AuthAuditEvent(
122+
UserId: Guid.TryParse(command.AdminUserId, out var adminId) ? adminId : Guid.Empty,
123+
TenantId: tenantId,
124+
TenantCode: tenantCode,
125+
AuthMethod: authMethod,
126+
EventType: "Graph.Preview.Internal",
127+
Succeeded: succeeded,
128+
ClientIp: command.ClientIp,
129+
FailureReason: failureReason,
130+
IdpProvider: null);
131+
132+
return auditService.RecordAuthEventAsync(evt, cancellationToken);
133+
}
134+
}

src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommand.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ namespace Ums.Application.Authorization.SystemSuite.Commands;
55
public sealed record AddDomainResourceCommand(
66
Guid SystemSuiteId,
77
Guid? ModuleId,
8+
Guid? ParentResourceId,
89
string Type,
910
string Code,
1011
string Name,

src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandHandler.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,11 @@ public async Task<Result> Handle(AddDomainResourceCommand request, CancellationT
2525
if (type is null) return Result.Failure($"Invalid DomainResourceType: {request.Type}");
2626

2727
var moduleId = request.ModuleId.HasValue ? ModuleId.Load(request.ModuleId.Value) : null;
28+
var parentId = request.ParentResourceId.HasValue ? IdValueObject.Load(request.ParentResourceId.Value) : null;
2829

2930
var result = suite.AddDomainResource(
3031
moduleId,
32+
parentId,
3133
type,
3234
Code.Create(request.Code),
3335
Name.Create(request.Name),

src/apps/ums.api/Ums.Application/Authorization/SystemSuite/Commands/AddDomainResourceCommandValidator.cs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ public AddDomainResourceCommandValidator()
2323

2424
RuleFor(x => x.Type)
2525
.NotEmpty().WithMessage("Type is required.")
26-
.Must(t => t == "Aggregate" || t == "Entity")
27-
.WithMessage("Type must be 'Aggregate' or 'Entity'.");
26+
.Must(t => t == "Aggregate" || t == "Entity" || t == "DomainMethod")
27+
.WithMessage("Type must be 'Aggregate', 'Entity', or 'DomainMethod'.");
28+
29+
RuleFor(x => x.ParentResourceId)
30+
.NotEmpty().WithMessage("ParentResourceId is required for DomainMethod resources.")
31+
.When(x => x.Type == "DomainMethod");
2832
}
2933
}

src/apps/ums.api/Ums.Application/Authorization/SystemSuite/DTOs/SystemSuiteDto.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ public static SystemSuiteDto Map(Ums.Domain.Authorization.SystemSuite.SystemSuit
6262
suite.DomainResources.Select(dr => new SystemSuiteDomainResourceDto(
6363
dr.Props.Id.GetValue(),
6464
dr.ModuleId?.GetValue(),
65+
dr.ParentResourceId?.GetValue(),
6566
dr.Type.Name,
6667
dr.Code.GetValue(),
6768
dr.Name.GetValue(),
@@ -112,6 +113,7 @@ public sealed record SystemSuiteActionDto(
112113
public sealed record SystemSuiteDomainResourceDto(
113114
Guid Id,
114115
Guid? ModuleId,
116+
Guid? ParentResourceId,
115117
string Type,
116118
string Code,
117119
string Name,

src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/DomainResource/DomainResource.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ private DomainResource(DomainResourceProps props) : base(props)
88

99
public SystemSuiteId SystemSuiteId => Props.SystemSuiteId;
1010
public ModuleId? ModuleId => Props.ModuleId;
11+
/// <summary>Non-null when this Entity or DomainMethod belongs to a parent Aggregate.</summary>
12+
public IdValueObject? ParentResourceId => Props.ParentResourceId;
1113
public DomainResourceType Type => Props.Type;
1214
public Code Code => Props.Code;
1315
public Name Name => Props.Name;
@@ -18,13 +20,14 @@ private DomainResource(DomainResourceProps props) : base(props)
1820
public static Result<DomainResource> Create(
1921
SystemSuiteId systemSuiteId,
2022
ModuleId? moduleId,
23+
IdValueObject? parentResourceId,
2124
DomainResourceType type,
2225
Code code,
2326
Name name,
2427
Description description,
2528
ActorId createdBy)
2629
{
27-
var props = new DomainResourceProps(IdValueObject.Create(), systemSuiteId, moduleId, type, code, name, description, createdBy);
30+
var props = new DomainResourceProps(IdValueObject.Create(), systemSuiteId, moduleId, parentResourceId, type, code, name, description, createdBy);
2831
var domainResource = new DomainResource(props);
2932

3033
if (!domainResource.IsValid())

src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/DomainResource/DomainResourceProps.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ public class DomainResourceProps : IProps
55
public IdValueObject Id { get; private set; }
66
public SystemSuiteId SystemSuiteId { get; private set; }
77
public ModuleId? ModuleId { get; private set; }
8+
/// <summary>Id of the parent Aggregate when this resource is a child Entity or DomainMethod.</summary>
9+
public IdValueObject? ParentResourceId { get; private set; }
810
public DomainResourceType Type { get; private set; }
911
public Code Code { get; private set; }
1012
public Name Name { get; private set; }
@@ -15,6 +17,7 @@ public DomainResourceProps(
1517
IdValueObject id,
1618
SystemSuiteId systemSuiteId,
1719
ModuleId? moduleId,
20+
IdValueObject? parentResourceId,
1821
DomainResourceType type,
1922
Code code,
2023
Name name,
@@ -24,6 +27,7 @@ public DomainResourceProps(
2427
Id = id;
2528
SystemSuiteId = systemSuiteId;
2629
ModuleId = moduleId;
30+
ParentResourceId = parentResourceId;
2731
Type = type;
2832
Code = code;
2933
Name = name;

src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/DomainResource/DomainResourceType.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@ namespace Ums.Domain.Authorization.SystemSuite.DomainResource;
22

33
public class DomainResourceType : DomainEnumeration
44
{
5-
public static readonly DomainResourceType Aggregate = new(1, nameof(Aggregate));
6-
public static readonly DomainResourceType Entity = new(2, nameof(Entity));
5+
public static readonly DomainResourceType Aggregate = new(1, nameof(Aggregate));
6+
public static readonly DomainResourceType Entity = new(2, nameof(Entity));
7+
public static readonly DomainResourceType DomainMethod = new(3, nameof(DomainMethod));
78

89
private DomainResourceType(int id, string name) : base(id, name) { }
910
}

src/apps/ums.api/Ums.Domain/Authorization/SystemSuite/SystemSuite.cs

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -499,19 +499,41 @@ public Result RenameAction(ActionCode code, Name newName, ActorId updatedBy)
499499

500500
// ── Domain Resource lifecycle ─────────────────────────────────────────────
501501

502-
public Result AddDomainResource(ModuleId? moduleId, DomainResourceType type, Code code, Name name, Description description, ActorId createdBy)
502+
public Result AddDomainResource(ModuleId? moduleId, IdValueObject? parentResourceId, DomainResourceType type, Code code, Name name, Description description, ActorId createdBy)
503503
{
504+
BrokenRules.Clear();
505+
504506
if (_domainResources.Any(dr => dr.Code == code))
505507
{
506-
BrokenRules.Add(new BrokenRule(nameof(DomainResources), DomainErrors.Common.Duplicate)); // Alternatively, a specific error
508+
BrokenRules.Add(new BrokenRule(nameof(DomainResources), DomainErrors.Common.Duplicate));
509+
}
510+
511+
// DDD rule: DomainMethod must belong to a parent resource
512+
if (type == DomainResourceType.DomainMethod && parentResourceId is null)
513+
{
514+
BrokenRules.Add(new BrokenRule(nameof(DomainResources), DomainErrors.Authorization.DomainMethodRequiresParent));
515+
}
516+
517+
// DDD rule: parent, when provided, must exist and must be an Aggregate or Entity
518+
if (parentResourceId is not null)
519+
{
520+
var parent = _domainResources.FirstOrDefault(r => r.Props.Id.GetValue() == parentResourceId.GetValue());
521+
if (parent is null)
522+
{
523+
BrokenRules.Add(new BrokenRule(nameof(DomainResources), DomainErrors.Authorization.ParentResourceNotFound));
524+
}
525+
else if (parent.Type == DomainResourceType.DomainMethod)
526+
{
527+
BrokenRules.Add(new BrokenRule(nameof(DomainResources), DomainErrors.Authorization.DomainMethodCannotBeParent));
528+
}
507529
}
508530

509531
if (!IsValid())
510532
{
511533
return Result.Failure(BrokenRules.GetBrokenRulesAsString());
512534
}
513535

514-
var drResult = DomainResourceEntity.Create(GetId(), moduleId, type, code, name, description, createdBy);
536+
var drResult = DomainResourceEntity.Create(GetId(), moduleId, parentResourceId, type, code, name, description, createdBy);
515537
if (drResult.IsFailure)
516538
{
517539
return Result.Failure(drResult.Error);

0 commit comments

Comments
 (0)