Scope the read path by tenant (AUDIT-WP-0008-T04).
_read gated on may_read alone and never called permits_tenant, so any reader credential could read every tenant through /v1/events, /v1/events/<id>, /v1/dead-letters and /v1/secret-findings. Deployment bounded the exposure -- the only production sender holds may_read: false -- but the boundary was not in the code, which is the difference between E2 and E1 on the tenancy posture enforcement ladder. Two rules, because the surfaces divide cleanly. Event reads are filtered to the tenants the credential may act for. Surfaces with no tenant key to filter on -- stats, integrity, dead letters, secret findings -- require full tenant scope and are refused rather than served instance-wide facts to a scoped reader. A cross-tenant fetch returns 404 rather than 403. A distinguishable forbidden would confirm that an event id exists and which tenant holds it, turning the read surface into an existence oracle. Correlation lookup is filtered rather than refused, since a correlation id legitimately spans tenants. _readable_by fails closed: a record with no tenant is readable only at full scope. Three existing tests read instance-wide surfaces with a scoped credential, which this makes a 403; bound_app now carries an unrestricted operator identity and those reads use it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
26defd9bec
commit
aaa2b4c50b
6 changed files with 196 additions and 13 deletions
|
|
@ -240,8 +240,21 @@ def bound_app(tmp_path, **kw):
|
|||
may_read=kw.get("may_read", False),
|
||||
secret_policy=kw.get("secret_policy", "redact"),
|
||||
)
|
||||
# An unrestricted operator sits alongside the scoped sender. The
|
||||
# instance-wide read surfaces — stats, dead letters, secret findings,
|
||||
# integrity — carry no tenant key and so require full scope
|
||||
# (AUDIT-WP-0008-T04); reading them as the scoped sender is what that task
|
||||
# made a 403.
|
||||
operator = SenderIdentity(
|
||||
name="operator",
|
||||
tokens=("operator",),
|
||||
sources=frozenset({"user-engine"}),
|
||||
tenants=frozenset({"*"}),
|
||||
may_write=False,
|
||||
may_read=True,
|
||||
)
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "bound.db"))
|
||||
return IngestionApplication(backend, SenderRegistry([identity])), backend
|
||||
return IngestionApplication(backend, SenderRegistry([identity, operator])), backend
|
||||
|
||||
|
||||
def test_credential_may_not_claim_another_tenant(tmp_path):
|
||||
|
|
@ -332,7 +345,7 @@ def test_secret_rejection_withholds_the_payload(tmp_path):
|
|||
material would write that material into the audit store."""
|
||||
app, _ = bound_app(tmp_path, secret_policy="reject", may_read=True)
|
||||
assert invoke(app, event(data={"password": "hunter2"}))[0].startswith("400")
|
||||
_, body = invoke(app, None, path="/v1/dead-letters", method="GET", body=b"")
|
||||
_, body = invoke(app, None, path="/v1/dead-letters", method="GET", body=b"", token="operator")
|
||||
entry = body["dead_letters"][0]
|
||||
assert entry["reason"] == "secret_shaped_field"
|
||||
assert entry["payload_withheld"] is True
|
||||
|
|
@ -385,7 +398,7 @@ def test_findings_are_counted_by_path_for_both_outcomes(tmp_path):
|
|||
strict, _ = bound_app(tmp_path, secret_policy="reject")
|
||||
invoke(strict, event(id="r1", data={"auth_token": "x"}), key="r1")
|
||||
|
||||
_, body = invoke(app, None, path="/v1/secret-findings", method="GET", body=b"")
|
||||
_, body = invoke(app, None, path="/v1/secret-findings", method="GET", body=b"", token="operator")
|
||||
rows = {(r["outcome"], r["field_path"]): r for r in body["secret_findings"]}
|
||||
assert rows[("redacted", "data.auth_token")]["occurrences"] == 3
|
||||
assert rows[("redacted", "data.auth_token")]["action"] == "membership.added"
|
||||
|
|
@ -485,7 +498,7 @@ def test_counters_track_each_outcome(tmp_path):
|
|||
invoke(app, event(id="e2", tenant="tenant:coulomb"), key="e2") # rejected
|
||||
invoke(app, event(), token="nope") # unauthorized
|
||||
|
||||
_, body = invoke(app, None, path="/v1/stats", method="GET", body=b"")
|
||||
_, body = invoke(app, None, path="/v1/stats", method="GET", body=b"", token="operator")
|
||||
counts = body["counts"]
|
||||
assert counts["accepted"] == 1
|
||||
assert counts["duplicate"] == 1
|
||||
|
|
@ -499,3 +512,83 @@ def test_stats_require_the_read_privilege(tmp_path):
|
|||
app, _ = bound_app(tmp_path, may_read=False)
|
||||
status, _ = invoke(app, None, path="/v1/stats", method="GET", body=b"")
|
||||
assert status.startswith("403")
|
||||
|
||||
|
||||
# --- read is tenant-scoped (AUDIT-WP-0008-T04) ------------------------------
|
||||
|
||||
def two_tenant_app(tmp_path):
|
||||
"""A seeded store, plus a reader scoped to one of the two tenants.
|
||||
|
||||
The writer is unrestricted so both tenants exist; the reader is bound to
|
||||
``binky`` only. Both applications share one backend, which is the point —
|
||||
the boundary has to hold in the read path, not in the store.
|
||||
"""
|
||||
backend = SQLiteAuditBackend(str(tmp_path / "scoped.db"))
|
||||
writer = SenderIdentity(
|
||||
name="seeder", tokens=("seed",), sources=frozenset({"user-engine"}),
|
||||
tenants=frozenset({"*"}), may_read=True,
|
||||
)
|
||||
reader = SenderIdentity(
|
||||
name="scoped-reader", tokens=("scoped",), sources=frozenset({"user-engine"}),
|
||||
tenants=frozenset({"tenant:friendly:binky"}), may_read=True,
|
||||
)
|
||||
seed = IngestionApplication(backend, SenderRegistry([writer]))
|
||||
assert invoke(seed, event(), token="seed")[0].startswith("202")
|
||||
assert invoke(
|
||||
seed, event(id="evt-2", tenant="tenant:coulomb"), key="evt-2", token="seed"
|
||||
)[0].startswith("202")
|
||||
return IngestionApplication(backend, SenderRegistry([reader])), seed
|
||||
|
||||
|
||||
def test_scoped_reader_cannot_fetch_another_tenants_event(tmp_path):
|
||||
"""The defect AUDIT-WP-0008 found: may_read was the only gate."""
|
||||
scoped, _ = two_tenant_app(tmp_path)
|
||||
|
||||
status, body = invoke(scoped, None, path="/v1/events/evt-1", method="GET",
|
||||
body=b"", token="scoped")
|
||||
assert status.startswith("200")
|
||||
assert body["tenant"] == "tenant:friendly:binky"
|
||||
|
||||
status, body = invoke(scoped, None, path="/v1/events/evt-2", method="GET",
|
||||
body=b"", token="scoped")
|
||||
assert status.startswith("404")
|
||||
assert body["error"] == "not_found"
|
||||
|
||||
|
||||
def test_cross_tenant_refusal_is_indistinguishable_from_absence(tmp_path):
|
||||
"""403 here would confirm the event exists and is someone else's."""
|
||||
scoped, _ = two_tenant_app(tmp_path)
|
||||
present = invoke(scoped, None, path="/v1/events/evt-2", method="GET",
|
||||
body=b"", token="scoped")
|
||||
absent = invoke(scoped, None, path="/v1/events/evt-nope", method="GET",
|
||||
body=b"", token="scoped")
|
||||
assert present == absent
|
||||
|
||||
|
||||
def test_correlation_lookup_is_filtered_not_refused(tmp_path):
|
||||
"""One correlation legitimately spans tenants; serve the readable slice."""
|
||||
scoped, seed = two_tenant_app(tmp_path)
|
||||
|
||||
status, body = invoke_query(scoped, "correlation_id=corr-1", token="scoped")
|
||||
assert status.startswith("200")
|
||||
assert {e["event_id"] for e in body["events"]} == {"evt-1"}
|
||||
|
||||
status, body = invoke_query(seed, "correlation_id=corr-1", token="seed")
|
||||
assert {e["event_id"] for e in body["events"]} == {"evt-1", "evt-2"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path", ["/v1/dead-letters", "/v1/stats", "/v1/secret-findings", "/v1/integrity"]
|
||||
)
|
||||
def test_unscoped_surfaces_require_full_tenant_scope(tmp_path, path):
|
||||
"""Not tenant-keyed, so they cannot be filtered — refuse instead."""
|
||||
scoped, seed = two_tenant_app(tmp_path)
|
||||
|
||||
status, body = invoke(scoped, None, path=path, method="GET", body=b"",
|
||||
token="scoped", key=None)
|
||||
assert status.startswith("403")
|
||||
assert body["error"] == "full_tenant_scope_required"
|
||||
|
||||
status, _ = invoke(seed, None, path=path, method="GET", body=b"",
|
||||
token="seed", key=None)
|
||||
assert status.startswith("200")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue