Audit Log¶
The Audit log at Admin → Settings → Audit log (/admin/settings?tab=audit-log) answers the two questions a security review asks of an instance that holds a map of the whole IT landscape: what changed in it, and who signed in and how. It is split into two sub-tabs:
| Sub-tab | URL | What it records |
|---|---|---|
| Changes | /admin/settings?tab=audit-log |
Every write to the landscape — cards, relations, risks, tags, stakeholder roles, draft decisions — grouped into mutation batches, with a per-event diff and a Rollback |
| Security & admin | /admin/settings?tab=audit-log&view=security |
Sign-ins and sign-outs, failed attempts, lockouts, SSO and proxy sessions, password events, role impersonation and every administrative action |
The two are kept apart on purpose. Changes is a working tool — it exists so you can see and undo a bad write — and it keeps a short history. Security & admin is an evidence trail: its entries cannot be edited or deleted, and it is kept for a year by default.
Who can see it¶
Both sub-tabs require the admin.events permission ("View audit trail and event stream"), which only the Admin role holds by default. To give a security or compliance officer read access without making them an administrator, create a role that carries admin.events under Users & Roles and assign it to them.
Changes¶
The Changes sub-tab is the mutation-batch ledger. Every mutating write lands here as a batch, together with who made it and where it came from:
| Origin | Meaning |
|---|---|
| Web | The Turbo EA web interface |
| API | A direct call to the REST API (a script, an integration) |
| AI tool | An AI assistant connected through the MCP server |
| Extension | An installed extension, recorded as an ext:<key> batch |
Click a batch to open its drawer: the per-event diff shows each field's old and new value, and Rollback reverts the card, relation, risk, stakeholder-role, tag and draft-decision writes it made.
Batches are kept for MUTATION_BATCH_RETENTION_DAYS days (an environment variable, default 15) and then purged automatically. The events themselves stay on each card's History tab after the batch is gone.
Security & admin¶

The Security & admin sub-tab lists one entry per security-relevant action, newest first:
- sign-ins and sign-outs, with password, SSO or a trusted reverse proxy;
- failed sign-in attempts, each with a reason code saying why it failed;
- account lockouts after repeated failures;
- registrations, and passwords being set, reset or changed;
- role impersonation — an administrator starting and stopping View as role…;
- administrative actions — changes to users, roles, settings, the metamodel, tags, calculations, extensions, imports, integrations, public portals and published diagrams, surveys and operator access (see Administrative actions);
- exports of this log itself.
Coming in the next release
Data exports (inventory CSV/JSON exports, workspace and migration exports) will be recorded in this same log in the next release.
Columns¶
| Column | Description |
|---|---|
| When | Date and time the action happened |
| Category | auth (authentication), admin (administrative actions) or access (exports and other data access) |
| Action | What happened, for example auth.login.failed — see the action catalogue |
| Outcome | Success or Failure |
| Actor | The signed-in user who performed the action. Empty when nobody was signed in — a failed sign-in, for instance |
| Subject | The account the action was about — the email address typed into a failed sign-in, or the user whose password was reset |
| Target | The object acted on, when there is one — for example the role chosen when starting an impersonation |
| IP | The client's IP address (see Client IP address) |
| Origin | Where the request came from: web, API, AI tool, extension, ops or system |
| Impersonated role | When an administrator was viewing the app as another role, that role. The real administrator stays the actor |
Columns can be shown, hidden, reordered and frozen like on every other data table; When and Action are always shown.
Filters¶
The filter sidebar on the left narrows the list by category, outcome, action, actor, IP address and a date range. Every section header shows how many values are selected, and the collapsed sidebar shows the number of active filters. The Columns tab of the same sidebar shows, hides and freezes columns. Your filters and column layout are remembered in this browser.
A search for an email address matches the actor, the subject and the target, so typing one address finds both what that person did and what was attempted against their account.
Entry details¶
Click a row to open the entry drawer. It shows every field of the entry, including the browser's user agent and the details recorded with it — the reason code of a failed sign-in, the time a lockout ends, the identity provider of an SSO sign-in. Where an entry records a change, the details are shown as a table of old and new values. Secrets are never stored: a password, token or client secret is replaced with *** before the entry is written.
Facts that frame a change without being part of it — a portal's current address and whether it is published, whether a connection's credentials were replaced, the types a migration mapping covers — are listed under Context beneath the table.
The drawer has no actions. Entries cannot be edited or deleted.
Export CSV¶
Export CSV in the toolbar downloads the entries matching the current filters as a CSV file, up to 50,000 rows. The export is itself recorded, as access.audit_log.exported, so an export of the security log always leaves a trace in it.
Action catalogue¶
| Action | Recorded when |
|---|---|
auth.login.succeeded |
A user signs in with email and password |
auth.login.failed |
A password sign-in is refused. The reason code says why (see below) |
auth.login.locked |
The fifth failed attempt in a row locks the account for 15 minutes. The details carry the time the lock ends |
auth.logout |
A signed-in user signs out. A sign-out without a valid session records nothing |
auth.sso.succeeded |
A user signs in through an SSO provider. The details carry the provider, the identity provider's subject and whether the account was created by this sign-in |
auth.sso.failed |
An SSO sign-in is refused — a domain that is not allowed, a missing email address, a local account with the same email, an inactive account. The details carry the provider and the reason |
auth.proxy_session.succeeded |
A user signs in through a trusted reverse proxy |
auth.proxy_session.failed |
A reverse-proxy sign-in is refused |
auth.register |
A user creates an account through self-registration |
auth.password.set |
An invited user sets their first password |
auth.password.reset_requested |
Someone asks for a password-reset email. Always recorded as a success with the typed address, so the response reveals nothing; the details say whether an account matched and whether an email was sent |
auth.password.reset |
A password is changed through a reset link |
auth.password.changed |
A signed-in user changes their own password |
auth.impersonation.started |
An administrator starts View as role…. The target is the role chosen |
auth.impersonation.stopped |
The administrator returns to their own role |
access.audit_log.exported |
Someone exports the security log to CSV |
Reason codes on auth.login.failed:
| Reason | Meaning |
|---|---|
unknown_user |
No account has that email address |
sso_account |
The account signs in through SSO and has no password |
no_password |
The account has no password set yet — an invitation that was never accepted |
locked |
The account is locked after repeated failures |
bad_password |
The password is wrong |
inactive |
The account is deactivated |
Reason codes on auth.sso.failed, auth.proxy_session.failed, auth.password.set and auth.password.reset:
| Reason | Meaning |
|---|---|
expired |
The account's time-limited access has ended |
hd_mismatch |
The Google account belongs to a domain that is not allowed |
no_email |
The identity provider returned no email address |
no_subject |
The identity provider returned no subject identifier |
local_account |
A local password account already uses that email |
no_account |
No account exists and accounts are not created automatically |
exchange_failed |
The identity provider did not complete the sign-in |
unauthenticated |
The reverse proxy did not authenticate the request |
identity_rejected |
The forwarded identity was rejected (domain or unverified email) |
invalid_token |
The invitation or reset link was invalid or has expired |
A burst of unknown_user failures from one IP address is what password spraying looks like; a run of bad_password against one account followed by auth.login.locked is a guessing attempt against that account.
Administrative actions¶
Every change an administrator makes to how the instance is configured is recorded in the admin category. The actor is the administrator, the target is the object changed — a user, a role, a setting, a card type — with its name as it was at that moment, and the details list what changed as old and new values, limited to the fields that actually changed. Two rules hold for every entry:
- Secrets are masked. An SMTP password, an SSO client secret, an AI provider key, a ServiceNow credential or any other secret value is written as
***: the entry shows that it changed, never what it was or what it became. - No license text or credential is ever stored. An extension license is identified, not copied, and a password an administrator sets for a user is recorded as changed, never with its value.
A save that changes nothing is not recorded, and neither is a user editing their own name or language in their profile. Large configuration — a card type's fields, translations or layout, a portal's filters — is summarised by its size rather than copied; when the size did not change, a short fingerprint of the content is added, so the entry still shows that something changed. A card type's per-role permission overrides are the exception: each cell that changed is listed with its old and new value, an empty value meaning the role's own permission applies.
| Area | Actions | What is recorded |
|---|---|---|
| Users & roles | admin.user.created, .updated, .deleted, .bulk_updated, .bulk_deleted, .unlocked; admin.user.invitation.resent, .deleted; admin.role.created, .updated, .archived, .restored; admin.stakeholder_role.created, .updated, .deleted, .archived, .restored |
Accounts created, edited, activated or deactivated, deleted (one by one or in bulk) and unlocked, with the changed fields — role, email, name, active state; invitations re-sent or revoked; application roles and per-type stakeholder roles, with the permissions granted or removed |
| Settings | admin.settings.updated |
One entry per setting saved — email, authentication and SSO, registration, AI, MCP, languages, module toggles, branding, logo and favicon, retention periods. The target is the setting; the details are its old and new value, with secrets masked |
| Metamodel | admin.metamodel.type.* (including .references_generated), admin.metamodel.relation_type.*, admin.metamodel.resource_type.*, admin.principle.* (including .imported), admin.regulation.* |
Card types, relation types and resource types created, changed, deleted or restored, with the changed properties; card references generated for a type; EA principles, including those imported from the catalogue; compliance regulations |
| Tags & calculations | admin.tag_group.*, admin.tag.*, admin.calculation.* |
Tag groups and tags; calculated fields created, changed, deleted, activated or deactivated, with the old and new formula, and a calculation run over all cards (.recalculated) |
| Extensions | admin.extension.install.uploaded, .applied, .discarded; admin.extension.store.claimed, .refreshed, .installed; admin.extension.enabled, .disabled, .uninstalled; admin.extension.license.updated, .removed |
Bundles uploaded, installed or discarded; licenses and downloads from the Extension Store; extensions enabled, disabled and uninstalled; the license replaced or removed — with the extension and its version, never the license text |
| Workspace transfer & migration | admin.workspace.import.uploaded, .applied, .completed, .discarded; admin.migration.uploaded, .field_mappings.updated, .applied, .deleted |
A workspace bundle uploaded, its import started, finished or discarded; a platform migration uploaded, its field mappings edited, applied or deleted |
| Integrations | admin.servicenow.connection.*, admin.servicenow.mapping.*, admin.servicenow.sync.pulled, .pushed, .applied |
ServiceNow connections, with credentials masked, and mappings; every pull, push and apply of staged records |
| Public exposure | admin.web_portal.created, .updated, .deleted; admin.diagram.published, .unpublished |
Web portals and published diagrams — everything that makes data reachable without an account |
| Surveys | admin.survey.sent, admin.survey.closed |
A survey sent to its recipients, or closed |
| Operator access | admin.ops.rescue_access.granted, .revoked |
Rescue access granted or revoked through the operations API, recorded alongside the existing ops.* events |
What is not recorded¶
- Token refresh. The web app renews its session on a timer, so every open tab would add an entry and none of them would mean anything.
- Requests rejected for an expired or invalid session token. These happen every time a session ends naturally.
- OData feed reads. Excel and Power BI poll a saved view's OData feed on a schedule, so every refresh would add an entry.
- Plain read access — opening a card, running a report, searching the inventory.
Retention¶
Entries are kept for 365 days by default. Change it under Admin → Settings → General → Data management → Security log retention: any number of days up to 3650, or 0 to keep entries forever. A job deletes the entries older than the window once a night, and re-reads the setting each time, so a change takes effect without a restart.
The retention job is the only thing that ever deletes an entry. There is no edit or delete action in the interface or the API, for any role.
The log is deliberately not part of Workspace Transfer: it is the security history of this instance, not workspace content, and cloning an instance does not copy it.
The JSON audit stream¶
Every entry is also written by the backend as one JSON line on the logger turbo_ea.audit, at the same moment it is stored. The backend logs to standard output, so the stream reaches whatever already collects your container logs — docker compose logs backend, Kubernetes log collection, or any SIEM shipper — with no configuration in Turbo EA:
docker compose logs -f backend | grep turbo_ea.audit
The line is written when the entry is recorded. If the request's transaction fails afterwards, the line exists without a row, so the table is the record and the stream is the live feed.
Each line carries the same fields as the entry:
| Field | Content |
|---|---|
audit_id, created_at |
The entry's id and the moment it was written |
category, action, outcome |
What happened and whether it succeeded |
actor_user_id, actor_email |
Who did it |
subject_email |
The account it was about |
target_type, target_id, target_label |
What it acted on |
ip, user_agent |
The client |
origin |
web, api, mcp, ext, ops or system |
impersonator_user_id, impersonated_role |
Set while an administrator is viewing the app as another role |
details |
Reason codes and old/new values, with secrets already masked |
Shipping the stream to a central log store keeps a copy outside the instance, out of reach of anyone who can administer it — which is what an insider-threat review needs. See Logging and the audit stream for how to collect it.
Client IP address¶
The IP column records the address of the client, not of the proxy in front of the backend. The bundled edge nginx sets the X-Real-IP header to the address it received the connection from, and the backend reads that header because the published backend image sets TRUST_PROXY_HEADERS=true. A value that is not a valid IP address is dropped rather than recorded.
- Leave
TRUST_PROXY_HEADERSon as long as every request reaches the backend through the bundled nginx — the default stack, the Helm chart and the managed container templates all work that way. - Turn it off (
TRUST_PROXY_HEADERS=false) if you expose the backend container directly, without the bundled nginx in front of it: a client could then send its ownX-Real-IPand choose the address that gets recorded. - Behind another load balancer, the bundled nginx sees that load balancer's address, so every entry records it. A setting that lets nginx trust the client address forwarded by your load balancer (
NGINX_TRUSTED_PROXIES) is planned for a later release.