Audit Log Frontend per Organization #651

Merged
tobru merged 12 commits from audit-log-frontend into main 2026-09-04 14:40:03 +00:00
Owner

Maybe add Staff view? With good filtering and search

Maybe add Staff view? With good filtering and search
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LogEntry is generic (content type plus object id) with no organization
column. Resolving the organization at query time loses exactly the events
an audit log exists for: remove a member or delete an instance and an IN
subquery over current objects stops matching, taking that object's whole
history with it.

Stamp the organization at write time into a new indexed
OrganizationAuditEntry instead, with a backfill for history. The table is
a derived index, not the record, so the receiver logs and swallows rather
than rolling back the user's actual save.

Entries render to sentences at read time from an allowlisted registry that
reads only data carried by the log entry, so deleted objects stay
renderable and django_scopes is never involved. An entry that renders to
nothing is not stamped at all, so a page of 25 shows 25 rows.

Also keeps the invitation secret out of the audit log. exclude_fields
alone only filters changes; serialize_auditlog_fields_only is what covers
serialized_data. Historic rows are scrubbed by migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Read-only "Audit Log" under Organization, gated by core.change_organization
so owners, admins and staff see it. Entries read as sentences with an
expandable old -> new table, category tabs and pagination.

Category tabs and pagination are plain links with HTMX layered on top, so
they work identically without JavaScript: the same view serves the full
page and the fragment. The disclosure is a native <details>, which is
keyboard-accessible and screen-reader-correct for free and survives the
HTMX swap without re-initialisation.

The Organization submenu now renders for any admin, owner or staff member
rather than only when invoice listing is enabled; Invoices stays
conditional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keep the origin signing secret out of the audit log. OrganizationOrigin
was registered with exclude_fields alone, which filters only the change
diff, so the HMAC key that authenticates signed onboarding URLs was
serialized in cleartext and survived rotation - under a comment claiming
the opposite. Same bug this branch already fixed for the invitation
secret, one registration further down. Historic rows are scrubbed.

Render deleted lookup objects as "deleted (#id)" rather than a bare
primary key, and narrow the bare except that was masking real errors into
that same raw value. This is the deleted-object case the design exists for
and it was handled worse than not resolving at all.

Resolve the membership subject from the serialized user id instead of
splitting object_repr on the first " in ", which truncated a user named
"Marc in der Maur" to "Marc".

Memoise reference lookups per page render, so a page of 25 no longer costs
up to 75 extra queries.

Filter unrenderable rows in the view rather than the template, so a page
whose rows all decline to render says so instead of showing a blank list
under working pagination.

Make backfill() actually repairable: refresh category and timestamp on
existing stamps, add --prune for narrowed allowlists, expose it as a
management command, and let migration 0066 degrade to "no stamps" rather
than aborting migrate on a schema-drifted replay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat: audit pause, limit profile and validity period changes
All checks were successful
Tests / test (push) Successful in 1m20s
323d1693e3
Pause and resume had no log entry at all: the replica count lives in the
Kubernetes resource, not in a tracked Django field, so auditlog never saw
it. Write one from pause()/unpause() rather than from the view, so the
admin action and the expiry cleanup are covered too, and only after the
spec update succeeds so a failed pause leaves no trace. A system pause
from the expiry cleanup reads as "System paused instance X".

limit_profile and validity_days_override were excluded from the
organization allowlist as internal. They decide when an organization
expires, which is exactly what an operator needs to be able to explain,
so surface them.

Edits to the limit profile itself fan out to every organization currently
on it, on the same reasoning: changing the validity period silently
changes when each of them expires. The profile holds no customer data, so
the fan-out leaks nothing, and a rename alone renders to nothing.

Hand-written entries carry no serialized_data, so the pause entry passes
display_name explicitly - otherwise the sentence fell back to object_repr
and read differently from every other instance event.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tobru force-pushed audit-log-frontend from 323d1693e3
All checks were successful
Tests / test (push) Successful in 1m20s
to 390701a571
All checks were successful
Tests / test (push) Successful in 1m27s
2026-09-04 14:08:34 +00:00
Compare
tobru changed title from WIP: Audit Log Frontend per Organization to Audit Log Frontend per Organization 2026-09-04 14:08:40 +00:00
fix: make the audit log swallows and its ordering hold up
All checks were successful
Tests / test (push) Successful in 1m19s
d12e24d6d2
Four review findings, none of which changed what the log says on the
normal path, but each of which undermines a guarantee it makes.

Savepoints in both audit swallows. on_log_entry_saved promises that a
failed stamp cannot roll back the user's save, and _log_spec_changes
promises the audit row is the recoverable loss. On PostgreSQL neither
promise held: a failed statement aborts the enclosing transaction and
every statement after it raises, so catching the exception just moves
the failure one statement later. ServiceInstance.delete() is
@transaction.atomic and its cascade is what writes these entries, so
this was reachable today.

Datetime change rows now go through localtime_tag like every other time
in the portal. auditlog.diff.get_field_value stores DateTimeField values
as naive UTC text, so "Terms accepted at" and "Accepted at" printed an
unmarked UTC time directly beneath the row's own browser-local
timestamp - the same instant shown twice, two hours apart, for a CEST
reader. The renderer parses those back to aware datetimes and a new
audit_change_value filter dispatches on the type, because the column
also carries "10Gi" and "not set", which must not be read as ISO
timestamps. local-time.js already re-runs on htmx:afterSwap, so the
fragment swap is covered.

Pagination orders by -pk as well. timestamp is copied verbatim from the
log entry and carries no uniqueness, so LIMIT/OFFSET was free to order
tied rows differently per request, showing one entry twice across a page
boundary and hiding another. Meta.ordering matches; the ordering lives
in this branch's own unreleased 0064, so it is fixed there rather than
in a state-only 0068.

Two of the four tests are structural, and say so: the transaction and
ordering failures are PostgreSQL-dependent, and the suite runs on
SQLite, where a behavioural test passes just as well without the fix.
All four were confirmed to fail against the unfixed code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tobru merged commit 32c7a7afe6 into main 2026-09-04 14:40:03 +00:00
tobru deleted branch audit-log-frontend 2026-09-04 14:40:03 +00:00
Sign in to join this conversation.
No description provided.