Skip to content

feat(audit): emit events for export, purge, reindex#179

Open
bomanaps wants to merge 3 commits into
HeliosSoftware:mainfrom
bomanaps:feat/persistence-audit-events
Open

feat(audit): emit events for export, purge, reindex#179
bomanaps wants to merge 3 commits into
HeliosSoftware:mainfrom
bomanaps:feat/persistence-audit-events

Conversation

@bomanaps

Copy link
Copy Markdown

Summary

  • Wires BALP-compliant AuditEvent emission for $purge, $reindex, and bulk
    export — closing Persistence-layer audit events for bulk export, purge, and reindex #168.
  • $purge and $reindex land as new REST handlers backed by an OperationsBundle,
    while the bulk-export worker now emits worker-complete / cancelled / failed events
    at the actual lifecycle boundary instead of leaving them silent.
  • Thirteen integration tests assert action codes, outcomes, and detail fields
    end-to-end via a new InMemoryAuditSink test fixture.

Test plan

  • cargo fmt --all -- --check
  • cargo clippy --no-deps on helios-audit, helios-rest,
    helios-persistence, helios-hfs
  • cargo test -p helios-audit -p helios-rest -p helios-persistence -p helios-hfs (1623 pass, 0 fail)
  • Manual end-to-end run against the hfs binary with HFS_AUDIT_BACKEND=file,
    confirming 11 expected AuditEvent records emit across $purge, $reindex, and the
    full bulk-export lifecycle (including worker-emitted completion)

@bomanaps bomanaps force-pushed the feat/persistence-audit-events branch from 0fad44f to 538cf2b Compare July 8, 2026 09:42
@bomanaps

bomanaps commented Jul 8, 2026

Copy link
Copy Markdown
Author

@smunini please can I get a review?

@smunini

smunini commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

$purge / $reindex auth: no bypass, but authz_middleware misclassifies the type-scoped operations

I traced the auth path end-to-end. No unauthenticated bypass — but the middleware-level authorization misclassifies these new operations, which causes over-restriction for least-privilege tokens and a misleading audit trail.

Authentication is solid. The new paths aren't in EXEMPT_PATHS, so with auth enabled a missing/invalid token → 401 before the handler runs. When auth is disabled, principal is None and the ops are open — identical to ordinary DELETE/PUT, so consistent.

Handler-level authz is correct and is the real backstop. Each handler runs its own SmartScopePolicy::check with the right operation (purge → Delete, reindex → Update), and purge hard-rejects AuditEvent targets. This matches the inline-check pattern in batch.rs / bulk_export.rs, so there is no under-enforcement / privilege-escalation hole — a token lacking the scope gets 403 here regardless.

The issue is extract_operation_for_routing in crates/rest/src/middleware/auth.rs. It doesn't understand $-suffixed operation endpoints on a resource type — only _search is special-cased in the POST branch, so everything else POSTed to /{rt}/... falls through to FhirOperation::Create:

Request Middleware classifies as Handler checks Match?
DELETE /Patient/123/$purge Delete on Patient Delete
POST /Patient/$purge Create on Patient Delete
POST /Patient/$reindex Create on Patient Update
POST /$reindex (system) None ($-prefix skipped) Update on * ⚠️ not gated at middleware

Consequences:

  1. Over-restriction for narrow scopes. A token scoped exactly to what the handler documents as sufficient — patient/Patient.d for purge, .u for reindex — is blocked by the middleware's Create check before the handler runs. Only tokens carrying create/write scope get through, so the documented scope requirement isn't what's enforced end-to-end. (Too strict, not too loose — not a security hole, but it'll surprise least-privilege clients.)

  2. Mislabeled audit trail. authz_middleware emits an AuditEvent reading Granted/Forbidden: Create on Patient for what is really a destructive purge or a reindex — misleading, and especially so in an audit-focused PR.

  3. Inconsistent enforcement surface. Instance purge is gated correctly at the middleware, type purge/reindex are misgated as Create, and system reindex isn't gated at the middleware at all — three behaviors for one feature.

Suggested fix — pick one so middleware and handler agree:

  • (a) Teach extract_operation_for_routing to map .../$purgeDelete and .../$reindexUpdate; or
  • (b) Return None for these paths (as it already does for $export, batch, and system $-ops) and rely solely on the handler check, dropping the misleading middleware audit event.

Option (b) is most consistent with the existing batch/bulk-export "defer to handler" pattern. Right now it accidentally does neither and lands on Create.

@bomanaps bomanaps force-pushed the feat/persistence-audit-events branch from ea83c79 to 4f1cc42 Compare July 8, 2026 15:59
@bomanaps

bomanaps commented Jul 8, 2026

Copy link
Copy Markdown
Author

$purge / $reindex auth: no bypass, but authz_middleware misclassifies the type-scoped operations

I traced the auth path end-to-end. No unauthenticated bypass — but the middleware-level authorization misclassifies these new operations, which causes over-restriction for least-privilege tokens and a misleading audit trail.

Authentication is solid. The new paths aren't in EXEMPT_PATHS, so with auth enabled a missing/invalid token → 401 before the handler runs. When auth is disabled, principal is None and the ops are open — identical to ordinary DELETE/PUT, so consistent.

Handler-level authz is correct and is the real backstop. Each handler runs its own SmartScopePolicy::check with the right operation (purge → Delete, reindex → Update), and purge hard-rejects AuditEvent targets. This matches the inline-check pattern in batch.rs / bulk_export.rs, so there is no under-enforcement / privilege-escalation hole — a token lacking the scope gets 403 here regardless.

The issue is extract_operation_for_routing in crates/rest/src/middleware/auth.rs. It doesn't understand $-suffixed operation endpoints on a resource type — only _search is special-cased in the POST branch, so everything else POSTed to /{rt}/... falls through to FhirOperation::Create:
Request Middleware classifies as Handler checks Match?
DELETE /Patient/123/$purge Delete on Patient Delete ✅
POST /Patient/$purge Create on Patient Delete ❌
POST /Patient/$reindex Create on Patient Update ❌
POST /$reindex (system) None ($-prefix skipped) Update on * ⚠️ not gated at middleware

Consequences:

1. **Over-restriction for narrow scopes.** A token scoped exactly to what the handler documents as sufficient — `patient/Patient.d` for purge, `.u` for reindex — is blocked by the middleware's `Create` check before the handler runs. Only tokens carrying create/`write` scope get through, so the documented scope requirement isn't what's enforced end-to-end. (Too strict, not too loose — not a security hole, but it'll surprise least-privilege clients.)

2. **Mislabeled audit trail.** `authz_middleware` emits an AuditEvent reading `Granted/Forbidden: Create on Patient` for what is really a destructive purge or a reindex — misleading, and especially so in an audit-focused PR.

3. **Inconsistent enforcement surface.** Instance purge is gated correctly at the middleware, type purge/reindex are misgated as `Create`, and system reindex isn't gated at the middleware at all — three behaviors for one feature.

Suggested fix — pick one so middleware and handler agree:

* **(a)** Teach `extract_operation_for_routing` to map `.../$purge` → `Delete` and `.../$reindex` → `Update`; or

* **(b)** Return `None` for these paths (as it already does for `$export`, batch, and system `$`-ops) and rely solely on the handler check, dropping the misleading middleware audit event.

Option (b) is most consistent with the existing batch/bulk-export "defer to handler" pattern. Right now it accidentally does neither and lands on Create.

Went with (b), I made extract_operation return None for any $-op path so it defers to the handler's SmartScopePolicy::check, and my tests show patient/Patient.d now reaches $purge with no misleading Create on event.

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.

3 participants