Skip to content

SELC-9099: multitenant Step 1 — tenant data mapping registry, tenantId backfill and strict isolation switch - #694

Open
andrea-putzu wants to merge 10 commits into
feature/SELC-9099from
feature/multitenant-step1
Open

SELC-9099: multitenant Step 1 — tenant data mapping registry, tenantId backfill and strict isolation switch#694
andrea-putzu wants to merge 10 commits into
feature/SELC-9099from
feature/multitenant-step1

Conversation

@andrea-putzu

Copy link
Copy Markdown
Contributor

Implements the data-layer groundwork of apps/docs/Multitenant/Step_1/EPIC.md, the epic that makes the AR (selfcare.pagopa.it) and PNPG (imprese.notifichedigitali.it) tenants stop sharing data stores. It targets feature/SELC-9099, not main.

List of Changes

A single source of truth for the per-tenant data mapping (sub-task 9)

  • infra/resources/_modules/local-env: new tenant_data_isolation local describing, per tenant, which Cosmos account/resource group/Key Vault secret name, which storage account infix and container suffix, which personal data vault tenant and which outbound email sender domain that tenant uses. Exported through the config output together with its JSON encoding.
  • infra/resources/_modules/container_app_microservice: opt-in tenant_data_isolation_json variable, injected as the SELFCARE_TENANT_DATA_ISOLATION env var.
  • libs/selfcare-sdk-security: new it.pagopa.selfcare.security.tenant package — TenantDataMapping, TenantDataIsolationRegistry, TenantDataIsolationRegistryProducer, UnresolvedTenantMappingException.

Only routing data crosses this channel. Connection strings are carried as Key Vault secret names, never values. The two dimensions that have no decision yet (personal data vault tenant, email sender domain) are explicitly null, and reading a null dimension throws instead of falling back to a default.

Fail-closed behaviour, and evidence of it (sub-task 11, data layer)

The registry refuses to answer rather than guessing: unknown tenant, unmapped dimension and undecided dimension all raise UnresolvedTenantMappingException. Every refusal is logged at WARN before the throw, inside the registry rather than at the call sites, so that a caller which recovers from the exception cannot silence a tenant-isolation failure. Log messages carry the tenant name and the missing dimension only — no PII, no secrets.

Backfilling the tenant discriminator (sub-task 10)

apps/docs/Multitenant/Step_1/scripts/backfill_tenant_id.py plus a runbook README. It tags pre-existing documents across the 15 tenant-scoped collections in 8 databases (selcAuth, selcDocument, selcIam, selcMsCore, selcOnboarding, selcUser, selcUserGroup, selcWebhook). The product catalogue is deliberately excluded, being cross-tenant by design. Dry-run by default, --apply to write, --verify to assert that nothing untagged is left. It only ever touches documents with no tenantId, so it is idempotent and cannot move a document from one tenant to the other.

Turning the migration branch into real isolation (sub-task 2)

Every tenant-scoped query currently matches tenantId = <caller> OR tenantId IS NULL, so pre-existing untagged data stays visible during the migration — which also means it is visible to both tenants. The new selfcare.tenant.strict-data-isolation flag (default false) drops that second branch at all fourteen chokepoints across ten services: auth, document-ms, iam, user-ms, onboarding-ms, user-group-ms, institution-ms, delegation-cdc, user-cdc.

It is a flag rather than a code deletion because the same artifact is promoted DEV → UAT → PROD: a hardcoded deletion could only merge after the last environment had been backfilled, holding every unrelated change behind a data migration. It is declared once per environment in local-env, not per service, because it describes the state of that environment's data — flipping it for some services and not others yields an environment that reports isolation it does not have. The flag is temporary and both the EPIC and the README say so: once every environment is strict, the flag and the branch are deleted so isolation holds by construction.

Documentation

Step_1/EPIC.md had no statuses at all while much of the work already existed on the parent branch. Each sub-task now carries a grounded status block, and the blockers list reflects what is actually blocked and on whom.

Motivation and Context

AR and PNPG are operated as separate services with separate privacy notices and separate data controllers, but they currently share Cosmos accounts, storage accounts, a personal data vault and an outbound mail domain. Tenant separation is enforced by an application-level tenantId predicate that, during the migration, also matches untagged documents — so any document written before the discriminator existed is readable from both tenants.

