Skip to content

fix(storage): apply all audit log filters on cassandra and couchbase - #802

Open
lakhansamani wants to merge 3 commits into
mainfrom
fix/audit-log-filter-parity
Open

lakhansamani wants to merge 3 commits into
mainfrom
fix/audit-log-filter-parity

Conversation

@lakhansamani

@lakhansamani lakhansamani commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Problem

ListAuditLogRequest advertises six filters. Two backends built their WHERE clause from action and actor_id only:

Backend action actor_id resource_type resource_id from/to_timestamp
SQL, MongoDB, ArangoDB, DynamoDB yes yes yes yes yes
Couchbase yes yes ignored ignored ignored
Cassandra/ScyllaDB yes yes ignored ignored ignored

The call returns rows and no error, so a caller cannot tell a filter was dropped. An auditor narrowing _audit_logs to one resource or one time window reads an unfiltered result as authoritative.

Second, previously unknown bug. On Scylla, two indexed equalities with no ALLOW FILTERING are rejected outright:

InvalidRequest: Cannot execute this query as it might involve data filtering
and thus may have unpredictable performance [...] use ALLOW FILTERING

That is exactly the query the pre-fix builder emitted, so _audit_logs(action:, actor_id:) — the one filter combination Cassandra did support — already errored on Scylla. It went unnoticed because no test combined two filters.

Root cause for both: admin_audit_rest_test.go and admin_audit_grpc_test.go only ever pass action.

Changes

  • cassandra: secondary indexes on resource_type and resource_id; all six filters applied. ALLOW FILTERING added only when a created_at bound or a second restriction is present — single-column equality keeps its index-served plan (verified directly via cqlsh).
  • cassandra: waitForCassandraIndexes probes every indexed column, not just actor_id. Scylla builds secondary indexes concurrently, so the last created is not the last ready; without this the new indexes are a startup race.
  • couchbase: all four missing filters applied as named N1QL parameters.
  • tests: four subtests in testAuditLogOperations, so every backend in TEST_DBS is covered.

Verification

Run on real containers, not inferred.

Backend Before fix After fix
ScyllaDB resource_id got 4 rows, resource_type 5, timestamp_range 2 — all expected 1 pass, incl. on a freshly created container (index-readiness race)
Couchbase same three failures pass
MongoDB — pass
ArangoDB — pass
DynamoDB — pass
SQLite (make test) — 0 failures

go build ./... and go vet ./... clean; gofmt clean on touched files.

The ALLOW FILTERING rule was verified rather than assumed: narrowing it to timestamp-bounds-only makes the combined-equality subtest fail on Scylla, and restoring it makes it pass.

Lint

CI's Lint (golangci-lint) job passes on this branch, along with Go tests (SQLite), govulncheck and Release smoke.

An earlier revision of this description claimed make lint was red on main with 7 pre-existing issues. That was wrong and is retracted. The repo pins golangci-lint v2.11.4 (Makefile), but GOLANGCI_LINT ?= $(shell command -v golangci-lint) means the pinned version is installed only when none is already on PATH — my local machine had v2.12.2 on go1.27.0, which reports findings the pinned version does not. Those 7 issues are a local-toolchain artifact, not a repo problem.

What does survive the correction: the before/after comparison was run with the same binary on both sides, and the issue set is byte-identical except one pre-existing finding shifting line number as added lines pushed it down. This branch introduces no new lint findings on either version.

Notes

ALLOW FILTERING on the timestamp range is a deliberate trade, marked in the code: the range is on a non-primary-key column so no index can serve it, and this is a rare admin-only query. The upgrade path, if it ever gets hot, is a materialized view keyed on a coarse time bucket.

Groundwork for recording before/after state on authorization changes (design: authorizerdev/docs#96) — setting resource_id on those audit rows is pointless while two backends cannot filter on it.

Review round

Both reviews were worked through; two findings were regressions in this branch, one was an AGENTS.md violation.

Fixed

  • ListAuditLogs never checked scanner.Err(). A scan dying part-way ends Next() normally, so it returned a truncated audit page with a nil error while Total (a separate query) reported the real count. Pre-existing, but newly reachable: the filters here can now produce ALLOW FILTERING scans, where partial reads are far likelier. DeleteAuditLogsBefore in the same file already did this.
  • The index-wait budget was shared across all four columns with return on expiry, so a slow first index could consume it and the rest were never probed — turning a race on one column into a race on every other. Now split per column.
  • Couchbase had no GSIs for resource_type / resource_id. Cassandra got indexes, Couchbase did not, so the same filter was index-served on one backend and a primary-index scan on the other — the parity bug AGENTS.md explicitly forbids. Added, and verified by EXPLAIN: the planner picks AuditLogResourceIdIndex, no PrimaryScan.
  • Extracted pollUntilQuerySucceeds so the backoff cannot drift between the single- and multi-column waits.
  • Corrected two comments still describing a single actor_id probe, and one that sized the ALLOW FILTERING trade for the timestamp range only when it also covers any two equality filters.

Not fixed, deliberately

Pre-existing bug found, reported not fixed

waitForCassandraSecondaryIndex (provider.go:646) probes with ... ALLOW FILTERING. That hint makes the query succeed whether or not the index exists, so the function returns on its first attempt and never waits. Verified on Scylla against a deliberately non-indexed column:

WITH    ALLOW FILTERING -> (0 rows)          <- succeeds, no index needed
WITHOUT ALLOW FILTERING -> InvalidRequest    <- correctly fails until indexed

All 13 of its callers therefore have a no-op index wait. The fix is to drop the hint from the probe, but that would make 13 startup waits genuinely block — a real latency change deserving its own PR and testing. Tracked in #803. The new waitForCassandraIndexes in this PR deliberately omits the hint for exactly this reason, now commented.

CI gap

Confirmed: no workflow in .github/workflows/ references any non-SQLite backend. CI runs Lint, govulncheck, Go tests (SQLite) and Release smoke only. AGENTS.md step 4 requires a non-SQL backend run for storage changes, and nothing enforces it — which is the structural reason both bugs in this PR shipped. Everything here was verified locally against real ScyllaDB and Couchbase containers. Tracked in #804.

ListAuditLogRequest advertises resource_type, resource_id,
from_timestamp and to_timestamp. Cassandra/ScyllaDB and Couchbase
built a WHERE clause from action and actor_id only, silently
returning unfiltered rows — an auditor narrowing to one resource or
one time window read the result as authoritative.

- cassandra: index resource_type and resource_id; ALLOW FILTERING
  only when a created_at bound or a second restriction is present,
  so indexed-equality queries keep their index-served plan
- cassandra: probe every indexed column for readiness, not just
  actor_id — Scylla builds indexes concurrently, so the last one
  created is not the last one ready
- couchbase: apply all four as named N1QL parameters

Verified by running the new subtests against both backends before
the fix (all three fail) and after (pass).
Two indexed equalities with no ALLOW FILTERING are rejected outright
by Scylla:

  InvalidRequest: Cannot execute this query as it might involve data
  filtering [...] use ALLOW FILTERING

That is the shape the pre-fix builder emitted, so
_audit_logs(action:, actor_id:) already errored on Scylla before this
branch — a second latent bug, invisible because no test combined two
filters. Single-column equality is unaffected and still index-served.
- cassandra: check scanner.Err() in ListAuditLogs. A scan dying
  part-way ends Next() normally, so the call returned a truncated
  audit page with a nil error while Total reported the real count.
  Newly reachable now that filters can produce ALLOW FILTERING scans.
  DeleteAuditLogsBefore in the same file already did this.
- cassandra: split the index-wait budget per column. One shared
  deadline let a slow first index consume it all, after which the
  remaining columns were never probed — turning a race on one column
  into a race on every other.
- cassandra: extract pollUntilQuerySucceeds so the backoff cannot
  drift between the single- and multi-column waits.
- couchbase: add GSIs for resource_type and resource_id. Without them
  the new predicates fall back to the primary-index scan while
  cassandra serves them from an index — the cross-provider parity bug
  AGENTS.md forbids. Verified via EXPLAIN that the planner picks
  AuditLogResourceIdIndex with no PrimaryScan.
- correct two comments that still described a single actor_id probe,
  and one that sized ALLOW FILTERING for the timestamp range only
  when it also covers any two equality filters.

This branch has not been deployed

No deployments
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