Skip to content

feat(multitenancy): API isolation v2 kill chain phase (#6402) - #7485

Draft
Romuald Lemesle (RomuDeuxfois) wants to merge 1 commit into
mainfrom
feat/tenant-v2-kill-chain-phase
Draft

feat(multitenancy): API isolation v2 kill chain phase (#6402)#7485
Romuald Lemesle (RomuDeuxfois) wants to merge 1 commit into
mainfrom
feat/tenant-v2-kill-chain-phase

Conversation

@RomuDeuxfois

@RomuDeuxfois Romuald Lemesle (RomuDeuxfois) commented Aug 17, 2026

Copy link
Copy Markdown
Member

Closes #6402.

Activates kill_chain_phases on multi-tenancy v2. Go-live included: the v1 @Filter removal and the active-tables entry ship together.

Why the diff is wide. AttackPattern#killChainPhases is a LAZY @ManyToMany re-derived by Inject/Scenario/Exercise/InjectorContract. The scope is transaction-local and open-in-view renders after the commit, where can_access_tenant is fail-closed — so a lazy load at rendering time serializes an empty phase list, with no error. KillChainPhaseInitializer hydrates it inside the scoped transaction, and every handler serializing those entities carries TxCtx. Same blind spot as #7025.

Also handled: SUPPORTS handlers hold no transaction, so TxCtx is threaded into the service that opens one; the scenario-detail CTE query joining the table is pinned by a regression test on the real production SQL (#7007 class).

Writes go through TenantWriteScopeResolver. The upsert resolves the write tenant before any lookup and matches on the per-tenant unique keys — a bare business-key lookup could link another tenant's row. TenantBaseListener is removed: every create path attributes the tenant explicitly (the two endpoints, the upsert service, the importer), and a test pins that an unattributed write is now refused by the non-nullable tenant_id instead of silently landing on TenantContext's tenant.

Child table. attack_patterns_kill_chain_phases carries no tenant_id and rides along with its parents; it is not added to the allowlist. Because it holds no tenant, nothing in the join keeps a phase in the simulation's tenant — findDistinctByExerciseId correlates the two explicitly, with a // TODO v2: marker for the eventual attack_patterns activation and a test that fails without the predicate.

Hot path (gate 0.4). The table is small (a few dozen rows per tenant) but it is not cold: it is joined by the scenario-detail projection and reached from every attack-pattern page. Read amplification is bounded by @BatchSize(size = 1000) on the association, so a page of attack patterns hydrates in one IN query instead of one per 50. No plan regression observed on the rewritten SQL; the CTE shape is pinned by test.

Client impact: creates and upserts need a single-tenant scope. Tenant-path and single-tenant callers are unaffected; a multi-tenant caller with no selector gets 400.

Tests: full openaev-api suite green — 5692 tests, the 2 failures are order-dependent flakes that pass in isolation and touch neither this table nor a modified file. Plus 22 isolation tests (tenant A vs B, header route, non-admin, per-tenant upsert duplication, attribution asserted at SQL level, association path, simulation-tenant correlation), the arch and production-config guards, spotless:check.

Rollback: revert the whole commit. Never drop only the property — with the @Filter gone that is an isolation hole, which the config guard fails the build to prevent.

Copilot AI lite review requested due to automatic review settings August 17, 2026 15:59
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

📖 Documentation check — ✅ Passed

31 functional file(s), 0 doc file(s) changed.

No documentation gaps detected.

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.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

⚠️ Container vulnerability scan — 8 findings

Core CI reports these findings in advisory mode. Review the JSON reports before merging.

Image Critical High Total Status
Standard 0 8 8 ⚠️ Findings
UBI9 0 0 0 ✅ Clear

View workflow run · Standard JSON report · UBI9 JSON report

Updated from CI run attempt 2.

Switch kill_chain_phases from v1 @filter isolation to v2 (TenantStatementInspector
+ can_access_tenant), go-live included: the @filter removal, the TenantBaseListener
removal and the active-tables entry ship together, never split.

Reads are scoped by threading TxCtx to every transaction that touches the table.
Beyond KillChainPhaseApi itself, that covers four less obvious surfaces:

- The association. AttackPattern#killChainPhases is a LAZY @manytomany re-derived
  by Inject, Scenario, Exercise and InjectorContract. The tenant scope is
  transaction-local (set_config(..., true)) and open-in-view renders JSON after the
  commit, where can_access_tenant is fail-closed: a lazy load at rendering time
  returns zero rows and silently serializes an EMPTY phase list.
  KillChainPhaseInitializer hydrates the association inside the scoped transaction
  instead (same blind spot as #7025, applied to this table), and @batchsize keeps a
  page of attack patterns to one IN-clause query instead of one per 50.
- Propagation.SUPPORTS handlers hold no transaction of their own, so TxCtx is
  threaded down to the service method that opens one, mirroring bulkDeleteScenarios.
- The scenario-detail projection joins kill_chain_phases inside a CTE, so the
  fail-closed rewriter now has to accept that shape (#7007 class of regression); a
  test pins the real production SQL read off the repository by reflection.
- findDistinctByExerciseId joins the table through
  attack_patterns_kill_chain_phases, which carries no tenant column: under a
  multi-tenant scope the join could return another tenant's copy of a phase, since
  can_access_tenant only caps it at the request scope. The query correlates the
  phase tenant with the simulation's row to row - read from the row, never from a
  thread-local, so unlike a v1 filter it cannot disagree with the v2 scope. A test
  fails without the predicate; a TODO v2 marker hands it to the eventual
  attack_patterns activation.

Write attribution is explicit through TenantWriteScopeResolver. The upsert resolves
the write tenant BEFORE any lookup and matches on the per-tenant unique keys: every
unique key on this table carries tenant_id, so a lookup by the bare business key
could match one row per in-scope tenant under a multi-tenant scope and link another
tenant's row to the write. TenantBaseListener is gone with it - every create path
attributes the tenant explicitly (both endpoints, the upsert service, the importer),
so the listener was only a TenantContext fallback able to mask a missing
attribution. The shared fixture takes the tenant as a parameter now, and a test
pins that an unattributed write is refused by the non-nullable column.

The importer follows the same rule. V1_DataImporter creates phases on the import
paths, and its write tenant now comes from the TxCtx threaded down from the import
entrypoints instead of TenantContext, which is only set on the /api/tenants route
and falls back to the default tenant everywhere else: a header-scoped import would
look the phase up in one tenant and insert it in another, an INSERT ... VALUES the
inspector never validates. There is no fallback, a scope that does not pin exactly
one tenant is refused. Its find-or-create goes through KillChainPhaseService rather
than the repository, which shortens the reviewed access surface. The inject-level
import endpoints (scenario, simulation, atomic testing) carried no TxCtx at all and
now do, so their reads stop being fail-closed too, and tenant provisioning declares
its own scope at the datapack call site.

Guards: the access arch test gains a rule refusing a joined @query on a
tenant-active table that does not correlate the tenant, and
TenantScopedEntrypointsTxCtxArchTest had six entrypoints declared twice in its
Set.of - which throws at class initialisation, so that guard had stopped running
at all.

Client impact: creates and upserts now need a single-tenant scope. Tenant-path and
single-tenant callers are unaffected; a multi-tenant caller without a selector gets
400 on create/upsert.

Also, outside the activation itself: KillChainPhase implements Auditable (the manual
updatedAt stamps become redundant and stop forcing an UPDATE on every no-op upsert),
convert-to-manual returns the resulting scenario id instead of the whole entity
nobody read, and the two chaining duplication endpoints that never had a caller are
removed with their tests.

Tests: isolation through the real endpoints (tenant A vs B, header route, non-admin,
per-tenant upsert duplication, write attribution asserted at the SQL level), the
association path, the simulation-tenant correlation, the import path (the phase
follows the request scope rather than TenantContext, an ambiguous scope is refused),
a regression test pinning the real production SQL of the scenario projection, plus
the arch and production-config guards required at go-live.
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.

feat(multitenancy): API isolation v2 kill chain phase

2 participants