Skip to content

feat(multi-tenancy): tenant transaction scope and SQL filtering foundations (#6212)#6255

Open
laugiov wants to merge 67 commits into
mainfrom
lgi/6212-tenant-tx-rewrite
Open

feat(multi-tenancy): tenant transaction scope and SQL filtering foundations (#6212)#6255
laugiov wants to merge 67 commits into
mainfrom
lgi/6212-tenant-tx-rewrite

Conversation

@laugiov

@laugiov laugiov commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

What this is

The v2 tenant isolation. The tenant scope lives on the transaction, and a SQL rewriter filters every tenant table against it. The mechanism is behind an allowlist that is empty by default, so for almost every table merging this changes nothing in production.

The one exception is import_mappers: this PR switches it fully onto v2 (its v1 filter removed, the table added to the allowlist), so it ships as the first table isolated entirely by the new mechanism rather than as dormant code.

The old v1 isolation (the @Filter driven by the TenantContext thread-local) is untouched and still runs for every other table. v2 sits next to it, dormant for those tables.

What's in here

The mechanism is live infrastructure, but dormant for a table until that table is in the allowlist:

  • TxCtx, the scope contract. Two states, no "all tenants" wildcard: a restricted list of tenant ids, or no scope (fail-closed, denies everything).
  • can_access_tenant(...), the function the filter calls (Flyway V5_26, fail-closed, PARALLEL SAFE).
  • An aspect that writes the scope into the transaction at begin with set_config(..., true), so it is transaction-local and never leaks. The order is pinned explicitly (lock, then transaction, then scope), and a nested transaction cannot redefine a scope that is already set.
  • TenantStatementInspector, the rewriter: SELECT (joins, sub-queries, CTEs, unions), UPDATE and UPDATE ... FROM, DELETE and DELETE ... USING, INSERT (with tenant validation on the write side). Anything it cannot safely filter on an active table is refused, not let through.
  • The HTTP binding: a resolver builds the TxCtx from the request (path tenant wins, else the X-Tenant-Ids header, else your full set of rights). The selector is intersected with your actual rights, and asking for a tenant you are not a member of is a 403, before RBAC. Rights are membership, not the admin flag.
  • The write policy: a write needs a single tenant in scope, else 400.
  • An ArchUnit rule that fails the build on any new raw JDBC in prod code, so nothing slips past the rewriter.

The first table: import_mappers (activated here)

I took one table all the way through and switched it on in this PR. import_mappers is HTTP-only (nothing but HTTP touches it), which is why it goes first.

What "fully on v2" means here:

  • its v1 @Filter is removed (v1 and v2 must not both filter the same table: v1 forces a single tenant, v2 wants the request scope, and on the header route the two together returned ~0 rows);
  • import_mappers is added to openaev.tenant.active-tables;
  • a guard test fails the build if it is ever dropped from the allowlist, since with v1 gone that would leave the table with no isolation at all.

The whole MapperApi surface is scoped, with real two-tenant tests against Postgres (no mocks on the isolation path): reads, update and delete, duplicate, create and import, plus the mapper lookup in the scenario and exercise import endpoints. There are non-admin tests (a foreign tenant is refused at the binding; a non-admin in two tenants only sees the one in the path), and the header-route test is now enabled (it was parked while v1 still filtered the table).

Behaviour change at merge: create and import now require a single-tenant scope (else 400). The frontend already tenant-path-scopes every API call, so its mapper create and import keep a single tenant, and no other caller (backend, connectors) creates mappers, so nothing breaks. The api suite is green.

What's left, and how

Coordination, roughly in this order.

1. Confirm the aspect order. The three @Order values move the global transaction-advisor precedence, which is your call (transaction layer). They are committed and the tests pin the behaviour either way, so it is a yes/no on keeping them, not work.

2. The next tables. Same recipe as import_mappers, but first every path touching the table has to carry a TxCtx. The HTTP paths do; the background ones (jobs, event handlers, indexing, startup seeding) do not yet, and an active table with no scope is fail-closed (zero rows). So those tables wait on the Track A work to carry the scope off the request thread. import_mappers went first only because nothing but HTTP touches it.

3. Drop v1. Once enough tables are on v2, remove the TenantContext thread-local and the filter aspect (overlaps #6229). End state, not a per-table step.

@Filigran-Automation Filigran-Automation added the filigran team Item from the Filigran team. label Jun 15, 2026
@xfournet xfournet force-pushed the issue/6212-transactional-updates branch 6 times, most recently from bc25355 to 698b8a9 Compare June 16, 2026 11:49
@laugiov laugiov force-pushed the lgi/6212-tenant-tx-rewrite branch from a9c61a0 to c6ae59a Compare June 16, 2026 13:00
@xfournet xfournet force-pushed the issue/6212-transactional-updates branch 8 times, most recently from e141fb9 to 5ad2db4 Compare June 17, 2026 15:38
@laugiov laugiov force-pushed the lgi/6212-tenant-tx-rewrite branch from 69e48a6 to 5b4007b Compare June 17, 2026 16:26
@laugiov laugiov requested a review from Copilot June 18, 2026 08:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Introduces the foundations for transaction-scoped tenant isolation by carrying an explicit tenant scope (TxCtx) into a transaction-local PostgreSQL setting, then using a Hibernate StatementInspector to rewrite SQL so tenant-scoped tables are filtered via can_access_tenant(...) (inert by default via an empty activation allowlist).

Changes:

  • Add TxCtx scope model + a transaction aspect to write scope into app.current_tenants.
  • Add PostgreSQL can_access_tenant(...) function and a SQL rewriter (TenantStatementInspector) with activation gating via TenantTables.
  • Add wiring/configuration and extensive tests (inspector behavior, resolver behavior, ordering with @Lock, and end-to-end table activation examples).

Reviewed changes

Copilot reviewed 38 out of 38 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
openaev-model/src/main/java/io/openaev/database/repository/FindingRepository.java Splits a previously unparseable modifying CTE into parseable statements to work with SQL rewriting.
openaev-model/src/main/java/io/openaev/context/TxCtx.java Adds a sealed tenant transaction-scope contract (missing vs restricted).
openaev-model/src/main/java/io/openaev/aop/TenantScopeTransactionAspect.java Writes TxCtx scope into app.current_tenants at transactional method entry.
openaev-api/src/main/java/io/openaev/rest/helper/RestBehavior.java Maps tenant-filtering refusal to a stable 500 error code.
openaev-api/src/main/java/io/openaev/rest/finding/FindingWriter.java Adds a REQUIRES_NEW writer for the split finding upsert/link statements.
openaev-api/src/main/java/io/openaev/rest/finding/FindingService.java Routes the agent-finding persistence path through FindingWriter.
openaev-api/src/main/java/io/openaev/migration/V5_22__Add_can_access_tenant_function.java Adds the PostgreSQL can_access_tenant(row_tenant_id, allow_platform) function.
openaev-api/src/main/java/io/openaev/config/TxCtxArgumentResolver.java Resolves TxCtx for controller params from path/header selectors constrained by user rights.
openaev-api/src/main/java/io/openaev/config/TenantTables.java Derives and manages strict vs dual-scope tenant table sets (plus activation allowlist).
openaev-api/src/main/java/io/openaev/config/TenantStatementInspector.java Implements the SQL rewrite/fail-closed logic for tenant-table filtering.
openaev-api/src/main/java/io/openaev/config/TenantScopeResolver.java Converts request selector + authorized tenants into a deterministic TxCtx.
openaev-api/src/main/java/io/openaev/config/TenantFilteringException.java Defines the fail-closed exception surfaced when SQL can’t be safely filtered.
openaev-api/src/main/java/io/openaev/config/TenantFilteringConfig.java Wires tenant table discovery + statement inspector into Hibernate (inert by default via empty allowlist).
openaev-api/src/main/java/io/openaev/config/MvcConfig.java Registers the TxCtxArgumentResolver with Spring MVC.
openaev-api/src/main/java/io/openaev/config/AppConfig.java Reorders transaction advisor to ensure @Lock -> tx -> tenant-scope precedence.
openaev-api/src/main/java/io/openaev/aop/lock/LockAspect.java Adjusts aspect ordering so locking wraps the transaction.
openaev-api/src/main/resources/spring.properties Forces Spring Data JPA native-query parser to regex to avoid jsqlparser enhancer incompatibilities.
openaev-api/pom.xml Adds explicit jsqlparser dependency + excludes it from JaCoCo instrumentation.
openaev-api/src/test/java/io/openaev/rest/helper/RestBehaviorTest.java Tests exception-to-response mapping for tenant-filtering failures.
openaev-api/src/test/java/io/openaev/context/TxCtxTest.java Unit tests for TxCtx semantics/validation/immutability.
openaev-api/src/test/java/io/openaev/context/CanAccessTenantFunctionTest.java Integration tests for the can_access_tenant SQL function behavior.
openaev-api/src/test/java/io/openaev/config/TxCtxArgumentResolverIntegrationTest.java End-to-end HTTP resolution tests for TxCtx argument binding.
openaev-api/src/test/java/io/openaev/config/TenantTablesTest.java Tests tenant table derivation from entity model + allowlist restriction behavior.
openaev-api/src/test/java/io/openaev/config/TenantTablesModelTest.java Verifies tenant table derivation from the real Hibernate metamodel.
openaev-api/src/test/java/io/openaev/config/TenantStatementInspectorTest.java Extensive rewrite behavior tests for SELECT/UPDATE/DELETE/INSERT shapes and the activation gate.
openaev-api/src/test/java/io/openaev/config/TenantSqlReplayMeasurementTest.java Optional replay test for captured real SQL to detect tenant-read leaks.
openaev-api/src/test/java/io/openaev/config/TenantSqlLeakOracleTest.java Tests for the parser-independent leak detector.
openaev-api/src/test/java/io/openaev/config/TenantSqlLeakOracle.java Adds a parser-independent tenant-table leak oracle used by empirical tests.
openaev-api/src/test/java/io/openaev/config/TenantScopeTransactionAspectTest.java Unit tests for TxCtx argument detection and set_config behavior.
openaev-api/src/test/java/io/openaev/config/TenantScopeTransactionAspectIntegrationTest.java Integration tests ensuring scope is set inside active transactions across propagations and doesn’t leak.
openaev-api/src/test/java/io/openaev/config/TenantScopeResolverTest.java Unit tests for selector-vs-rights tenant scope resolution rules.
openaev-api/src/test/java/io/openaev/config/TenantScopeLockOrderingIntegrationTest.java Integration tests proving @Lock wraps the transaction and scope is set inside it.
openaev-api/src/test/java/io/openaev/config/TenantIsolationIntegrationTest.java Base utilities for end-to-end tenant isolation tests against the DB layer.
openaev-api/src/test/java/io/openaev/config/TenantIsolationExampleTest.java End-to-end tenant isolation example test (tags table) under activated filtering.
openaev-api/src/test/java/io/openaev/config/TenantInspectorEmpiricalGateTest.java Empirical barrier test capturing real emitted SQL per entity and checking for leaks/fail-closed.
openaev-api/src/test/java/io/openaev/config/TenantFilteringConfigTest.java Tests schema-derived tenant table classification and Hibernate wiring correctness.
openaev-api/src/test/java/io/openaev/config/ImportMapperTenantIsolationTest.java End-to-end readiness test for first intended activated table (import_mappers).
openaev-api/src/test/java/io/openaev/config/CapturingStatementInspector.java Test-only inspector to capture emitted SQL in-memory or to a file for replay.

@xfournet xfournet force-pushed the issue/6212-transactional-updates branch from 5ad2db4 to 89627a8 Compare June 18, 2026 10:21
@laugiov laugiov force-pushed the lgi/6212-tenant-tx-rewrite branch from 5610ca2 to 35fb2de Compare June 18, 2026 10:55
@laugiov laugiov requested a review from Copilot June 18, 2026 15:34
@laugiov laugiov marked this pull request as ready for review June 18, 2026 15:34

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 66 out of 66 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (1)

openaev-api/src/main/java/io/openaev/rest/mapper/MapperApi.java:95

  • issue (moderate): This controller method returns a JPA entity (ImportMapper) directly. This makes the API contract harder to evolve (entity changes become breaking API changes) and is contrary to the project’s API-layer conventions (use Output DTOs + mapper).

Consider introducing an ImportMapperOutput DTO and mapping ImportMapper to it in the controller responses (including this endpoint).

  @GetMapping("/{mapperId}")
  @Transactional
  @AccessControl(
      resourceId = "#mapperId",
      actionPerformed = Action.READ,
      resourceType = ResourceType.MAPPER)
  // TxCtx is resolved from the request and applied by the transaction aspect; it scopes this read
  // to the caller's tenants. The handler does not use it directly.
  public ImportMapper getImportMapperById(TxCtx ctx, @PathVariable String mapperId) {
    return importMapperRepository
        .findById(UUID.fromString(mapperId))
        .orElseThrow(ElementNotFoundException::new);
  }

Base automatically changed from issue/6212-transactional-updates to main June 19, 2026 16:19
@laugiov laugiov force-pushed the lgi/6212-tenant-tx-rewrite branch from 367bebe to 024e334 Compare June 19, 2026 16:50
@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.96764% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 44.08%. Comparing base (99de019) to head (7137631).

Files with missing lines Patch % Lines
...va/io/openaev/config/TenantStatementInspector.java 86.30% 7 Missing and 13 partials ⚠️
.../java/io/openaev/config/TxCtxArgumentResolver.java 81.48% 0 Missing and 5 partials ⚠️
.../java/io/openaev/config/TenantFilteringConfig.java 84.21% 2 Missing and 1 partial ⚠️
.../src/main/java/io/openaev/config/TenantTables.java 93.87% 0 Missing and 3 partials ⚠️

❌ Your project check has failed because the head coverage (2.96%) is below the target coverage (80.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6255      +/-   ##
============================================
+ Coverage     43.79%   44.08%   +0.29%     
- Complexity     7043     7174     +131     
============================================
  Files          2254     2265      +11     
  Lines         62116    62400     +284     
  Branches       8183     8240      +57     
============================================
+ Hits          27203    27512     +309     
+ Misses        33180    33132      -48     
- Partials       1733     1756      +23     
Flag Coverage Δ
backend 66.46% <89.96%> (+0.31%) ⬆️
e2e 18.31% <ø> (ø)
frontend 2.96% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@laugiov laugiov force-pushed the lgi/6212-tenant-tx-rewrite branch from 024e334 to 4975a12 Compare June 22, 2026 12:30
laugiov added 25 commits June 25, 2026 17:26
…'s tenants and remove the unused unscoped repositories on import_mapper child tables(#6212)
…tenant and guard the v1/v2 header double-filter (#6212)
…ble gate so a literal cannot fail-close an unrelated statement (#6212)
… the other modifying queries in InjectRepository (#6212)
@laugiov laugiov force-pushed the lgi/6212-tenant-tx-rewrite branch from d347224 to 7e51131 Compare June 25, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

filigran team Item from the Filigran team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants