Skip to content
Updated Oct 5, 2026 by Barča Dvořáková · Owner: analysisdraft Edit on GitHub

Audit Log ​

Instance of: Audit Log (common generic)
Status: Analysis — documenting the end-to-end design of the investigation surface, plus the generic per-record history component and the entry-point conventions built on it. Which screens actually carry an entry point, and each one's own wireframe and review status, are documented as a Functional Requirement in that screen's own owning feature doc (see UI/UX Design below).
Scope: Investigation surface (cross-entity search) across every tenant the caller can access — a single-tenant caller's resolved scope is just size one, not a separate mode. Per-record history — a view of one record's own change history, reached in place from a screen already showing that record — is part of this feature too: this doc owns the shared destination-view component and its entry-point conventions; each entity's own screen, and the Functional Requirement for its entry point, live with that entity's own feature doc.


Business Context ​

Business-Level Definition ​

An audit log is an append-only record of what happened to EM3's data: which record was affected, what happened to it, who or what caused it, when, and with what context. It spans every entity in EM3 rather than giving each feature a history of its own.

The reason it is shared isn't economy of code: per-feature histories can't be checked for completeness against one another — each looks correct alone, and nothing reveals the entity with no history at all. One trail makes "is anything missing?" answerable.

Once written, an entry is never changed or removed, so the trail can be relied on for troubleshooting and auditing. Reading it is permission-gated and scoped to whichever tenant(s) the reader's permission grant resolves to.

Requirements Definition ​

  • One mechanism any entity can record through, including ones that don't exist yet.
  • Each entry answers: what was affected, what happened, who did it, when, and with what context.
  • The trail is append-only, and an entry exists only where the change it records really happened.
  • Searchable and filterable — by record, kind of change, actor, tenant, time, and the action a group belongs to.
  • Each reader sees only the scopes (tenants) they're entitled to.
  • History is readable as a cross-entity investigation surface (dense table for troubleshooting and compliance review).

Technical Context ​

User Stories / Use Cases ​

  1. As a backend feature, I record an entry for an entity I changed, so that the entry exists exactly when the change does.
  2. As an investigator, I filter the whole trail by entity type, kind of change, actor, tenant, time range and the identifier that ties one action together.
  3. As a tenant administrator, I browse that tenant's history; a system-wide change is visible only to whoever administers the system as a whole.
  4. As a person entitled to more than one tenant (e.g. Porsenna support staff, or an analyst covering several client organisations), I browse one combined trail across every tenant I can access, rather than switching tenant-by-tenant — the tenant set resolved from my audit-read grant (see Read path), which is a distinct grant from the tenant-access membership GET /v1/me/tenants reads for the tenant switcher.

UI/UX Design ​

Investigation Surface:

  • Dense table layout: entity type, record id (+ resolved name), event, actor, timestamp, action id, raw context.
  • One "find" field matches any identifier in hand exactly — record id, subject id, or action id (identifier, see API-A). It is uuid-only: pasting a business key such as a contract number yields a correct empty result, not a resolved match.
  • Discrete filters alongside it: entity type, record id, event type, actor, tenant, time range, action id.
  • Actor filter is a picker populated from GET /v1/audit-log/actors, not free text — nobody types an actor id from memory.
  • Tenant filter is a multi-select picker populated from GET /v1/audit-log/tenants — the tenants in the caller's resolved scope, not the tenant switcher's list. It filters on tenantId and displays tenantName; it is not a free-text name search. Several tenants can be selected at once, like the other filters.
  • Opens on a bounded 30-day window, not the entire trail — visible and removable like any filter.
  • Filters are collapsed by default; filter and page state belong in the URL so a view can be shared.
  • Entries sharing one action id collapse into one expandable row — a cascade occupies one line, every entry reachable.
  • Timestamps are absolute and locale-formatted.
  • Field names and enumerated values from logMetadata's before/after are translated, never raw column names or codes (see Internationalization).
  • Severity shows only when it is not ordinary.
  • Every row expands to raw context and technical identifiers, e.g. an id to copy elsewhere, beside the resolved form.
  • Entries whose record no longer resolves are retained and shown.
  • Error state is never rendered as empty: "Nothing happened" and "we could not find out" are different answers.
  • One screen for every caller — not a separate cross-tenant sibling. The tenant column and the tenant filter appear whenever the caller's resolved tenant scope (see Read path below) has more than one tenant in it, as reported by GET /v1/audit-log/tenants — never inferred from the tenants present on the loaded page, so neither flips from page to page. A caller entitled to only one tenant never sees either, not a different surface.
  • Sorting and pagination apply to the merged result across the caller's whole resolved scope, not per-tenant — see API-A.

Per-Record History (component):