This PR closes the data-layer half of that gap in the order the dependencies require:

  1. Sub-tasks 1–8 all need to know which store belongs to which tenant, and each was about to answer that question for itself. Two registries can disagree about which tenants exist; one cannot. Hence sub-task 9 first.
  2. Isolation cannot be enforced while untagged data exists, hence the backfill.
  3. Enforcement must be separable from the migration, hence the flag.

The remaining sub-tasks are blocked on decisions outside this repository (personal data vault provider, per-tenant email sender domains) or on infrastructure not declared here (blob containers for document-ms); they are documented as such in the EPIC rather than half-implemented.

How Has This Been Tested?

Infrastructureterraform fmt -check and terraform validate pass on both _modules/local-env and _modules/container_app_microservice. Module documentation regenerated by the terraform_docs pre-commit hook.

Unit tests — 13 new tests for the registry (TenantDataIsolationRegistryTest, TenantDataIsolationRegistryProducerTest) covering resolution, unknown tenants, unmapped and undecided dimensions, the empty-string vs null distinction for the AR container suffix, and malformed configuration. Full selfcare-sdk-security suite: 54/54.

Strict-mode tests — the flag is read at startup, so strict behaviour needs its own test classes: DocumentRepositoryStrictTenantTest (9 tests, Quarkus QuarkusTestProfile with config overrides) and TenantDataIsolationStrictRepositoryTest (4 tests, Spring @DataMongoTest(properties = ...)). Both run against a real MongoDB and assert that an untagged document is invisible under strict mode while remaining visible under the default.

Regression — clean builds of every touched service, no failures: user-ms 366, auth 133, user-cdc 50, iam 67, user-group-ms 106, document-ms 20 lenient + 9 strict, institution-ms dao 4 lenient + 4 strict, delegation-cdc 23, plus onboarding-ms. The lenient suites are unchanged and still pass, which is the point: deploying this changes no behaviour until an environment opts in.

Backfill script — exercised end to end against a MongoDB 7 container. Verified that a dry run writes nothing; that --apply tags only untagged documents; that a document pre-tagged PNPG is left as PNPG when running as AR; that re-running for the other tenant is a no-op; and that --verify flips from exit 1 to exit 0 after the backfill. Container and virtualenv removed afterwards.

Screenshots (if appropriate):

N/A — no user-facing surface changes.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

Non-breaking as merged: selfcare.tenant.strict-data-isolation defaults to false and the Terraform variables are opt-in, so nothing changes until an environment is explicitly switched. Enabling it in an environment before backfill_tenant_id.py --verify exits 0 there is breaking — untagged documents become invisible to everyone rather than visible to both tenants. The runbook states the ordering.

Checklist:

  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.

Step_1/EPIC.md statuses and blockers, a new operational README for the backfill script including the "after the backfill" procedure, and the regenerated container_app_microservice module README.

andrea-putzu and others added 7 commits July 31, 2026 21:50
Step_1 EPIC sub-task 9. The mapping tenant -> {Cosmos account, Storage
naming, personal data vault, email sender domain} reuses the Step 0
registry rather than a second one: local.tenant_data_isolation now sits
next to tenant_frontend_origins/tenant_ids in _modules/local-env, so one
file declares everything a tenant is. Two registries could have disagreed
about which tenants exist.

Values re-declare what the -ar/-pnpg stacks already name today, since
local.mongo_db picks a Cosmos account by deployment folder and a
consolidated deployment serving both tenants can no longer do that.
Connection strings appear as Key Vault secret NAMES, never values; PNPG's
is suffixed because after consolidation both tenants' secrets share the
surviving stack's vault.

personal_data_vault_tenant and email_sender_domain stay null: the vault
provider (SELC-10.3) and the sender domain table (SELC-11.3) are open, and
inventing a value would be exactly the silent default SELC-10.2/11.2
forbid. Reading a null dimension throws.

TenantDataIsolationRegistry parses the map from one env var and is
fail-closed on every path: unknown tenant, missing entry, null tenant or
undecided dimension all raise UnresolvedTenantMappingException. It takes
the tenant already validated by TenantContext and never derives one.
Unknown JSON properties are ignored so a dimension can be rolled out
service by service; a malformed payload or unknown tenant key fails at
startup, where a disagreement between config and the TenantId enum belongs.

container_app_microservice gains tenant_data_isolation_json so a consuming
stack adds one line instead of a literal map. Not yet wired into the ~40
existing stacks: shipping the variable to services that do not read it
would roll every container app revision for nothing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Step_1 EPIC still read as if nothing had been built, while sub-tasks 1-4
were decided and largely implemented under Step_0's sub-task 6, which ran
ahead of this document. Same drift that was fixed once already for Step_0;
an epic that under-reports its own state is how work gets done twice.

Recorded per sub-task: Cosmos discriminator chosen for all 15 Mongo services
and rolled out, but still not enforcement while the migration-window
"or tenantId is null" read branch stands (which makes the backfill, sub-task
10, a gate rather than a tail task); database-per-tenant selected by nobody,
with the product-driven routing built in onboarding-ms described for what it
is - an orthogonal axis, not this sub-task; storage inventory done except
dashboard-bff.

Two blockers found while scoping sub-task 5, now written down instead of
being rediscovered later: the blob containers are not declared in this
repository (the app stacks only read the storage accounts through data
blocks), so per-tenant containers cannot be provisioned here; and the SYSTEM
blob client serves both tenant data and shared templates, so suffixing its
call sites wholesale would relocate templates into a container that does not
hold them. Also noted that the six Spring apps can consume the sub-task 9 env
var but have no parser for it yet.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every tenant-scoped read still matches `tenantId = <tenant> OR tenantId IS NULL`. That branch was
necessary the day the discriminator shipped — a strict filter would have hidden every pre-existing
document from both tenants — but it also means an untagged document is readable by *both* tenants.
Until it is gone the Cosmos isolation is a convention, not a boundary, and it cannot be removed
while untagged documents exist. This adds the tool that makes their removal possible.

The tenant is an explicit argument rather than something inferred from the data. Each tenant has its
own Cosmos account, so it is the connection string that decides which tenant is being migrated;
requiring the caller to say which one turns a wrong MONGO_HOST into a mismatch they notice instead
of a silently mislabelled database.

Two properties make it safe to run in production. It only writes to documents that have no
`tenantId`, so a document already attributed to a tenant is never touched: replaying the script, or
running it for the wrong tenant after a correct run, cannot move data across tenants, and an
interrupted run simply resumes. And it writes nothing at all unless --apply is passed.

The collection list is derived from the entities that actually declare the field, not from the set
of databases that exist. The product catalogue is excluded on purpose: product definitions are
global and shared, so tagging them would be wrong rather than merely unnecessary. Collections that
are absent from a given host are reported as skipped, since one connection string reaches only the
databases that deployment owns.

--verify exists so the follow-up has an objective gate: it exits non-zero while untagged documents
remain, and only when it is clean for both tenants in an environment may that environment's reads
be made strict.

Verified end to end against a MongoDB container: dry run reports without writing, --apply tags only
the untagged documents while leaving pre-tagged ones on their original tenant, a second run for the
other tenant is a no-op, and --verify flips from non-zero to zero.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The registry already refused to guess a mapping, but it refused silently: the only trace was the
exception itself. A refusal means a request reached the data layer without a usable tenant mapping,
which is exactly the security-relevant event sub-task 11 asks to be able to audit — and it is the
kind of event a caller can erase, by retrying or by mapping the exception to a generic 500, before
anyone sees it.

The log line is emitted inside the registry rather than at its call sites for that reason: it is the
only place that cannot be bypassed. It mirrors what TenantValidationFilter already does at the
request boundary, so both halves of the tenant enforcement now leave the same kind of trail.

The messages name the tenant and the missing dimension and nothing else: no connection string, no
secret value, no user data. The registry only ever holds Key Vault secret *names*, so there is
nothing sensitive to leak in the first place, and keeping the messages to routing metadata makes
that property hold as the vault and email dimensions get filled in.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Every tenant-scoped read matches `tenantId = <tenant> OR tenantId IS NULL`, and the comment at each
of those eight places said the null branch must be deleted once the backfill has run. Taken
literally that is a single commit that flips isolation on everywhere at once, and it can only be
merged after the last environment has been migrated: the same artifact is promoted DEV to UAT to
PROD, so a hardcoded switch would hold every unrelated change behind a data migration in PROD.

The branch is now controlled by selfcare.tenant.strict-data-isolation, default false. Deploying this
changes nothing; an environment turns isolation strict the moment its own --verify comes back clean,
and reverts by unsetting a variable rather than by rolling back a release. One variable name works
for both stacks, since Spring's relaxed binding and MicroProfile Config resolve
SELFCARE_TENANT_STRICT_DATA_ISOLATION to the same property.

All eight chokepoints are covered, so no service can be left behind when an environment flips:
document-ms (both the Panache and the native-syntax query), iam (the entity helper and the
aggregation pipeline), user-ms, onboarding-ms, user-group-ms, institution-ms and delegation-cdc's
two repositories. In iam's entity helper the flag is read through the static config API for the same
reason the tenant itself is read through CDI.current() there: the predicate is built statically.

The strict path is the one that has never run anywhere, so it gets its own tests rather than
inheriting the existing ones: a Quarkus profile-based suite in document-ms and a Spring
property-based one in institution-ms, both asserting on real MongoDB that an untagged document is
now invisible while the current tenant's own data still is not. Without them the first execution of
this code would have been in whichever environment flipped the flag first.

The flag is deliberately scoped to the tenant predicate. Callers with no resolvable tenant -
schedulers, event consumers - still read unscoped; that concession is a separate decision from the
backfill and is left untouched here.

It is also temporary, and both the README and the EPIC say so: once every environment runs strict,
the flag and the null branch must both be deleted, so that isolation holds by construction rather
than by configuration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous commit missed four chokepoints: auth's OtpFlowRepository, user-cdc's
UserInstitutionRepository, and the two service-level lookups in user-ms that bypass QueryUtils
(UserInfoServiceDefault and UserInstitutionServiceDefault). They kept matching untagged documents
unconditionally.

Partial coverage is worse than none here, because the flag is what an environment uses to declare
itself strict. An operator who has run the backfill, flipped the variable and seen the other
services go strict would reasonably conclude the environment is isolated, while otp flows, the CDC
mirror and two user lookups still served untagged documents to whichever tenant asked. The value of
a single switch is that it means the same thing everywhere.

auth's helper had to stop being static to read injected configuration; it is only ever called from
instance methods, so this is not a change in how it is used.

Verified with clean builds: user-ms 366 tests, auth 133, user-cdc 50, all green.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The flag shipped as code only. The runbook said "set SELFCARE_TENANT_STRICT_DATA_ISOLATION", but
there was no place to set it: an operator would have had to hand-edit app_settings in each affected
stack, which is exactly the kind of per-service edit that ends with an environment half strict.

It is declared once per environment, in local-env, next to the tenant registry, because it describes
the state of that environment's data rather than the configuration of a service. Every stack that
carries tenant-scoped data reads the same local, so an environment goes strict in a single edit and
cannot drift between services.

The module variable stays null by default and is omitted from the container env block entirely when
unset, rather than being rendered as "false". That keeps an unwired stack on the application default
and, more importantly, makes the eventual deletion of the flag a no-op for those stacks instead of a
removal of an env var they were pinned to.

Wiring the individual app stacks is deliberately left out: passing the value to a service only
matters when that environment is ready to flip, and injecting it now would roll every container app
revision to deliver a variable whose value equals the default.

terraform fmt and validate pass on both modules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sub-task 5 is blocked on blob containers this repository does not declare, so the code cannot ship.
The scoping work behind it can, and leaving it unwritten means the next person re-derives it — or,
worse, guesses. The status said only that the SYSTEM client mixes tenant data with shared templates;
it did not say which of the 21 call sites is which.

The classification is recorded in full: 14 tenant-owned, 7 reading shared templates. The point worth
carrying forward is that the split does not follow StorageOrigin — clientFor(SYSTEM) serves both
categories — it follows the storage path. Routing therefore belongs beside the path construction,
and a change that keyed off StorageOrigin alone would suffix the mail and contract templates into
per-tenant containers that do not hold them.

