docs(multitenant): add requirements, architecture, security, and epic specs - #675
docs(multitenant): add requirements, architecture, security, and epic specs#675andrea-putzu wants to merge 34 commits into
Conversation
…ation for multitenant support
…for multitenant architecture rollout
- Added ARCHITECTURE.md detailing the multitenant data isolation strategy for Cosmos DB, Azure Storage, personal data vault, and outbound email. - Created EPIC.md outlining the Jira epic for the multitenant data isolation rollout, including goals, sub-tasks, and definition of done. - Introduced REQUIREMENTS.md specifying functional requirements for tenant data isolation across services. - Established SECURITY.md with security rules and guidelines for implementing multitenant data isolation, referencing OWASP standards.
…laim) Implements Step_0 sub-task 5 (EPIC.md) in selfcare-sdk-security: - TenantId: enum AR/PNPG mirroring existing infra naming (-ar/-pnpg) - TenantConstants: X-Tenant-Id header, tenant_id JWT claim, issuer constants (SPID=hub-spid-login, PAGOPA=OneIdentity/auth), default tenant PNPG for hub-spid-login when claim is absent (SELC-3.1/3.4) - TenantContext: request-scoped holder for the validated tenant - TenantProblem: RFC7807-style error payload local to this library - TenantValidationFilter: JAX-RS ContainerRequestFilter enforcing header/claim presence and consistency for authenticated requests, fail-closed on any mismatch, unknown value, or duplicated header Covered by 14 new unit tests (TenantIdTest, TenantValidationFilterTest). Verified selfcare-sdk-security compiles cleanly together with all dependent microservices (mvn --also-make-dependents compile). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…module Move the AR/PNPG frontend origin -> tenant registry (previously duplicated across 27 apim_api call sites) into a single local.tenant_frontend_origins table inside infra/resources/_modules/local-env, exposed as module.local.config.tenant_ids. Every microservice call site now sets tenant_ids = module.local.config.tenant_ids instead of repeating the AR/PNPG origin list inline, following the module's existing domain-aware conditional pattern (mongo_db, key_vault_name). Own-tenant ordering (used by APIM as the no-Origin/Referer fallback) is preserved per deployment domain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…task 3) Resolve tenant from the X-Tenant-Id header at each OneIdentity login entry point (SAML callback, OIDC exchange, OTP verify) and embed it as the tenant_id JWT claim on every session token issued by SessionServiceImpl. - Add TenantHeaderUtils.resolveTenantId: fail-closed (400) if the X-Tenant-Id header is missing or references an unknown tenant, reusing TenantId/TenantConstants from selfcare-sdk-security. - Thread tenantId through UserClaims -> SAMLService(Impl), OidcService(Impl), OtpFlowService(Impl) -> SessionServiceImpl, which now fails closed (500) if a token would be signed without a tenantId. - Add selfcare-sdk-security dependency to apps/auth. - Update unit/integration tests for the new signatures; fix two pre-existing test bugs surfaced by the change (claim key mismatch in OidcServiceTest, missing otpFlow.userId fixture in OtpFlowServiceTest). - Update EPIC.md sub-task 3 status. 167 unit tests pass in apps/auth (no regressions). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TenantValidationFilter rejects with 400 any request carrying a JWT but no X-Tenant-Id. No caller in the monorepo was sending that header, so enabling the filter had silently broken every internal call. An audit of all outbound code paths - Quarkus ClientHeadersFactory implementations, Spring Feign interceptor configurations, and the multi-module web/connector layouts missed by earlier scans - found and fixed: - auth to iam: the header is derived from the tenant that went into the session token auth just minted, so it can never disagree with the claim it is validated against. - user-ms to onboarding-ms, and the 18 internal Feign clients of dashboard-bff, external-api and institution-ms: the header carries the validated tenant from the request context, never the raw inbound header. - institution-send-mail-scheduler and onboarding-cdc have no inbound request: the header is decoded from their machine token's own tenant claim, so a configuration value cannot drift out of sync with the token. Both provisioned JWT_BEARER_TOKENs must be re-issued with a tenant_id claim before release. Callers authenticating with a subscription key rather than a JWT are exempt from the filter and were left untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The tenant claim name and the data-isolation model were still listed as open blockers although both are decided and implemented, and sub-task 5 still described itself as a product-only proof of concept after being rolled out to every service. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… and user-ms Extends the document-ms pattern (implicit tenant filtering inside the data-access layer, never opt-in ...ForTenant variants) to four more services, and adds the reference deployment consolidation for sub-task 7. iam: tenantId on UserClaims; reads scoped in the active-record helpers and in the UserPermissionsRepository aggregation entry matches; writes stamped only when unset. Roles is left unscoped - it is a global role catalogue, not tenant-owned data. auth: OTP lookups went through an unscoped UUID query, so a PNPG-issued OTP could be redeemed from the AR frontend. Closed via a new OtpFlowRepository chokepoint. auth issues sessions before TenantContext exists, so the tenant comes from the fail-closed X-Tenant-Id header, not the request context. OtpUtils now takes the distinct-user count as a supplier so that count is tenant-scoped too. user-group-ms: scoping applied in UserGroupServiceImpl, the single layer through which both the MongoRepository writes and the MongoTemplate reads/updates pass. user-ms: the tenant predicate is injected inside QueryUtils.buildQueryDocument / buildQueryDocumentByDate, the shared builder ~20 query call sites go through, so a newly added query starts out scoped rather than unscoped. The five call sites that bypass it (findById, find(field,value), mongoCollection().updateMany) are scoped individually, and persistOrUpdate stamps the tenant without ever reassigning one. infra: infra/resources/iam/dev is the reference consolidation - one container app and one database serving both tenants, with both APIM APIs kept so neither frontend changes. It adopts existing resources by import rather than standing up a parallel stack, and is deliberately not wired into CI until that import happens. Prerequisites that fail silently (backfill before merge, composite unique index, multi-issuer JWT key) are documented in its README. Migration-phase note: every filter still matches "tenant or no tenant", so untagged legacy records stay visible to both tenants. That branch must be dropped once the backfill has run; until then this is additive defence, not a hard boundary. Tests: iam 67, auth 133, user-group-ms 104, user-ms QueryUtilsTenantTest 6/6. user-ms's Quarkus test bootstrap is broken by a pre-existing SDK classpath conflict (reproduced at HEAD in a clean worktree), so its new test was also run standalone via the JUnit console launcher against javac output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replicates the document-ms isolation pattern (tenant filter applied inside the data-access layer, never as an opt-in variant) to the remaining Mongo-using microservices: institution-ms, onboarding-ms, delegation-cdc, user-cdc, user-group-cdc, onboarding-cdc and institution-send-mail-scheduler. CDC apps and the scheduler receive tenant *propagation* rather than request-scoped filtering: they have no request context and read a change stream spanning both tenants, so they carry the source tenantId through to mirror collections and outbound events. institution-ms: TenantDataIsolation centralises scoping in MongoCustomConnectorImpl and the repository defaults without making connector-dao depend on web. A cross-tenant write now raises InvalidRequestException instead of silently discarding the caller's data. product/product-cdc stay unscoped by design: they hold the global product catalogue, including the dataIsolation block that drives database routing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds infra/resources/product/dev, the second deployment-consolidation conversion. product is the only app in scope that is simultaneously a call-graph leaf, zero-divergence between the two legacy stacks, and not exposed through APIM, which makes it the smallest end-to-end exercise of the import-based cutover runbook. Reading the stacks line by line contradicted the stateless/stateful ordering recorded earlier: - The two stacks live in different Container App Environments, so every conversion needs cross-environment connectivity for its PNPG callers. - Callers are wired by private DNS name, so conversions are ordered by the call graph. BFFs and gateways must go last, not first. Also records measured per-app config divergence. A non-zero count is application work, not Terraform work: user-cdc emits no user events on PNPG today, so consolidating it without a decision would silently start emitting PNPG events to AR consumers. Nothing is applied, imported or wired into CI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The multitenant design assumed every backend-to-backend call goes direct, callee private DNS to callee private DNS, carrying a JWT and X-Tenant-Id. user-cdc does not: its three internal clients go through APIM with an Ocp-Apim-Subscription-Key. The inbound policy overrides X-Tenant-Id unconditionally from Origin/Referer, and a CDC process has no Origin, so setting the header application-side would have been silently discarded and the call would resolve to default_tenant_id, i.e. 403 fail-closed. Widening default_tenant_id would reintroduce the silent fallback removed earlier in sub-task 2. Instead add service_caller_tenants: a map of APIM subscription id to tenant, evaluated before default_tenant_id and only for requests with no Origin/Referer, so an s2s caller's tenant is pinned to a credential it does not control. Document the finding and the two remaining options in the EPIC, since the API serving external/internal/v1 is not declared through this module. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…coped TenantValidationFilter returned early whenever the request had no JWT issuer, leaving TenantContext unset. CurrentTenantProvider then returned empty and every tenantScoped(...) call fell through to the unscoped query. That path is not hypothetical: five clients across auth and user-cdc call selfcare APIs through APIM with an Ocp-Apim-Subscription-Key and no JWT, including the login path where auth looks a user up in user-ms. Those calls were reading and writing across both tenants. The filter now scopes such a request from a usable X-Tenant-Id header. It still never rejects unauthenticated requests, so public and health endpoints are unaffected and the change is strictly additive. Trusting the header is safe only because APIM overrides it unconditionally on every request it forwards, and services are not reachable from outside the private network without passing through APIM. Also fix four user-ms tests that still stubbed the find(String, Object...) overload after sub-task 6 moved the production code to find(Document); the stub silently did not match and PanacheMock returned null. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The previous commit fixed the unscoped-request defect in the Quarkus filter in libs/selfcare-sdk-security. The same early-return exists in the six independent Spring copies of the filter, which were written separately and carry the same bug: a request with no authenticated principal left the tenant request attribute unset, so downstream repository queries ran unscoped across all tenants. Apply the identical fix to institution-ms, external-api, dashboard-bff, onboarding-bff, registry-proxy and user-group-ms: scope the request from a usable X-Tenant-Id when there is no authenticated principal, while still never rejecting such requests so public and health endpoints keep working. That the same defect had to be fixed in seven places is an argument for extracting the Spring filter into a shared library; noted in the EPIC as a follow-up rather than done here, since it changes the dependency graph of six services. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Step 0 review fixes are available in commit Resolved findings:
Validation:
Migration-dependent items remain explicitly tracked in |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ed clients Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…uting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
📋 Pre-commit Output LogGenerated on Wed Aug 12 07:42:13 UTC 2026 |
📋 Pre-commit Output LogGenerated on Wed Aug 12 07:42:14 UTC 2026 |
📋 Pre-commit Output LogGenerated on Wed Aug 12 07:42:19 UTC 2026 |
📋 Pre-commit Output LogGenerated on Wed Aug 12 07:42:25 UTC 2026 |
📋 Pre-commit Output LogGenerated on Wed Aug 12 07:42:32 UTC 2026 |
|
Warning
|
| Severity | ID | File | Line | Title |
|---|---|---|---|---|
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/_modules/github_repository_environment/main.tf |
L53 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/dev-ar/data.tf |
L25 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/dev-ar/data.tf |
L20 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/dev-ar/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/dev-ar/data.tf |
L30 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/dev-ar/data.tf |
L11 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/prod-ar/data.tf |
L25 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/prod-ar/data.tf |
L20 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/prod-ar/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/prod-ar/data.tf |
L30 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/prod-ar/data.tf |
L11 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/uat-ar/data.tf |
L25 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/uat-ar/data.tf |
L20 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/uat-ar/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/uat-ar/data.tf |
L30 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/bootstrap/uat-ar/data.tf |
L11 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/cert/_modules/cert/data.tf |
L7 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/cert/_modules/cert/data.tf |
L7 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/cert/_modules/cert/data.tf |
L7 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/cert/_modules/cert/data.tf |
L7 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/cert/_modules/cert/data.tf |
L7 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/cert/_modules/cert/data.tf |
L7 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L45 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L45 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L45 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L55 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L55 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L55 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L40 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L40 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L40 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L71 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L71 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L71 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L50 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L50 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L50 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L60 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L60 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L60 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/apim/data.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L11 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L11 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L11 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L22 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L16 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L28 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L28 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/azure_key_vault_items/main.tf |
L28 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/docker_credentials/main.tf |
L6 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/data/docker_credentials/main.tf |
L1 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L26 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L26 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L26 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L26 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L26 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L26 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L31 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L31 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L31 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L31 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L31 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L31 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L36 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L36 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L36 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L36 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L36 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L36 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L41 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L41 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L41 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L41 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L41 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L41 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L159 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L159 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L159 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L159 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L159 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L159 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L92 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L92 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L92 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L92 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L92 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L92 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L117 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L117 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L117 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L117 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L117 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L117 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L164 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L164 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L164 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L164 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L164 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/key_vault/main.tf |
L164 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L81 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L81 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L81 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L96 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L96 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L96 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L86 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L86 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L86 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L91 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L91 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/monitor/main.tf |
L91 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/spid_testenv/spid-testenv.tf |
L14 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/spid_testenv/spid-testenv.tf |
L14 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/spid_testenv/spid-testenv.tf |
L9 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/_modules/spid_testenv/spid-testenv.tf |
L9 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/dev-pnpg/app.tf |
L33 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/prod-pnpg/app.tf |
L24 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/uat-ar/commons.tf |
L541 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/core/uat-pnpg/app.tf |
L33 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1679 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1679 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1679 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1684 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1684 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1684 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1689 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1689 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/apim.tf |
L1689 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L35 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L45 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L45 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L45 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L55 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L55 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L55 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L40 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L40 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L40 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L71 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L71 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L71 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L50 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L50 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L50 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L60 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L60 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L60 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/apim_external_api/data.tf |
L76 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/dapr/data.tf |
L17 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/dapr/data.tf |
L17 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/dapr/data.tf |
L17 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/functions/function.tf |
L87 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/functions/function.tf |
L87 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/functions/function.tf |
L87 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/functions/function.tf |
L87 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/functions/function.tf |
L87 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/functions/function.tf |
L87 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/namirial_sws/data.tf |
L15 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/namirial_sws/data.tf |
L15 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/namirial_sws/data.tf |
L15 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/namirial_sws/data.tf |
L10 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/namirial_sws/data.tf |
L10 | Terraform must not read Key Vault secrets via data source |
| HIGH | AVD-DX-0001 | infra/resources/_modules/namirial_sws/data.tf |
L10 | Terraform must not read Key Vault secrets via data source |
📋 Pre-commit Output LogGenerated on Wed Aug 12 07:42:36 UTC 2026 |
📖 Terraform Plan (infra/resources/dashboard-bff/dev-pnpg) - successShow Plan # module.container_app_dashboard_bff_pnpg.azurerm_container_app.container_app will be updated in-place
~ resource "azurerm_container_app" "container_app" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-container-app-rg/providers/Microsoft.App/containerApps/selc-d-pnpg-dashboard-backend-ca"
name = "selc-d-pnpg-dashboard-backend-ca"
tags = {
"CostCenter" = "TS310 - PAGAMENTI & SERVIZI"
"CreatedBy" = "Terraform"
"Environment" = "Dev"
"Owner" = "Selfcare"
"Source" = "https://github.com/pagopa/selfcare"
}
# (10 unchanged attributes hidden)
~ template {
# (6 unchanged attributes hidden)
~ container {
~ image = "ghcr.io/pagopa/selfcare-dashboard-backend:sha-9069e0e" -> "ghcr.io/pagopa/selfcare-dashboard-backend:sha-$(git r"
name = "selc-d-pnpg-dashboard-backend"
# (5 unchanged attributes hidden)
# (39 unchanged blocks hidden)
}
# (1 unchanged block hidden)
}
# (8 unchanged blocks hidden)
}
# module.apim_api_bff_dashboard_pnpg.module.apim_api.azurerm_api_management_api_policy.this[0] will be updated in-place
~ resource "azurerm_api_management_api_policy" "this" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-api-v2-rg/providers/Microsoft.ApiManagement/service/selc-d-apim-v2/apis/selc-d-pnpg-api-bff-dashboard"
~ xml_content = <<-EOT
- <policies>
- <inbound>
- <cors allow-credentials="true">
- <allowed-origins>
- <origin>https://pnpg.dev.selfcare.pagopa.it</origin>
- <origin>https://api-pnpg.dev.selfcare.pagopa.it</origin>
- <origin>http://localhost:3000</origin>
- </allowed-origins>
- <allowed-methods>
- <method>GET</method>
- <method>POST</method>
- <method>PUT</method>
- <method>HEAD</method>
- <method>DELETE</method>
- <method>OPTIONS</method>
- </allowed-methods>
- <allowed-headers>
- <header>*</header>
- </allowed-headers>
- </cors>
- <base />
- </inbound>
- <backend>
- <base />
- </backend>
- <outbound>
- <base />
- </outbound>
- <on-error>
- <base />
- </on-error>
+ <policies>
+ <inbound>
+ <cors allow-credentials="true">
+ <allowed-origins>
+ <origin>https://api-pnpg.dev.selfcare.pagopa.it</origin>
+
+ <origin>https://pnpg.dev.selfcare.pagopa.it</origin>
+
+ <origin>https://dev.selfcare.pagopa.it</origin>
+
+
+ <origin>http://localhost:3000</origin>
+
+ </allowed-origins>
+ <allowed-methods>
+ <method>GET</method>
+ <method>POST</method>
+ <method>PUT</method>
+ <method>HEAD</method>
+ <method>DELETE</method>
+ <method>OPTIONS</method>
+ </allowed-methods>
+ <allowed-headers>
+ <header>*</header>
+ </allowed-headers>
+ </cors>
+ <base />
+ <!--
+ Multitenant tenant resolution & propagation.
+ See apps/docs/Multitenant/Step_0/{REQUIREMENTS,ARCHITECTURE,SECURITY}.md (SELC-1, SELC-2).
+ Single API group serving every tenant frontend listed in tenant_ids: X-Tenant-Id is
+ ALWAYS derived here from the calling origin against that origin -> tenant list, never
+ trusted from the caller.
+
+ Matching is EXACT on the serialised origin (scheme + authority), never a prefix: a
+ prefix test would let https://selfcare.pagopa.it.attacker.example resolve as the AR
+ tenant. When the request carries no Origin, the Referer is parsed and reduced to its
+ scheme + authority before the same exact lookup.
+
+ A subscription listed in var.service_caller_tenants is resolved FIRST, regardless of
+ Origin/Referer. A non-browser caller controls those headers and must not be able to
+ override the tenant pinned to its APIM credential by pretending to be a browser.
+ Note the caller cannot express its tenant any other way:
+ the policy below OVERRIDES X-Tenant-Id unconditionally, so a header set by the calling
+ application is discarded — mapping the subscription is the only supported mechanism.
+
+ An unknown subscription then follows browser origin resolution. var.default_tenant_id
+ applies to origin-less requests, or to the exact operations listed in
+ var.default_tenant_operation_ids (for example a SAML ACS posted from the IdP origin).
+ It defaults to null => rejected.
+ -->
+ <set-variable name="callerOrigin" value="@{
+ var origin = context.Request.Headers.GetValueOrDefault("Origin", "");
+ if (string.IsNullOrEmpty(origin)) {
+ var referer = context.Request.Headers.GetValueOrDefault("Referer", "");
+ if (!string.IsNullOrEmpty(referer)) {
+ try {
+ var refererUri = new Uri(referer);
+ origin = refererUri.Scheme + "://" + refererUri.Authority;
+ } catch (Exception) {
+ origin = "";
+ }
+ }
+ }
+ return origin.Trim().ToLowerInvariant();
+ }" />
+ <set-variable name="resolvedTenant" value="@{
+ var tenantByOrigin = new Dictionary<string, string> {
+
+ { "https://pnpg.dev.selfcare.pagopa.it", "PNPG" },
+
+ { "https://dev.selfcare.pagopa.it", "AR" },
+
+
+ { "http://localhost:3000", "PNPG" },
+
+ };
+ var tenantBySubscription = new Dictionary<string, string> {
+
+ };
+ var subscriptionId = context.Subscription == null ? "" : (context.Subscription.Id ?? "").ToLowerInvariant();
+ string serviceTenant;
+ if (!string.IsNullOrEmpty(subscriptionId) && tenantBySubscription.TryGetValue(subscriptionId, out serviceTenant)) {
+ return serviceTenant;
+ }
+
+ var defaultOperations = new HashSet<string> {
+
+ };
+ var caller = (string)context.Variables["callerOrigin"];
+ if (string.IsNullOrEmpty(caller)) {
+ return defaultOperations.Count == 0 || defaultOperations.Contains(context.Operation.Id)
+ ? ""
+ : "";
+ }
+ string tenant;
+ if (tenantByOrigin.TryGetValue(caller, out tenant)) {
+ return tenant;
+ }
+ return defaultOperations.Contains(context.Operation.Id) ? "" : "";
+ }" />
+ <choose>
+ <when condition="@(string.IsNullOrEmpty((string)context.Variables["resolvedTenant"]))">
+ <return-response>
+ <set-status code="403" reason="Forbidden" />
+ <set-header name="Content-Type" exists-action="override">
+ <value>application/problem+json</value>
+ </set-header>
+ <set-body>@("{\"status\":403,\"title\":\"tenant_url_mismatch\",\"detail\":\"Calling URL does not match any known tenant frontend for this API, or the request carried no Origin/Referer and this API defines no default tenant.\"}")</set-body>
+ </return-response>
+ </when>
+ </choose>
+ <set-header name="X-Tenant-Id" exists-action="override">
+ <value>@((string)context.Variables["resolvedTenant"])</value>
+ </set-header>
+ </inbound>
+ <backend>
+ <base />
+ </backend>
+ <outbound>
+ <base />
+ </outbound>
+ <on-error>
+ <base />
+ </on-error>
</policies>
EOT
# (3 unchanged attributes hidden)
}
Plan: 0 to add, 2 to change, 0 to destroy. |
📖 Terraform Plan (infra/resources/dashboard-bff/dev-ar) - successShow Plan # module.container_app_dashboard_bff.azurerm_container_app.container_app will be updated in-place
~ resource "azurerm_container_app" "container_app" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-container-app-002-rg/providers/Microsoft.App/containerApps/selc-d-dashboard-backend-ca"
name = "selc-d-dashboard-backend-ca"
tags = {
"CostCenter" = "TS310 - PAGAMENTI & SERVIZI"
"CreatedBy" = "Terraform"
"Environment" = "Dev"
"Owner" = "Selfcare"
"Source" = "https://github.com/pagopa/selfcare"
}
# (10 unchanged attributes hidden)
~ template {
# (6 unchanged attributes hidden)
~ container {
~ image = "ghcr.io/pagopa/selfcare-dashboard-backend:sha-9069e0e" -> "ghcr.io/pagopa/selfcare-dashboard-backend:sha-$(git r"
name = "selc-d-dashboard-backend"
# (5 unchanged attributes hidden)
# (42 unchanged blocks hidden)
}
# (2 unchanged blocks hidden)
}
# (9 unchanged blocks hidden)
}
# module.apim_api_bff_dashboard.module.apim_api.azurerm_api_management_api_policy.this[0] will be updated in-place
~ resource "azurerm_api_management_api_policy" "this" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-api-v2-rg/providers/Microsoft.ApiManagement/service/selc-d-apim-v2/apis/selc-d-api-bff-dashboard"
~ xml_content = <<-EOT
- <policies>
- <inbound>
- <cors allow-credentials="true">
- <allowed-origins>
- <origin>https://dev.selfcare.pagopa.it</origin>
- <origin>https://api.dev.selfcare.pagopa.it</origin>
- <origin>http://localhost:3000</origin>
- </allowed-origins>
- <allowed-methods>
- <method>GET</method>
- <method>POST</method>
- <method>PUT</method>
- <method>HEAD</method>
- <method>DELETE</method>
- <method>OPTIONS</method>
- </allowed-methods>
- <allowed-headers>
- <header>*</header>
- </allowed-headers>
- </cors>
- <base />
- </inbound>
- <backend>
- <base />
- </backend>
- <outbound>
- <base />
- </outbound>
- <on-error>
- <base />
- </on-error>
+ <policies>
+ <inbound>
+ <cors allow-credentials="true">
+ <allowed-origins>
+ <origin>https://api.dev.selfcare.pagopa.it</origin>
+
+ <origin>https://dev.selfcare.pagopa.it</origin>
+
+ <origin>https://pnpg.dev.selfcare.pagopa.it</origin>
+
+
+ <origin>http://localhost:3000</origin>
+
+ </allowed-origins>
+ <allowed-methods>
+ <method>GET</method>
+ <method>POST</method>
+ <method>PUT</method>
+ <method>HEAD</method>
+ <method>DELETE</method>
+ <method>OPTIONS</method>
+ </allowed-methods>
+ <allowed-headers>
+ <header>*</header>
+ </allowed-headers>
+ </cors>
+ <base />
+ <!--
+ Multitenant tenant resolution & propagation.
+ See apps/docs/Multitenant/Step_0/{REQUIREMENTS,ARCHITECTURE,SECURITY}.md (SELC-1, SELC-2).
+ Single API group serving every tenant frontend listed in tenant_ids: X-Tenant-Id is
+ ALWAYS derived here from the calling origin against that origin -> tenant list, never
+ trusted from the caller.
+
+ Matching is EXACT on the serialised origin (scheme + authority), never a prefix: a
+ prefix test would let https://selfcare.pagopa.it.attacker.example resolve as the AR
+ tenant. When the request carries no Origin, the Referer is parsed and reduced to its
+ scheme + authority before the same exact lookup.
+
+ A subscription listed in var.service_caller_tenants is resolved FIRST, regardless of
+ Origin/Referer. A non-browser caller controls those headers and must not be able to
+ override the tenant pinned to its APIM credential by pretending to be a browser.
+ Note the caller cannot express its tenant any other way:
+ the policy below OVERRIDES X-Tenant-Id unconditionally, so a header set by the calling
+ application is discarded — mapping the subscription is the only supported mechanism.
+
+ An unknown subscription then follows browser origin resolution. var.default_tenant_id
+ applies to origin-less requests, or to the exact operations listed in
+ var.default_tenant_operation_ids (for example a SAML ACS posted from the IdP origin).
+ It defaults to null => rejected.
+ -->
+ <set-variable name="callerOrigin" value="@{
+ var origin = context.Request.Headers.GetValueOrDefault("Origin", "");
+ if (string.IsNullOrEmpty(origin)) {
+ var referer = context.Request.Headers.GetValueOrDefault("Referer", "");
+ if (!string.IsNullOrEmpty(referer)) {
+ try {
+ var refererUri = new Uri(referer);
+ origin = refererUri.Scheme + "://" + refererUri.Authority;
+ } catch (Exception) {
+ origin = "";
+ }
+ }
+ }
+ return origin.Trim().ToLowerInvariant();
+ }" />
+ <set-variable name="resolvedTenant" value="@{
+ var tenantByOrigin = new Dictionary<string, string> {
+
+ { "https://dev.selfcare.pagopa.it", "AR" },
+
+ { "https://pnpg.dev.selfcare.pagopa.it", "PNPG" },
+
+
+ { "http://localhost:3000", "AR" },
+
+ };
+ var tenantBySubscription = new Dictionary<string, string> {
+
+ };
+ var subscriptionId = context.Subscription == null ? "" : (context.Subscription.Id ?? "").ToLowerInvariant();
+ string serviceTenant;
+ if (!string.IsNullOrEmpty(subscriptionId) && tenantBySubscription.TryGetValue(subscriptionId, out serviceTenant)) {
+ return serviceTenant;
+ }
+
+ var defaultOperations = new HashSet<string> {
+
+ };
+ var caller = (string)context.Variables["callerOrigin"];
+ if (string.IsNullOrEmpty(caller)) {
+ return defaultOperations.Count == 0 || defaultOperations.Contains(context.Operation.Id)
+ ? ""
+ : "";
+ }
+ string tenant;
+ if (tenantByOrigin.TryGetValue(caller, out tenant)) {
+ return tenant;
+ }
+ return defaultOperations.Contains(context.Operation.Id) ? "" : "";
+ }" />
+ <choose>
+ <when condition="@(string.IsNullOrEmpty((string)context.Variables["resolvedTenant"]))">
+ <return-response>
+ <set-status code="403" reason="Forbidden" />
+ <set-header name="Content-Type" exists-action="override">
+ <value>application/problem+json</value>
+ </set-header>
+ <set-body>@("{\"status\":403,\"title\":\"tenant_url_mismatch\",\"detail\":\"Calling URL does not match any known tenant frontend for this API, or the request carried no Origin/Referer and this API defines no default tenant.\"}")</set-body>
+ </return-response>
+ </when>
+ </choose>
+ <set-header name="X-Tenant-Id" exists-action="override">
+ <value>@((string)context.Variables["resolvedTenant"])</value>
+ </set-header>
+ </inbound>
+ <backend>
+ <base />
+ </backend>
+ <outbound>
+ <base />
+ </outbound>
+ <on-error>
+ <base />
+ </on-error>
</policies>
EOT
# (3 unchanged attributes hidden)
}
Plan: 0 to add, 2 to change, 0 to destroy. |
📖 Terraform Plan (infra/resources/auth/dev-ar) - successShow Plan # module.container_app_auth_ms.azurerm_container_app.container_app will be updated in-place
~ resource "azurerm_container_app" "container_app" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-container-app-002-rg/providers/Microsoft.App/containerApps/selc-d-auth-ms-ca"
name = "selc-d-auth-ms-ca"
tags = {
"CostCenter" = "TS310 - PAGAMENTI & SERVIZI"
"CreatedBy" = "Terraform"
"Environment" = "Dev"
"Owner" = "Selfcare"
"Source" = "https://github.com/pagopa/selfcare"
}
# (10 unchanged attributes hidden)
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
- secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
+ secret {
# At least one attribute in this block is (or was) sensitive,
# so its contents will not be displayed.
}
~ template {
# (6 unchanged attributes hidden)
~ container {
~ image = "ghcr.io/pagopa/selfcare-auth-ms:sha-c1cedff" -> "ghcr.io/pagopa/selfcare-auth-ms:sha-$(git r"
name = "selc-d-auth-ms"
# (5 unchanged attributes hidden)
~ env {
~ name = "ONE_MAIL_URL" -> "APPLICATIONINSIGHTS_CONNECTION_STRING"
+ secret_name = "appinsights-connection-string"
- value = "https://uat.onemail.pagopa.it" -> null
}
~ env {
~ name = "MAIL_SENDER_ADDRESS" -> "FEATURE_FLAG_OTP_BETA_USERS"
+ secret_name = "feature-flag-otp-beta-users"
- value = "noreply@selfcare.pagopa.it" -> null
}
~ env {
~ name = "APPLICATIONINSIGHTS_CONNECTION_STRING" -> "INTERNAL-API-KEY"
~ secret_name = "appinsights-connection-string" -> "internal-api-key"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "FEATURE_FLAG_OTP_BETA_USERS" -> "INTERNAL-MS-USER-API-KEY"
~ secret_name = "feature-flag-otp-beta-users" -> "internal-ms-user-api-key"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "INTERNAL-API-KEY" -> "MONGODB-CONNECTION-STRING"
~ secret_name = "internal-api-key" -> "mongodb-connection-string"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "INTERNAL-MS-USER-API-KEY" -> "ONE_IDENTITY_CLIENT_ID"
~ secret_name = "internal-ms-user-api-key" -> "oneidentity-client-id"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "MONGODB-CONNECTION-STRING" -> "ONE_IDENTITY_CLIENT_SECRET"
~ secret_name = "mongodb-connection-string" -> "oneidentity-client-secret"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "ONE-MAIL-API-KEY" -> "SAML_IDP_CERT"
~ secret_name = "onemail-api-key" -> "saml-idp-cert"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "ONE_IDENTITY_CLIENT_ID" -> "SAML_IDP_ENTITY_ID"
~ secret_name = "oneidentity-client-id" -> "saml-idp-entity-id"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "ONE_IDENTITY_CLIENT_SECRET" -> "SAML_IDP_METADATA"
~ secret_name = "oneidentity-client-secret" -> "saml-idp-metadata"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "SAML_IDP_CERT" -> "SESSION_TOKEN_PRIVATE_KEY"
~ secret_name = "saml-idp-cert" -> "jwt-private-key-pkcs8"
# (1 unchanged attribute hidden)
}
~ env {
~ name = "SAML_IDP_ENTITY_ID" -> "USER-REGISTRY-API-KEY"
~ secret_name = "saml-idp-entity-id" -> "user-registry-api-key"
# (1 unchanged attribute hidden)
}
- env {
- name = "SAML_IDP_METADATA" -> null
- secret_name = "saml-idp-metadata" -> null
# (1 unchanged attribute hidden)
}
- env {
- name = "SESSION_TOKEN_PRIVATE_KEY" -> null
- secret_name = "jwt-private-key-pkcs8" -> null
# (1 unchanged attribute hidden)
}
- env {
- name = "USER-REGISTRY-API-KEY" -> null
- secret_name = "user-registry-api-key" -> null
# (1 unchanged attribute hidden)
}
# (20 unchanged blocks hidden)
}
# (1 unchanged block hidden)
}
# (2 unchanged blocks hidden)
}
# module.apim_api_auth.module.apim_api.azurerm_api_management_api.this will be updated in-place
~ resource "azurerm_api_management_api" "this" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-api-v2-rg/providers/Microsoft.ApiManagement/service/selc-d-apim-v2/apis/selc-d-api-auth;rev=1"
name = "selc-d-api-auth"
# (17 unchanged attributes hidden)
~ import {
~ content_value = jsonencode(
~ {
~ components = {
~ schemas = {
- Date = {
- examples = [
- "2022-03-10",
]
- format = "date"
- type = "string"
}
- MailStatusHistory = {
- properties = {
- changedAt = {
- "$ref" = "#/components/schemas/OffsetDateTime"
}
- status = {
- type = "string"
}
}
- type = "object"
}
- ObjectId = {
- properties = {
- date = {
- "$ref" = "#/components/schemas/Date"
}
- nonce = {
- format = "int64"
- type = "integer"
}
- timestamp = {
- format = "int32"
- type = "integer"
}
}
- type = "object"
}
- OffsetDateTime = {
- examples = [
- "2022-03-10T12:15:50-04:00",
]
- format = "date-time"
- type = "string"
}
- OtpFlow = {
- properties = {
- attempts = {
- format = "int32"
- type = "integer"
}
- createdAt = {
- "$ref" = "#/components/schemas/OffsetDateTime"
}
- expiresAt = {
- "$ref" = "#/components/schemas/OffsetDateTime"
}
- id = {
- "$ref" = "#/components/schemas/ObjectId"
}
- mailRequestId = {
- type = "string"
}
- otp = {
- type = "string"
}
- status = {
- "$ref" = "#/components/schemas/OtpStatus"
}
- updatedAt = {
- "$ref" = "#/components/schemas/OffsetDateTime"
}
- userId = {
- type = "string"
}
- uuid = {
- type = "string"
}
}
- type = "object"
}
- OtpMailInfoResponse = {
- properties = {
- attempts = {
- format = "int32"
- type = "integer"
}
- history = {
- items = {
- "$ref" = "#/components/schemas/MailStatusHistory"
}
- type = "array"
}
- mailRequestId = {
- type = "string"
}
- recipient = {
- type = "string"
}
- status = {
- type = "string"
}
}
- type = "object"
}
# (10 unchanged attributes hidden)
}
# (1 unchanged attribute hidden)
}
~ paths = {
- "/otp/info" = {
- get = {
- description = "Retrieve OTP information for a user"
- operationId = "getOtpInfo"
- parameters = [
- {
- in = "query"
- name = "status"
- schema = {
- "$ref" = "#/components/schemas/OtpStatus"
}
},
- {
- in = "query"
- name = "userId"
- required = true
- schema = {
- pattern = "\\S"
- type = "string"
}
},
]
- responses = {
- "200" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/OtpFlow"
}
}
}
- description = "OK"
}
- "400" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/Problem"
}
}
}
- description = "Bad Request"
}
- "404" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/Problem"
}
}
}
- description = "Not Found"
}
- "500" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/Problem"
}
}
}
- description = "Internal Server Error"
}
}
- summary = "Get OTP information"
- tags = [
- "OTP",
]
}
}
- "/otp/mail-info/{mailRequestId}" = {
- get = {
- description = "Retrieve the processing status of an email managed by OneMail"
- operationId = "getOtpMailInfo"
- parameters = [
- {
- in = "path"
- name = "mailRequestId"
- required = true
- schema = {
- type = "string"
}
},
]
- responses = {
- "200" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/OtpMailInfoResponse"
}
}
}
- description = "OK"
}
- "400" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/Problem"
}
}
}
- description = "Bad Request"
}
- "404" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/Problem"
}
}
}
- description = "Not Found"
}
- "500" = {
- content = {
- "application/json" = {
- schema = {
- "$ref" = "#/components/schemas/Problem"
}
}
}
- description = "Internal Server Error"
}
}
- summary = "Get OTP mail information"
- tags = [
- "OTP",
]
}
}
# (4 unchanged attributes hidden)
}
tags = [
{
name = "OIDC"
},
{
name = "OTP"
},
{
name = "SAML"
},
]
# (3 unchanged attributes hidden)
}
)
# (1 unchanged attribute hidden)
}
# (1 unchanged block hidden)
}
# module.apim_api_auth.module.apim_api.azurerm_api_management_api_policy.this[0] will be updated in-place
~ resource "azurerm_api_management_api_policy" "this" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-api-v2-rg/providers/Microsoft.ApiManagement/service/selc-d-apim-v2/apis/selc-d-api-auth"
~ xml_content = <<-EOT
- <policies>
- <inbound>
- <cors allow-credentials="true">
- <allowed-origins>
- <origin>https://dev.selfcare.pagopa.it</origin>
- <origin>https://api.dev.selfcare.pagopa.it</origin>
- <origin>http://localhost:3000</origin>
- </allowed-origins>
- <allowed-methods>
- <method>GET</method>
- <method>POST</method>
- <method>PUT</method>
- <method>HEAD</method>
- <method>DELETE</method>
- <method>OPTIONS</method>
- </allowed-methods>
- <allowed-headers>
- <header>*</header>
- </allowed-headers>
- </cors>
- <base />
- </inbound>
- <backend>
- <base />
- </backend>
- <outbound>
- <base />
- </outbound>
- <on-error>
- <base />
- </on-error>
+ <policies>
+ <inbound>
+ <cors allow-credentials="true">
+ <allowed-origins>
+ <origin>https://api.dev.selfcare.pagopa.it</origin>
+
+ <origin>https://dev.selfcare.pagopa.it</origin>
+
+ <origin>https://pnpg.dev.selfcare.pagopa.it</origin>
+
+
+ <origin>http://localhost:3000</origin>
+
+ </allowed-origins>
+ <allowed-methods>
+ <method>GET</method>
+ <method>POST</method>
+ <method>PUT</method>
+ <method>HEAD</method>
+ <method>DELETE</method>
+ <method>OPTIONS</method>
+ </allowed-methods>
+ <allowed-headers>
+ <header>*</header>
+ </allowed-headers>
+ </cors>
+ <base />
+ <!--
+ Multitenant tenant resolution & propagation.
+ See apps/docs/Multitenant/Step_0/{REQUIREMENTS,ARCHITECTURE,SECURITY}.md (SELC-1, SELC-2).
+ Single API group serving every tenant frontend listed in tenant_ids: X-Tenant-Id is
+ ALWAYS derived here from the calling origin against that origin -> tenant list, never
+ trusted from the caller.
+
+ Matching is EXACT on the serialised origin (scheme + authority), never a prefix: a
+ prefix test would let https://selfcare.pagopa.it.attacker.example resolve as the AR
+ tenant. When the request carries no Origin, the Referer is parsed and reduced to its
+ scheme + authority before the same exact lookup.
+
+ A subscription listed in var.service_caller_tenants is resolved FIRST, regardless of
+ Origin/Referer. A non-browser caller controls those headers and must not be able to
+ override the tenant pinned to its APIM credential by pretending to be a browser.
+ Note the caller cannot express its tenant any other way:
+ the policy below OVERRIDES X-Tenant-Id unconditionally, so a header set by the calling
+ application is discarded — mapping the subscription is the only supported mechanism.
+
+ An unknown subscription then follows browser origin resolution. var.default_tenant_id
+ applies to origin-less requests, or to the exact operations listed in
+ var.default_tenant_operation_ids (for example a SAML ACS posted from the IdP origin).
+ It defaults to null => rejected.
+ -->
+ <set-variable name="callerOrigin" value="@{
+ var origin = context.Request.Headers.GetValueOrDefault("Origin", "");
+ if (string.IsNullOrEmpty(origin)) {
+ var referer = context.Request.Headers.GetValueOrDefault("Referer", "");
+ if (!string.IsNullOrEmpty(referer)) {
+ try {
+ var refererUri = new Uri(referer);
+ origin = refererUri.Scheme + "://" + refererUri.Authority;
+ } catch (Exception) {
+ origin = "";
+ }
+ }
+ }
+ return origin.Trim().ToLowerInvariant();
+ }" />
+ <set-variable name="resolvedTenant" value="@{
+ var tenantByOrigin = new Dictionary<string, string> {
+
+ { "https://dev.selfcare.pagopa.it", "AR" },
+
+ { "https://pnpg.dev.selfcare.pagopa.it", "PNPG" },
+
+
+ { "http://localhost:3000", "AR" },
+
+ };
+ var tenantBySubscription = new Dictionary<string, string> {
+
+ };
+ var subscriptionId = context.Subscription == null ? "" : (context.Subscription.Id ?? "").ToLowerInvariant();
+ string serviceTenant;
+ if (!string.IsNullOrEmpty(subscriptionId) && tenantBySubscription.TryGetValue(subscriptionId, out serviceTenant)) {
+ return serviceTenant;
+ }
+
+ var defaultOperations = new HashSet<string> {
+
+ "loginSaml",
+
+ };
+ var caller = (string)context.Variables["callerOrigin"];
+ if (string.IsNullOrEmpty(caller)) {
+ return defaultOperations.Count == 0 || defaultOperations.Contains(context.Operation.Id)
+ ? "AR"
+ : "";
+ }
+ string tenant;
+ if (tenantByOrigin.TryGetValue(caller, out tenant)) {
+ return tenant;
+ }
+ return defaultOperations.Contains(context.Operation.Id) ? "AR" : "";
+ }" />
+ <choose>
+ <when condition="@(string.IsNullOrEmpty((string)context.Variables["resolvedTenant"]))">
+ <return-response>
+ <set-status code="403" reason="Forbidden" />
+ <set-header name="Content-Type" exists-action="override">
+ <value>application/problem+json</value>
+ </set-header>
+ <set-body>@("{\"status\":403,\"title\":\"tenant_url_mismatch\",\"detail\":\"Calling URL does not match any known tenant frontend for this API, or the request carried no Origin/Referer and this API defines no default tenant.\"}")</set-body>
+ </return-response>
+ </when>
+ </choose>
+ <set-header name="X-Tenant-Id" exists-action="override">
+ <value>@((string)context.Variables["resolvedTenant"])</value>
+ </set-header>
+ </inbound>
+ <backend>
+ <base />
+ </backend>
+ <outbound>
+ <base />
+ </outbound>
+ <on-error>
+ <base />
+ </on-error>
</policies>
EOT
# (3 unchanged attributes hidden)
}
Plan: 0 to add, 3 to change, 0 to destroy. |
📖 Terraform Plan (infra/resources/product/dev-ar) - successShow Plan # module.container_app_product_ms.azurerm_container_app.container_app will be updated in-place
~ resource "azurerm_container_app" "container_app" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-container-app-002-rg/providers/Microsoft.App/containerApps/selc-d-product-ms-ca"
name = "selc-d-product-ms-ca"
tags = {
"CostCenter" = "TS310 - PAGAMENTI & SERVIZI"
"CreatedBy" = "Terraform"
"Environment" = "Dev"
"Owner" = "Selfcare"
"Source" = "https://github.com/pagopa/selfcare"
}
# (10 unchanged attributes hidden)
~ template {
# (6 unchanged attributes hidden)
~ container {
~ image = "ghcr.io/pagopa/selfcare-product-ms:sha-bb3e485" -> "ghcr.io/pagopa/selfcare-product-ms:sha-$(git r"
name = "selc-d-product-ms"
# (5 unchanged attributes hidden)
# (17 unchanged blocks hidden)
}
# (1 unchanged block hidden)
}
# (8 unchanged blocks hidden)
}
Plan: 0 to add, 1 to change, 0 to destroy. |
📖 Terraform Plan (infra/resources/iam/dev-ar) - successShow Plan # module.container_app_iam_ms.azurerm_container_app.container_app will be updated in-place
~ resource "azurerm_container_app" "container_app" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-container-app-002-rg/providers/Microsoft.App/containerApps/selc-d-iam-ms-ca"
name = "selc-d-iam-ms-ca"
tags = {
"CostCenter" = "TS310 - PAGAMENTI & SERVIZI"
"CreatedBy" = "Terraform"
"Environment" = "Dev"
"Owner" = "Selfcare"
"Source" = "https://github.com/pagopa/selfcare"
}
# (10 unchanged attributes hidden)
~ template {
# (6 unchanged attributes hidden)
~ container {
~ image = "ghcr.io/pagopa/selfcare-iam-ms:sha-9069e0e" -> "ghcr.io/pagopa/selfcare-iam-ms:sha-$(git r"
name = "selc-d-iam-ms"
# (5 unchanged attributes hidden)
# (15 unchanged blocks hidden)
}
# (1 unchanged block hidden)
}
# (7 unchanged blocks hidden)
}
# module.apim_api.module.apim_api.azurerm_api_management_api_policy.this[0] will be updated in-place
~ resource "azurerm_api_management_api_policy" "this" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-api-v2-rg/providers/Microsoft.ApiManagement/service/selc-d-apim-v2/apis/selc-d-api-iam"
~ xml_content = <<-EOT
- <policies>
- <inbound>
- <cors allow-credentials="true">
- <allowed-origins>
- <origin>https://dev.selfcare.pagopa.it</origin>
- <origin>https://api.dev.selfcare.pagopa.it</origin>
- <origin>http://localhost:3000</origin>
- </allowed-origins>
- <allowed-methods>
- <method>GET</method>
- <method>POST</method>
- <method>PUT</method>
- <method>HEAD</method>
- <method>DELETE</method>
- <method>OPTIONS</method>
- </allowed-methods>
- <allowed-headers>
- <header>*</header>
- </allowed-headers>
- </cors>
- <base />
- </inbound>
- <backend>
- <base />
- </backend>
- <outbound>
- <base />
- </outbound>
- <on-error>
- <base />
- </on-error>
+ <policies>
+ <inbound>
+ <cors allow-credentials="true">
+ <allowed-origins>
+ <origin>https://api.dev.selfcare.pagopa.it</origin>
+
+ <origin>https://dev.selfcare.pagopa.it</origin>
+
+ <origin>https://pnpg.dev.selfcare.pagopa.it</origin>
+
+
+ <origin>http://localhost:3000</origin>
+
+ </allowed-origins>
+ <allowed-methods>
+ <method>GET</method>
+ <method>POST</method>
+ <method>PUT</method>
+ <method>HEAD</method>
+ <method>DELETE</method>
+ <method>OPTIONS</method>
+ </allowed-methods>
+ <allowed-headers>
+ <header>*</header>
+ </allowed-headers>
+ </cors>
+ <base />
+ <!--
+ Multitenant tenant resolution & propagation.
+ See apps/docs/Multitenant/Step_0/{REQUIREMENTS,ARCHITECTURE,SECURITY}.md (SELC-1, SELC-2).
+ Single API group serving every tenant frontend listed in tenant_ids: X-Tenant-Id is
+ ALWAYS derived here from the calling origin against that origin -> tenant list, never
+ trusted from the caller.
+
+ Matching is EXACT on the serialised origin (scheme + authority), never a prefix: a
+ prefix test would let https://selfcare.pagopa.it.attacker.example resolve as the AR
+ tenant. When the request carries no Origin, the Referer is parsed and reduced to its
+ scheme + authority before the same exact lookup.
+
+ A subscription listed in var.service_caller_tenants is resolved FIRST, regardless of
+ Origin/Referer. A non-browser caller controls those headers and must not be able to
+ override the tenant pinned to its APIM credential by pretending to be a browser.
+ Note the caller cannot express its tenant any other way:
+ the policy below OVERRIDES X-Tenant-Id unconditionally, so a header set by the calling
+ application is discarded — mapping the subscription is the only supported mechanism.
+
+ An unknown subscription then follows browser origin resolution. var.default_tenant_id
+ applies to origin-less requests, or to the exact operations listed in
+ var.default_tenant_operation_ids (for example a SAML ACS posted from the IdP origin).
+ It defaults to null => rejected.
+ -->
+ <set-variable name="callerOrigin" value="@{
+ var origin = context.Request.Headers.GetValueOrDefault("Origin", "");
+ if (string.IsNullOrEmpty(origin)) {
+ var referer = context.Request.Headers.GetValueOrDefault("Referer", "");
+ if (!string.IsNullOrEmpty(referer)) {
+ try {
+ var refererUri = new Uri(referer);
+ origin = refererUri.Scheme + "://" + refererUri.Authority;
+ } catch (Exception) {
+ origin = "";
+ }
+ }
+ }
+ return origin.Trim().ToLowerInvariant();
+ }" />
+ <set-variable name="resolvedTenant" value="@{
+ var tenantByOrigin = new Dictionary<string, string> {
+
+ { "https://dev.selfcare.pagopa.it", "AR" },
+
+ { "https://pnpg.dev.selfcare.pagopa.it", "PNPG" },
+
+
+ { "http://localhost:3000", "AR" },
+
+ };
+ var tenantBySubscription = new Dictionary<string, string> {
+
+ };
+ var subscriptionId = context.Subscription == null ? "" : (context.Subscription.Id ?? "").ToLowerInvariant();
+ string serviceTenant;
+ if (!string.IsNullOrEmpty(subscriptionId) && tenantBySubscription.TryGetValue(subscriptionId, out serviceTenant)) {
+ return serviceTenant;
+ }
+
+ var defaultOperations = new HashSet<string> {
+
+ };
+ var caller = (string)context.Variables["callerOrigin"];
+ if (string.IsNullOrEmpty(caller)) {
+ return defaultOperations.Count == 0 || defaultOperations.Contains(context.Operation.Id)
+ ? ""
+ : "";
+ }
+ string tenant;
+ if (tenantByOrigin.TryGetValue(caller, out tenant)) {
+ return tenant;
+ }
+ return defaultOperations.Contains(context.Operation.Id) ? "" : "";
+ }" />
+ <choose>
+ <when condition="@(string.IsNullOrEmpty((string)context.Variables["resolvedTenant"]))">
+ <return-response>
+ <set-status code="403" reason="Forbidden" />
+ <set-header name="Content-Type" exists-action="override">
+ <value>application/problem+json</value>
+ </set-header>
+ <set-body>@("{\"status\":403,\"title\":\"tenant_url_mismatch\",\"detail\":\"Calling URL does not match any known tenant frontend for this API, or the request carried no Origin/Referer and this API defines no default tenant.\"}")</set-body>
+ </return-response>
+ </when>
+ </choose>
+ <set-header name="X-Tenant-Id" exists-action="override">
+ <value>@((string)context.Variables["resolvedTenant"])</value>
+ </set-header>
+ </inbound>
+ <backend>
+ <base />
+ </backend>
+ <outbound>
+ <base />
+ </outbound>
+ <on-error>
+ <base />
+ </on-error>
</policies>
EOT
# (3 unchanged attributes hidden)
}
Plan: 0 to add, 2 to change, 0 to destroy. |
📖 Terraform Plan (infra/resources/registry-proxy/dev-ar) - successShow Plan # module.container_app_registry_proxy_ms.azurerm_container_app.container_app will be updated in-place
~ resource "azurerm_container_app" "container_app" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-container-app-002-rg/providers/Microsoft.App/containerApps/selc-d-party-reg-proxy-ca"
name = "selc-d-party-reg-proxy-ca"
tags = {
"CostCenter" = "TS310 - PAGAMENTI & SERVIZI"
"CreatedBy" = "Terraform"
"Environment" = "Dev"
"Owner" = "Selfcare"
"Source" = "https://github.com/pagopa/selfcare"
}
# (10 unchanged attributes hidden)
~ template {
# (6 unchanged attributes hidden)
~ container {
~ image = "ghcr.io/pagopa/selfcare-registry-proxy-ms:sha-f5b052e" -> "ghcr.io/pagopa/selfcare-registry-proxy-ms:sha-$(git r"
name = "selc-d-party-reg-proxy"
# (5 unchanged attributes hidden)
# (61 unchanged blocks hidden)
}
# (1 unchanged block hidden)
}
# (23 unchanged blocks hidden)
}
# module.apim_api_registry_proxy.module.apim_api.azurerm_api_management_api_policy.this[0] will be updated in-place
~ resource "azurerm_api_management_api_policy" "this" {
id = "/subscriptions/1ab5e788-3b98-4c63-bd05-de0c7388c853/resourceGroups/selc-d-api-v2-rg/providers/Microsoft.ApiManagement/service/selc-d-apim-v2/apis/selc-d-api-bff-proxy"
~ xml_content = <<-EOT
- <policies>
- <inbound>
- <cors allow-credentials="true">
- <allowed-origins>
- <origin>https://dev.selfcare.pagopa.it</origin>
- <origin>https://api.dev.selfcare.pagopa.it</origin>
- <origin>http://localhost:3000</origin>
- </allowed-origins>
- <allowed-methods>
- <method>GET</method>
- <method>POST</method>
- <method>PUT</method>
- <method>HEAD</method>
- <method>DELETE</method>
- <method>OPTIONS</method>
- </allowed-methods>
- <allowed-headers>
- <header>*</header>
- </allowed-headers>
- </cors>
- <base />
- </inbound>
- <backend>
- <base />
- </backend>
- <outbound>
- <base />
- </outbound>
- <on-error>
- <base />
- </on-error>
+ <policies>
+ <inbound>
+ <cors allow-credentials="true">
+ <allowed-origins>
+ <origin>https://api.dev.selfcare.pagopa.it</origin>
+
+ <origin>https://dev.selfcare.pagopa.it</origin>
+
+ <origin>https://pnpg.dev.selfcare.pagopa.it</origin>
+
+
+ <origin>http://localhost:3000</origin>
+
+ </allowed-origins>
+ <allowed-methods>
+ <method>GET</method>
+ <method>POST</method>
+ <method>PUT</method>
+ <method>HEAD</method>
+ <method>DELETE</method>
+ <method>OPTIONS</method>
+ </allowed-methods>
+ <allowed-headers>
+ <header>*</header>
+ </allowed-headers>
+ </cors>
+ <base />
+ <!--
+ Multitenant tenant resolution & propagation.
+ See apps/docs/Multitenant/Step_0/{REQUIREMENTS,ARCHITECTURE,SECURITY}.md (SELC-1, SELC-2).
+ Single API group serving every tenant frontend listed in tenant_ids: X-Tenant-Id is
+ ALWAYS derived here from the calling origin against that origin -> tenant list, never
+ trusted from the caller.
+
+ Matching is EXACT on the serialised origin (scheme + authority), never a prefix: a
+ prefix test would let https://selfcare.pagopa.it.attacker.example resolve as the AR
+ tenant. When the request carries no Origin, the Referer is parsed and reduced to its
+ scheme + authority before the same exact lookup.
+
+ A subscription listed in var.service_caller_tenants is resolved FIRST, regardless of
+ Origin/Referer. A non-browser caller controls those headers and must not be able to
+ override the tenant pinned to its APIM credential by pretending to be a browser.
+ Note the caller cannot express its tenant any other way:
+ the policy below OVERRIDES X-Tenant-Id unconditionally, so a header set by the calling
+ application is discarded — mapping the subscription is the only supported mechanism.
+
+ An unknown subscription then follows browser origin resolution. var.default_tenant_id
+ applies to origin-less requests, or to the exact operations listed in
+ var.default_tenant_operation_ids (for example a SAML ACS posted from the IdP origin).
+ It defaults to null => rejected.
+ -->
+ <set-variable name="callerOrigin" value="@{
+ var origin = context.Request.Headers.GetValueOrDefault("Origin", "");
+ if (string.IsNullOrEmpty(origin)) {
+ var referer = context.Request.Headers.GetValueOrDefault("Referer", "");
+ if (!string.IsNullOrEmpty(referer)) {
+ try {
+ var refererUri = new Uri(referer);
+ origin = refererUri.Scheme + "://" + refererUri.Authority;
+ } catch (Exception) {
+ origin = "";
+ }
+ }
+ }
+ return origin.Trim().ToLowerInvariant();
+ }" />
+ <set-variable name="resolvedTenant" value="@{
+ var tenantByOrigin = new Dictionary<string, string> {
+
+ { "https://dev.selfcare.pagopa.it", "AR" },
+
+ { "https://pnpg.dev.selfcare.pagopa.it", "PNPG" },
+
+
+ { "http://localhost:3000", "AR" },
+
+ };
+ var tenantBySubscription = new Dictionary<string, string> {
+
+ };
+ var subscriptionId = context.Subscription == null ? "" : (context.Subscription.Id ?? "").ToLowerInvariant();
+ string serviceTenant;
+ if (!string.IsNullOrEmpty(subscriptionId) && tenantBySubscription.TryGetValue(subscriptionId, out serviceTenant)) {
+ return serviceTenant;
+ }
+
+ var defaultOperations = new HashSet<string> {
+
+ };
+ var caller = (string)context.Variables["callerOrigin"];
+ if (string.IsNullOrEmpty(caller)) {
+ return defaultOperations.Count == 0 || defaultOperations.Contains(context.Operation.Id)
+ ? ""
+ : "";
+ }
+ string tenant;
+ if (tenantByOrigin.TryGetValue(caller, out tenant)) {
+ return tenant;
+ }
+ return defaultOperations.Contains(context.Operation.Id) ? "" : "";
+ }" />
+ <choose>
+ <when condition="@(string.IsNullOrEmpty((string)context.Variables["resolvedTenant"]))">
+ <return-response>
+ <set-status code="403" reason="Forbidden" />
+ <set-header name="Content-Type" exists-action="override">
+ <value>application/problem+json</value>
+ </set-header>
+ <set-body>@("{\"status\":403,\"title\":\"tenant_url_mismatch\",\"detail\":\"Calling URL does not match any known tenant frontend for this API, or the request carried no Origin/Referer and this API defines no default tenant.\"}")</set-body>
+ </return-response>
+ </when>
+ </choose>
+ <set-header name="X-Tenant-Id" exists-action="override">
+ <value>@((string)context.Variables["resolvedTenant"])</value>
+ </set-header>
+ </inbound>
+ <backend>
+ <base />
+ </backend>
+ <outbound>
+ <base />
+ </outbound>
+ <on-error>
+ <base />
+ </on-error>
</policies>
EOT
# (4 unchanged attributes hidden)
}
Plan: 0 to add, 2 to change, 0 to destroy.
Warning: Redundant ignore_changes element
on ../../_modules/ai_search_ipa/ipa.tf line 37, in resource "restapi_object" "ipa_institution_index":
37: resource "restapi_object" "ipa_institution_index" {
Adding an attribute name to ignore_changes tells Terraform to ignore future
changes to the argument in configuration after the object has been created,
retaining the value originally configured.
The attribute api_data is decided by the provider alone and therefore there
can be no configured value to compare with. Including this attribute in
ignore_changes has no effect. Remove the attribute from ignore_changes to
quiet this warning.
(and 9 more similar warnings elsewhere) |
List of Changes
apps/docs/Multitenant/Step_0/REQUIREMENTS.md.apps/docs/Multitenant/Step_0/ARCHITECTURE.md.apps/docs/Multitenant/Step_0/SECURITY.md.apps/docs/Multitenant/Step_0/EPIC.md.Motivation and Context
The current platform runs separate backend deployments for
selfcare.pagopa.itandimprese.notifichedigitali.it.This PR documents the first aligned specification package for moving to a shared multitenant backend while preserving tenant isolation, APIM-based tenant resolution, and JWT/header tenant consistency across both authentication flows.
How Has This Been Tested?
REQUIREMENTS.md,ARCHITECTURE.md,SECURITY.md, andEPIC.md.Screenshots (if appropriate):
N/A
Types of changes
Checklist: