T05 · Unauthorized Access and Privilege Escalation
- Location
src/second_perspective/api/main.py:82- Finding
Missing Tenant Enforcement Enables Cross-Tenant Data Access and Mutation
- Content
View full analysis
Vulnerability Details
File Location:
src/second_perspective/api/main.py:82-88;src/second_perspective/persistence/postgres.py:25-247;src/second_perspective/repository.py:31-66;src/second_perspective/hub/repository.py:24-75
Vulnerability Type: Broken tenant isolation / insecure direct object reference
Risk Level: HighVulnerable Code
The tenant middleware trusts the caller-supplied tenant header before consulting verified OIDC claims:
python tenant_id = request.headers.get(TENANT_HEADER) if not tenant_id and authz_enforced(): claims = getattr(request.state, "oidc_claims", None) tenant_id = resolve_tenant_from_claims(claims) token = None if tenant_id: token = set_tenant(tenant_id)However, the PostgreSQL decision repository does not include the active tenant in its storage or lookup operations:
python def get(self, decision_id: str) -> DecisionRecord | None: with self._lock: with self._conn() as conn, conn.cursor() as cur: cur.execute( "SELECT body FROM nomos_decisions WHERE decision_id = %s ORDER BY revision DESC LIMIT 1", (decision_id,), ) row = cur.fetchone() if row is None: return None record = DecisionRecord.model_validate(json.loads(row["body"])) history = self.history(decision_id) if history and not verify_chain(history): raise ValueError(f"integrity verification failed for {decision_id}") return record def history(self, decision_id: str) -> list[DecisionRecord]: with self._lock: with self._conn() as conn, conn.cursor() as cur: cur.execute( "SELECT body FROM nomos_decisions WHERE decision_id = %s ORDER BY revision ASC", (decision_id,), ) rows = cur.fetchall() reco ...[truncated 5490 chars]- Remediation
View remediation
Remediation Suggestions
- Derive the tenant exclusively from verified identity claims when OIDC authorization is active. Do not allow an untrusted
X-Tenant-Idheader to override a verified tenant claim. - If header-based tenant selection is required for service integrations, restrict it to a separately authenticated and explicitly authorized administrative mechanism.
- Add
tenant_idto every decision, report, and session write. - Include
tenant_id = %sin every repository lookup, history query, update, revision check, and deletion operation. - Change uniqueness and primary-key constraints to include tenant identity where identifiers are not globally unique, such as
(tenant_id, decision_id, revision). - Key in-memory repositories by
(tenant_id, object_id)and obtain the tenant fromget_tenant()at every repository boundary. - Reject requests with no resolved tenant in multi-tenant production mode rather than silently using the global empty tenant.
- Add integration tests proving that credentials for tenant A cannot read, update, approve, or advance objects belonging to tenant B, even when object identifiers are known.
- Ensure migrations not only create tenant columns but also update runtime queries to populate and enforce them.
- Derive the tenant exclusively from verified identity claims when OIDC authorization is active. Do not allow an untrusted
