Skip to content

Administrative Audit Coverage ​

Sensitive administrative actions across the framework are recorded in a unified Audit Event log (tab_audit_event): who did what to which target, whether it was allowed or denied, and when. The document API — Insert, Save, Delete and DBSet, whether reached through ddcore.db, the HTTP API or the Go engine directly — cannot alter or remove a row once written. The log is sanitized against credential leakage, and subject to an operational retention policy that can still purge or, through a migration patch's ctx.sql, rewrite rows (see "Retention" and "CLI Tooling" below).


Action Taxonomy ​

ActionTargetDescriptionDetail Payload
role.assignUserRole added to a user account. Fires once per role already present when a User is inserted, and again for each role added on a later save.{"role": "..."}
role.revokeUserRole removed from a user account{"role": "..."}
permission.scope_grantUserUser access scope granted (User Permission created or changed){"allow": "...", "for_value": "...", "applicable_for": "..."}
permission.scope_revokeUserUser access scope revoked (User Permission deleted or changed){"allow": "...", "for_value": "..."}
permission.share_grantthe documentDocument shared with a user. Denied when the sharer lacks share, lacks write for a write share, or may not override a scope{"user": "...", "read": bool, "write": bool, "share": bool, "override_scope": bool}
permission.share_updatethe documentRights of an existing share changedsame as share_grant
permission.share_revokethe documentShare removed. Denied when the caller may not remove it{"user": "..."}
account.enableUserUser account enablednone
account.disableUserUser account disablednone
account.inviteUserUser invitation sent{"fullName": "..."}
account.resend_inviteUserUser invitation resent—
account.reset_passwordUserPassword reset initiated—
account.unlockUserUser account unlocked after lockout{"cleared": N}
account.login_credentialsUserSign-in through an app's credential provider, Allowed or Denied (not written when throttled){"provider": "...", "tenant": "...", "username": "...", "subject": "...", "reason": "..."}
account.revoke_sessionsUserAll sessions of user revoked—
account.impersonateUserAn administrator started working as the user, Allowed or Denied (see "Impersonation" in auth). The actor is the administrator—
account.impersonate_stopUserThe impersonation ended: from the banner, or because the administrator lost the role. The actor is the administrator—
workflow.transitionthe documentA workflow action was applied or refused. Allowed on success; Denied for the wrong role, a self-approval attempt, or a failed condition.{"action": "...", "from_state": "...", "to_state": "..."}
job.cancelJobBackground job cancelled{"method": "...", "queue": "...", "status": "..."}
job.retryJobFailed/cancelled background job retried{"newId": N, "method": "...", "queue": "..."}
job.purgeJobFinished background jobs purged{"done": N, "failed": N, "dryRun": bool, "doneDays": N, "failedDays": N}
job.adminJobUnauthorized job administration attemptOutcome: Denied
vault.readVault SecretVault secret read—
vault.writeVault SecretVault secret stored or updated—
vault.deleteVault SecretVault secret deleted—
webhook.replayWebhook DeliveryOutgoing webhook redelivered. Denied when the caller is not a System Manager, or is a System Manager with access scopes.{"webhook": "...", "previous_status": "..."}
ops.maintenance_onnoneMaintenance mode switched on by ddcore maintenance on, the maintenance_set tool, or backup --maintenance opening its window. ddcore restore writes the flag without an engine and records nothing.{"reason": "..."}
ops.maintenance_offnoneMaintenance mode switched off, by the same three{"reason": ""} — the reason is cleared, and the key is always written
backup.createnoneddcore backup ran. Denied records a failed run{"archive": "...", "bytes": N, "files": N, "uploaded": bool, "error": "..."}
backup.restorenoneddcore restore restored an archive into this database{"archive": "...", "ddcore": "...", "started": "...", "files": N}
data.importthe DocTypeA Data Import run (not a dry run) finished; the rows it wrote carry their own Version entries{"file": "...", "sha256": "...", "format": "csv|xlsx", "mode": "insert|update", "rows": N, "inserted": N, "updated": N, "errors": N}
doc.deletethe documentA document was deleted, by any path that goes through the document API. Written for every DocType; see "What a delete leaves behind" in controller-api{"version": "..."} — the deletion Version holding the document, absent for Version, Error Log, Email Delivery and Webhook Delivery
doc.mergethe document that staysAnother document of its DocType was merged into it (see "Merging duplicates" in controller-api). The merged one also gets its own doc.delete{"from": "<id of the merged document>"}
method.<path>noneA whitelisted method's roles option refused the callerOutcome: Denied

