Scope the read path by tenant (AUDIT-WP-0008-T04).
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

_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:
tegwick 2026-08-17 22:05:52 +02:00
parent 26defd9bec
commit aaa2b4c50b
6 changed files with 196 additions and 13 deletions

View file

@ -224,16 +224,39 @@ class IngestionApplication:
)
def _read(self, start_response, environ, path: str, identity):
"""Operator read surface (AUDIT-WP-0004-T05).
"""Operator read surface (AUDIT-WP-0004-T05, AUDIT-WP-0008-T04).
Read is a distinct privilege from write: a sender credential must not
be able to read the audit trail back.
Read is also tenant-scoped. Until AUDIT-WP-0008-T04 this method gated
on ``may_read`` alone and never consulted ``permits_tenant``, so any
reader could read every tenant. Deployment bounded the exposure the
production sender holds ``may_read: false`` but the boundary was not
in the code, which is the difference between E2 and E1 in the tenancy
posture (framework §4.3).
Two rules, because the surfaces divide cleanly:
* Event reads are filtered to the tenants the credential may act for.
* Surfaces that are not tenant-keyed chain verification, counters,
dead letters, secret findings require full tenant scope. They
cannot be filtered, so a scoped reader is refused rather than served
instance-wide facts.
"""
if not identity.may_read:
return self._json(start_response, HTTPStatus.FORBIDDEN, {"error": "read_forbidden"})
query = parse_qs(environ.get("QUERY_STRING", ""))
try:
if path in _UNSCOPED_READ_PATHS and not identity.has_full_tenant_scope():
# Refused rather than filtered: these carry no tenant key, so
# there is nothing to filter on and serving them to a scoped
# reader would leak across the boundary this method enforces.
return self._json(
start_response, HTTPStatus.FORBIDDEN,
{"error": "full_tenant_scope_required"},
)
if path == "/v1/dead-letters":
return self._json(
start_response, HTTPStatus.OK,
@ -261,12 +284,22 @@ class IngestionApplication:
start_response, HTTPStatus.BAD_REQUEST,
{"error": "correlation_id_required"},
)
return self._json(
start_response, HTTPStatus.OK,
{"events": self.backend.by_correlation(correlation, _limit(query))},
)
# A correlation id deliberately spans services, so a single
# correlation can carry events for more than one tenant.
# Filtering here rather than refusing keeps the surface useful
# to a scoped reader without widening what it sees.
events = [
event for event in self.backend.by_correlation(correlation, _limit(query))
if _readable_by(identity, event)
]
return self._json(start_response, HTTPStatus.OK, {"events": events})
event_id = path[len("/v1/events/"):]
record = self.backend.get(event_id) if event_id else None
if record is not None and not _readable_by(identity, record):
# 404, not 403. A distinguishable "forbidden" would confirm
# that an event id exists and which tenant holds it, turning
# the read surface into an existence oracle.
record = None
if record is None:
return self._json(start_response, HTTPStatus.NOT_FOUND, {"error": "not_found"})
return self._json(start_response, HTTPStatus.OK, record)
@ -431,6 +464,32 @@ def _normalize_timestamp(value: Any) -> str:
return parsed.astimezone(timezone.utc).isoformat()
# Read surfaces with no tenant key to filter on. Serving these to a scoped
# reader would cross the boundary _read enforces, so they require full scope
# (AUDIT-WP-0008-T04).
_UNSCOPED_READ_PATHS = frozenset({
"/v1/dead-letters",
"/v1/stats",
"/v1/secret-findings",
"/v1/integrity",
})
def _readable_by(identity, record: dict) -> bool:
"""Whether ``identity`` may read ``record``.
Fails closed: a record carrying no tenant is readable only by an identity
with full scope. A stored event always has one ``_validate`` requires it
so reaching that branch means the row predates the requirement or was
written by something other than the ingest path, and neither is a case to
resolve in favour of the reader.
"""
if identity.has_full_tenant_scope():
return True
tenant = record.get("tenant") if isinstance(record, dict) else None
return bool(tenant) and identity.permits_tenant(str(tenant))
def _limit(query: dict[str, list[str]], default: int = 100, ceiling: int = 1000) -> int:
try:
return max(1, min(int((query.get("limit") or [default])[0]), ceiling))

View file

@ -56,6 +56,16 @@ class SenderIdentity:
def permits_source(self, source: str) -> bool:
return WILDCARD in self.sources or source in self.sources
def has_full_tenant_scope(self) -> bool:
"""Whether this identity is unrestricted across tenants.
The read surfaces that are not tenant-keyed chain verification,
counters, dead letters, secret findings cannot be filtered, so they
are reserved for an identity that could read every tenant anyway.
Anything else would hand a scoped reader instance-wide facts.
"""
return WILDCARD in self.tenants
def permits_tenant(self, tenant: str) -> bool:
"""Whether this identity may write for ``tenant``.