navree

The audit log

An audit trail of data changes, sign-ins, permission changes and access denials: recorded by default, queryable over HTTP and in code, and pruned on a schedule.

On this page

An audit log answers "who did what, and when?" after the fact: who changed a deal's amount, who signed in from where, who gave a user the admin role, who tried to open a record they weren't allowed to. Once AuditModule is in your app, Navree keeps that trail in the audit_logs table without any code of yours. You can read it over HTTP or in code, add your own entries, and delete old ones on a schedule.

Setting up

Featureaudit The audit feature of the navree facade (part of the default full feature) brings navree-audit, which builds on http and db. Add AuditModule to your app root:

src/main.rs

AuditModule is global and needs DatabaseModule; the app refuses to boot without it. It contributes the migration set audit, which creates audit_logs with indexes on the subject, the actor and the time. The table has no foreign key to users, so auditing works without navree-auth, and entries outlive the users they mention.

The module also mounts GET /api/audit, which requires the permission audit.read. Checking a permission needs the access module; in an app without it, turn the route off with audit.routes = false, or the app refuses to boot.

What is recorded

Data changes

Every insert, update and delete you make through Repo in a unit of work becomes a data entry: in a request, the request's unit of work; elsewhere, one from Db::uow (see The unit of work). The entry is written inside the same transaction, right before it commits, so a change that rolls back leaves no entry, and an entry never exists without its change.

JSON
  • The subject type comes from the table name, singular and in PascalCase: deals is Deal, companies is Company, deal_notes is DealNote. Only the last word is made singular, with simple English rules; map irregular names with audit.subject_types.
  • The action is the subject type in snake case plus created, updated or deleted: deal.updated, deal_note.created.
  • The actor and the request id are those of the unit of work: in a request, the signed-in user and the request's id.
  • A created record is stored whole in new_values, a deleted one in old_values. An update keeps only the fields that changed, before and after; an update that changes nothing records nothing.

Data entries don't carry the client's IP address or user agent. To see where a change came from, match its request_id with the request's log lines or its other audit entries.

Sign-ins and account events

navree-auth records every sign-in, failed sign-in, refresh, sign-out and account change: auth.login, auth.login_failed, auth.throttled, auth.refresh_reuse_detected, auth.password_reset, auth.two_factor_confirmed and more. The full lists are on Authentication and Account flows. Passwords, tokens and codes are never recorded.

Permission changes

navree-access records each change made through AccessService, with the kind access, once its unit of work commits:

ActionSubjectValues
access.role_created, access.role_deletedrole, the role nameThe role; its permissions when deleted
access.permission_granted, access.permission_revokedrole{"permission": ...}
access.role_permissions_syncedrole{"permissions": [...]} before and after
access.role_assigned, access.role_removeduser, the user id{"role": ...}
access.user_roles_synceduser{"roles": [...]} before and after
access.user_permission_granted, access.user_permission_revokeduser{"permission": ...}
JSON

The actor is the unit of work's: the signed-in user for a change made in a request, null for one made by a seeder or a command.

Denials and other security events

Security events have the kind security:

ActionRecorded when
access.deniedA request got a 403: from a role, permission or ability check (metadata.check is roles, permissions or can), from a route guard (guard), or from the handler itself (handler). metadata also holds the method and the route
http.rate_limitedThe per-IP limit (metadata.limit is per_ip) or a route throttle (route) refused a request; once per client and window, so a flood is one entry
csrf.mismatchA cookie-session request failed the CSRF check (Sessions and CSRF)
auth.*Failed sign-ins, throttled attempts, reused refresh tokens, wrong two-factor codes
JSON

A record check that fails names the record as the subject ("subject_type": "Post", "subject_id": "..."). Security entries are written on the audit log's own database connection, outside the request's transaction, so they survive the rollback that follows a refused request.

Route actions

A route can record that it was used. #[audit(action = "...")] writes a custom entry after every 2xx response, with the user, the client's IP address and user agent, the request id, the method and the route. The CRM records contact exports this way:

apps/crm-api/crates/crm-companies/src/contacts/controller.rs
JSON

Hand-written routes use .audit("contacts.exported") on RouteDef.

The record shape

Each row of audit_logs is a navree::audit::AuditRecord (the model of navree::audit::audit_log::Entity), and GET /api/audit returns the same fields:

FieldType
idUUID v7Ordered by time
kindstringdata, auth, access, security or custom
actionstringWhat happened: deal.updated, auth.login, access.denied
actor_idUUID or nullThe user who did it
subject_type, subject_idstring or nullWhat it happened to: Deal and its id
old_values, new_valuesobject or nullValues before and after (changed fields only for updates)
request_idstring or nullThe request, to connect the entries of one request
ip, user_agentstring or nullThe client
metadataobjectAnything else; {} when empty
created_atUTC timeWhen it was recorded, by the app's dyn Clock

Reading the trail

Over HTTP

GET /api/audit lists entries newest first, a page at a time. It requires the permission audit.read; in the CRM only admins have it, through the super-admin role, and the admin's Settings → Audit screen reads this route. Grant audit.read to a role to let others read the trail.

HTTP
JSON
ParameterKeeps entries
actor_idBy this user (a UUID)
subject_type, subject_idAbout this kind of record, or this record
actionWith this action: exactly (deal.updated), or, without a ., any action ending in it (updated matches deal.updated and company.updated). % and _ are plain characters
kindOf this kind
page, per_pageThe page (default 1) and its size (default 15, at most 100)

The filters combine with AND. A malformed actor_id or an unknown kind is a 400:

JSON

The route is in the OpenAPI document (response schema AuditPage). audit.routes = false removes it.

In code

Inject Arc<AuditLog> and call list with an AuditFilter:

Rust

For queries list can't express, AuditFilter::apply adds the filter to a SeaORM Select of navree::audit::audit_log::Entity that you build yourself.

Adding entries

Build an entry with navree::contracts::AuditEntry::new(kind, action) and its setters: actor, subject, old_values, new_values, request_id, ip, user_agent and metadata. Then choose where to write it:

src/modules/deals/exports.rs
  • record writes on the audit log's own connection, outside any transaction: the entry stays even if the request fails afterwards. Use it for attempts and security events. Entries go into a queue of 4,096 and a background task writes them in order; record fails with AuditError::QueueFull only when that queue is full, and the entry is then dropped with a warning in the log. flush().await waits until everything queued so far is written.
  • record_in writes inside the unit of work's transaction, so the entry commits or rolls back with the work. Use it for business events that belong to a change. The actor and the request id default to the unit of work's.

When the app shuts down gracefully, the queue is written out first; entries recorded after that are written directly.

A module that shouldn't depend on navree-audit writes through the AuditSink contract instead, and does nothing when the app has no audit log:

Rust

That is how navree-auth, navree-access and navree-http write theirs.

Keeping secrets out

These field names never reach the table, whatever the configuration says: password, password_hash, token, token_hash, secret, two_factor_secret, two_factor_recovery_codes and remember_token (navree::audit::DEFAULT_SKIP_FIELDS). They are removed from old values, new values and metadata, at any depth and in any letter case.

Add your own with audit.skip_fields:

  • a plain name (ssn) is removed everywhere, at any depth;
  • table.field (users.phone) is removed from the top level of that table's values only. A subject type works in place of the table name (User.phone).

audit.skip_entities leaves whole tables out of the data entries, by table name or subject type (sessions or Session). The audit_logs table itself is never audited.

Retention and pruning

audit:prune deletes entries older than audit.retention_days (365 days by default); --days N overrides it for one run:

Shell
Text

With audit.retention_days = 0 the command keeps everything and says so. Nothing prunes on its own: schedule the command. The CRM runs it every night at 03:00 UTC, from its activities module:

Rust

See Task scheduling. In code, AuditLog::prune(days) and AuditLog::prune_before(cutoff) return the number of entries deleted.

Configuration

Every key of the audit section is optional:

config/app.toml
KeyDefault
audit.enabledtrueWhen off, nothing is written; existing entries can still be listed and pruned
audit.retention_days365How many days audit:prune keeps; 0 keeps everything
audit.skip_entities[]Tables left out of data entries, by table name or subject type
audit.skip_fieldsthe default namesField names, or table.field, never stored. The default names are always skipped, even when you set this list
audit.subject_types{}Subject type per table, overriding the derived name
audit.routestrueMounts GET /api/audit

From the environment, lists are comma-separated strings: APP_AUDIT__SKIP_FIELDS=ssn,users.phone. In code, pass AuditOptions to AuditModule::for_root:

Rust

Testing

Entries written with record (and every security entry) go through the background queue, so call flush before you look for them. The CRM's security tests read the trail like this:

apps/crm-api/tests/security_audit.rs
  • On SQLite, don't call flush while your test holds an open unit of work: the writer needs the database's write lock.
  • created_at comes from the app's dyn Clock, so override it with a FixedClock to test retention.
  • Run the prune command with app.run_command("audit:prune", &["--days", "30"]).

Limits

  • Data changes are seen only through Repo and the unit of work.
  • Entries are ordinary rows in your database: there is no tamper protection, and anyone who can write to the table can change it.
  • Under extreme load, record drops entries once 4,096 are waiting, with a warning in the log.
  • There is no export or forwarding to an external log service.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks