Skip to content

✨ Every admin change is logged, Harvest's and Windmill's too (VOTE-AUDIT, SW-F-0364) - #3928

Merged
ereslibre merged 9 commits into
ovcsfrom
feat/meta-14259-audit-completion/ovcs
Oct 17, 2026
Merged

ereslibre merged 9 commits into
ovcsfrom
feat/meta-14259-audit-completion/ovcs

Conversation

@ereslibre

@ereslibre ereslibre commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Parent issue: https://github.com/sequentech/meta/issues/14259

Requirement SW-F-0364: log all system activity from start to finish. #3900 and #3902 log what a person changes through Hasura, accounts and roles, Keycloak events and the tenant-level actions. #3902's "Not in this pull request" left the rest open: most event-level routes (areas, elections, dates, voting status, ceremonies, tallies, publications, scheduled events, reports, applications, the approval matrix, monitoring, signing settings) write with Harvest's or Windmill's own SQL, which audit_change() skipped, so they wrote no AdminChange. This pull request closes that gap.

Scope agreed with EMS-AUDIT (meta#13781): this pull request carries the route coverage. The tenant deletion and country-limit entries come from that session's work; its own pull request keeps the rest of EMS-AUDIT.

What changes

Area Change
Database Migration 1791800000000_audit_changes_step_makes: audit_change() also logs the writes of a transaction whose admin Step names in the setting sequent.audit_actor ({user_id, username, role, origin, tenant_id}), with old and new values; a Hasura session still takes precedence. The trigger on the tables only Step writes for an admin: keys_ceremony, messaging_account, messaging_event_config, monitoring_config, signing_checks, staff_certificate, tally_results_publication, tally_sheet_import, tally_sheet_import_item. webhook_key is a secret (SQL and Rust rules)
Windmill audit_log::act_for(&tx, &actor, origin, tenant_id). Results publication (publish, revoke, website policy), monitoring settings (save_as, reset_to_preset_as, set_mode_as) and a signing approval (as the signer whose signature runs the action) name their admin. insert_tenant takes the creating admin
Harvest audit::act_for(&tx, &claims) right after the transaction opens in every state-changing admin route: areas, elections, election dates and schedule recompute, keys and tally ceremonies, trustee custody, ballot publication, voting status, tally sheets and their imports, signing certificates, issuers, checks and rules, messaging accounts and settings, applications, scheduled events, lifecycle policies, uploads and previews, certificate authority import and delete
Harvest, own entries Where the trigger cannot see the change: area and candidate imports (one entry with the document and declared checksum), export requests (event, tally results, ballot publication, applications, tasks, trustees, certificate authorities, reports), the approval matrix (old and new version), a report's password set (named, never recorded), a document's password revealed, an electoral-log console query, a voter information letter (it resets the voter's password), the country limits before and after
Windmill, own entries Deleting a tenant: an AdminChange staged in the deleting transaction, in the log of the tenant whose admin deleted it (the route passes the admin as an optional task input)
Guard harvest/tests/support/audited_routes.rs fails when a route file commits a transaction without naming its admin or staging an entry. Exemptions, with their reasons, in tests/fixtures/unaudited-committing-routes.txt (voting portal routes, reads that commit, callbacks)
Docs Developer page "Tenant log and audit entries": sections 5, 6.1, 6.2 and 13

Also fixed on the way:

  • /encrypt-report logged the report's password (info!("body …") and the tracing span).
  • /miru/upload-signature recorded its input, password included, in its tracing span.
  • /import-candidates wrote its task row before checking the permission; it now checks first (its route_permissions case is updated).
  • /delete-election-event passes the admin's role like every other route.

Not in this pull request

  • What a Windmill task writes on its own after the route answered (ballot styles of a publication, a tally's execution, documents of reports and letters, reconciliation patches) has no admin; the route's entry records the decision.
  • Keycloak changes when an application is confirmed or rejected are recorded only by Keycloak's admin events.
  • /fetch-document downloads write no entry.

Tests

  • windmill: the whole suite, 3088 pass, including postgres_audit_change (24 tests; 6 new: an admin named by Harvest or Windmill, the setting ending with the transaction, Hasura's precedence, the new tables, a keys ceremony, webhook_key).
  • harvest: 570 pass, including the route guard, act_for on the migrated test database, import and export entries, and the route permission inventory.
  • hasura: migrations apply, roll back and reapply to an identical schema; 115 metadata tests pass.
  • reuse lint passes.

Demo

A narrated video of the feature on a local stack is attached to meta#14259.

🤖 Generated with Claude Code

SW-F-0364 asks for all system activity to be logged. The audit_change()
trigger logged only what a person changed through Hasura; most event-level
actions run in Harvest and Windmill with their own SQL and wrote no
AdminChange.

- audit_change() also logs the writes of a transaction whose admin Step
  names with the setting sequent.audit_actor (audit_log::act_for and
  Harvest's audit::act_for), with old and new values and its origin. A
  Hasura session still takes precedence.
- The trigger on the tables only Step writes for an admin: keys_ceremony,
  messaging_account, messaging_event_config, monitoring_config,
  signing_checks, staff_certificate, tally_results_publication,
  tally_sheet_import(_item). webhook_key is a secret.
- Every state-changing admin route names its admin; Windmill's results
  publication, monitoring settings and signing approval take it from the
  caller.
- Hand-written entries where the trigger cannot see the change: imports,
  exports, the approval matrix, report and document passwords, console
  queries, voter information letters, the creator of a tenant.
- A Harvest test fails when a route file commits without logging.
- /encrypt-report no longer logs the report's password; /import-candidates
  checks its permission before writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ereslibre ereslibre self-assigned this Oct 16, 2026
@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review: 2
  • release/10.0
  • feat/meta-12767/.*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: sequentech/step/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: fa2d6804-380c-4d52-b4c9-3a22b5e6a1ee

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-10-17 03:16 UTC

From the EMS-AUDIT session's dropped tenant-level work (877171f0d5),
now carried here:

- Deleting a tenant stages an AdminChange in the deleting transaction,
  in the log of the tenant whose admin deleted it; the route passes the
  admin as an optional task input.
- Limiting access by countries records the country lists before and
  after, once Cloudflare set them.
- /miru/upload-signature no longer records its input, password
  included, in its tracing span.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ereslibre
ereslibre marked this pull request as ready for review October 16, 2026 21:08
ereslibre and others added 2 commits October 16, 2026 23:22
- Routes record their after-the-fact entries through audit::record with
  Harvest's services (record_after removed); act_for's invalid-tenant and
  failed-transaction paths are tested.
- audit::json_error replaces the per-route closures that turned an audit
  error into a JSON error.
- The approval matrix's entry is built by matrix_change, unit-tested.
- Windmill: stage_tenant_deletion on the migrated test database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- audit_changes() treats a nested key set to JSON null as a key left out,
  as diff_values does: saving a form that writes every key no longer logs
  "(none) -> (none)" changes.
- audit::queue_error and audit::json_queue_error replace the per-route
  closures for an entry that cannot be queued; import-areas builds its
  entry inside its transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ereslibre and others added 2 commits October 17, 2026 00:22
… by tenant id

The windmill coverage gate found the new actor paths untested: the monitoring
configuration tests now save, reset and switch modes for an admin, and
act_for_tenant moves next to act_for with tests for a valid and an invalid
tenant id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ereslibre added a commit that referenced this pull request Oct 17, 2026
…dmin keys after commit, new tenants' realms (part 2) (#3929)

Parent issue: sequentech/meta#13781

Design package
[EMS-AUDIT](https://project-1.sequent.sh/#package/EMS-AUDIT), second
part. #3902 gave each tenant a signed tenant log; this pull request
closes the gaps it listed or left open, apart from the route-by-route
entries (see "Split with #3928" below).

## What changes

| Area | Change |
| --- | --- |
| Imports | Importing an election event or a tenant configuration
records the file's SHA-256, computed by the task from the file it read,
and compares it with the checksum the admin declared (`undeclared`,
`matches` or `mismatch`). The entry also counts what the file held:
elections, contests, candidates, areas and areas per contest, or
configuration sections. A failed commit of a tenant configuration import
no longer records the import. |
| Entry popup | A new **The imported file** section shows the checksum
verdict, the SHA-256, the counts and the document, instead of raw extra
details. The deleted election event section names **The log's system
key**. Translated into the portal's 8 languages. |
| Deleted events in exports | One account's export with "every election
event" now also reads the logs of deleted events from their retired
databases, merged by time; their rows name `retired event <ID>`. Each
retired database is checked against the catalog, opened read-only under
the event database lock, and closed to connections again, including when
the read fails. The deletion entry keeps the retired log's system public
key, since the event's keys are deleted with it. |
| Admin public keys | `AdminPublicKey` is posted to the tenant log only
once the transaction that saved the key commits. A rollback leaves
nothing behind (before, it could leave a public key in the log whose key
was then lost). The delivery id is per account and key, so the key is
posted once. The tenant log migration backfills keys created before keys
went to the tenant log, and running it again does nothing. The outbox
can't carry this statement (it only posts `Audit` bodies), so a small
post-commit task does it instead. |
| New tenants | Creating a tenant prepares its Keycloak realm for the
tenant log whatever the realm template: `custom-event-listener`, admin
events with details, and the `tenant-logs-read` / `tenant-logs-export`
roles granted to the `admin` group. |
| Monitoring | Every audit outbox worker pass writes
`{"target":"audit_log_outbox","pending_entries":N,"oldest_pending_seconds":S}`,
zeros included, so security monitoring can alert on entries waiting more
than 5 minutes. This replaces the `waiting`/`oldest_seconds` names. It
is documented in the developer page. |
| Permissions | `/export-tenant-logs` needs `tenant-logs-read` as well
as `tenant-logs-export` (403 otherwise). The portal shows the tenant
log's Export only to people who have both. |
| Fix | Tenant log exports could not be downloaded: `fetch-document`
built a private document's S3 key from the event the caller named. The
tenant log asks with an empty event, so the link pointed at
`tenant-…/event-/document-…` (NoSuchKey). The key now comes from the
document's own event. |
| Docs | Settings > Logs reference: the popup's new sections, deleted
events in account exports, the export permissions. Developer page: the
backlog line and its alert threshold. Auditor tutorial: retired logs,
which have no system key in the manifest. |

## Split with #3928

#3928 (VOTE-AUDIT, meta#14259) closes the "routes write no AdminChange"
gap from #3902 for every Harvest route, including messaging accounts,
certificate authorities, countries and deleting a tenant. It does this
by letting the `audit_change()` trigger see Step's own writes. To avoid
logging each change twice, this pull request adds no per-route entries.
The two pull requests touch different files.

## Acceptance (meta#13781)

Ticked where #3902 (merged) and this pull request together meet the
criterion. Items 2, 3 and 5 also need #3928.

- [x] Signing in to the Admin Portal, failing (wrong password, wrong
authenticator code, unknown account), being locked out, setting up or
removing an authenticator, resetting or changing a password and signing
out each write one KeycloakUserEvent entry to the tenant log, with the
client, IP address and authentication used. An unknown username is
recorded as unknown. Token refreshes and code exchanges are not logged.
*(#3902; this PR adds the listener to new tenants' realms)*
- [ ] Every tenant-level change writes one AdminChange entry to the
tenant log with the person, the object, the action and each changed
field's old and new value, and no secret values. This covers the
Settings tabs, the tenant record, election types, trustees, templates,
staff accounts, roles and permissions, countries, backup and restore,
and creating, importing, archiving, unarchiving and deleting election
events. *(Imports now record the file's checksum and counts here.
Messaging accounts, countries with old values and deleting a tenant are
in #3928.)*
- [ ] Event-level create, update, delete, import, configure, approve and
schedule operations write AdminChange entries with old and new values to
their event's log (baseline). *(Changes made through Hasura are covered
since #3902; the Harvest routes are in #3928.)*
- [x] A change made through Hasura to any table an admin role can write
is logged in the same transaction. The coverage test fails when such a
table has no trigger. *(#3902)*
- [ ] A change whose transaction rolls back leaves no entry. A committed
change always gets exactly one entry, even when immudb or the worker is
down for a while: its entries wait and are posted in order afterwards.
*(The outbox does this, and this PR moves AdminPublicKey to after
commit. Certificate-authority routes still post their own entry after
commit, on a best-effort basis; with #3928 they also get an AdminChange
through the outbox.)*
- [x] A change to the tenant realm made in Keycloak's console is logged
as KeycloakAdminEvent, with the operator and the new value. Changes Step
makes through its service account appear as SYSTEM entries next to the
admin's AdminChange. *(#3902; new tenants' realms have admin events with
details since this PR)*
- [x] Windmill puts an event without an election event in the tenant
log. An event it can't place becomes an ERROR entry and the rest of its
batch is still posted. An unreadable message goes to the dead-letter
queue, and later messages are processed. *(#3902)*
- [x] Deleting an election event writes its tenant log entry, with the
event log's last entry and state hash, before anything is removed. The
event's log database is unloaded, not deleted. *(#3902; the entry also
keeps the retired log's system key since this PR. Checked on the local
stack.)*
- [x] Settings shows the Logs tab only with tenant-logs-read, and its
other tabs only with tenant-write: an Auditor sees only Logs, a Security
Officer every tab, a Configuration Manager every tab but Logs, and an
SBEI no Settings. Without tenant-logs-read, listElectoralLog and the
exports refuse (403), including direct API calls. *(#3902; this PR adds
the tenant-logs-read check to the tenant export)*
- [x] Settings > Logs has the same header, date range, filters, columns,
details popup and export menu as Election Event > Logs, and differs only
in its subtitle, timezone, statement kinds and export scopes. *(#3902)*
- [x] Times show in the tenant log's timezone (PhST for COMELEC) and in
my time. Filters by username, statement kind, event type, log type and
created range (inclusive, to the minute, with PhST boundary cases
verified) return exactly the matching entries in the tenant log and in
event logs. *(#3902)*
- [x] Clicking an entry in either list opens a popup (the shared Dialog,
not a side drawer) with who, when (the log's zone, my time and UTC),
what, the old and new values, the sign-in details or the Keycloak
change, the log and the signing keys; Close and Escape dismiss it.
*(#3902; this PR adds the imported file and the deleted log's system
key)*
- [x] Exports reconcile exactly with the list for the same scope and
range. They cover the tenant log (whole, or one account with or without
its election event entries) and event logs (All Posts, one Post, one
country, one account), over a date range, as CSV, SQL or PDF. Each
export writes LogsExported with the file's SHA-256. The event export
checks logs-export and the tenant export checks tenant-logs-export.
*(#3902. This PR adds deleted events' logs to one account's export and
fixes the tenant export's download. CSV was checked on the local stack;
PDF was not, because it does not render in the dev image.)*
- [x] The SQL export loads into PostgreSQL, and every entry's signatures
verify with the exported public keys. *(#3902; test
`postgres_audit_log_outbox`)*
- [x] Every posted entry appears as one JSON line in Windmill's output,
without old or new values or secrets. The outbox's waiting time is
available to VOTE-SECOPS's alerts. *(#3902; this PR writes the waiting
time on every worker pass)*
- [x] The one-off task gives every existing tenant its log and key, and
AdminPublicKey entries are written to the tenant log. *(#3902; this PR
posts keys after commit and backfills keys made earlier)*
- [x] The second organization's tenant log works by configuration only
(its timezone, accounts and labels); no code refers to COMELEC. *(#3902;
nothing in this PR names a client)*
- [ ] Apply every linked source requirement, preserving its exact
targets, alternatives and lifecycle stage. This package estimate is
shared and must not be summed per row. *(A rule for the whole package,
not something one pull request can tick)*

## Not in this pull request

- EMS-SBEI-QUORUM's signing entries still use their own
`signing_log_outbox`.
- Tenant log checkpoints and an audit task for the tenant log
(VOTE-AUDIT).
- Erasing or reopening a retired event database (OPS-RETENTION).
- Beyond's client-setup chart still configures only `jboss-logging` for
the tenant realm. Existing realms are set up by the one-off task, and
new tenants by this pull request.

## Upgrade notes

- Monitoring that parsed the outbox line's `waiting`/`oldest_seconds`
must read `pending_entries`/`oldest_pending_seconds`.
- Roles that exported the tenant log with only `tenant-logs-export` need
`tenant-logs-read` too.

## Tests

- electoral-log: 124 tests with the database tests
(`--include-ignored`), including reading a retired database and closing
it again.
- windmill: 1705 lib tests pass.
`decrypted_archives_use_private_owned_files_and_leave_no_plaintext`
needs an `openssl` binary on the host, and passes with one.
`postgres_audit_log_outbox` (8, including the post-commit key
announcement, the backfill and retired events in exports) and
`postgres_audit_change` (18) pass.
- harvest: 561 pass, including the new 403 for an export without read
permission.
- admin-portal: 2233 jest tests, `LogEntryDialog` stories (17, with a
new import story), story inventory, `typecheck:stories`, and the
journeys `settings/logs`, `settings/access` and `events/operations`
(29).
- End to end on a local stack built from this branch (Keycloak, Hasura,
Harvest, Windmill, PostgreSQL, MinIO, production portals), using the
backend E2E bootstrap. All 11 UI journeys pass. The stack also checked,
by hand: the import entry's checksum and counts; retired databases read
and closed again; the account export's download and its retired rows; a
new tenant's realm settings and roles; the backlog line. That run found
the export download bug fixed here.

Not measured locally: the per-package coverage gates.

## Demo

Video with voiceover, to upload to meta#13781: import, then an event
edit, deletion, the account export with the retired log, a new tenant's
realm, and the backlog line.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
ereslibre and others added 3 commits October 17, 2026 02:50
A dependency's generic code compiled into an instrumented binary names files
in the cargo registry. package_directories took such a crate for a measured
package, and relative_to(ROOT) then failed the run after every journey had
passed (tracing-0.1.44 in run 38982215384).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eta-14259-audit-completion/ovcs

# Conflicts:
#	docs/docusaurus/docs/07-developers/13-electoral-log/04-electoral-log-tenant-log.md
…nd custody for a tally

The harvest coverage gate found the regions this branch added to the voting
status routes and the server custody route untested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ereslibre
ereslibre merged commit b591e4d into ovcs Oct 17, 2026
64 checks passed
@ereslibre
ereslibre deleted the feat/meta-14259-audit-completion/ovcs branch October 17, 2026 03:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant