Activate a Table on Multi-Tenancy v2
Switch one table from v1 isolation (Hibernate @Filter on the entity) to v2
(SQL rewriting by TenantStatementInspector + the can_access_tenant function).
The reference implementation is the pilot, import_mappers, in PR #6255.
Every template in this skill points to a real pilot file. Open it and copy the
pattern; do not invent new mechanisms.
For the full mechanism (read path, write path, exact names), read the pilot
PR #6255 description once before starting, plus the javadoc of
TenantStatementInspector, TenantWriteScopeResolver and
TenantScopeTransactionAspect.
If the table has a background writer (a scheduler job, queue consumer or
startup task that writes it), read the background transaction primitive too:
TenantScopedTransaction (openaev-model/src/main/java/io/openaev/context/TenantScopedTransaction.java).
The HTTP path carries its scope through @Transactional + TxCtx + the aspect;
the background path must NOT use @Transactional (its self-invocation trap
silently skips both the transaction and the scope) and opens transactions through
the primitive instead. Phase 5b (this runbook's background-writer conversion
phase, defined below between Phase 5 and the go-live) converts those writers;
it is the prerequisite for activating any table a background job writes.
Inputs
- The table name (e.g.
mitigations) and its API class (e.g.MitigationApi). - The activation issue (e.g. #6400). Its Context block names the mapped table(s) and the current state.
Hard rules
These are not style preferences. Each one prevents a security or availability incident. Do not trade them away to make a test pass.
TDD, strictly. Write the isolation test first and run it. It must fail for the expected reason before you write any production code. Never weaken, delete or
@Disabledan existing test to get green, with one exception introduced in Phase 2 and resolved in Phase 6 (the documented go-live guard).Red for the intended reason is not enough. A test that goes red when you remove your fix may only be pinning that fix, not the behaviour it is meant to protect. Before calling it done, break the behaviour a SECOND way, on a different line or in a different layer, and confirm it fails again.
Lot C's gauge tests are the example. They were red without their fix and green with it, then stayed green when the gauge was re-wired to the unscoped repository: they called the scoped method directly instead of the supplier the gauge actually registers.
Background writers go through the primitive, never
@Transactional. A scheduler job, queue consumer, connector-side path or startup task that writes the table is NOT an automatic stop anymore (it was before #6398). It becomes activable ONCE every such writer is converted toTenantScopedTransaction(Phase 5b), carrying a real per-tenant scope. Until that conversion is done and tested, an unconverted background writer is still a hard blocker: activating the table under it would make the writer read and write zero rows, silently. ONE documented waiver exists: a writer that only INSERTs fresh rows (VALUES inserts are not blocked by the inspector), never READS the table, and attributestenant_idcorrectly (listener +TenantContext, or explicit) may ship unconverted IF a test pins that write shape under activation AND the conversion is a tracked follow-up. The tenant-provisioning datapack writingcweswas the original example; it has since been converted (it now writes under the primitive scope MigrationProcessor sets), so no active waiver relies on this today. Use it only for a genuinely INSERT-only, never-reads writer you cannot convert in the same PR. A background READER, or any read-then-write path, gets no waiver. Background code must never use@Transactionalfor the write (self-invocation trap) and must never open raw transactions; both are guarded by the ArchUnit rules inopenaev-api/src/test/java/io/openaev/architecture/TenantBackgroundTransactionRules.java.Strict tables only. If
tenant_idis nullable (dual-scope:roles,groups,parameters, ...), STOP and report. Platform-row writes are an open policy question (Q7); this skill does not cover them.One-commit go-live. Removing the v1
@Filterand adding the table toopenaev.tenant.active-tableshappen in the same commit, never split.No hardcoded Flyway numbers. Refer to migrations by name. Never pin a version number in code comments, docs or tests.
Fail-closed is the point. If a query returns zero rows after wiring, the fix is to pass the scope correctly, never to bypass the inspector, never to add raw JDBC, never to widen the scope.
Full regression before done. The activation rewrites the SQL of every query touching the table. The full API suite must pass, not just your new tests.
Evidence over claims. Every red and every green leaves a trace: keep the raw test output (the failing assertion for the red, the passing summary for the green) and paste it into the Phase 8 report. A TDD step without its output did not happen.
Do not improvise on drift. If a model file referenced by this skill does not exist anymore, stop and find its successor (
git log --oneline --follow --all -- <path>). Never substitute an invented pattern for a missing reference.TxCtxposition is fixed. In every new or modified method signature that carriesTxCtx, it must be the FIRST parameter (annotations allowed, e.g.@RequireTenantSelector TxCtx ctx). Keep this order through service call chains too, so wiring remains grep-auditable and consistent.No v1 tenant context in v2/integration tests. For API v2 activation tests and integration tests, do not add
TenantContext.getCurrentTenant(),TenantContext.setCurrentTenant(...), orenableFilter("tenantFilter"). Use explicit tenant ids +TxCtx/TenantScopedTransactionhelpers.
Baseline: controller entrypoints already carry TxCtx
Every @Transactional method under io.openaev.api/** and
io.openaev.rest/** declares a bare TxCtx ctx parameter, added in one pass
across the codebase, with five deliberate exclusions that carry
@NoTenantScope instead: UserApi.login, UserApi.passwordReset,
UserApi.changePasswordReset and UserApi.validatePasswordResetToken
(permitAll, pre-auth), and StreamApi.streamFlux (propagation = NEVER, so
there is no transaction to scope). That changes what a single-table
activation needs to do:
- Phase 1 no longer hunts exhaustively for missing
TxCtxon controller entrypoints, though it still spot-checks the ones it needs (see below). That search (the biggest source of the regressions cited throughout this skill — #6409, #6410, #7026, #7605/#7621) is done, once, for the whole codebase. Phase 1 is now scoped to what the blanket wiring does NOT cover: background paths (Phase 5b), non-@Transactionalhandlers, native/raw SQL shapes, and OSIV/lazy-serialization sinks (Phase 3b) — aTxCtxparameter on a method signature does not by itself fix a lazy association or computed getter resolved by Jackson AFTER the transaction has already closed. - A
TxCtxparameter is not inert. It resolves a scope and sets it on the transaction whether or not the table it touches is active, andTenantScopeTransactionAspectthrows when a nested@Transactionalmethod tries to redefine a scope already set in the same transaction. Adding or removing one is a behaviour change, not a signature change: assume it can break a caller, and re-run the suite. - This is a point-in-time fact, not a self-enforcing invariant, until the
default-secure compile rule (
EndpointTxScopeRule, #7726) is enabled foropenaev-api; it currently ships disabled. A NEW controller endpoint added after this baseline, or one that was not yet@Transactionalat the time, may still be missing it — spot-check the entrypoints this activation actually needs (Phase 1) rather than assuming. - Phase 5's
TX_SCOPED_ENTRYPOINTSregistration is now mostly documentation and drift-detection, not discovery. Most entries you would have added by hand already exist; add a new one only for a genuinely new gap (a Phase 3b force-initialize fix, a handler that just became@Transactional, or a converted background path).
Procedure
Phase 0 — Eligibility gate (stop conditions)
Replace {table}, {Entity}, {EntityRepository}, {Api} below with your
target (e.g. mitigations, Mitigation, MitigationRepository,
MitigationApi).
# 0.1 The entity must be strict tenant-scoped: implements TenantBase, not DualScopeBase
grep -n "TenantBase\|DualScopeBase" openaev-model/src/main/java/io/openaev/database/model/{Entity}.java
# 0.2 The tenant column must be non-nullable. The entity mapping is the
# reliable static check; tenant_id was added to most tables by bulk
# migrations that loop over a table list, so grepping migrations for
# "{table}" + "tenant_id" on one line finds nothing.
grep -n -A2 'name = "tenant_id"' openaev-model/src/main/java/io/openaev/database/model/{Entity}.java
# expect: @JoinColumn(name = "tenant_id", ..., nullable = false)
# authoritative check when a DB is running:
# SELECT is_nullable FROM information_schema.columns
# WHERE table_name = '{table}' AND column_name = 'tenant_id';
# 0.3 Unique constraints must be tenant-aware. A global unique index on a
# business key (external id, name, key) means two tenants cannot hold the
# same value: activation would turn that into cross-tenant interference.
grep -rn -i "unique" openaev-api/src/main/java/io/openaev/migration/ | grep -i "{table}"
# the grep misses multi-line definitions; on a live DB, `\d {table}` in psql
# lists every index and constraint and is authoritative
0.4 Hot-path check. The inspector wraps every active table in a filtered
sub-query, which can change query plans. If the table sits in frequent joins
or heavy list endpoints, look at the rewritten SQL before go-live: captured
real SQL can be replayed through the inspector with
openaev-api/src/test/java/io/openaev/config/TenantSqlReplayMeasurementTest.java
(gated by -Dtenant.sql.replay.file), and the heaviest query deserves an
EXPLAIN with the rewrite applied. For a small config table this is a
one-line note in the report; for a hot table it is a real measurement.
There is no reliable one-line grep for "no background writer" (a keyword filter on scheduler/job/consumer misses renamed packages and matches string literals). The authoritative classification is the Phase 1 inventory: read every hit.
STOP conditions, report instead of continuing:
- entity implements
DualScopeBase, or the tenant column is nullable → dual-scope, out of scope (hard rule 3) - Phase 1 finds any non-HTTP path that WRITES the table → NOT an automatic stop
since #6398, but it moves the table into the background-writer track: every
such writer must be converted to the primitive in Phase 5b before go-live. If
the conversion is out of scope for this run (e.g. the writer is a large
execution-surface job you are not converting now), STOP and report it as a
blocker; the table cannot be activated while an unconverted writer touches it.
A background READ-only hit (e.g. a telemetry counter) is not a blocker but
must be listed in the report as a documented degradation: once the table is
active it reads zero rows unless that reader also carries a scope.
ProductInventoryMetricCollector(below) is the single, always-present instance of this shape — check it on every activation, not only when Phase 1 happens to surface it. - 0.3 finds a unique index on a business key that does not include
tenant_id→ the schema needs a prep migration first (model: the existing__Update_unique_constraints_for_tenantsmigration inopenaev-api/src/main/java/io/openaev/migration/; same pattern, new migration).CREATE UNIQUE INDEX mitigations_unique ON mitigations (mitigation_external_id), global, so two tenants cannot both hold MITRE mitigation M1013. Fix the constraint (addtenant_idto it) in its own reviewed change BEFORE the activation, and only if per-tenant duplication is the intended semantics; if the rows are meant to be platform-shared reference data, the table may not be a good activation candidate at all. Report and let a human decide.
When a stop condition is hit, still run Phase 1 (the inventory is what makes the stop useful), then produce a stop report instead of code and post it on the table's activation issue. Format:
## activate-tenant-table skill run: STOPPED at eligibility (gate <n>)
**Gates**
- 0.1 PASS/STOP: <entity check result>
- 0.2 PASS/STOP: <tenant column nullability>
- 0.3 PASS/STOP: <unique constraints; quote the offending index if any>
- 0.4: <hot-path note>
**Blocker to decide before activation**
<the failed gate, the options this skill lists for it, and what each option
needs (e.g. prep migration modeled on V4_82 vs shared-reference-data
discussion)>
**Inventory (phase 1)**
<repository users and their classification; child tables; other APIs>
**Side findings**
<anything found on the way that someone should look at, one line of impact each>
The evidence rule (hard rule 8) applies here too: quote the actual grep output or file lines behind each gate verdict, do not paraphrase them.
Phase 1 — Inventory what the mass TxCtx wiring does NOT already cover
Quick hygiene checks on changed files before deeper inventory:
# Any signature carrying TxCtx with non-first position must be fixed
grep -rn "(.*,[[:space:]]*[@A-Za-z0-9_ ]*TxCtx[[:space:]]+[a-zA-Z_][a-zA-Z0-9_]*" \
openaev-api/src/main/java openaev-model/src/main/java --include="*.java"
# In API v2 / integration tests, block v1 tenant-context/filter idioms
grep -rn "TenantContext.getCurrentTenant\|TenantContext.setCurrentTenant\|enableFilter(\"tenantFilter\")" \
openaev-api/src/test/java --include="*.java"
Because of the baseline above, Phase 1 no longer hunts for missing TxCtx on
controller entrypoints. It is still fully required for everything the
blanket wiring cannot fix by construction:
- Background paths (scheduler jobs, queue consumers, startup runners,
@Asynctasks) — untouched by the mass wiring, since none of it is a@RestController. Every writer found here is a Phase 5b conversion; every reader is a documented degradation (Phase 0) or gets an explicit scope. - Non-
@Transactionalhandlers. The aspect only fires on@Transactionalmethods, so a handler that wasn't@Transactionalat baseline time was correctly left untouched. If its call graph reaches{table}, make it@Transactionaland addTxCtxnow (Phase 2). - OSIV / lazy-serialization sinks (Phase 3b). A
TxCtxparameter on an entrypoint's signature does not fix a lazy association or computed getter resolved by Jackson AFTER the transaction (and its GUC) has already ended. Phase 3b is unaffected by the baseline and remains mandatory. - Native/raw SQL shapes that
JOIN {table}— orthogonal toTxCtxentirely, since the inspector inspects SQL text, not method signatures. - Drift since the baseline. A controller method added, or made
@Transactional, after the mass-wiring PR may still be missingTxCtx. Spot-check the entrypoints this activation actually needs rather than assuming full coverage. - Query shapes that stop being valid SQL once the table is wrapped. The
inspector rewrites
FROM {table} tinto a derived table. PostgreSQL's functional-dependency rule — selecting ungrouped columns is legal when theGROUP BYcovers the table's primary key — applies to BASE TABLES only, so anyGROUP BYrelying on it becomes invalid SQL. See the GROUP BY section below; this one is not aTxCtxproblem at all and no amount of wiring fixes it.
grep -rln "{EntityRepository}" openaev-api/src/main/java openaev-model/src/main/java
grep -rn "{table}" openaev-api/src/main/java --include="*.java" | grep -v "^Binary"
The table-name grep matches string literals too (e.g. "apply mitigations" inside seeded CVE descriptions). Read each hit before classifying it; a textual match is not a code path.
Classify every hit:
- the table's own API and service → verified in Phase 2 (should already carry
TxCtx; wire it only if genuinely missing) - another API or service that reads the table → verify it already carries
TxCtx(Phase 5 is now mostly a confirmation, not new wiring) - background reader → documented degradation (Phase 0), or give it a scope too
(wrap its read in
tenantTx.execute(scope, …)) if it must keep seeing rows - background writer → convert to the primitive in Phase 5b. If you are not converting it in this run, it is a blocker: stop and report (Phase 0)
Removing the v1 @Filter silently disarms every isolation test that does not activate the table
The test profile declares no openaev.tenant.active-tables, so the inspector never fires in a test
context unless that context sets it. Every isolation test therefore needs
@TestPropertySource(properties = "openaev.tenant.active-tables={table}"), which the RED phase
above already tells you to write.
The trap is the other direction, and it is about tests you did NOT write. A suite that asserted this
table's isolation before the activation was relying on the v1 @Filter. Removing that filter
takes its isolation away, and because the inspector is not active in its context either, the
assertions keep running against nothing. They do not fail loudly: a cross-tenant read simply starts
returning the other tenant's rows, and only an assertion precise enough to notice will catch it.
That is what happened on the assets activation (#6438). EndpointApiTest's TenantIsolation
nested class had four cross-tenant tests and no @TestPropertySource. Removing Asset's @Filter
turned one of them red (given_endpointInTenantX_should_notAppearInTenantYSearch returned tenant X's
endpoint to a search under tenant Y) while the other three stayed green without isolating
anything. Attribution was correct throughout; only the read was unprotected.
Find them before go-live:
# test classes that assert something about this table's isolation, and whether they activate it
grep -rln "{Entity}\|{table}" openaev-api/src/test --include="*.java" | xargs grep -ln "Tenant\|tenant" | xargs grep -Ln "TestPropertySource"
Read every hit. A class that asserts cross-tenant behaviour and does not set active-tables is
either proving nothing or about to break.
Put the annotation on the outermost test class, not on a @Nested one. A @TestPropertySource
on a nested class builds a second Spring context, and the mock user provisioned by
WithMockUserTestExecutionListener lives in the parent's TestUserHolder; the nested context gets
an empty one and every test fails on "The given id must not be null" before reaching its assertion.
Code that relied on the v1 @Filter breaks silently, and it is not only tests
The previous section is about test suites. The same removal takes isolation away from production
paths that never carried a TxCtx and were scoped by the v1 filter alone, enabled on every
@Transactional method by HibernateFilterTransactionAspect from the thread-local.
NotificationMatchingService.matches is the case to remember. It is @Transactional with no
TxCtx, reached from an @Async @TransactionalEventListener - a pool thread, after commit, no
ambient transaction - and NotificationEngineService states the dependency in its own comment:
"runs with the trigger's tenant so the Hibernate tenant filter scopes every query correctly".
Activating assets, asset_groups or findings removes that filter, so every LIVE notification
trigger with a non-empty filter on those resources counted zero rows and stopped firing. No log, no
exception: matches catches and returns false.
session.disableFilter("tenantFilter") is the same family read from the other side. It is how v1
code declares "this read is deliberately cross-tenant", and it is inert under v2: it does nothing
to app.current_tenants, and the inspector never consults the Hibernate filter.
# both directions, before go-live
rg -n 'disableFilter\("tenantFilter"\)' --type java
rg -n 'HibernateFilterTransactionAspect|tenant filter' --type java openaev-api/src/main
# then, for each hit, answer: does this path reach {table}, and what sets its scope now?
Follow the call chain to the tables it actually reaches, not the imports of the class in front of
you. QueueChainingJob was classified as safe because its own imports name only steps,
workflow_runs and step_delay_queue; four levels down, createReadySteps reaches
ScopeService.getValidAssets -> assetService.assets(ids) on the activated assets table.
The inspector rewrites UPDATE and DELETE, not only SELECT
rewriteUpdate adds can_access_tenant(...) to an UPDATE's WHERE. With no scope the statement
updates zero rows and reports success. On the findings activation this hit the test-only date
setters (endpointRepository.setCreationDate): they silently did nothing, every endpoint kept
now() as its creation date, and three date-range dashboard assertions counted all of them. The
symptom looked like over-counting across tenants, which is the wrong diagnosis entirely.
Any @Modifying query on the table needs a scope, in tests as much as in production.
A @Transactional MockMvc test keeps the scope the request resolved
The tenant aspect is @Before-only and writes set_config(..., true), which is transaction-local.
In a @Transactional test the handler joins the test transaction, so after mvc.perform returns,
the scope the request resolved is still set. Anything the test then reads through a repository
sees it.
That silently couples the expectation to the response. FindingApiTest compared its HTTP response
against findingRepository.findAll() taken in that same transaction: if the search ever failed
closed, both sides came back empty and [] == [] held. Eight assertions were affected.
Either materialise the expectation from the fixtures you seeded, read it through raw JDBC (which the
inspector never rewrites), or at minimum assert it is non-empty - that single guard is what turns
[] == [] back into a failure.
Telemetry gauge check: ProductInventoryMetricCollector
openaev-api/src/main/java/io/openaev/telemetry/metric_collectors/ProductInventoryMetricCollector.java
registers a platform-wide {table}_total gauge for most entities, evaluated by
a Supplier lambda OUTSIDE any HTTP request — no TxCtx from the mass wiring
ever reaches it, so it is always a background reader in the Phase 1 sense
above, and it is the single, recurring, always-present instance of that shape:
check it on every activation regardless of whether the earlier greps surfaced
it. Its own javadoc documents the exact failure mode: with no scope open,
TenantStatementInspector fails closed and the gauge silently reports 0, not
an error.
grep -n "{table}\|{entity}Repository" \
openaev-api/src/main/java/io/openaev/telemetry/metric_collectors/ProductInventoryMetricCollector.java
No hit at all → nothing to do, the table has no gauge.
A hit using
safeCount({entity}Repository::count)directly (the plain, un-scoped form) → this is a go-live blocker for that one gauge line, not the whole activation: it must be converted to the scoped form BEFORE go-live, following the pattern already used for three other v2-active tables in the same file,countAssetGroups()/countChannels()/countImportMappers()(model fix forchallenges, #6416):// registration: this::count{Entities} instead of {entity}Repository::count metricRegistry.registerGauge( "{table}_total", "Number of {entities}", () -> safeCount(this::count{Entities})); /** Counts {entities} across the whole platform ({table} is v2-active, #<issue>). */ long count{Entities}() { return countAcrossAllTenants({entity}Repository::count); }A hit already wrapped in
countAcrossAllTenants(...)→ already correct, nothing to do; note it in the Phase 9 report as verified, not skipped.
There is no test in CI that would catch a regression here on its own: the gauge only degrades silently in a real deployment (no assertion fails, no exception is thrown). Treat this grep as mandatory evidence for the Phase 9 report even when the answer is "no hit" — a claimed activation with no note on this file is unverified, not verified-empty.
GROUP BY on a wrapped table: valid SQL before activation, a 500 after
The inspector rewrites FROM {table} t into
FROM (SELECT * FROM {table} t WHERE can_access_tenant(t.tenant_id)) AS t.
PostgreSQL lets a query select ungrouped columns when the GROUP BY covers the
table's primary key, but that rule holds for base tables only. A derived
table has no primary key to infer the dependency from, so the same query stops
being valid:
-- base table: accepted
SELECT ag.asset_group_id, ag.asset_group_name FROM asset_groups ag GROUP BY 1;
-- wrapped exactly as the inspector wraps it: refused
SELECT ag.asset_group_id, ag.asset_group_name
FROM (SELECT * FROM asset_groups ag WHERE can_access_tenant(ag.tenant_id, true)) AS ag
GROUP BY 1;
ERROR: column "ag.asset_group_name" must appear in the GROUP BY clause
Hibernate's criteria layer emits exactly that shape whenever a query helper
groups on root.get("id") alone and multiselects other columns, which is the
normal way list and search endpoints are written here.
This fails in production and passes in CI. The test profile ships an empty
active-tables, so the inspector never fires and the query keeps its base-table
form. The symptom is a 500 on a search or list endpoint, after go-live.
Find every site before activating:
# every GROUP BY in code that can reach the table, then read each one:
# does it group on the id alone while multiselecting other columns?
grep -rn "groupBy(" openaev-api/src/main/java --include="*.java"
Fix by listing every non-aggregated projected column in the GROUP BY. It is
equivalent for the planner and does not depend on the FROM item being a base
table. Worked example, AssetGroupQueryHelper in the asset_groups
activation (#6435):
cq.groupBy(
List.of(
assetGroupRoot.get("id"),
assetGroupRoot.get("name"),
assetGroupRoot.get("description"),
dynamicFilterAsJsonb));
A column of a type PostgreSQL cannot group on directly (json, for instance)
needs a groupable expression on both sides: project to_jsonb(...) and group on
that same expression, not on the raw column.
Do not wait for a fix in the rewriter to skip this step (tracked in #7843). Making the
inspector keep the primary FROM item as a base table (moving its predicate into
the WHERE) would only cover columns of that primary table. Joined tables stay
wrapped, so a query grouping on a joined table's id while selecting its other
columns breaks the same way as soon as that joined table is activated in turn:
UserQueryHelper selects the organization's name while grouping only on its id.
Listing the columns is what covers both cases.
Still walk the transitive closure of callers — but now for background
paths, association/computed-getter sinks, and other non-controller code, not
for missing TxCtx on REST entrypoints. A single hop only finds direct
callers of the repository; it misses a shared utility
(InjectUtils.resolveInjector, CollectorService.getCollectorRelationsId,
...) called from several unrelated places, each a separate hop. Most of the
historical regressions this walk used to catch were exactly "a REST
controller sibling was never re-grepped once one caller looked wired" — the
injectors #7026-class gap (AtomicTestingService.createOrUpdate, a second,
never-visited caller of InjectUtils.resolveInjector two hops from
InjectService), the executors #6409 gap (ExerciseApi#changeExerciseStatus,
a third, unenumerated caller of throwIfExerciseNotLaunchable), and the
injectors #6410 gap (ThreatArsenalApi's sibling callers of
InjectorContractService.searchInjectorContracts, never re-grepped once
InjectorContractApi#injectorContracts looked done) were all controller
entrypoints missing TxCtx — the exact failure mode the baseline now
prevents by construction. The risk that remains is the OTHER shape: a shared
symbol whose new caller is NOT a @Transactional controller method, so the
baseline never touched it:
collectors(#7026):SecurityPlatform#collectors, a lazy association serialized by a custom serializer AFTER the transaction returned — no amount ofTxCtxon the controller method fixes this; it needs a force-initialize inside the transaction (Phase 3b).- a background job, queue consumer, or startup task calling the same shared utility — invisible to the baseline entirely, and a Phase 5b conversion if it writes, a documented degradation or explicit scope if it only reads.
- a DEPRECATED controller sharing the same service as an already-wired one
(the cwes activation had to wire
CveApi, deprecated since 1.19, still deployed, alongsideVulnerabilityApi) — this one IS a REST entrypoint, so it should already carryTxCtxper the baseline; treat a miss here as baseline drift to fix directly, not as a new discovery to wire by hand.
The point of the BFS is no longer REST controllers — it's finding the
NON-controller leaves the baseline cannot reach. Use a worklist/BFS over
caller edges (an IDE's "Find Usages"/"Call Hierarchy" or a code-intelligence
tool, if available, is more reliable than chained greps; fall back to
grep -rn "\.{symbol}(" per newly found symbol otherwise) and keep expanding
each newly found caller until it resolves to one of two outcomes:
- a
@RestController@Transactionalmethod → a cheap, immediate stop: it already carriesTxCtxper the baseline, nothing to do. This is the ONLY leaf type the walk can skip without further action. - anything else — a
@RestControllermethod that is NOT@Transactional, a@Scheduled/@RabbitListener/background entrypoint, or a lazy/computed serialization sink — is real work: Phase 2 (make it@Transactional), Phase 5b (convert the background writer), or Phase 3b (force-initialize the sink) respectively.
Never stop at "a service I already expected to see" — that is exactly the
trap that hid AtomicTestingService.createOrUpdate and SecurityPlatform's
collectors association above. For every shared helper or service method
found while walking, grep every call site of that exact method name
codebase-wide, walk each one up to its enclosing method, and repeat until
every hit is a real entrypoint. Finding and fixing the first caller is a
signal that the symbol is shared, never a signal to stop.
Also list child tables (FKs pointing at {table}). A child without its own
tenant_id rides along with the parent and is NOT added to active-tables.
A child with its own tenant_id is a separate activation; report it.
Association and computed-getter accessors (entity.get{Entities}(), or a
computed @JsonProperty getter deriving a scalar from {table}) bypass the
repository grep entirely and are NOT fixed by the mass TxCtx wiring even
when their controller already carries the parameter — this is exactly the
OSIV timing problem above. Do the full scan in Phase 3b now; do not defer it
or duplicate it here.
Native query shape scan (#7007). Activating {table} does not just gate
the queries that read {table} itself — it pulls into the fail-closed
TenantStatementInspector rewrite EVERY native @Query that so much as
mentions {table} in a JOIN, however unrelated to the rest of that query's
predicates. TenantStatementInspector only accepts a closed list of
FROM/JOIN shapes; anything else is refused with TENANT_FILTERING_REFUSED,
even a shape that has nothing to do with tenant isolation. The #6751
(collectors) activation shipped this exact regression to production: adding
collectors to active-tables pulled findAgentlessExpectationsNotFilledForSource
into rewriting because it had JOIN collectors c, and its unrelated
NOT EXISTS (SELECT 1 FROM jsonb_array_elements(...) r ...) predicate — a
table-function FROM item without the LATERAL prefix — was refused fail-closed,
breaking the AI defense collector endpoint on every call (see #7007 / PR #7008).
# every native @Query that JOINs {table}, anywhere in the codebase - not just
# the table's own repository
grep -rln "JOIN {table}\|join {table}" openaev-model/src/main/java openaev-api/src/main/java --include="*.java"
For every hit, read the FULL query text (not just the JOIN {table} line)
and check every FROM/JOIN item against what TenantStatementInspector
already accepts (see TenantStatementInspectorTest). The recurring offender
is a table-function FROM item (jsonb_array_elements, jsonb_each,
unnest, ...) missing LATERAL: LATERAL is a noise word for a
function-call FROM item in PostgreSQL (identical semantics and plan) but is
exactly the marker the inspector uses to accept it — add it. If the query
uses a FROM/JOIN shape the inspector does not cover at all, that is a
blocker: stop and report (Phase 0), do not attempt to teach the inspector a
new shape inside a table-activation PR.
Also avoid CTE and alias names that match real active table names (for
example WITH tags AS (...) once tags is active). PostgreSQL can resolve
that shadowing, but the inspector's relation-name matching may treat the CTE
as the active table and fail-close the read. Prefer explicit names such as
scenario_tags_agg.
Pin the fix with a regression test in TenantStatementInspectorTest using
the REAL production SQL (read the @Query value via reflection off the
repository method, as PR #7008 does), not a hand-simplified paraphrase — the
whole point is to catch the exact shape that broke in production. Also note
for the record: openaev-api/src/test/resources/application.properties
ships an EMPTY openaev.tenant.active-tables, so IntegrationTest-based API
tests never exercise the rewriter for {table} and cannot catch this class
of regression — TenantStatementInspectorTest (constructed directly with
{table} in its active-table set) is the only test layer that does.
Test compatibility scan — now scoped to genuinely NEW wiring only. The
mass-wiring baseline already fixed every test broken by adding a TxCtx
parameter to an already-@Transactional controller method (standaloneSetup/
@WebMvcTest MockMvc tests missing a TxCtxArgumentResolver, direct Java
calls to the controller method missing the argument). This scan is needed
only for an entrypoint THIS activation newly makes @Transactional (item 2
above), and for the v1-filter case, which is unaffected by the baseline: any
test that calls session.enableFilter("tenantFilter") and then asserts a
findAll()-style read on the entity silently changes meaning once the filter
is removed at go-live and the test context keeps the allowlist empty — the
read returns EVERY tenant's rows. Found on the cwes activation:
TenantServiceTest expected 7 cwes and saw 14.
# tests relying on the v1 filter over the entity you are activating
grep -rln 'enableFilter("tenantFilter")' openaev-api/src/test/java | xargs grep -l "{EntityRepository}\|{Entity}"
Fix by asserting on explicit attribution instead
(.filteredOn(e -> tenantId.equals(e.getTenant().getId()))), never by
re-adding the filter. If item 2 above did convert a handler to
@Transactional for the first time, also register the
TxCtxArgumentResolver on any standaloneSetup test hitting that URL (or
migrate it to the full-context IntegrationTest base class) and add the
TxCtx arg to any direct Java call to that method.
Phase 2 — RED: write the HTTP isolation test first
Before writing the test, map the controller on both URIs — this is a
structural, per-table change the TxCtx baseline does not touch:
@RequestMapping({{Api}.URI, {Api}.TENANT_URI}) with
TENANT_URI = TenantUriUtils.TENANT_PREFIX + "/...". Without both mappings
the tenant-path assertions below have no route to hit.
Per the baseline, every already-@Transactional handler on {Api} should
already declare TxCtx ctx; confirm it while you're in the file rather than
assuming it (baseline drift, or a handler just made @Transactional in
Phase 1 item 2, are the two ways it can be missing). The aspect only fires on
@Transactional methods — a TxCtx parameter without the annotation is
silently ignored and the endpoint stays fail-closed, so if a handler that
touches the table isn't @Transactional, make it so and add TxCtx ctx with
the pilot's one-line comment explaining the parameter (so a reviewer doesn't
delete the "unused" argument). A handler that provably never touches the
table (works on other tables, or on transient objects never persisted) needs
neither.
Model: openaev-api/src/test/java/io/openaev/rest/mapper/ImportMapperHttpIsolationTest.java.
If the table has NO API of its own and is reached through another aggregate's
association, prove isolation through THAT aggregate's real endpoints instead;
model: openaev-api/src/test/java/io/openaev/rest/vulnerability/CweHttpIsolationTest.java
(cwes proven through the vulnerability endpoints: own-path read exposes the
row, cross-tenant read sees an empty association, ground truth by raw JDBC).
Such a test cannot be @Transactional when it must touch two tenant paths:
each request needs its own transaction (see the model's javadoc). Everything it
creates is therefore COMMITTED: clean the table rows explicitly in @AfterEach,
then remove the tenants with
TenantIsolationTestHelper#deleteCommittedTenants (null-safe, handles the one
non-cascading tenant child).
Place the new test next to the API under test
(openaev-api/src/test/java/io/openaev/rest/{domain}/).
Copy the model's structure. Key elements that must all be present:
@Transactional
@TestPropertySource(properties = "openaev.tenant.active-tables={table}")
@WithMockUser(isAdmin = true)
class {Entity}HttpIsolationTest extends IntegrationTest {
@Autowired private MockMvc mvc;
@Autowired private TenantIsolationTestHelper tenantHelper;
@BeforeEach
void seedTwoTenantsWithOneRowEach() throws Exception {
tenantA = tenantHelper.createTenantWithCurrentUser("http-iso-a").getId();
tenantB = tenantHelper.createTenantWithCurrentUser("http-iso-b").getId();
// seed one row per tenant with a native INSERT carrying an explicit tenant_id
}
}
Notes that make or break the test:
@TestPropertySourceactivates the table for this test only. The test classpath keeps the allowlist empty on purpose; never add your table to the test-wide properties.- Leave
@WithMockUserat its default (autoJoinDefaultTenantstaysfalse) on this class. The class-level mock user must resolve to exactly the tenantstenantHelper.createTenantWithCurrentUser(...)granted it — nothing more. SettingautoJoinDefaultTenant = truehere silently adds a second tenant membership, which (a) defeats the "create with no selector → 400" assertion below (the fallback selector now sees an unambiguous default tenant instead of an ambiguous multi-tenant scope) and (b) can tripTenantScopeTransactionAspect's "scope already set for this transaction" guard t
…(truncated)