Classifying also surfaced something that outlives this sub-task: every shared-template path is
supplied by the caller. The container will be derived from the validated tenant as the acceptance
criteria require, but the path inside it is not, and an implementation tempted to classify by
inspecting the supplied path would let a crafted path pick the container too. Recorded as a finding
with the mitigation, since it is a separate concern from provisioning the containers.

No behaviour change; the isolation itself remains blocked on whoever owns the containers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@andrea-putzu

Copy link
Copy Markdown
Contributor Author

Added 62e2eee94 after opening this PR: it records, in Step_1/EPIC.md sub-task 5, the full classification of the 21 storageRegistry.clientFor(...) call sites in document-ms — 14 tenant-owned, 7 reading shared templates.

Two points worth a reviewer's attention, both documentation-only (no behaviour change):

  1. The split does not follow StorageOrigin. clientFor(StorageOrigin.SYSTEM) serves both tenant data (aggregates CSV, merchant visura, generated contract PDFs) and shared platform assets (mail/contract templates). It follows the storage path, so per-tenant routing has to sit beside the path construction rather than inside clientFor. An implementation keyed off StorageOrigin alone would move the shared templates into per-tenant containers that do not hold them.

  2. Security finding. Every shared-template path is client-supplied (retrieveTemplateAttachment(..., templatePath, ...), request.getContractTemplatePath(), request.getAttachmentTemplatePath(), azureFilePath). Sub-task 5's acceptance criterion — container derived only from the validated tenant, never from client input — will hold for the container, but not for the path within it. Flagged with its mitigation (route by call site, a compile-time property, never by parsing the supplied path) because it outlives this sub-task and probably deserves its own ticket.

Sub-task 5 itself stays blocked: the per-tenant blob containers are not declared in this repository.

The strict-mode switch existed at the application and module layers but stopped at the module
boundary: no service stack passed it, so changing the per-environment local could never change a
running service. Pass both the registry and the switch from local-env into every one of the 46
Container App module invocations that owns one of the fourteen tenant query chokepoints. This makes
the documented one-edit environment transition real rather than aspirational.

The backfill verification also treated an inaccessible or simply wrong host as clean when none of
the expected collections could be listed. Make discovery fail closed, require every absent
collection to be explicitly acknowledged, and reject a run that inspects no collection. The
expected Cosmos account is now mandatory and checked both against MONGO_HOST and the requested
tenant before connecting.

Scan every collection before the first write and abort the entire run if an existing tenantId is
not exactly the requested scalar string. Missing and BSON null values remain backfillable, while
arrays, numbers, foreign tenant strings and other malformed values are evidence to investigate,
not values to overwrite. The predicates use expression-level BSON type checks so arrays containing
null or both tenant names cannot bypass the preflight or verification.

Seven unit tests cover the failure modes and the exact query shapes. The predicates were also
exercised against MongoDB 7 with missing, null, scalar, array and numeric tenantId values. Terraform
validation passes for both shared modules and a representative root of every affected service.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@andrea-putzu

Copy link
Copy Markdown
Contributor Author

Resolved all high-severity findings from the branch review in 0325777c3:

  1. Strict isolation is now deliverable. Both module.local.config.tenant_data_isolation_json and module.local.config.strict_tenant_data_isolation are passed into all 46 container_app_microservice invocations for the ten services owning the fourteen tenant query chokepoints. The environment-level switch now actually reaches every running service.
  2. Backfill verification fails closed. Collection discovery errors, unexpected missing collections, and runs inspecting zero collections return failure. Intentional absence must be acknowledged individually with --allow-missing database.collection.
  3. Wrong-account writes are blocked. --account-name is mandatory and must exactly match the Cosmos account in MONGO_HOST and the selected tenant before a connection is made.
  4. Existing inconsistent data blocks all writes. The script performs a complete preflight scan and aborts before modifying any collection when any tenantId is not exactly the requested scalar tenant string.
  5. BSON edge cases are covered. Missing and exact BSON-null fields are backfillable; arrays (including arrays containing null or both tenant IDs), numbers, malformed values, and foreign tenant IDs are mismatches. The type-exact predicates were verified against MongoDB 7.

Validation: 7 unit tests pass; MongoDB 7 predicate integration passes; Terraform fmt, generated docs, and validation hooks pass; both shared modules and a representative root for every affected service validate successfully. A final code review found no remaining significant issue in these fixes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant