Skip to content

v0.47.0: tenant isolation across the evidence path#179

Merged
vaaraio merged 2 commits into
mainfrom
fix/tenant-read-sse-isolation
May 31, 2026
Merged

v0.47.0: tenant isolation across the evidence path#179
vaaraio merged 2 commits into
mainfrom
fix/tenant-read-sse-isolation

Conversation

@vaaraio
Copy link
Copy Markdown
Owner

@vaaraio vaaraio commented May 31, 2026

v0.47.0: tenant isolation across the evidence path

Closes the tenant-isolation findings from the v0.46.0 audit. Two of the three
were verified by reproduction against live code before fixing; the third (the
registry "lag") was a stale read and is not a code issue.

What was real, and how it is closed

Cross-tenant audit-chain read (was HIGH). GET /v1/audit/actions/{id}/chain
read the in-memory action map with no tenant check, so any caller who knew an
action_id could read another tenant's full chain. Reproduced: a tenant-B
caller read tenant-A's tool parameters. Now served through a tenant-scoped read
gated on X-Vaara-Tenant; unknown and cross-tenant actions both return 404 with
an identical body, so the response is not an existence oracle.

SSE broadcast leak (was MEDIUM). Upstream-pushed notifications on a shared
upstream fanned out to every tenant. Now scoped to the originating tenant;
unattributable log notifications broadcast only within a single scope.

Tenant identity outside the hash chain (was MEDIUM). Reproduced: mutating a
record's tenant_id from A to B left verify_chain() green. Now AuditRecord
carries a chain_version; v2 records fold tenant_id (and the version itself,
to block a downgrade) into the hash, so re-attribution breaks the chain. After
the fix the same mutation is detected.

Backward compatibility

Pre-v0.47 records carry chain_version 1 and hash exactly as before, so existing
trails and signed exports re-verify byte for byte. A fixed-digest test guards the
v1 hash against drift. SQLite gains a chain_version column (schema v4) with a
migration defaulting legacy rows to 1; a migration test proves a legacy record
with a populated tenant_id column still verifies under v1 rules. The standalone
verifier mirrors the v1/v2 rule.

Verification

1087 passed / 12 skipped, ruff clean. New tests cover v1/v2 hash behavior, the
fixed v1 digest, live re-attribution detection, SQLite round-trip, signed-export
round-trip through the standalone verifier, and the v3 to v4 migration.

Summary by CodeRabbit

  • Security Enhancements

    • Audit records now bind tenant identity in the tamper-evident hash chain
    • Audit chain read endpoint enforces tenant-scoped access control
    • Server-sent event notifications are now restricted to tenant boundaries
  • Chores

    • Version updated to 0.47.0
    • Audit database schema upgraded to v4

vaaraio added 2 commits May 31, 2026 09:09
The reference HTTP server's GET /v1/audit/actions/{id}/chain read
state.audit._by_action with no tenant check, returning any action's full
chain (agent_id, tool_name, payload) to any caller who knew the action_id,
despite the registry advertising per-tenant isolation. Add a tenant-scoped
read (AuditTrail.get_action_chain_scoped) gated on X-Vaara-Tenant; unknown
and cross-tenant actions both 404 with the same body so the response is not
an existence oracle. The scoped read also resolves chain positions in one
pass, removing the O(n^2) _records.index per event.

SSE notification broadcast (_mcp_notify HttpRouter.deliver) ignored the
per-session tenant: two tenants on a shared upstream each received the
other's upstream-pushed notifications. Progress notifications now scope the
broadcast to the captured originating tenant (threaded from the inflight
map); unattributable log notifications (no progressToken) suppress fan-out
across distinct tenant scopes on a shared upstream and broadcast only within
a single scope, preserving single-tenant and empty-tenant behavior.

Adds negative isolation tests for both paths. 255 server/proxy/notify tests
pass; ruff clean.
Completes the tenant-isolation pass on the evidence path. The reference
server's audit-chain read and SSE broadcast were already scoped per tenant
in the previous commit; this binds tenant identity into the tamper-evident
hash chain itself, so the multi-tenant evidence claim holds against a signed
anchor rather than only in the live store.

Security:
- AuditRecord gains a chain_version field. Records appended from this
  release on (chain v2) fold tenant_id and chain_version into compute_hash,
  so re-attributing a record to another tenant after the fact breaks
  verify_chain() instead of passing silently. Binding chain_version too means
  a downgrade to v1 (which would strip the tenant binding) also breaks the
  chain.
- Pre-v0.47 records carry chain_version 1 and hash exactly as before
  (tenant_id excluded), so existing trails and signed exports re-verify byte
  for byte. A fixed-digest test guards that v1 hash against future drift.
- The standalone verifier (vaara.audit.verify) mirrors the v1/v2 rule, kept
  in lockstep with AuditRecord.compute_hash.

Storage:
- SQLite schema v4 adds a chain_version column with a migration defaulting
  legacy rows to 1. write_record persists the record's chain_version and,
  for v2 records, stores tenant_id verbatim (the instance-scope substitution
  is confined to v1, where tenant_id was never hashed) so stored == hashed
  and the chain re-verifies on reload.

Tests:
- v1/v2 hash behavior, fixed v1 digest, live-trail re-attribution detection,
  SQLite v2 round-trip, signed-export round-trip through the standalone
  verifier, and a v3->v4 migration that proves a legacy record with a
  populated tenant_id column still verifies under v1 rules.

1087 passed / 12 skipped, ruff clean.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 31, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR hardens multi-tenant security by versioning the audit hash-chain, binding tenant identity into tamper-evident hashes for new records, scoping HTTP audit-chain reads by tenant, filtering SSE broadcasts per tenant, and migrating the SQLite schema from v3 to v4.

Changes

Multi-Tenant Audit Hardening

Layer / File(s) Summary
Release version and changelog
CHANGELOG.md, clients/ts/package.json, pyproject.toml, server-vaara-server.json, server.json, src/vaara/__init__.py
All distribution manifests and modules updated to version 0.47.0, and changelog documents the tenant-binding and tenant-scoping changes.
Audit record versioning foundation
src/vaara/audit/trail.py
Introduced _CURRENT_CHAIN_VERSION = 2 and AuditRecord.chain_version: int field (default 1). AuditRecord.compute_hash() now conditionally includes tenant_id and chain_version in the hashed content when chain_version >= 2. AuditTrail._append() stamps newly written records with the current chain version.
SQLite schema upgrade and persistence
src/vaara/audit/sqlite_backend.py
Schema bumped from v3 to v4 with new chain_version column. Migration v3→v4 adds the column with default value 1. write_record persists chain_version and selects tenant identity based on record version (per-record tenant for v2+, fallback for legacy). _row_to_record defensively decodes chain_version (defaulting to 1 for backward compatibility).
Hash-chain verification for versioned records
src/vaara/audit/verify.py
Export chain verification updated to handle v2 records: when chain_version >= 2, tenant_id and chain_version are included in the canonical hash recomputation.
Tenant-scoped audit chain read
src/vaara/audit/trail.py, src/vaara/server/routes.py
New AuditTrail.get_action_chain_scoped(action_id, tenant_id="") filters records by tenant and returns (position, record) pairs, hiding cross-tenant actions. HTTP /v1/audit/actions/{action_id}/chain handler now accepts optional X-Vaara-Tenant header and uses get_action_chain_scoped to return tenant-filtered events (or 404 if unknown/cross-tenant).
Notification router tenant-scoped delivery
src/vaara/integrations/_mcp_notify.py, src/vaara/integrations/mcp_proxy.py
NotificationRouter, StdioRouter, and HttpRouter.deliver() now accept optional tenant parameter. HttpRouter filters attributed broadcasts to sessions matching the tenant and suppresses unattributed broadcasts when candidates span multiple tenant scopes. VaaraMCPProxy._on_upstream_notification captures and forwards tenant from progress-token context to enable tenant-scoped routing.
Tests: Audit hash-chain tenant binding
tests/test_v040_tenant.py
Comprehensive test suite covering: v1 hash excludes tenant (legacy compatibility), v2 hash binds and differs by tenant, v1 byte-stability against fixed SHA-256, trail stamping detects tenant reattribution, v2 records survive SQLite roundtrip, and signed exports with v2 chains verify correctly.
Tests: Tenant-scoped reads, notifications, and migrations
tests/test_integrations_mcp_proxy.py, tests/test_mcp_notify.py, tests/test_server.py, tests/test_sqlite_backend.py
Integration tests for: HTTP audit-chain endpoint tenant-scoping (cross-tenant callers receive 404), HttpRouter attributed/unattributed broadcast suppression and fan-out, schema v3→v4 migration with preserved hashes, and proxy test compatibility with updated router signatures.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • vaaraio/vaara#75: Added the /v1/audit/actions/{action_id}/chain HTTP endpoint that this PR now makes tenant-scoped.
  • vaaraio/vaara#177: Prior tenant-scoping work in AuditTrail that this PR extends with chain-version binding and tenant-scoped chain reads.
  • vaaraio/vaara#165: Introduced SSE/session routing and fan-out logic that this PR extends with tenant-scoped delivery and broadcast suppression.

Poem

🐰 Chain links now bind the tenant tight,
Each version guards the audit's right,
Cross-tenant peeks return just four-oh-four,
Broadcasts scoped to tenantsores,
Security whispers through the core! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'v0.47.0: tenant isolation across the evidence path' directly and concisely summarizes the main security improvements in this release, matching the core changes described in the raw summary and PR objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tenant-read-sse-isolation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

