diff --git a/database/migrations/V009__activate_permisos_overrides.sql b/database/migrations/V009__activate_permisos_overrides.sql
new file mode 100644
index 0000000..b95c910
--- /dev/null
+++ b/database/migrations/V009__activate_permisos_overrides.sql
@@ -0,0 +1,43 @@
+-- V009__activate_permisos_overrides.sql
+-- Activates Usuario.PermisosJson as explicit overrides {grant, deny} on top of role permissions.
+-- Idempotent: safe to run multiple times.
+
+SET QUOTED_IDENTIFIER ON;
+SET ANSI_NULLS ON;
+GO
+
+-- 1. Drop old default constraint if it exists (handles any previous shape)
+IF EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+)
+BEGIN
+ ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
+ PRINT 'Dropped DF_Usuario_Permisos.';
+END
+GO
+
+-- 2. Re-add default constraint with canonical shape
+IF NOT EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+)
+BEGIN
+ ALTER TABLE dbo.Usuario
+ ADD CONSTRAINT DF_Usuario_Permisos
+ DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
+ PRINT 'Added DF_Usuario_Permisos with new shape {"grant":[],"deny":[]}.';
+END
+GO
+
+-- 3. Migrate legacy values to new canonical shape
+UPDATE dbo.Usuario
+SET PermisosJson = '{"grant":[],"deny":[]}'
+WHERE PermisosJson IN ('[]', '["*"]', '')
+ OR PermisosJson IS NULL
+ OR LTRIM(RTRIM(PermisosJson)) = '';
+
+PRINT 'Migrated legacy PermisosJson rows to canonical shape.';
+GO
diff --git a/src/api/SIGCM2.Domain/Entities/Usuario.cs b/src/api/SIGCM2.Domain/Entities/Usuario.cs
index aab7f18..deefe66 100644
--- a/src/api/SIGCM2.Domain/Entities/Usuario.cs
+++ b/src/api/SIGCM2.Domain/Entities/Usuario.cs
@@ -47,7 +47,7 @@ public sealed class Usuario
///
/// Factory for creating a new user (no Id — DB assigns via IDENTITY).
- /// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false.
+ /// Defaults: Activo=true, PermisosJson={"grant":[],"deny":[]}, MustChangePassword=false.
///
public static Usuario ForCreation(
string username,
@@ -65,7 +65,7 @@ public sealed class Usuario
apellido: apellido,
email: email,
rol: rol,
- permisosJson: "[]",
+ permisosJson: """{"grant":[],"deny":[]}""",
activo: true,
fechaModificacion: null,
ultimoLogin: null,
@@ -131,6 +131,26 @@ public sealed class Usuario
ultimoLogin: UltimoLogin,
mustChangePassword: value);
+ ///
+ /// UDT-009: Returns a new instance with PermisosJson replaced.
+ /// Sets FechaModificacion = UtcNow.
+ /// Accepts raw JSON string so Domain stays free of Application dependencies.
+ ///
+ public Usuario WithPermisosJson(string permisosJson)
+ => new(
+ id: Id,
+ username: Username,
+ passwordHash: PasswordHash,
+ nombre: Nombre,
+ apellido: Apellido,
+ email: Email,
+ rol: Rol,
+ permisosJson: permisosJson,
+ activo: Activo,
+ fechaModificacion: DateTime.UtcNow,
+ ultimoLogin: UltimoLogin,
+ mustChangePassword: MustChangePassword);
+
///
/// Returns a new instance with only UltimoLogin updated.
/// Does NOT touch FechaModificacion.
diff --git a/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs
new file mode 100644
index 0000000..8566027
--- /dev/null
+++ b/tests/SIGCM2.Application.Tests/Integration/V009MigrationTests.cs
@@ -0,0 +1,256 @@
+using Dapper;
+using Microsoft.Data.SqlClient;
+using Respawn;
+
+namespace SIGCM2.Application.Tests.Integration;
+
+///
+/// SUITE-B-MIGRATION-V009 — M-01 a M-07 (UDT-009)
+/// Validates the V009 migration SQL and SqlTestFixture.EnsureV009SchemaAsync.
+/// Uses SIGCM2_Test database directly.
+///
+[Collection("Database")]
+public sealed class V009MigrationTests : IAsyncLifetime
+{
+ private const string ConnectionString =
+ "Server=TECNICA3;Database=SIGCM2_Test;User Id=desarrollo;Password=desarrollo2026;TrustServerCertificate=True;";
+
+ private SqlConnection _connection = null!;
+ private Respawner _respawner = null!;
+
+ public async Task InitializeAsync()
+ {
+ _connection = new SqlConnection(ConnectionString);
+ await _connection.OpenAsync();
+
+ _respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
+ {
+ DbAdapter = DbAdapter.SqlServer,
+ TablesToIgnore =
+ [
+ new Respawn.Graph.Table("dbo", "Rol"),
+ new Respawn.Graph.Table("dbo", "Permiso"),
+ new Respawn.Graph.Table("dbo", "RolPermiso"),
+ ]
+ });
+
+ await _respawner.ResetAsync(_connection);
+ await SeedRolAsync();
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_connection is not null)
+ {
+ await _connection.CloseAsync();
+ await _connection.DisposeAsync();
+ }
+ }
+
+ // M-01: migration file exists on filesystem
+ [Fact]
+ public void MigrationFile_Exists()
+ {
+ // Walk up from test assembly looking for the repo root
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ string? repoRoot = null;
+
+ while (dir is not null)
+ {
+ if (dir.GetFiles("SIGCM2.slnx").Length > 0)
+ {
+ repoRoot = dir.FullName;
+ break;
+ }
+ dir = dir.Parent;
+ }
+
+ // Known fallback
+ if (repoRoot is null && Directory.Exists(@"E:\SIG-CM2.0"))
+ repoRoot = @"E:\SIG-CM2.0";
+
+ Assert.NotNull(repoRoot);
+ var migrationPath = Path.Combine(repoRoot!, "database", "migrations", "V009__activate_permisos_overrides.sql");
+ Assert.True(File.Exists(migrationPath), $"Migration file not found at: {migrationPath}");
+ }
+
+ // M-02: re-run idempotent (EnsureV009SchemaAsync twice → no error)
+ [Fact]
+ public async Task EnsureV009SchemaAsync_IsIdempotent()
+ {
+ await EnsureV009SchemaAsync();
+ await EnsureV009SchemaAsync(); // second call must not throw
+ }
+
+ // M-03: after migration, DEFAULT constraint is the new shape
+ [Fact]
+ public async Task EnsureV009SchemaAsync_DefaultConstraint_IsNewShape()
+ {
+ await EnsureV009SchemaAsync();
+
+ const string sql = """
+ SELECT object_definition(default_object_id) AS DefaultDef
+ FROM sys.columns
+ WHERE object_id = OBJECT_ID('dbo.Usuario')
+ AND name = 'PermisosJson'
+ """;
+
+ var definition = await _connection.QuerySingleOrDefaultAsync(sql);
+
+ Assert.NotNull(definition);
+ Assert.Contains(@"{""grant"":[]", definition);
+ Assert.Contains(@"""deny"":[]}", definition);
+ }
+
+ // M-04: rows with '[]' are migrated to new shape
+ [Fact]
+ public async Task EnsureV009SchemaAsync_MigratesLegacyEmptyArray()
+ {
+ await EnsureV009SchemaAsync();
+
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES ('legacyempty', '$2a$12$hash', 'L', 'E', 'admin', '[]', 1, 0)
+ """);
+
+ // Run migration again to migrate the newly inserted row
+ await EnsureV009SchemaAsync();
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacyempty'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // M-05: rows with '["*"]' are migrated
+ [Fact]
+ public async Task EnsureV009SchemaAsync_MigratesLegacyWildcard()
+ {
+ await EnsureV009SchemaAsync();
+
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES ('legacywild', '$2a$12$hash', 'L', 'W', 'admin', '["*"]', 1, 0)
+ """);
+
+ await EnsureV009SchemaAsync();
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'legacywild'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // M-06: the migration UPDATE statement includes NULL / empty-string conditions
+ // The column is NOT NULL (V001 constraint), so we verify the UPDATE logic covers
+ // all the WHERE conditions syntactically and that rows with '' are migrated.
+ [Fact]
+ public async Task EnsureV009SchemaAsync_MigratesEmptyStringRows()
+ {
+ // First apply V009 schema so the constraint is updated
+ await EnsureV009SchemaAsync();
+
+ // Temporarily drop and re-add without the DEFAULT so we can insert ''
+ await _connection.ExecuteAsync("""
+ IF EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
+ """);
+
+ await _connection.ExecuteAsync("""
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES ('emptystruser', '$2a$12$hash', 'E', 'S', 'admin', '', 1, 0)
+ """);
+
+ // Re-apply V009 (which restores constraint and migrates '' rows)
+ await EnsureV009SchemaAsync();
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'emptystruser'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // M-07: admin seed in SqlTestFixture uses new shape
+ [Fact]
+ public async Task SqlTestFixture_SeedAdmin_UsesNewPermisosJsonShape()
+ {
+ await EnsureV009SchemaAsync();
+
+ // Seed admin as TestFixture does post-V009
+ await _connection.ExecuteAsync("""
+ IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
+ INSERT INTO dbo.Usuario (Username, PasswordHash, Nombre, Apellido, Rol, PermisosJson, Activo, MustChangePassword)
+ VALUES (
+ 'admin',
+ '$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
+ 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
+ )
+ """);
+
+ var permisosJson = await _connection.QuerySingleAsync(
+ "SELECT PermisosJson FROM dbo.Usuario WHERE Username = 'admin'");
+
+ Assert.Equal("""{"grant":[],"deny":[]}""", permisosJson);
+ }
+
+ // ── helpers ───────────────────────────────────────────────────────────────
+
+ private async Task SeedRolAsync()
+ {
+ await _connection.ExecuteAsync("""
+ SET QUOTED_IDENTIFIER ON;
+ MERGE dbo.Rol AS t
+ USING (VALUES ('admin', N'Administrador', N'Supervisor total'))
+ AS s (Codigo, Nombre, Descripcion)
+ ON t.Codigo = s.Codigo
+ WHEN NOT MATCHED BY TARGET THEN
+ INSERT (Codigo, Nombre, Descripcion, Activo) VALUES (s.Codigo, s.Nombre, s.Descripcion, 1);
+ """);
+ }
+
+ ///
+ /// Replicates V009 migration idempotently — mirrors SqlTestFixture.EnsureV009SchemaAsync.
+ ///
+ private async Task EnsureV009SchemaAsync()
+ {
+ const string dropConstraint = """
+ IF EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
+ END
+ """;
+
+ const string addConstraint = """
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario
+ ADD CONSTRAINT DF_Usuario_Permisos
+ DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
+ END
+ """;
+
+ const string migrateRows = """
+ UPDATE dbo.Usuario
+ SET PermisosJson = '{"grant":[],"deny":[]}'
+ WHERE PermisosJson IN ('[]', '["*"]', '')
+ OR PermisosJson IS NULL
+ OR LTRIM(RTRIM(PermisosJson)) = ''
+ """;
+
+ await _connection.ExecuteAsync(dropConstraint);
+ await _connection.ExecuteAsync(addConstraint);
+ await _connection.ExecuteAsync(migrateRows);
+ }
+}
diff --git a/tests/SIGCM2.TestSupport/SqlTestFixture.cs b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
index 783a672..2bf7c92 100644
--- a/tests/SIGCM2.TestSupport/SqlTestFixture.cs
+++ b/tests/SIGCM2.TestSupport/SqlTestFixture.cs
@@ -29,6 +29,9 @@ public sealed class SqlTestFixture : IAsyncLifetime
// V008: ensure MustChangePassword column and IX_Usuario_Activo_Rol exist in test DB
await EnsureV008SchemaAsync();
+ // V009: update PermisosJson DEFAULT constraint and migrate legacy rows
+ await EnsureV009SchemaAsync();
+
_respawner = await Respawner.CreateAsync(_connection, new RespawnerOptions
{
DbAdapter = DbAdapter.SqlServer,
@@ -215,6 +218,7 @@ public sealed class SqlTestFixture : IAsyncLifetime
private async Task SeedAdminAsync()
{
+ // V009: PermisosJson uses new canonical shape {"grant":[],"deny":[]} — NOT legacy '["*"]'
const string sql = """
SET QUOTED_IDENTIFIER ON;
IF NOT EXISTS (SELECT 1 FROM dbo.Usuario WHERE Username = 'admin')
@@ -222,9 +226,53 @@ public sealed class SqlTestFixture : IAsyncLifetime
VALUES (
'admin',
'$2a$12$rmq6tlSAQ8WXhR2CwLCSeuwCJKz/.8Eab95UQCUNfwe4dokeOqMcW',
- 'Administrador', 'Sistema', 'admin', '["*"]', 1, 0
+ 'Administrador', 'Sistema', 'admin', '{"grant":[],"deny":[]}', 1, 0
);
""";
await _connection.ExecuteAsync(sql);
}
+
+ ///
+ /// Applies V009 schema changes idempotently to the test database.
+ /// Mirrors V009__activate_permisos_overrides.sql.
+ /// Drops and re-adds DF_Usuario_Permisos with the new shape, then migrates legacy rows.
+ ///
+ private async Task EnsureV009SchemaAsync()
+ {
+ const string dropConstraint = """
+ IF EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario DROP CONSTRAINT DF_Usuario_Permisos;
+ END
+ """;
+
+ const string addConstraint = """
+ IF NOT EXISTS (
+ SELECT 1 FROM sys.default_constraints
+ WHERE name = 'DF_Usuario_Permisos'
+ AND parent_object_id = OBJECT_ID('dbo.Usuario')
+ )
+ BEGIN
+ ALTER TABLE dbo.Usuario
+ ADD CONSTRAINT DF_Usuario_Permisos
+ DEFAULT('{"grant":[],"deny":[]}') FOR PermisosJson;
+ END
+ """;
+
+ const string migrateRows = """
+ UPDATE dbo.Usuario
+ SET PermisosJson = '{"grant":[],"deny":[]}'
+ WHERE PermisosJson IN ('[]', '["*"]', '')
+ OR PermisosJson IS NULL
+ OR LTRIM(RTRIM(PermisosJson)) = ''
+ """;
+
+ await _connection.ExecuteAsync(dropConstraint);
+ await _connection.ExecuteAsync(addConstraint);
+ await _connection.ExecuteAsync(migrateRows);
+ }
}