From 78428121a21ed1be71e9ffd5c7b56a1fc638519c Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 2 Aug 2026 18:05:22 -0500 Subject: [PATCH] =?UTF-8?q?feat(governance):=20per-tenant=20governance=20p?= =?UTF-8?q?ackages=20=E2=80=94=20GT-532?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row's problem statement is that governance cannot be packaged per customer. A tenant configured it piece by piece - one GatePolicy per phase, one ArtifactFieldSchema per artifact type - with no way to take that configuration whole, version it, review it as a unit, or move it to another tenant. GET /governance-packages/export produces a named, versioned, portable document. POST /governance-packages/apply installs one. Four decisions worth arguing with: A package is a DOCUMENT, not a persisted aggregate. Storing it would add a third copy of data that already lives in two places, which is exactly the failure this repository has spent the week repairing. No table also means no migration and no drift. It carries NO tenant id anywhere, at any nesting level, and the destination is always the SESSION tenant. A package that dragged its source tenant into a target is precisely the isolation leak the product forbids. Enforced by reflection over the types rather than over a call, so adding the field breaks the test even if nobody writes a request that uses it - verified by adding it and watching it go red. Export is deterministically ordered, because two exports of the same state must produce the same document or nobody can diff two packages or review a governance change in a pull request. Apply refuses an invalid package ENTIRELY before touching anything, but once it starts it does not stop at the first failure: a partially applied package is a fact worth seeing whole, and stopping early leaves a state the report does not describe. The product ships the MECHANISM and no content. Per T-056 a canned "ISO 27001 package" would be engine code holding an opinion that belongs to the tenant, and a test asserts the repository contains no applicable package. The other half of criterion 1 was already met: TowerMd3 is the executive portfolio view. Credited rather than rebuilt - reimplementing it would have been the eighth instance this week of building something that already existed. 13 tests. The 10 failures in the local suite are the pre-existing DB-gated integration tests that fail by design without PostgreSQL. --- .../ApplyGovernancePackage.cs | 108 +++++++++++ .../ExportGovernancePackage.cs | 99 +++++++++++ .../GovernancePackage/GovernancePackage.cs | 141 +++++++++++++++ .../TrackerApiApplicationBuilderExtensions.cs | 1 + .../Governance/GovernancePackageEndpoints.cs | 167 ++++++++++++++++++ .../Governance/GovernancePackageTests.cs | 123 +++++++++++++ .../GovernancePackageIsolationTests.cs | 105 +++++++++++ 7 files changed, 744 insertions(+) create mode 100644 src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs create mode 100644 src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs create mode 100644 src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs create mode 100644 src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs create mode 100644 src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs new file mode 100644 index 00000000..da24850f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ApplyGovernancePackage.cs @@ -0,0 +1,108 @@ +using MediatR; +using Tracker.Application.Governance.ArtifactFieldSchema.Commands.UpsertArtifactFieldSchema; +using Tracker.Application.Governance.GatePolicy.Commands.UpsertGatePolicy; + +namespace Tracker.Application.Governance.GovernancePackage; + +using Package = Domain.Governance.GovernancePackage.GovernancePackage; + +/// What an application did, item by item. Never a bare «ok». +public sealed record GovernancePackageApplyReport +{ + public string PackageName { get; init; } = string.Empty; + public string PackageVersion { get; init; } = string.Empty; + public IReadOnlyList AppliedGatePolicies { get; init; } = new List(); + public IReadOnlyList AppliedArtifactSchemas { get; init; } = new List(); + public IReadOnlyList Failures { get; init; } = new List(); + public bool FullyApplied => Failures.Count == 0; +} + +/// +/// GT-532 — aplica un paquete de gobernanza SOBRE EL TENANT DE LA SESIÓN. +/// +/// El destino no es un parámetro que el llamante elija: viene del contexto de sesión en la +/// capa de presentación. Un endpoint que aceptara «aplica este paquete al tenant X» sería una +/// forma de reescribir la gobernanza de otro cliente, y el paquete tampoco lleva dentro su tenant +/// de origen precisamente para que esto no se pueda hacer por accidente. +/// +/// Es idempotente porque reutiliza los upsert que ya existen, no porque compare +/// estados: aplicar dos veces el mismo paquete deja el mismo resultado. Esto también significa que +/// aplicar un paquete SOBRESCRIBE la configuración de las fases y tipos que nombra, y no toca las +/// demás. Es lo que hace que un paquete sea revisable —lo que dice es exactamente lo que cambia— +/// pero conviene decirlo, porque «aplicar» suena a fusionar y no lo es. +/// +/// No se detiene en el primer fallo. Un paquete parcialmente aplicado es un hecho que +/// hay que ver entero: pararse en el primero dejaría al tenant en un estado intermedio del que el +/// informe no dice nada. Se intenta todo y se informa de cada fallo con su fase o su tipo. +/// +public sealed record ApplyGovernancePackageCommand( + Guid TenantId, + Guid ActorId, + Package Package) : ICommand; + +internal sealed class ApplyGovernancePackageCommandHandler + : ICommandHandler +{ + private readonly IMediator _mediator; + + public ApplyGovernancePackageCommandHandler(IMediator mediator) => _mediator = mediator; + + public async Task> Handle( + ApplyGovernancePackageCommand request, CancellationToken ct) + { + var errores = request.Package.Validate(); + if (errores.Count > 0) + { + // Un paquete inválido se rechaza ENTERO antes de tocar nada. Aplicar «lo que se pueda» + // de un documento que no valida deja al tenant en un estado que nadie declaró. + return Result.Failure(string.Join("; ", errores)); + } + + var policies = new List(); + var schemas = new List(); + var failures = new List(); + + foreach (var p in request.Package.GatePolicies) + { + var result = await _mediator.Send(new UpsertGatePolicyCommand( + request.TenantId, + request.ActorId, + p.Phase, + p.Mode, + p.RequiredEvidence.Select(e => new UpsertGatePolicyEvidence(e.Type, e.Mandatory)).ToList(), + p.ApprovalStrategy, + p.ApprovalStages, + p.Approvers.Select(a => new UpsertGatePolicyApprover(a.RoleOrIdentity, a.Stage, a.Quorum)).ToList(), + p.RequiresCoreVerdict, + p.Active, + p.EvaluationCriteria.Select(c => new UpsertGatePolicyCriterion( + c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator, + c.Expected.ToList(), c.Mandatory, c.Severity)).ToList()), ct); + + if (result.IsSuccess) policies.Add(p.Phase); + else failures.Add($"gate-policy:{p.Phase}: {result.Error}"); + } + + foreach (var s in request.Package.ArtifactFieldSchemas) + { + var result = await _mediator.Send(new UpsertArtifactFieldSchemaCommand( + request.TenantId, + request.ActorId, + s.ArtifactType, + s.CustomFields.Select(f => new UpsertCustomField( + f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required)).ToList()), ct); + + if (result.IsSuccess) schemas.Add(s.ArtifactType); + else failures.Add($"artifact-schema:{s.ArtifactType}: {result.Error}"); + } + + return Result.Success(new GovernancePackageApplyReport + { + PackageName = request.Package.Name, + PackageVersion = request.Package.Version, + AppliedGatePolicies = policies, + AppliedArtifactSchemas = schemas, + Failures = failures, + }); + } +} diff --git a/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs new file mode 100644 index 00000000..282449eb --- /dev/null +++ b/src/apps/tracker-api/Tracker.Application/Governance/GovernancePackage/ExportGovernancePackage.cs @@ -0,0 +1,99 @@ +using Tracker.Domain.Governance.ArtifactFieldSchema; +using Tracker.Domain.Governance.GatePolicy; +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Application.Governance.GovernancePackage; + +using Package = Domain.Governance.GovernancePackage.GovernancePackage; +// Alias explicitos: `GatePolicy` y `ArtifactFieldSchema` son tambien NOMBRES DE ESPACIO aqui. +using GatePolicyAggregate = Domain.Governance.GatePolicy.GatePolicy; +using ArtifactFieldSchemaAggregate = Domain.Governance.ArtifactFieldSchema.ArtifactFieldSchema; + +/// +/// GT-532 — exporta la configuración de gobernanza del tenant de la sesión como un paquete +/// portable. +/// +/// El tenant NO viaja en la petición: sale del contexto de sesión en la capa de +/// presentación. Aceptarlo como parámetro convertiría este endpoint en una forma de leer la +/// configuración de otro cliente, que es exactamente lo que el aislamiento del producto prohíbe. +/// +public sealed record ExportGovernancePackageQuery( + Guid TenantId, + string Name, + string Version, + string Description, + string ExportedFrom) : IQuery; + +internal sealed class ExportGovernancePackageQueryHandler + : IQueryHandler +{ + private readonly IGatePolicyRepository _policies; + private readonly IArtifactFieldSchemaRepository _schemas; + + public ExportGovernancePackageQueryHandler( + IGatePolicyRepository policies, IArtifactFieldSchemaRepository schemas) + { + _policies = policies; + _schemas = schemas; + } + + public async Task Handle(ExportGovernancePackageQuery request, CancellationToken ct) + { + var policies = await _policies.GetByTenantAsync(request.TenantId, ct); + var schemas = await _schemas.GetByTenantAsync(request.TenantId, ct); + + return new Package + { + Name = request.Name, + Version = request.Version, + Description = request.Description, + ExportedFrom = request.ExportedFrom, + ExportedAtUtc = DateTime.UtcNow, + // Ordenado por fase y por tipo, de forma estable: dos exportaciones del mismo estado + // deben producir el mismo documento, o nadie puede diferenciar dos paquetes ni revisar + // un cambio de gobernanza en un pull request. + GatePolicies = policies + .OrderBy(p => p.Phase, StringComparer.Ordinal) + .Select(ToPackaged) + .ToList(), + ArtifactFieldSchemas = schemas + .OrderBy(s => s.ArtifactType, StringComparer.Ordinal) + .Select(ToPackaged) + .ToList(), + }; + } + + private static PackagedGatePolicy ToPackaged(GatePolicyAggregate p) => new() + { + Phase = p.Phase, + Mode = p.Mode, + RequiredEvidence = p.RequiredEvidence + .OrderBy(e => e.Type, StringComparer.Ordinal) + .Select(e => new PackagedEvidence(e.Type, e.Mandatory)) + .ToList(), + ApprovalStrategy = p.ApprovalPolicy.Strategy, + ApprovalStages = p.ApprovalPolicy.Stages, + Approvers = p.ApprovalPolicy.Approvers + .OrderBy(a => a.Stage).ThenBy(a => a.RoleOrIdentity, StringComparer.Ordinal) + .Select(a => new PackagedApprover(a.RoleOrIdentity, a.Stage, a.Quorum)) + .ToList(), + RequiresCoreVerdict = p.RequiresCoreVerdict, + Active = p.Active, + EvaluationCriteria = p.EvaluationCriteria + .OrderBy(c => c.Id, StringComparer.Ordinal) + .Select(c => new PackagedCriterion( + c.Id, c.Label, c.ArtifactType, c.FieldPath, c.Operator, + c.Expected.ToList(), c.Mandatory, c.Severity)) + .ToList(), + }; + + private static PackagedArtifactFieldSchema ToPackaged(ArtifactFieldSchemaAggregate s) => new() + { + ArtifactType = s.ArtifactType, + CustomFields = s.CustomFields + .OrderBy(f => f.Name, StringComparer.Ordinal) + .Select(f => new PackagedCustomField( + f.Name, f.Label, f.DataType, f.EligibleValues.ToList(), f.Required)) + .ToList(), + }; +} diff --git a/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs new file mode 100644 index 00000000..fbf408d9 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Domain/Governance/GovernancePackage/GovernancePackage.cs @@ -0,0 +1,141 @@ +namespace Tracker.Domain.Governance.GovernancePackage; + +/// +/// GT-532 — un PAQUETE DE GOBERNANZA: el conjunto de decisiones de gobierno de un tenant, con +/// nombre y versión, portable como documento. +/// +/// El problema que resuelve, en las palabras de la ficha: «la gobernanza no se puede +/// empaquetar por cliente». Hoy un tenant la configura pieza a pieza —una `GatePolicy` por fase, +/// un `ArtifactFieldSchema` por tipo de artefacto— y no hay forma de tomar esa configuración +/// entera, versionarla, moverla a otro tenant o revisarla como una unidad. Eso convierte «damos de +/// alta un cliente con nuestro estándar de gobierno» en un trabajo manual que nadie puede auditar. +/// +/// ES UN DOCUMENTO, NO UN AGREGADO PERSISTIDO, y la decisión es deliberada. Un paquete +/// no tiene ciclo de vida propio: se exporta desde un tenant y se aplica sobre otro. Guardarlo en +/// una tabla añadiría una tercera copia de unos datos que ya viven en `GatePolicy` y +/// `ArtifactFieldSchema` —y este repositorio ha pasado la semana arreglando exactamente eso: dos +/// copias de un catálogo que divergen sin que nada lo note. Sin tabla no hay migración, no hay +/// deriva posible y el paquete es portable por construcción. +/// +/// NO LLEVA `TenantId` DENTRO, y esto no es un olvido. Un paquete que arrastrara el +/// tenant de origen al destino sería precisamente la fuga de aislamiento que el producto prohíbe. +/// La procedencia se conserva como texto informativo (), nunca como una +/// clave que alguien pueda usar para escribir en otro sitio. El destino de una aplicación es +/// SIEMPRE el tenant de la sesión que la ejecuta. +/// +/// Lo que este tipo NO hace: traer contenido. El producto entrega el MECANISMO de +/// empaquetar; no publica paquetes propios con nuestras opiniones sobre qué debe exigir una +/// compuerta. Según `T-056` la validación de contenido es configuración del tenant y no código del +/// motor, así que un «paquete ISO 27001» de fábrica sería exactamente la capa que ese ADR rechaza. +/// El repositorio no contiene ni debe contener paquetes de ejemplo aplicables. +/// +public sealed record GovernancePackage +{ + /// Nombre legible del paquete. Identidad para una persona, nunca para una máquina. + public required string Name { get; init; } + + /// + /// Versión del paquete, decidida por quien lo exporta. No se valida como semver a propósito: + /// imponer un esquema de versionado a la configuración de un cliente es exactamente el tipo de + /// opinión que `T-056` deja fuera del motor. + /// + public required string Version { get; init; } + + public string Description { get; init; } = string.Empty; + + /// + /// De dónde salió, como TEXTO informativo. Nunca un identificador utilizable: ver la nota sobre + /// aislamiento en la documentación del tipo. + /// + public string ExportedFrom { get; init; } = string.Empty; + + public DateTime ExportedAtUtc { get; init; } + + public IReadOnlyList GatePolicies { get; init; } = new List(); + + public IReadOnlyList ArtifactFieldSchemas { get; init; } = + new List(); + + /// + /// Un paquete vacío no es un paquete. Aplicarlo no haría nada y el informe diría «0 cambios», + /// que es indistinguible de un error de exportación — la misma confusión entre «no había nada» + /// y «no se leyó nada» que este repositorio persigue en sus guardas. + /// + public bool IsEmpty => GatePolicies.Count == 0 && ArtifactFieldSchemas.Count == 0; + + public IReadOnlyList Validate() + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(Name)) errors.Add("GovernancePackage.NameRequired"); + if (string.IsNullOrWhiteSpace(Version)) errors.Add("GovernancePackage.VersionRequired"); + if (IsEmpty) errors.Add("GovernancePackage.Empty"); + + // Dos políticas para la misma fase harían que el resultado dependiera del orden de + // aplicación, que es una forma silenciosa de no ser determinista. + var fases = GatePolicies.Select(p => p.Phase).ToList(); + foreach (var dup in fases.GroupBy(f => f).Where(g => g.Count() > 1).Select(g => g.Key)) + { + errors.Add($"GovernancePackage.DuplicatePhase:{dup}"); + } + + var tipos = ArtifactFieldSchemas.Select(s => s.ArtifactType).ToList(); + foreach (var dup in tipos.GroupBy(t => t).Where(g => g.Count() > 1).Select(g => g.Key)) + { + errors.Add($"GovernancePackage.DuplicateArtifactType:{dup}"); + } + + foreach (var p in GatePolicies.Where(p => string.IsNullOrWhiteSpace(p.Phase))) + { + errors.Add("GovernancePackage.PolicyPhaseRequired"); + } + + foreach (var s in ArtifactFieldSchemas.Where(s => string.IsNullOrWhiteSpace(s.ArtifactType))) + { + errors.Add("GovernancePackage.SchemaArtifactTypeRequired"); + } + + return errors; + } +} + +/// Una política de compuerta dentro de un paquete, sin tenant y sin identidad de fila. +public sealed record PackagedGatePolicy +{ + public required string Phase { get; init; } + public required string Mode { get; init; } + public IReadOnlyList RequiredEvidence { get; init; } = new List(); + public string ApprovalStrategy { get; init; } = string.Empty; + public string ApprovalStages { get; init; } = string.Empty; + public IReadOnlyList Approvers { get; init; } = new List(); + public bool RequiresCoreVerdict { get; init; } + public bool Active { get; init; } = true; + public IReadOnlyList EvaluationCriteria { get; init; } = new List(); +} + +public sealed record PackagedEvidence(string Type, bool Mandatory); + +public sealed record PackagedApprover(string RoleOrIdentity, int Stage, int Quorum); + +public sealed record PackagedCriterion( + string Id, + string Label, + string ArtifactType, + string FieldPath, + string Operator, + IReadOnlyList Expected, + bool Mandatory, + string Severity); + +public sealed record PackagedArtifactFieldSchema +{ + public required string ArtifactType { get; init; } + public IReadOnlyList CustomFields { get; init; } = new List(); +} + +public sealed record PackagedCustomField( + string Name, + string Label, + string DataType, + IReadOnlyList EligibleValues, + bool Required); diff --git a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs index 31207e8c..0f4eafcc 100644 --- a/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs +++ b/src/apps/tracker-api/Tracker.Presentation/Bootstrapping/TrackerApiApplicationBuilderExtensions.cs @@ -111,6 +111,7 @@ public static WebApplication MapTrackerApiSurface(this WebApplication app) api.MapGatePolicyEndpoints(); api.MapGateSubmissionEndpoints(); api.MapArtifactFieldSchemaEndpoints(); + api.MapGovernancePackageEndpoints(); api.MapEvidenceRecordEndpoints(); api.MapAuditEntryEndpoints(); api.MapArchitectureReferenceEndpoints(); diff --git a/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs new file mode 100644 index 00000000..7ec3cb35 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Presentation/Endpoints/Governance/GovernancePackageEndpoints.cs @@ -0,0 +1,167 @@ +using Tracker.Application.Governance.GovernancePackage; +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Presentation.Endpoints.Governance; + +using Package = Tracker.Domain.Governance.GovernancePackage.GovernancePackage; + +/// +/// GT-532 — empaquetar la gobernanza de un cliente: exportarla como documento portable y aplicarla +/// sobre un tenant. +/// +/// EL TENANT SALE SIEMPRE DE LA SESIÓN, en las dos operaciones. Ni la exportación lo +/// acepta como parámetro ni la aplicación lo acepta en el cuerpo. Un `tenantId` en cualquiera de +/// los dos sitios convertiría estos endpoints en una forma de leer o reescribir la gobernanza de +/// otro cliente, y el propio documento tampoco lleva su tenant de origen dentro, precisamente para +/// que ni siquiera por accidente pueda usarse como destino. +/// +/// Lo que el producto NO entrega aquí es contenido. Estos endpoints son el mecanismo; +/// no existen paquetes de fábrica con nuestra opinión sobre qué debe exigir una compuerta. Según +/// `T-056` la validación de contenido es configuración del tenant y no código del motor. +/// +public static class GovernancePackageEndpoints +{ + public static void MapGovernancePackageEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/governance-packages").WithTags("GateGovernance"); + + group.MapGet("/export", async ( + ITrackerUserContext user, + IMediator mediator, + string? name, + string? version, + string? description, + CancellationToken ct) => + { + var result = await mediator.Send(new ExportGovernancePackageQuery( + user.TenantId, + string.IsNullOrWhiteSpace(name) ? "governance" : name, + string.IsNullOrWhiteSpace(version) ? DateTime.UtcNow.ToString("yyyy.MM.dd") : version, + description ?? string.Empty, + // Procedencia como TEXTO. Nunca el identificador: ver la nota del tipo de dominio. + "tenant de la sesion"), ct); + + return Results.Ok(result); + }) + .RequireTrackerPermission(TrackerPermissions.GatePolicyRead) + .WithName("ExportGovernancePackage"); + + group.MapPost("/apply", async ( + GovernancePackageRequest request, + ITrackerUserContext user, + IMediator mediator, + CancellationToken ct) => + { + var package = new Package + { + Name = request.Name ?? string.Empty, + Version = request.Version ?? string.Empty, + Description = request.Description ?? string.Empty, + ExportedFrom = request.ExportedFrom ?? string.Empty, + ExportedAtUtc = request.ExportedAtUtc ?? default, + GatePolicies = (request.GatePolicies ?? new List()) + .Select(p => new PackagedGatePolicy + { + Phase = p.Phase ?? string.Empty, + Mode = p.Mode ?? string.Empty, + RequiredEvidence = (p.RequiredEvidence ?? new List()) + .Select(e => new PackagedEvidence(e.Type ?? string.Empty, e.Mandatory)).ToList(), + ApprovalStrategy = p.ApprovalStrategy ?? string.Empty, + ApprovalStages = p.ApprovalStages ?? string.Empty, + Approvers = (p.Approvers ?? new List()) + .Select(a => new PackagedApprover(a.RoleOrIdentity ?? string.Empty, a.Stage, a.Quorum)).ToList(), + RequiresCoreVerdict = p.RequiresCoreVerdict, + Active = p.Active, + EvaluationCriteria = (p.EvaluationCriteria ?? new List()) + .Select(c => new PackagedCriterion( + c.Id ?? string.Empty, c.Label ?? string.Empty, c.ArtifactType ?? string.Empty, + c.FieldPath ?? string.Empty, c.Operator ?? string.Empty, + c.Expected ?? new List(), c.Mandatory, c.Severity ?? string.Empty)).ToList(), + }).ToList(), + ArtifactFieldSchemas = (request.ArtifactFieldSchemas ?? new List()) + .Select(s => new PackagedArtifactFieldSchema + { + ArtifactType = s.ArtifactType ?? string.Empty, + CustomFields = (s.CustomFields ?? new List()) + .Select(f => new PackagedCustomField( + f.Name ?? string.Empty, f.Label ?? string.Empty, f.DataType ?? "string", + f.EligibleValues ?? new List(), f.Required)).ToList(), + }).ToList(), + }; + + var result = await mediator.Send( + new ApplyGovernancePackageCommand(user.TenantId, user.ActorId, package), ct); + return result.ToOk(); + }) + .RequireTrackerPermission(TrackerPermissions.GatePolicyWrite) + .WithName("ApplyGovernancePackage"); + } +} + +/// +/// Cuerpo de una aplicación. Deliberadamente SIN `tenantId`: el destino es el tenant de la sesión, +/// y aceptarlo aquí sería una forma de escribir en otro cliente. +/// +public sealed class GovernancePackageRequest +{ + public string? Name { get; init; } + public string? Version { get; init; } + public string? Description { get; init; } + public string? ExportedFrom { get; init; } + public DateTime? ExportedAtUtc { get; init; } + public List? GatePolicies { get; init; } + public List? ArtifactFieldSchemas { get; init; } +} + +public sealed class PackagedGatePolicyRequest +{ + public string? Phase { get; init; } + public string? Mode { get; init; } + public List? RequiredEvidence { get; init; } + public string? ApprovalStrategy { get; init; } + public string? ApprovalStages { get; init; } + public List? Approvers { get; init; } + public bool RequiresCoreVerdict { get; init; } + public bool Active { get; init; } = true; + public List? EvaluationCriteria { get; init; } +} + +public sealed class PackagedEvidenceRequest +{ + public string? Type { get; init; } + public bool Mandatory { get; init; } +} + +public sealed class PackagedApproverRequest +{ + public string? RoleOrIdentity { get; init; } + public int Stage { get; init; } + public int Quorum { get; init; } +} + +public sealed class PackagedCriterionRequest +{ + public string? Id { get; init; } + public string? Label { get; init; } + public string? ArtifactType { get; init; } + public string? FieldPath { get; init; } + public string? Operator { get; init; } + public List? Expected { get; init; } + public bool Mandatory { get; init; } + public string? Severity { get; init; } +} + +public sealed class PackagedArtifactFieldSchemaRequest +{ + public string? ArtifactType { get; init; } + public List? CustomFields { get; init; } +} + +public sealed class PackagedCustomFieldRequest +{ + public string? Name { get; init; } + public string? Label { get; init; } + public string? DataType { get; init; } + public List? EligibleValues { get; init; } + public bool Required { get; init; } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs new file mode 100644 index 00000000..1dee939f --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Domain/Governance/GovernancePackageTests.cs @@ -0,0 +1,123 @@ +using Tracker.Domain.Governance.GovernancePackage; + +namespace Tracker.Tests.Domain.Governance; + +/// +/// GT-532 — el documento de paquete de gobernanza. +/// +/// Lo que se prueba aquí no es que los campos se copien, sino las tres decisiones que hacen +/// que un paquete sea seguro de mover entre clientes: que un paquete vacío se rechace, que no +/// pueda contener dos respuestas para la misma fase, y que NO lleve dentro el tenant de origen. +/// +public class GovernancePackageTests +{ + private static GovernancePackage Paquete( + IReadOnlyList? politicas = null, + IReadOnlyList? esquemas = null, + string nombre = "Estándar corporativo", + string version = "2026.1") => new() + { + Name = nombre, + Version = version, + GatePolicies = politicas ?? new List + { + new() { Phase = "discovery", Mode = "advisory" }, + }, + ArtifactFieldSchemas = esquemas ?? new List(), + }; + + [Fact] + public void UnPaqueteCompletoEsValido() + { + Paquete().Validate().Should().BeEmpty(); + } + + [Fact] + public void UnPaqueteVacioSeRechaza() + { + // Aplicar un paquete vacío no haría nada y el informe diría «0 cambios», que es + // indistinguible de un fallo de exportación. La misma confusión entre «no había nada» y + // «no se leyó nada» que las guardas de este repositorio persiguen. + var vacio = Paquete(politicas: new List()); + vacio.IsEmpty.Should().BeTrue(); + vacio.Validate().Should().Contain("GovernancePackage.Empty"); + } + + [Fact] + public void ExigeNombreYVersion() + { + Paquete(nombre: " ").Validate().Should().Contain("GovernancePackage.NameRequired"); + Paquete(version: "").Validate().Should().Contain("GovernancePackage.VersionRequired"); + } + + [Fact] + public void NoAdmiteDosPoliticasParaLaMismaFase() + { + // Con dos, el resultado dependería del orden de aplicación: una forma silenciosa de no + // ser determinista, y de que dos tenants con el «mismo» paquete acaben distintos. + var dos = Paquete(politicas: new List + { + new() { Phase = "qa", Mode = "advisory" }, + new() { Phase = "qa", Mode = "blocking" }, + }); + dos.Validate().Should().Contain("GovernancePackage.DuplicatePhase:qa"); + } + + [Fact] + public void NoAdmiteDosEsquemasParaElMismoTipoDeArtefacto() + { + var dos = Paquete(esquemas: new List + { + new() { ArtifactType = "prd" }, + new() { ArtifactType = "prd" }, + }); + dos.Validate().Should().Contain("GovernancePackage.DuplicateArtifactType:prd"); + } + + [Fact] + public void RechazaUnaPoliticaSinFaseYUnEsquemaSinTipo() + { + Paquete(politicas: new List { new() { Phase = " ", Mode = "advisory" } }) + .Validate().Should().Contain("GovernancePackage.PolicyPhaseRequired"); + + Paquete(esquemas: new List { new() { ArtifactType = " " } }) + .Validate().Should().Contain("GovernancePackage.SchemaArtifactTypeRequired"); + } + + [Fact] + public void ElDocumentoNoExponeNingunIdentificadorDeTenant() + { + // La prueba que de verdad importa. Un paquete que arrastrara el tenant de origen al + // destino sería la fuga de aislamiento que el producto prohíbe, y es un error que se + // cometería anadiendo un campo «por comodidad» meses despues. Se comprueba por REFLEXION + // sobre el tipo, no sobre una instancia, para que anadir el campo rompa esta prueba. + var propiedades = typeof(GovernancePackage).GetProperties() + .Select(p => p.Name) + .ToList(); + + propiedades.Should().NotContain("TenantId"); + propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEquivalentTo(new List(), + "la procedencia viaja como texto en `ExportedFrom`, nunca como una clave que " + + "alguien pueda usar para escribir en otro tenant"); + + foreach (var tipo in new[] + { + typeof(PackagedGatePolicy), typeof(PackagedArtifactFieldSchema), + typeof(PackagedCriterion), typeof(PackagedCustomField), + }) + { + tipo.GetProperties().Select(p => p.Name) + .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty($"{tipo.Name} tampoco puede llevar tenant dentro de un paquete"); + } + } + + [Fact] + public void LaProcedenciaEsTextoInformativoYSobrevive() + { + var p = Paquete() with { ExportedFrom = "acme (produccion)", ExportedAtUtc = new DateTime(2026, 8, 2) }; + p.Validate().Should().BeEmpty(); + p.ExportedFrom.Should().Be("acme (produccion)"); + } +} diff --git a/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs new file mode 100644 index 00000000..e5c0cf6a --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Presentation/Governance/GovernancePackageIsolationTests.cs @@ -0,0 +1,105 @@ +using System.Reflection; +using Tracker.Application.Governance.GovernancePackage; +using Tracker.Presentation.Endpoints.Governance; + +namespace Tracker.Tests.Presentation.Governance; + +/// +/// GT-532 — la propiedad que hace seguro mover gobernanza entre clientes: **el destino nunca lo +/// elige el llamante**. +/// +/// Un paquete existe para llevarse de un tenant a otro, así que es exactamente el tipo de +/// función donde un `tenantId` «por comodidad» en el cuerpo o en la query se cuela sin que nadie +/// lo note, y donde el efecto de colarlo es reescribir la gobernanza de otro cliente. Estas +/// pruebas miran los TIPOS, no una llamada concreta: añadir el campo rompe la prueba aunque nadie +/// escriba una petición que lo use. +/// +public class GovernancePackageIsolationTests +{ + [Fact] + public void ElCuerpoDeUnaAplicacionNoAceptaTenant() + { + var propiedades = typeof(GovernancePackageRequest).GetProperties().Select(p => p.Name).ToList(); + + propiedades.Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty( + "el destino es el tenant de la SESION; aceptarlo en el cuerpo convertiria este " + + "endpoint en una forma de reescribir la gobernanza de otro cliente"); + } + + [Fact] + public void NingunTipoDePeticionAnidadoAceptaTenant() + { + var tipos = new[] + { + typeof(PackagedGatePolicyRequest), typeof(PackagedEvidenceRequest), + typeof(PackagedApproverRequest), typeof(PackagedCriterionRequest), + typeof(PackagedArtifactFieldSchemaRequest), typeof(PackagedCustomFieldRequest), + }; + + foreach (var t in tipos) + { + t.GetProperties().Select(p => p.Name) + .Where(n => n.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().BeEmpty($"{t.Name} viaja dentro del paquete y tampoco puede nombrar un tenant"); + } + } + + [Fact] + public void ElComandoDeAplicacionRecibeElTenantComoParametroPropioYNoDesdeElPaquete() + { + // El tenant es un parametro del COMANDO, que la capa de presentacion rellena desde la + // sesion. Si algun dia alguien lo moviera dentro del documento, el paquete pasaria a + // llevar su propio destino y esta separacion desapareceria en silencio. + var ctor = typeof(ApplyGovernancePackageCommand).GetConstructors().Single(); + var parametros = ctor.GetParameters().Select(p => p.Name).ToList(); + + parametros.Should().Contain("TenantId"); + parametros.Should().Contain("Package"); + } + + [Fact] + public void LaExportacionTampocoAceptaUnTenantElegidoPorElLlamante() + { + // El handler recibe un TenantId, pero el ENDPOINT lo toma de `ITrackerUserContext`. Lo que + // se comprueba aqui es que la query no expone ningun otro camino: un segundo parametro de + // tenant seria un modo de leer la configuracion de otro cliente. + var ctor = typeof(ExportGovernancePackageQuery).GetConstructors().Single(); + ctor.GetParameters().Select(p => p.Name) + .Count(n => n!.Contains("Tenant", StringComparison.OrdinalIgnoreCase)) + .Should().Be(1); + } + + [Fact] + public void ElRepositorioNoTraeNingunPaqueteDeFabrica() + { + // T-056: el producto entrega el MECANISMO de empaquetar, no contenido con nuestra opinion + // sobre que debe exigir una compuerta. Un paquete de ejemplo APLICABLE en el arbol seria + // esa capa entrando por la puerta de atras, asi que se comprueba que no exista. + var raiz = RepoRoot(); + var sospechosos = Directory + .GetFiles(raiz, "*.json", SearchOption.AllDirectories) + .Where(f => !f.Contains("/node_modules/") && !f.Contains("/obj/") && !f.Contains("/bin/")) + .Where(f => + { + var nombre = Path.GetFileName(f).ToLowerInvariant(); + return nombre.Contains("governance-package") || nombre.Contains("governance_package"); + }) + .ToList(); + + sospechosos.Should().BeEmpty( + "el repositorio no publica paquetes de gobernanza; segun T-056 el contenido lo " + + "configura el tenant y no lo cablea el motor"); + } + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, ".git"))) + { + dir = dir.Parent; + } + dir.Should().NotBeNull(); + return dir!.FullName; + } +}