try:
reloaded = backend.load_trail(strict=True) # raises if chain broke
finally:
backend.close()
Comment thread tests/test_v040_tenant.py
)
)
finally:
backend.close()
Comment thread tests/test_v040_tenant.py
try:
reloaded = reopened.load_trail(strict=True) # raises if chain broken
finally:
reopened.close()
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/test_sqlite_backend.py (1)

296-300: 💤 Low value

Optional: use a with block for the backend.

CodeQL flags the manual try/finally close. Since SQLiteAuditBackend is a context manager and the rest of this file already uses with, this aligns for consistency. The in-memory reloaded trail remains usable after the block.

♻️ Proposed refactor
-        backend = SQLiteAuditBackend(db_path)  # migrates 3 -> 4
-        try:
-            reloaded = backend.load_trail(strict=True)  # raises if chain broke
-        finally:
-            backend.close()
+        with SQLiteAuditBackend(db_path) as backend:  # migrates 3 -> 4
+            reloaded = backend.load_trail(strict=True)  # raises if chain broke
         self._assert_current(db_path)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_sqlite_backend.py` around lines 296 - 300, Replace the manual
try/finally close with a context manager: use a with block when instantiating
SQLiteAuditBackend so that backend.close() is called automatically; call
backend.load_trail(strict=True) inside the with and assign the returned reloaded
trail to a variable (it remains usable after the with). Update the code around
SQLiteAuditBackend and load_trail to follow the pattern used elsewhere in the
file.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/test_sqlite_backend.py`:
- Around line 296-300: Replace the manual try/finally close with a context
manager: use a with block when instantiating SQLiteAuditBackend so that
backend.close() is called automatically; call backend.load_trail(strict=True)
inside the with and assign the returned reloaded trail to a variable (it remains
usable after the with). Update the code around SQLiteAuditBackend and load_trail
to follow the pattern used elsewhere in the file.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a8649c1-2fc9-4b77-8151-d02d5fa6c0be

📥 Commits

Reviewing files that changed from the base of the PR and between e2dfb10 and d09e3ca.

📒 Files selected for processing (17)
  • CHANGELOG.md
  • clients/ts/package.json
  • pyproject.toml
  • server-vaara-server.json
  • server.json
  • src/vaara/__init__.py
  • src/vaara/audit/sqlite_backend.py
  • src/vaara/audit/trail.py
  • src/vaara/audit/verify.py
  • src/vaara/integrations/_mcp_notify.py
  • src/vaara/integrations/mcp_proxy.py
  • src/vaara/server/routes.py
  • tests/test_integrations_mcp_proxy.py
  • tests/test_mcp_notify.py
  • tests/test_server.py
  • tests/test_sqlite_backend.py
  • tests/test_v040_tenant.py

@vaaraio vaaraio merged commit 9eb7d1b into main May 31, 2026
13 checks passed
@vaaraio vaaraio deleted the fix/tenant-read-sse-isolation branch May 31, 2026 07:05
vaaraio added a commit that referenced this pull request May 31, 2026
* feat(audit): external time anchor for the hash chain (v0.48.0)

Adds vaara.audit.timeanchor: AuditTrail.anchor_head() obtains an RFC 3161
trusted timestamp over the current chain head (a SHA-256 digest) from an
external Time-Stamp Authority, so the chain's existence is provable against
a clock outside Vaara's trust boundary even if the signing key is later
compromised. RFC 3161 underpins eIDAS qualified electronic timestamps, which
makes this regulator-grade evidence under EU AI Act Article 12. Token
verification is offline (verify_anchor, verify_anchor_over_records) and binds
the anchor to a specific record, so a rewritten chain or a token over a
different digest is rejected. HTTP uses the stdlib; the ASN.1 and signature
checks need the new optional 'timeanchor' extra (asn1crypto + cryptography).

This is the anti-backdating mechanism cited by the new server-side signed
execution-record SEP draft (docs/sep/sep-server-execution-record.md), the
follow-up to SEP-2817 that Vaara is positioned to author.

Also reframes the public lead back to EU AI Act runtime evidence and data
sovereignty (README, package descriptions, MCP manifests, vaara.io) with the
tamper-evident receipt as the mechanism, not the headline.

1096 passed / 12 skipped, ruff clean, mypy clean on the new module.

* test(audit): use context-manager form for SQLiteAuditBackend in new tests

Clears three CodeQL 'should use a with statement' advisories on the v0.47
audit tests (test_sqlite_backend.py, test_v040_tenant.py). The fix was
authored during PR #179 but its commit never reached the remote before that
PR squash-merged, so the advisories are still live on main; this lands it.
No behavior change.

---------

Co-authored-by: vaaraio <267591518+vaaraio@users.noreply.github.com>
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.

2 participants