A view of one record's own change history, reached in place from a screen already showing that record — distinct from the investigation surface above, which stays the only way to search across records, entity types or tenants. It is one reusable destination view, not a separate screen per entity: a modal listing who changed what and when, grouped per action (an actionId cascade collapses into one entry, the same way the investigation surface's own rows collapse above), filterable by event type, with an expandable, centered before/after table for an action that changed more than one field. It reads the same auditLog trail as the investigation surface, filtered to one record — no new kind of tracking, only a more direct way to reach it from where the record already is.

Two entry-point conventions carry it, reused rather than reinvented per screen:

  • Header-level "Historie" button — on a screen that edits one record's own attributes directly (a detail screen), a button in the header opens that record's own history.
  • Inline history icon — on a list a person browses regularly, a per-row clock icon opens a history preview for that one row, without opening its detail screen.

Which screens actually carry which entry point, and each one's own reviewed or proposed wireframe, are documented as a Functional Requirement in that screen's own owning feature doc, not here.

Functional Requirements ​

Scope of the trail

  • Track every entity change in EM3 — no exemptions. The default is comprehensive coverage.
    • Entities audited: building, building-calculated-consumption, building-energy-baseline, building-energy-profile, building-parameter, building-schedule, contract, contract-supplier, data-import, document, file, gauge, invoice, invoice-value, organisation, physical-meter, reading, reading-action-log, responsible-person-assignment, sector, user, group, group-membership, permission-set, permission-set-user-assignment, permission-set-group-assignment, tenant-user, client-group, client-group-membership
    • Platform entities (ui-label-read): not tracked (platform-scoped, not tenant business data)
    • Planned/upcoming: weather-station
  • A write that changes nothing produces no entry — several save paths rewrite rows identically, and entries record decisions, not idempotent writes.

Entry shape

  • The audited record is referenced by type + id, with no foreign key constraint — entries can reference entities that no longer exist, and new entities need no trail schema change.
  • Each entry may also carry an optional subject reference (subjectEntityType/subjectEntityId) — a second type + id for an entry filed against one record but concerning another. No entity here has adopted it yet; the columns are live now, so a later feature needs no schema change — see API-A's Design Notes.
  • Full column shape, the event vocabulary, and the actor/time/scope/action-identifier semantics are specified in the auditLog DAT and the AuditLogEvent enum, not restated here.

Write path

  • Entries are written only by EM3's own code, inside the caller's transaction, fail-closed — see Transactional Operations below.
  • The trail is append-only: nothing updates or deletes an entry once written.

