Status: Accepted Date: 2026-05-20 Authors: Server foundations epic (#1097)
Cross-workspace data leakage is a security risk every Hephaestus deployment must
avoid. ArchUnit tests catch some categories at the repository layer, but nothing
catches a query that emits SQL against a workspace-scoped table without a
workspace_id predicate. The bug class is: someone writes a @Query("SELECT ... FROM pull_request") and forgets the workspace filter — the test suite passes,
the leak ships.
Hibernate 6 ships native multi-tenancy support via @TenantId +
CurrentTenantIdentifierResolver. That's the modern discriminator-based approach,
auto-injects the predicate, and auto-fills the column on insert. But:
- It does NOT cover native queries,
@Modifyingdeletes, or some projection shapes - Multi-hop entities (CommitFileChange → Commit → Repository → Organization ←
Workspace) need
workspace_iddenormalization onto every entity for@TenantIdto attach the predicate cheaply — that's a 6–12 week migration epic of its own
A Hibernate StatementInspector works at the SQL-emit boundary (catches
everything, including native queries) but needs a runtime escape hatch for
legitimately-cross-workspace queries (workspace listing, slug history, admin
maintenance).
- Catch the leak class at PR time, not in production
- Don't block on a 6–12 week schema migration (
@TenantIddenormalization) - Make
@WorkspaceAgnosticannotation load-bearing (not just docs) - Performance: hot-path SQL must stay fast (regex + cache)
- Single source of truth for which tables are scoped
- The inspector must NEVER throw — fail-open with observability for pathological input
StatementInspector+ AOP bypass + JPA-metamodel SSOT (regex-only pipeline) — pragmatic; ships without schema migration; defense-in-depth.@TenantIdnative multi-tenancy — modern, integrated; needs denormalization first.@TenantId+StatementInspectorhybrid — best long-term, but option 1 is the prerequisite (inspector exists;@TenantIdadoption layers on later).- Hibernate
@Filter/@FilterDef— legacy; per-repository activation; doesn't catch native queries; effectively superseded by@TenantId.
Option 1 for this epic. Statement inspector + AOP-driven bypass on
@WorkspaceAgnostic + JPA-metamodel-derived WorkspaceScopedTables SSOT.
Regex-only pipeline (no SQL parser dependency):
- ThreadLocal bypass check (
@WorkspaceAgnosticaspect open) - Enforcement mode
OFFshort-circuit - Caffeine cache lookup (10k entries, LRU)
- INSERT short-circuit (the value list carries
workspace_idby construction) - PK-anchored DML short-circuit (
WHERE id = ?with optional optimistic-lockAND version = ?) - PK-anchored SELECT short-circuit (
FROM table alias … WHERE alias.<id|*_id> = ?) workspace_idword-boundary fast path- Table-extract fallback — pull table identifiers after
FROM/JOIN/UPDATE/INTO, intersect withWorkspaceScopedTables.scopedTables(). Any match without aworkspace_idreference is a violation.
Enforcement mode is configurable (hephaestus.tenancy.enforcement = throw | log | off); throw in test, log (with Micrometer counter
tenancy.violation.total{table, mode}) elsewhere.
Why regex, not JSqlParser: JSqlParser was tried and rejected. Adding it to the
classpath caused Spring Data JPA to auto-activate its JSqlParserQueryEnhancer,
which fails on legitimate Postgres-escaped @Query natives (e.g.,
CONCAT(:id\:\:text, ...)) and breaks application boot. A regex-only inspector
has no transitive blast radius and is sufficient for the predicate-shape check
we actually care about.
Native @TenantId adoption is filed as a follow-up epic that lands after the
multi-hop denormalization migration.
The inspector is shape enforcement, not authorization. The controller layer
(workspace context filter + @PreAuthorize + URL slug → workspace resolution)
remains the security boundary. Documented trade-offs:
- PK-anchored SELECT (
WHERE alias.*_id = ?) admits FK-only predicates. Lazy@OneToMany/@ManyToManycollection loads emitSELECT * FROM child WHERE parent_id = ?. Safe by construction because the caller obtainedparent_idvia a workspace-scoped path — a user-supplied URL ID would have to bypass controller-level workspace resolution first. The surrogate key is opaque to inputs; the upstream find is the boundary. workspace_idword-boundary fast path is mention-anywhere. A query of shapeSELECT … FROM pull_request pr JOIN issue i ON … WHERE i.workspace_id <> ?would pass — the regex sees the token, not the binding direction. Hibernate doesn't emit such shapes from JPQL/Criteria; a developer writing this in a@Querynative is explicitly bypassing tenancy and the code review is the catch.- SQL comments / string literals containing
workspace_idfalsely pass. Same regex limitation. Acceptable because Hibernate-emitted SQL is parameterized; user input does not appear inline. - Leading CTE (
WITH … AS (…) SELECT …) breaks the PK-anchored carve-outs. Falls through to the standardworkspace_idcheck; legitimate CTE queries on scoped tables need@WorkspaceAgnostic(Hibernate rarely emits CTEs against the dialects we use).
The CrossWorkspaceIsolationTest that asserts 403 on cross-workspace HTTP access
for every @WorkspaceScopedController is the controller-layer counterpart and
is scheduled as a follow-up epic (cut from #1097 because the MockMvc fixture
work deserves its own PR).
- Test profile fails loudly on any workspace-scoped query that lacks the predicate.
- Production runs in
logmode for the staging canary period; flip tothrowafter a calendar week of clean counter readings (tracked separately). - The
@WorkspaceAgnosticannotation now has runtime semantics: dropping it from a repository that issues cross-workspace queries causesTenancyViolationExceptionin test. Annotation is no longer documentation-only. - The inspector itself must never throw — pathological input increments
tenancy.parse_failure.totaland falls open. Observable rather than request-failing. WorkspaceScopedTablesderives the table set from the JPA metamodel at startup — new scoped entities get auto-protected; explicitGLOBAL_TABLESallowlist holds the 11 known exceptions with per-entry rationale.- Repositories that scope through FK chains rather than a direct
workspace_idcolumn carry@WorkspaceAgnosticwith a one-line rationale on the type — the parent'sworkspace_idpredicate catches the same defect class one hop up.
@TenantId denormalization epic completes; or the Micrometer counter shows zero
violations for a calendar week and we flip to throw in prod; or the regex fast
path produces a false-negative that ships a real leak (would mean the trade-offs
above are not actually defense-in-depth and we need stronger SQL parsing or
controller-layer enforcement only); or
tenancy.parse_failure.total starts non-zero in prod (would mean the regex hits a
pathological input class worth handling explicitly).
Update — 2026-08-30 (issue #1603)
throw is now the default in every profile. The canary period described above has ended; log and
off remain explicit diagnostic overrides and are not production settings. This changes only the
default response to a violation the inspector already detects. The documented parser carve-outs and
the requirement for workspace authorization remain unchanged.