Skip to main content
Sintesis maintains a tamper-evident audit log that records every meaningful change to tenant data. The log is written by PostgreSQL triggers so no application-layer code can bypass it. Only tenant admins and owners can view their tenant’s log.

What is logged

Audit triggers are attached to the following tables:
TableTrigger attachedDescription
obrasaudit_log_obrasProject creation, edits, and deletion
obra_tablasaudit_log_obra_tablasOCR table creation/configuration
obra_tabla_columnsaudit_log_obra_tabla_columnsColumn definitions within OCR tables
obra_tabla_rowsaudit_log_obra_tabla_rowsIndividual data row changes
obra_flujo_actionsaudit_log_flujo_actionsWorkflow action events
obra_pendientesaudit_log_pendientesTask/pending item changes
pendiente_schedulesaudit_log_pendiente_schedulesReminder schedule changes
certificatesaudit_log_certificatesCertificate creation and edits
calendar_eventsaudit_log_calendar_eventsCalendar event changes

Audit log schema

All events land in the audit_log table:
ColumnTypeDescription
iduuidPrimary key
tenant_iduuidOwning tenant (nullable; entries without a tenant are silently discarded by the trigger)
actor_iduuidauth.uid() of the user who triggered the change, or app.current_actor for background jobs
actor_emailtextEmail from the JWT, or app.current_actor_email for background jobs
table_nametextThe table that was modified
row_pkjsonbPrimary key column(s) and their values (e.g., {"id": "abc-123"})
actiontextINSERT, UPDATE, or DELETE
changed_keystext[]For UPDATE events: the column names whose values changed. NULL for inserts and deletes.
before_datajsonbFull row state before the change (UPDATE/DELETE only)
after_datajsonbFull row state after the change (INSERT/UPDATE only)
contextjsonbReserved for additional structured context
created_attimestamptzWhen the event was recorded
Indexes on (tenant_id, created_at DESC) and (table_name, created_at DESC) ensure fast filtering by tenant and table.

Tenant resolution in triggers

The record_audit_log() trigger function supports two modes for resolving the tenant of a changed row:
  • Direct column (tenant_hint = 'tenant_id'): The row has a tenant_id column and the value is used directly.
  • FK lookup (tenant_hint = 'fk:parent_table:fk_column'): The row references a parent table and the trigger walks the foreign key chain to find tenant_id. For tables like obra_tablas (which do not have a direct tenant_id column), the trigger walks up through obras to resolve the tenant.

Actor resolution

The trigger first tries auth.uid() (the authenticated Supabase user). For background operations that run outside a user session, it falls back to reading the app.current_actor session variable. The same fallback applies to actor_email vs app.current_actor_email.

Noise reduction

Migration 0086 added a guard against high-frequency metadata noise: if an UPDATE event’s only changed column is updated_at, the trigger returns early without writing an audit row. This prevents timestamp-only heartbeat updates from flooding the log.
-- Simplified noise filter in record_audit_log()
SELECT COALESCE(array_agg(key), ARRAY[]::text[])
INTO changed_without_meta
FROM unnest(changed) AS key
WHERE key <> 'updated_at';

IF COALESCE(array_length(changed_without_meta, 1), 0) = 0 THEN
  RETURN NEW;  -- Skip this update
END IF;
The application layer applies the same filter when reading the log: isMetadataOnlyUpdate() returns true for any remaining UPDATE rows where changed_keys contains only updated_at (or only data with no cell-level changes), and those rows are excluded from the display.

Viewing audit logs

Navigate to Admin → Audit log (/admin/audit-log). The page is restricted to users with allowedRoles: ["admin"]. The log is displayed as a grouped, paginated table with 50 groups per page. Events from the same actor, table, action, and second are automatically collapsed into a single group to reduce visual noise from bulk operations. Each row in the table shows:
ColumnDescription
MomentRelative timestamp (e.g., “3 minutes ago”) with the absolute timestamp on hover
TableThe database table name rendered as a code badge
ActionINSERT (secondary badge), UPDATE (default badge), or DELETE (destructive badge)
UserActor email as the primary identifier, actor UUID shown below if both are available. Falls back to “Sistema” for background jobs.
DetailNatural-language summary of the change, changed field names, and an expandable section showing raw before_data/after_data JSON

Natural-language summaries

The UI builds readable summaries for common event types:
TableExample summary
obras”Edito una obra (nombre, descripcion) (Obra #42)“
obra_document_uploads”Subio un documento (contrato.pdf) en la carpeta contratos de la obra Edificio Norte”
obra_tabla_rows”Edito una fila de datos”
ocr_document_processing”Inicio un proceso de OCR”
Other tables”Creo un registro en certificados” / “Elimino un registro de flujos”

Filtering the audit log

Four filter controls are available at the top of the page:

Search

Free-text search across actor_email, table_name, and primary key values (row_pk->>id, row_pk->>obra_id). Commas are stripped from the input before the query.

Table filter

Dropdown listing all audited tables. Selecting a specific table adds an eq('table_name', ...) filter to the query.

Action filter

Filter to INSERT (Creaciones), UPDATE (Actualizaciones), or DELETE (Eliminaciones) only.

Technical events

A checkbox to include or exclude structural/configuration table events. When unchecked (the default), the following tables are hidden: obra_tablas, obra_tabla_columns, obra_default_folders, obra_default_tablas, obra_default_tabla_columns, macro_table_sources, macro_table_columns, sidebar_macro_tables, tenant_main_table_configs.
All filters are reflected in the URL query string, making filtered views shareable and bookmarkable.

Pagination

The log fetches data in chunks of 1 000 rows from the database and groups them client-side before paginating at 50 groups per page. Pagination links preserve all active filter parameters. When the total number of groups is known (no more rows to fetch), the page shows “Página N de M”; while more rows exist it shows “Página N”.

Access control and retention

  • The audit_log insert policy allows all authenticated sessions (WITH CHECK (true)) so that triggers from any table owner can write rows.
  • The audit_log select policy restricts reads to is_admin_of(tenant_id) — only admin and owner members of a tenant can query that tenant’s audit entries.
  • There is currently no automated retention or purge policy. All audit rows are retained indefinitely.
Because audit triggers write with SECURITY DEFINER and the trigger function resolves the tenant from the row data, it is not possible for a regular user to insert crafted entries into another tenant’s audit log. Entries with a NULL tenant are silently dropped by the trigger.

Build docs developers (and LLMs) love