Read path (Investigation Surface)

  • Newest-first, filtered by entity type, record id, an optional subject pin, event, actor (createdBy), tenant (tenantId, narrowing within the caller's resolved scope), time range, action id, and identifier (exact match against record id / subject id / action id).
  • Defaults to the last 30 days when no time range is given.
  • Optionally grouped server-side by correlation (groupByCorrelation), so a client pages by action rather than re-grouping a flat page.
  • Paginated on every read.
  • One endpoint (GET /v1/audit-log, platform-backend) serves every caller — there is no separate tenant-scoped endpoint. It resolves the caller's accessible tenant scope from their audit-log permission grant: global scope resolves to every active tenant, a scoped grant resolves to exactly its enumerated tenantIds — for an ordinary caller entitled to only their own tenant, this is a scope of size one. This is a distinct grant from the tenant-access membership GET /v1/me/tenants reads for the tenant switcher (see Cybersecurity Considerations), not a re-derivation of it. The endpoint then reads each tenant in the resolved scope in turn and merges, sorts and paginates the combined result in memory. A tenant whose read fails is skipped and logged, not fatal to the rest — the same resilience ListAllConnectorsUseCase already established for cross-tenant connector reads. See API-A for the endpoint.
  • Authorisation: the audit read permission gates this endpoint and the actor-enumeration endpoint alike — never a separate capability.

Read path (Per-Record History)

  • Pinned to one record via entityType/entityId, or a subjectEntityType/subjectEntityId subject pin.
  • Served by a new, apps/backend-hosted tenant-scoped endpoint reading the tenant's own audit_log table directly — not a call into platform-backend's cross-tenant endpoint above, and no proxy or passthrough between the two backends. Tenant isolation comes from the table's own row-level security policy (tenant_id = current_setting('app.tenant_id')), the same as any other tenant endpoint.
  • Actor names resolve against platform.users, read directly via the platform connection pool — the same pattern apps/backend's ui-labels module already uses for platform.ui_label (no HTTP call into platform-backend).
  • Authorisation: gated by its own "view history" permission, distinct from the entity's own view permission and from the investigation surface's audit-read grant above. The concrete permission-storage/resolution mechanism is defined under separate Roles & Permissions work, same as the investigation surface's own grant above; this feature only consumes the resolved permission once that work exposes it.

Visibility (future consideration)

  • Feed-visibility flagging — separating internal bookkeeping from human-facing feeds for high-volume entities (e.g. sensor readings) — is out of scope for this analysis.
  • All entries are visible without filtering.

Internationalization & Localization ​

  • Entity-type and event display names resolve through the project's translation mechanism.
  • Event descriptions (human-readable text derived from raw entries) are owned by the writing feature, not by the audit log itself.
  • The descriptions are server-side derived and returned with every entry (translation key + parameters, severity, actor display name — see API-A's Response Data Mapping).
  • Two separate mechanisms render logMetadata's before/after: field names and enumerated values (label and translated status, never column name or code) go through the UI's own field-translation lookup; record references — a uuid value naming another row — resolve through the same read-time name registry entityName uses, one field at a time as each is declared for its entity type. Either an untranslated field/value or an unresolved reference falls back to raw form, logged, never an error.

Non-Functional Requirements ​

  • Scope isolation on every read; every entry carries tenantId and queries filter by it (SQL, RLS).
  • Immutability: entries are never modified after being written.
  • Pagination on every list read. There is no unbounded read of this table.

Performance ​

  • Writing an entry is a single indexed INSERT on the same connection as the operation, cheap enough to run inline.
  • The table grows monotonically without bound (one entry per tracked change). It is not partitioned; no retention policy exists yet to partition against.

Transactional Operations ​

Central design decision: Entries are written inside the caller's transaction. Every created/updated/deleted entry is a change entry — it rides the transaction of the row it describes.

  • Guarantee: An entry exists if and only if the change committed — "no entry = no change happened."
  • Failure mode: Fail-closed. If the audit write fails, the whole operation rolls back; audit is a hard dependency, not a background concern.
  • Rationale: eventual consistency would leave gaps an audit/compliance trail can't tolerate; the cost (one indexed INSERT per operation) is acceptable.
  • Enforcement: the write port's transaction parameter is optional, so the guarantee holds per call site, not by the port's shape — omitting it silently chooses after-commit semantics.
  • Event entries differ. AuditLogEvent is open; a future value like approved or synced may record a step with no row to ride — an event entry, where the insert is the fact. This guarantee covers change entries only; an event value's own feature states its own transactional policy.
  • Initiation: Every write operation in EM3 that changes data (HTTP endpoint, background job, CLI migration) records an audit entry.
  • Recording: The entry is recorded before the response is sent to the client (but after the transaction commits).
  • Read surface: A separate investigation endpoint, callable only by a user holding the audit read permission.

Diagrams & Models ​

graph TB
    subgraph Request["HTTP Request / Job"]
        A["Change data<br/>(INSERT/UPDATE/DELETE)"]
        B["Record audit entry<br/>(same transaction)"]
    end
    A --> B
    B --> Commit["Commit both<br/>or rollback both"]
    
    subgraph Read["Read Path (Investigation)"]
        C["Query auditLog<br/>(paginated, filtered)"]
        C --> D["Check permission<br/>(audit read)"]
        D --> E["Return entries<br/>(scoped to caller's tenant set)"]
    end
    
    Commit -.->|success| Read
    style Request fill:#e1f5ff
    style Read fill:#f3e5f5

API Analysis (API-A) ​

See Audit Log — API Analysis.

The audit log is a single entity, auditLog, that references all others by type + id, with no foreign key constraint.

ER Diagram:

erDiagram
    AUDIT_LOG {
        uuid id PK
        uuid tenantId
        string entityType
        uuid entityId
        string subjectEntityType
        uuid subjectEntityId
        string event
        json logMetadata
        uuid actionId
        timestamp createdAt
        timestamp updatedAt
        timestamp deletedAt
        string createdBy
        string updatedBy
    }

Entity Definition: See Audit Log — Data Attribute Table.

Data ​

N/A — no initial dataset is required. The table starts empty and is populated only by application activity; there is no seed/config data to load for production or testing.

Test Data ​

Search the project's Test Data location (overlay) for the auditLog table, or use the generic fixtures for entity changes (every test that changes data seeds entries).

Logging ​

.info

  • An audit entry was written: entity type, record id, event, actor (createdBy), action id.

.error

  • An audit entry could not be written (fail-closed: the operation is rolled back). Logged at error level; this is the only trace of the failure under fail-closed.

No Shared Logging Conventions page exists yet in this project; this list is the feature's own events only.

Monitoring ​

No Shared Monitoring Conventions page exists yet in this project (org-wide convention not yet defined). Once one exists, this feature's alerts are:

  • Repeated audit write failures (signals a critical availability issue).
  • Growth of the auditLog table against the retention horizon, once a retention policy sets one.

Caching ​

None. Reads are index-backed and paginated; writes are single inserts. A cached view of an append-only trail would answer questions about a moment that has passed.

Backward Compatibility and Migration ​

  • This introduces the audit log as a new system. No backward compatibility required.
  • Per-record history is a read surface over the same schema as the investigation surface above, built as this same feature's own component — no schema change of its own. Each owning feature's Functional Requirement for its own entry point (see UI/UX Design above) adds no schema change either, only a UI entry point onto the trail that already exists.

N/A — no contract, licence, or regulation currently requires this audit trail; EM3 has none in place for it today. It exists for operational troubleshooting and compliance readiness, independent of any external mandate. No tenant agreement currently references it.


Cybersecurity Considerations ​

Data Privacy:

  • The log metadata may reference personal data (e.g., a changed email address); scope isolation and the audit read permission protect it like the rest of EM3's data.
  • Writers never place secrets or credentials in the payload.
  • The append-only design means personal data referenced by an entry cannot be corrected in place. Only technical redaction (a future path for legally mandated erasure) removes an entry.

Access Control:

  • The investigation surface — the one endpoint every caller reads through — is gated by a single audit read permission, separate from any entity's own. Its scope is either global (every tenant) or an explicit, enumerated set of tenantIds — the same userTenantAccess-style shape already sketched for cross-tenant person lookup (docs/porsenna/features/users-access/cross-tenant-person-lookup.md), not a re-derivation of ordinary tenant-membership access. Granting a global-scope grant exposes every tenant's audit trail at once, one of the highest-value grants in the system; a scoped grant (including the common case of a single tenantId) exposes only its own tenant(s). The concrete permission-storage/resolution mechanism is out of scope for this epic — it is being defined, and an existing partial implementation revised to align with it, under separate Roles & Permissions work; this feature only consumes the resolved scope (global, or a tenantId set) once that work exposes it. See API-A's Design Notes.
  • Per-record history pins a read to one record (entityType/entityId, or a subjectEntityType/subjectEntityId subject pin) — served by its own tenant-scoped endpoint, not the investigation surface's cross-tenant one (see Read path (Per-Record History) above). Access to it is gated by its own permission, distinct from the entity's own view permission and from the investigation surface's audit-read grant. The concrete permission-storage/resolution mechanism is defined under separate Roles & Permissions work, same as the investigation surface's own grant above; this feature only consumes the resolved permission once that work exposes it.

Risk Assessment ​

Business Risks ​

  • Small and indirect: the audit log is a governance capability, not a revenue path. The exposure is that incomplete entries undermine investigation — and that readers, trusting the trail, act on what it appears to say.
  • Mitigation: entries are written inside the caller's transaction (fail-closed) — an entry exists if and only if the change happened, so readers can rely on the trail.

Technical Risks ​

  • Unbounded growth: one table receives every entity's changes, unexempted. Mitigations: pagination, indexes on filter columns, and a retention rule as a design requirement, not an afterthought.
  • Dangling references: entries outlive the records they describe; a nonexistent entityId is not an error — the investigation surface shows these, indicating deleted entities.
  • Action identifier drift: if the backend's requestId infrastructure breaks, new entries may carry no action id, making related changes impossible to group. Mitigation: alert on entries with null actionId.
  • Fan-out cost for a multi-tenant scope: reading every tenant in the caller's resolved scope in turn means cost scales with that scope's size — for the common single-tenant caller this is just one indexed query, but a caller with a broad grant pays for each tenant read. A tenant's rows are silently missing from the merged result if that tenant's read fails mid-fan-out (meta carries no partial-result flag). Mitigation: the same wide-fan-out warning threshold and per-tenant failure isolation already used for cross-tenant connector reads (aggregator-admin).

Auditing, Reporting & Measurement ​

The audit log has no trail of its own — reads aren't recorded. Deliberate: recording reads would inflate the very table that already grows fastest.

Coverage measurement: the vocabulary of entity types (what EM3 audits) is the basis for coverage — entities in the data model, less those tracked, less explicit exemptions. This project keeps that roster as an open string against the Entity catalog (see the auditLog DAT) rather than a closed enum — every entity in the roster above now has its own audited-field set, specified on its own DAT page's Audited fields section (the fields recorded, and an explicit reason for anything excluded). A dedicated mechanism ties each entity's set to its own validation map so the two can't silently drift once implemented. The roster itself doesn't yet carry a verification ref of its own, so coverage is checked entity-by-entity via each DAT, not from one standing report.

Reporting: Browsing the investigation surface is the primary reporting mechanism. Aggregate analysis is out of scope for this analysis.