TCMProject.Audit
26.9.16.2
dotnet add package TCMProject.Audit --version 26.9.16.2
NuGet\Install-Package TCMProject.Audit -Version 26.9.16.2
<PackageReference Include="TCMProject.Audit" Version="26.9.16.2" />
<PackageVersion Include="TCMProject.Audit" Version="26.9.16.2" />
<PackageReference Include="TCMProject.Audit" />
paket add TCMProject.Audit --version 26.9.16.2
#r "nuget: TCMProject.Audit, 26.9.16.2"
#:package TCMProject.Audit@26.9.16.2
#addin nuget:?package=TCMProject.Audit&version=26.9.16.2
#tool nuget:?package=TCMProject.Audit&version=26.9.16.2
TCMProject.Audit
Log de cambios campo a campo para las APIs del ecosistema TCM: quién cambió qué, cuándo, desde dónde, con el valor anterior y el nuevo.
Se engancha desde afuera vía DbContextOptions: no hay que tocar ningún handler ni
repositorio.
// InfrastructureConfiguration.cs
services.AddDbContext<CoreDBContext>((sp, options) =>
{
options.UseNpgsql(connectionString);
options.AddTcmAuditInterceptor(sp); // ← engancha el interceptor
});
services.AddTcmAudit<CoreDBContext>(configuration, audit =>
{
audit.ServiceName = "scheduleeasycore";
audit.Entity<Appointment>();
audit.Entity<Person>().Redact(p => p.IDNumber);
audit.Entity<MedicalEvaluation>().Truncate(e => e.Notes, maxBytes: 8_192);
// Lo que no se declara, NO se audita.
});
⚠️ Requiere mensajería con transporte
AddTcmAudit revienta el arranque si Messaging:Enabled=false o
Messaging:RabbitMq:Enabled=false.
No es capricho. El outbox aguanta perfectamente que el broker se caiga: el sobre queda
persistido y se recupera solo cuando vuelve. Pero sin transporte configurado Wolverine no
encuentra ruta para el mensaje, dispara NoRoutesFor y lo descarta sin persistir nada.
Para cualquier otro evento eso sería molesto; para auditoría es inaceptable — se perderían registros de cambios ya confirmados, y en silencio.
Un servicio adopta la auditoría DESPUÉS de tener broker, no antes.
Qué se audita
Opt-in explícito, en dos formas equivalentes que se suman:
audit.Entity<Appointment>().Ignore(a => a.InternalBlob);
[Audited]
public class Appointment
{
[NotAudited] public byte[] InternalBlob { get; set; }
[AuditRedacted] public string? IDNumber { get; set; }
[AuditTruncated(4096)] public string? Notes { get; set; }
}
Deny-list global
Nunca se auditan, en ninguna entidad: RowVersion, AppCreatedBy, AppCreationDate,
AppLastUpdatedBy, AppLastUpdatedDate.
Son ruido puro — el encabezado del registro ya trae quién y cuándo. Sin esta lista, cada update generaría tres diffs de basura además de los reales.
Cómo se guardan los valores
| Modo | Qué guarda | Para qué |
|---|---|---|
Plain |
El valor en JSON invariante | Evita que un decimal o un DateTime se guarden distinto según la cultura del servidor |
Redacted |
Solo sha256 |
Responder "¿cambió el documento?" sin almacenarlo otra vez |
Truncated |
sha256 + longitud en bytes |
Sin esto, auditar notas médicas duplicaría el peso de la base cada vez que alguien corrige una tilde |
Configuración — sección Audit
| Clave | Tipo | Default | Qué hace |
|---|---|---|---|
Enabled |
bool | true |
Kill switch. En false no se registra el interceptor y no se valida nada — se apaga sin redesplegar |
ServiceName |
string | — | Obligatoria. Queda grabada en cada registro como servicio de origen |
DefaultMaxBytes |
int | 8192 |
Umbral de truncado cuando la propiedad no declara el suyo |
Las entidades auditables no van en el appsettings: se declaran en código, a propósito.
Una lista en configuración permitiría desplegar a producción con una entidad clave desactivada
por un typo, sin que nada avise.
FailOpen: un fallo de captura no tumba la escritura
Si el interceptor no puede construir el registro, la transacción de negocio commitea igual. Se loguea con severidad alta.
La alternativa —abortar la transacción para no dejar huecos— convertiría cualquier bug de este paquete en una caída de escritura para toda la clínica a la vez. El subsistema de auditoría existe para observar, no para bloquear la operación clínica.
El costo aceptado es un hueco acotado y alertado. Por eso el log es LogError y no
LogWarning: es un incidente.
Detalles que importan
Captura por los dos caminos. El interceptor sobrescribe SavingChanges y
SavingChangesAsync. RepositoryBase.Update() del core llama al síncrono; cubrir solo el
asíncrono —como casi todos los ejemplos— dejaría sin auditar todos los updates de dos servicios.
IsModified no alcanza. Con entidades adjuntas (Attach + Update) EF marca todas las
propiedades como modificadas aunque el valor sea idéntico. Por eso se compara además
OriginalValue contra CurrentValue.
OnBehalfOfUid desde el día uno. Hay un login de soporte con password maestro que entra
como el usuario. Sin ese campo, un cambio hecho por soporte queda registrado como del propio
usuario — y eso es un hallazgo de auditoría en sí mismo.
Un evento, N diffs. Un comando que toca ocho campos produce un registro con ocho cambios, no ocho registros.
Documentación completa
platform/infrastructure/audit-trail-design.md— el diseño, las garantías y el almacén centralplatform/infrastructure/messaging-design.md— el outbox sobre el que corre
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.EntityFrameworkCore (>= 10.0.4)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.4)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.0)
- TCMProject.Common (>= 26.8.29.1)
- TCMProject.Events (>= 26.9.15.1)
- TCMProject.Messaging (>= 26.9.15.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
TCMProject.Audit: SnapshotAll(). Para entidades que son RENGLONES DE UN DOCUMENTO --una linea de receta-- el evento de update lleva TODOS los campos (los quietos con old == new) y no solo los que cambiaron. Sin esto, editar la duracion de un medicamento emitia DurationQuantity 5 -> 3 y nada mas: imposible componer la frase completa en el historial, porque el nombre y la dosis no viajaban. Es opt-in POR ENTIDAD y NO cambia el contrato: mismo AuditFieldChange, unas filas chicas mas por evento. NO usar en entidades con campos grandes --cada guardado re-copiaria el blob aunque no cambie--; para eso esta Truncate. Guarda anti no-op: un Attach+Update sin ningun cambio real NO emite evento aunque SnapshotAll este activo, porque una foto de puros iguales seria ruido que ademas parece actividad. Los demas paquetes se republican solo por la version compartida, sin cambios. Nota anterior (26.9.16.1) - TCMProject.Utilities: PermissionEnum PLATFORM_IMPERSONATE_USER=124, dentro del rango 121-129 reservado para PLATFORM_*. Es el permiso del "login as" del panel: abrir una sesion auditada como otro usuario para reproducir un problema de produccion. Va SEPARADO y no reusa PLATFORM_CREATE_CROSS_TENANT_USER=104 a proposito: entrar como alguien no es lo mismo que crear un usuario, y con un permiso compartido quien puede lo uno puede lo otro -- el mismo error que ya se corrigio separando aprobar de crear un descuento. Como todo permiso nuevo, NO cae solo sobre los roles existentes: hay que sembrarlo. Ver knowledge-base/platform/identity/login-as-auditado.md. Los demas paquetes no cambian. Nota anterior (26.9.15.2) - TCMProject.Events: se ELIMINA AppointmentNoShowV1. Era el contrato piloto del bus, elegido a proposito por ser inofensivo -un hecho consumado que nadie consumia con efecto de negocio- para ejercitar outbox, RabbitMQ y consumidor de punta a punta. Cumplio esa funcion: el ciclo completo quedo demostrado por el pipeline de auditoria, que cruza cuatro procesos con un contrato real. Ningun servicio del ecosistema lo referenciaba fuera de scheduleeasycore, y alli solo lo usaban el consumidor de humo y los tests de outbox, que pasan a un contrato de prueba propio. CAMBIO INCOMPATIBLE si alguien lo usaba: las versiones anteriores siguen en nuget.org. AuditChangeRecordedV1 NO se toca. Nota anterior (26.9.10.1) - TCMProject.Messaging: sin TransactionScope, el flush se dispara por el evento SavedChanges del DbContext y no por un ISaveChangesInterceptor del contenedor. Medido: EF Core no descubria ese interceptor desde DI y los sobres quedaban sin soltar en jobs, consumidores y comandos con SkipCommandTransaction. El evento no depende de como se haya registrado el contexto, y este paquete no controla el AddDbContext del servicio. Nota anterior (26.9.9.5) - TCMProject.Messaging: el outbox ENTREGA SOLO tras el commit. Publicar alcanza. Antes, PublishAsync encolaba y el sobre quedaba en la tabla como propiedad del nodo esperando un flush que en algunos caminos no llegaba nunca; el agente de durabilidad no lo tocaba porque solo reclama sobres de nodos MUERTOS. Sintoma: cero errores, la publicacion parece correcta, y la cola no recibe nada mientras el outbox crece. Ahora la garantia vive en el outbox y no en quien publica. Con TransactionScope, PublishAsync se suscribe a TransactionCompleted: eso solo REGISTRA una funcion, no abre conexiones; el flush corre cuando el evento dispara, o sea despues del commit, y solo si Status es Committed. Sin TransactionScope (jobs, consumidores, SkipCommandTransaction), OutboxFlushInterceptor suelta al terminar el SaveChanges, que ahi ya commiteo por su cuenta; EF Core lo descubre solo del contenedor. NO se suelta durante el SaveChanges a proposito: con la conexion de EF abierta, el flush abre una SEGUNDA conexion, .NET promueve a transaccion distribuida y Postgres responde 55000 prepared transactions are disabled, abortando tambien el cambio de negocio. El flush va con TransactionScopeOption.Suppress o intentaria enlistarse en la transaccion recien cerrada. MultiFlushMode AllowMultiples: con el default OnlyOnce, un sobre encolado DESPUES de un flush se descarta en silencio (JasperFx/wolverine issue 1825). NUEVO Outbox:StaleMinutes (10 por defecto) libera los sobres que nadie solto; es la RED y no el camino, alto a proposito para que en operacion normal el outbox este vacio y la metrica de profundidad siga sirviendo de alarma. SaveChangesAndFlushAsync queda OBSOLETO: suelta dentro del TransactionScope, antes del commit final. TCMProject.Audit no cambia. Nota anterior (26.9.9.4) - TCMProject.Utilities: recupera PLATFORM_RESEND_EMAILS=121 en main (publicado en 26.8.25.6 y en uso por tcmadmingateway; las 26.9.x lo habian perdido y rompian la compilacion del gateway). Ademas, permisos de Ulandin (26.9.9.3): ModuleEnum.PLATFORM_ULANDIN (anexado al final, enum sin valores explicitos) y PermissionEnum PLATFORM_VIEW_ULANDIN=122 / PLATFORM_MANAGE_ULANDIN=123, dentro del rango 121-129 reservado para PLATFORM_*; el 121 sigue reclamado por la rama de reenvio de correos. El seeder de tcmaccounts los concede solo a Owner/Administrator de TCM-X al adoptar esta version. Los demas paquetes se republican solo por la version compartida, sin cambios. Nota anterior (26.9.9.2) - TCMProject.Audit: primera version. Log de cambios campo a campo -quien cambio que, cuando, valor anterior y nuevo- para las APIs del ecosistema. Se engancha via DbContextOptions: no hay que tocar ningun handler ni repositorio. Fase 0 del RFC (knowledge-base/platform/infrastructure/audit-trail-design.md). OJO AL ADOPTAR: AddTcmAudit REVIENTA el arranque si Messaging:Enabled=false o Messaging:RabbitMq:Enabled=false. No es capricho: el outbox aguanta que el broker se CAIGA -el sobre queda persistido y se recupera solo- pero SIN transporte configurado Wolverine no encuentra ruta, dispara NoRoutesFor y descarta el mensaje sin persistirlo. Para auditoria eso rompe el requisito 6 y encima en silencio. Un servicio adopta la auditoria DESPUES de tener broker, no antes. La auditoria es opt-in por entidad y por campo (leccion de Salesforce: auditar todo multiplica el volumen de escritura); lo que no se declara con audit.Entity de T o [Audited], no se audita. Deny-list global: RowVersion, AppCreatedBy, AppCreationDate, AppLastUpdatedBy y AppLastUpdatedDate nunca se auditan -son ruido, el encabezado ya los trae- sin ella cada update generaria tres diffs de basura. Valores en JSON invariante, no ToString(), para que un decimal o un DateTime no queden distintos segun la cultura del servidor. Redact() guarda solo el hash; Truncate() guarda hash mas longitud por encima del umbral. FailOpen es el unico modo: un fallo de captura NO tumba la escritura de negocio, se loguea con severidad alta. TCMProject.Events suma los contratos de auditoria (AuditChangeRecordedV1, AuditFieldChange y sus enums) y sigue SIN dependencias, para que TCM.AuditApi pueda referenciarlo sin heredar EF ni el transporte. TCMProject.Messaging no cambia de contenido; se republica porque Audit lo referencia por ProjectReference y la dependencia queda fijada a esta misma version. Verificado con 20 tests, sin Postgres ni RabbitMQ: deny-list, opt-in, redactado, truncado, delete, impersonacion, captura por SavingChanges Y SavingChangesAsync -RepositoryBase.Update usa el sincrono, cubrir solo el asincrono dejaria sin auditar dos servicios enteros-, y que la validacion reviente cuando falta transporte.