diff --git a/src/api/SIGCM2.Application/Common/PermisoResolver.cs b/src/api/SIGCM2.Application/Common/PermisoResolver.cs
new file mode 100644
index 0000000..b5fa640
--- /dev/null
+++ b/src/api/SIGCM2.Application/Common/PermisoResolver.cs
@@ -0,0 +1,29 @@
+namespace SIGCM2.Application.Common;
+
+///
+/// UDT-009: Resolves effective permissions as (rolPermisos ∪ grant) \ deny.
+/// Static helper — no dependencies, pure algorithm, freely testable.
+///
+public static class PermisoResolver
+{
+ ///
+ /// Returns the effective permission set for a user.
+ /// Algorithm: start with role permissions, add grant, remove deny.
+ /// Deny always wins over grant (last operation). Idempotent on duplicates.
+ /// Never throws.
+ ///
+ public static IReadOnlySet Resolve(
+ IEnumerable rolPermisos,
+ PermisosOverride overrides)
+ {
+ var set = new HashSet(rolPermisos, StringComparer.Ordinal);
+
+ foreach (var g in overrides.Grant)
+ set.Add(g);
+
+ foreach (var d in overrides.Deny)
+ set.Remove(d);
+
+ return set;
+ }
+}
diff --git a/src/api/SIGCM2.Application/Common/PermisosOverride.cs b/src/api/SIGCM2.Application/Common/PermisosOverride.cs
new file mode 100644
index 0000000..00b022c
--- /dev/null
+++ b/src/api/SIGCM2.Application/Common/PermisosOverride.cs
@@ -0,0 +1,60 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace SIGCM2.Application.Common;
+
+///
+/// UDT-009: Overrides explícitos sobre permisos heredados del rol.
+/// Shape: { "grant": [...], "deny": [...] }
+///
+public sealed record PermisosOverride(
+ [property: JsonPropertyName("grant")] IReadOnlyList Grant,
+ [property: JsonPropertyName("deny")] IReadOnlyList Deny)
+{
+ /// No overrides — empty grant and deny.
+ public static readonly PermisosOverride Empty =
+ new(Array.Empty(), Array.Empty());
+
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ };
+
+ ///
+ /// Parses tolerantly:
+ /// - null / "" / whitespace → Empty
+ /// - starts with '[' (legacy '[]' or '["*"]') → Empty (backward compat)
+ /// - valid JSON object with grant/deny → parsed record
+ /// - malformed or wrong-shape JSON → Empty (tolerant in runtime)
+ ///
+ public static PermisosOverride FromJson(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json))
+ return Empty;
+
+ var trimmed = json.Trim();
+
+ // Legacy: '[]' or '["*"]' — array shape, treat as no overrides
+ if (trimmed.StartsWith('['))
+ return Empty;
+
+ try
+ {
+ var parsed = JsonSerializer.Deserialize(trimmed, Options);
+ if (parsed is null)
+ return Empty;
+
+ return new PermisosOverride(
+ parsed.Grant ?? Array.Empty(),
+ parsed.Deny ?? Array.Empty());
+ }
+ catch (JsonException)
+ {
+ // Tolerant: malformed JSON → Empty (protects authorization handler)
+ return Empty;
+ }
+ }
+
+ /// Serializes to canonical JSON shape.
+ public string ToJson() => JsonSerializer.Serialize(this, Options);
+}
diff --git a/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs b/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
new file mode 100644
index 0000000..bb78d3f
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Common/PermisoResolverTests.cs
@@ -0,0 +1,134 @@
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Tests.Common;
+
+///
+/// SUITE-B-RESOLVER — R-01 a R-09 (UDT-009)
+/// Unit tests for PermisoResolver.Resolve static helper.
+/// Pure unit: no DB, no mocks.
+///
+public sealed class PermisoResolverTests
+{
+ // R-01: Override vacío → effective = solo rol sin cambios
+ [Fact]
+ public void Resolve_EmptyOverride_ReturnsRolPermisosUnchanged()
+ {
+ var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-02: Grant nuevo permiso → se agrega al set
+ [Fact]
+ public void Resolve_GrantNewPermiso_AddsToEffective()
+ {
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Contains("C", result);
+ Assert.Equal(3, result.Count);
+ }
+
+ // R-03: Deny permiso del rol → se quita del set
+ [Fact]
+ public void Resolve_DenyRolPermiso_RemovesFromEffective()
+ {
+ var overrides = new PermisosOverride(Grant: [], Deny: ["A"]);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.DoesNotContain("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(1, result.Count);
+ }
+
+ // R-04: Grant duplicado (ya en rol) → idempotente, no duplicados
+ [Fact]
+ public void Resolve_GrantDuplicated_Idempotent()
+ {
+ var overrides = new PermisosOverride(Grant: ["B"], Deny: []);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count); // no duplicates
+ }
+
+ // R-05: Deny código inexistente en rol → no-op
+ [Fact]
+ public void Resolve_DenyNonExistentCode_NoOp()
+ {
+ var overrides = new PermisosOverride(Grant: [], Deny: ["X"]);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-06: Grant + Deny combinados
+ [Fact]
+ public void Resolve_GrantAndDeny_Combined()
+ {
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: ["A"]);
+
+ var result = PermisoResolver.Resolve(["A", "B"], overrides);
+
+ Assert.DoesNotContain("A", result);
+ Assert.Contains("B", result);
+ Assert.Contains("C", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-07: PermisosOverride.Empty literal → mismo que rol
+ [Fact]
+ public void Resolve_EmptyLiteral_ReturnsRolPermisosOnly()
+ {
+ var result = PermisoResolver.Resolve(["A", "B"], PermisosOverride.Empty);
+
+ Assert.Contains("A", result);
+ Assert.Contains("B", result);
+ Assert.Equal(2, result.Count);
+ }
+
+ // R-08: Rol vacío + grant → effective = solo el grant
+ [Fact]
+ public void Resolve_EmptyRol_WithGrant_ReturnsGrant()
+ {
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: []);
+
+ var result = PermisoResolver.Resolve([], overrides);
+
+ Assert.Single(result);
+ Assert.Contains("C", result);
+ }
+
+ // R-09: Rol vacío + sin overrides → effective vacío
+ [Fact]
+ public void Resolve_EmptyRol_EmptyOverrides_ReturnsEmpty()
+ {
+ var result = PermisoResolver.Resolve([], PermisosOverride.Empty);
+
+ Assert.Empty(result);
+ }
+
+ // Extra: Deny gana sobre grant explícito (defense en runtime — validator lo bloquea antes)
+ [Fact]
+ public void Resolve_DenyWinsOver_ExplicitGrant()
+ {
+ // Mismo código en grant y deny → deny gana (algoritmo: grant primero, deny al final)
+ var overrides = new PermisosOverride(Grant: ["C"], Deny: ["C"]);
+
+ var result = PermisoResolver.Resolve(["A"], overrides);
+
+ Assert.DoesNotContain("C", result);
+ Assert.Contains("A", result);
+ }
+}
diff --git a/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs b/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs
new file mode 100644
index 0000000..fa95d82
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Common/PermisosOverrideParsingTests.cs
@@ -0,0 +1,116 @@
+using SIGCM2.Application.Common;
+
+namespace SIGCM2.Application.Tests.Common;
+
+///
+/// SUITE-B-PERMISOS-OVERRIDE-PARSING — P-01 a P-08 (UDT-009)
+/// Unit tests for PermisosOverride.FromJson parsing logic.
+///
+public sealed class PermisosOverrideParsingTests
+{
+ // P-01: JSON válido con grant y deny → record correcto
+ [Fact]
+ public void FromJson_ValidGrantAndDeny_ReturnsParsedRecord()
+ {
+ const string json = """{"grant":["textos:editar"],"deny":["ventas:contado:cobrar"]}""";
+
+ var result = PermisosOverride.FromJson(json);
+
+ Assert.Single(result.Grant);
+ Assert.Equal("textos:editar", result.Grant[0]);
+ Assert.Single(result.Deny);
+ Assert.Equal("ventas:contado:cobrar", result.Deny[0]);
+ }
+
+ // P-02: JSON vacío canónico → equivalente a Empty
+ [Fact]
+ public void FromJson_EmptyCanonical_ReturnsEmpty()
+ {
+ const string json = """{"grant":[],"deny":[]}""";
+
+ var result = PermisosOverride.FromJson(json);
+
+ Assert.Empty(result.Grant);
+ Assert.Empty(result.Deny);
+ }
+
+ // P-03: Legacy "[]" → Empty (backward compat)
+ [Fact]
+ public void FromJson_LegacyEmptyArray_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson("[]");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-04: Legacy '["*"]' → Empty (backward compat)
+ [Fact]
+ public void FromJson_LegacyWildcard_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson("""["*"]""");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-05: null → Empty
+ [Fact]
+ public void FromJson_Null_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson(null);
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-06a: string vacío → Empty
+ [Fact]
+ public void FromJson_EmptyString_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson(string.Empty);
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-06b: whitespace → Empty
+ [Fact]
+ public void FromJson_Whitespace_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson(" ");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-07: JSON malformado → Empty (tolerante en runtime)
+ [Fact]
+ public void FromJson_MalformedJson_ReturnsEmpty()
+ {
+ // Nota: FromJson es tolerante — catch(JsonException) → Empty.
+ // Ver tasks note 2: "P-07/P-08 verifican que JSON malformado → Empty (no FormatException)"
+ var result = PermisosOverride.FromJson("{grant:[");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // P-08: JSON de tipo incorrecto (número) → Empty (tolerante)
+ [Fact]
+ public void FromJson_WrongJsonType_ReturnsEmpty()
+ {
+ var result = PermisosOverride.FromJson("42");
+
+ Assert.Same(PermisosOverride.Empty, result);
+ }
+
+ // Extra: ToJson produce JSON re-parseable con shape correcto
+ [Fact]
+ public void ToJson_ProducesCanonicalJson()
+ {
+ var overrides = new PermisosOverride(
+ Grant: new[] { "textos:editar" },
+ Deny: new[] { "ventas:contado:cobrar" });
+
+ var json = overrides.ToJson();
+ var reparsed = PermisosOverride.FromJson(json);
+
+ Assert.Equal(overrides.Grant, reparsed.Grant);
+ Assert.Equal(overrides.Deny, reparsed.Deny);
+ }
+}