permission.scope_grant and permission.scope_revoke also fire from DBSet on User Permission, since that is the path dbSet/setValue use to change a scope in place.

While an administrator impersonates a user, every event the requests write names the user as actor and the administrator in detail.impersonated_by; the Versions written meanwhile carry the same key in their data.

App-authored entries ​

Apps write to the same log through the SDK — the log is append-only, not engine-only:

ts
ddcore.audit(action: string, targetDoctype?: string, targetID?: string, detail?: Record<string, any>): void;
ddcore.auditDenied(action: string, targetDoctype?: string, targetID?: string, detail?: Record<string, any>): void;

audit records an allowed action on the caller's transaction; auditDenied records a refused one directly on the pool. Both redact sensitive detail keys the same way the engine's own entries are redacted. On the Go side these are (*Ctx).Audit and (*Ctx).AuditDenied.


Security & Immutability Guarantees ​

  1. Immutability: Insert, Save, Delete and DBSet all refuse Audit Event with a PermissionError, whether the caller goes through ddcore.db, the HTTP API (/api/resource/Audit Event), or the Go engine directly. That is the only path closed: the retention sweep (ops.auditRetentionDays), ddcore audit purge, and a migration patch's ctx.sql can still remove or rewrite rows (see "Retention Policy" and "CLI Tooling").
  2. Payload Sanitization: Any detail dictionary is inspected recursively before persistence. Keys matching sensitive patterns (password, secret, token, key, hash, credential, auth, ciphertext, nonce) are replaced with [REDACTED]. String values exceeding 500 characters are truncated.
  3. Transaction Safety:
    • Permitted events (Allowed) are recorded on the caller's active database transaction so a rolled-back operation does not leave a false record.
    • Refused attempts (Denied, recorded via c.AuditDenied) are written immediately on the database connection pool so they survive the transaction rollback that follows.
    • job.cancel, job.retry and job.purge are the exception among allowed events: RecordAudit writes them directly on the pool, outside the caller's transaction, because job administration in jobs_admin.go does not run inside one.
    • Vault access recorded with no request context in scope (the internal case in vault.go, not routed through a Ctx) is written on the pool with actor System.

Retention Policy ​

Audit retention is configured in ddcore.json under ops:

json
{
  "ops": {
    "auditRetentionDays": 90
  }
}
  • A value of 0 (or omitted) retains audit logs forever. A negative value is refused at boot.
  • A positive integer purges audit records older than N days via core.services.audit.sweep, which runs daily — but only on a site that has "scheduler": true in ddcore.json. On a site with the scheduler off, nothing purges audit events on its own; use ddcore audit purge.

CLI Tooling ​

ddcore audit list ​

Inspect audit events with optional filters:

bash
# List the latest 20 audit events
ddcore audit list

# Filter by action, actor, target, or outcome
ddcore audit list --action role.assign --outcome Allowed
ddcore audit list --actor admin@example.com --since 7d --limit 50
ddcore audit list --target user@example.com

# Output as JSON
ddcore audit list --json

--action is an exact match, not a prefix. --target matches the target id (target_id), not the target doctype.

ddcore audit purge ​

Purge audit logs older than a retention threshold:

bash
# Dry run: preview how many events would be purged
ddcore audit purge --days 90 --dry-run

# Execute purge
ddcore audit purge --days 90

--days defaults to ops.auditRetentionDays when omitted. If neither is set — --days is left out and ops.auditRetentionDays is 0 or unset — the command errors instead of purging, because 0 means "keep forever" and a purge with no window would be a mistake, not a decision.

MIT Licensed · ddcore (Data Driven Core) · Development documentation (main)