Skip to main content

Connector → Platform Tenant Mapping

Which platform tenant (PLATFORM_TENANT_ID) each connector submits its NormalizedGraph to, and why. Getting this wrong lands demo data in the wrong tenant — the failure that motivated sv0-connectors#181.

Source of truth: sv0-platform/src/domain/tenants/canonical-tenants.ts (the CANONICAL_TENANTS registry, #1226). This runbook is the operator-facing restatement of that registry from the connector side; if the two ever disagree, the registry wins.


The mapping

ConnectorSource system(s)Platform tenantTenant class
entra-servicenowAzure Entra ID + ServiceNow, on SV0's shared Azure subscription 2a25bc41-c1ce-4d04-9cb6-a62deccc3bcccontosodemo_real
azure-foundryMicrosoft AI Foundry, on the same Azure subscriptioncontosodemo_real
azure-sentinel-socMicrosoft Sentinel / Logic App playbooks / Defender·Intune action paths, on the same Azure subscription (demo lab sv0-demo-labs/labs/x2-sentinel-soc)contosodemo_real
aws (multi-account)SV0-owned AWS accounts (workloads/security/data) with Entra→AWS federationenterprise-nimbusdemo_real
aws (single account, lab-1)SV0-owned AWS account (sv0-demo-labs lab-1)nimbus-clouddemo_real
jira-cloudAtlassian Jira Cloud — Jira Service Management webhooks → AWS Lambda audit motiondemo-jira-awsdemo_seed

Rule of thumb: everything scanned from SV0's Azure estate (Entra, ServiceNow, Foundry, Sentinel SOC) is the Contoso demo and goes to contoso. The Nimbus tenants (enterprise-nimbus, nimbus-cloud) are AWS-only. Never submit Entra/ServiceNow/Foundry/Sentinel data to a Nimbus tenant. Jira Cloud goes to demo-jira-aws (the Atlassian+AWS audit demo — demo_seed, distinct from the AWS-connector Nimbus tenants).

contoso (real) vs demo-microsoft-soc (synthetic). The azure-sentinel-soc connector scanning SV0's real Azure estate goes to contoso. Do not confuse this with demo-microsoft-soc — a demo_seed sandbox tenant the platform's scripts/seed-demo-sentinel.ts creates on the fly with fabricated subscription/tenant ids to demo the same investigation→action drift without any cloud spend. Real connector output must not be submitted to demo-microsoft-soc (the seed wipes it and fails if it already holds data).

⚠️ Do not confuse enterprise-nimbus (the canonical multi-account AWS slug) with nimbus-enterprise — the latter is a reversed typo, not a registered tenant. Submissions to it land in an orphan bucket.


Full refresh set per tenant — run ALL of these (tenant-first view)

The table above is connector-first ("which tenant does connector X feed?"). When you are asked to fully re-scan / refresh a tenant, you need the inverse: every connector that feeds it. A real tenant is populated by multiple connectors, and running only the obvious one silently drops cross-system reach (see the next section).

TenantClassConnectors to run for a FULL refresh
contosodemo_realentra-servicenow + azure-foundry + azure-sentinel-soc (all three)
enterprise-nimbusdemo_realaws (multi-account) + entra-servicenow (Entra→AWS federation)
nimbus-clouddemo_realaws (single account, lab-1)
demo-jira-awsdemo_seedjira-cloud (seed-built)
demo-w1demo_seedseed-demo-w1.ts (synthetic — not a connector)
demo-microsoft-socdemo_seedseed-demo-sentinel.ts (synthetic — not a connector)

The trap (sv0-platform#1669): "refresh contoso" by running only azure-foundry (skipping azure-sentinel-soc) succeeds with no error but the SOC agent's blast radius to Defender silently disappears. contoso and enterprise-nimbus are the multi-connector real tenants where partial refreshes bite.

Cross-connector bridges — why co-ingestion is mandatory

Cross-system reach (an agent in one system reaching a resource in another) is materialized platform-side by correlation rules in sv0-platform/src/ingestion/stitching/rules/registry.ts. Each rule fires only when BOTH connectors have ingested entities into the same tenant — the two connectors stamp different source_system values for the same real resource, so the natural ingest key never merges them; only the bridge rule does. Run one connector and skip its partner and the bridge cannot form — no error, just missing reach.

Bridge ruleConnector A ↔ Connector BMatches onAffected tenant(s)
azure-arm-resource-adjacencyazure-foundry ↔ azure-sentinel-socARM resource idcontoso
aws-oidc-federation-to-entra-sp / aws-saml-federation-to-entra-spaws ↔ entra-servicenowfederation trust subject ↔ Entra SPenterprise-nimbus
connection-endpoint-bridgeany ↔ anynormalized endpoint URIe.g. demo-jira-aws (webhook→Lambda)
human-identity-by-emailany ↔ anyemailany multi-connector tenant

Verify after a refresh. A post-ingest check prints a per-bridge coverage table for the tenant, showing whether each cross-system bridge has the match keys it needs on both join sides:

TENANT=contoso npx tsx sv0-platform/scripts/check-tenant-bridge-coverage.ts

Treat it as a diagnostic, not a hard gate. It exits non-zero only for a bridge whose join sides name concrete (non-wildcard) source systems. The azure-arm-resource-adjacency (foundry↔sentinel) bridge has wildcard join sides, so a foundry-only contoso refresh — the exact #1669 case — can still exit 0. Read the table and confirm the sentinel rows carry keys; do not rely on the exit code alone for that bridge. Run it whenever you refresh a demo_real tenant.


Why contoso (and the default history)

contoso is the demo persona for "real Entra/ServiceNow/Foundry scans against SV0's own Azure subscription." It used to be the slug default — renamed to contoso in sv0-platform#1203 (the demo_real classifier was repointed from default to contoso in canonical-tenants.ts). Two historical traps this caused:

  • The shared PlatformClient defaulted to tenant_id="default" (shared/sv0_common/sv0_common/platform_client.py). Any connector run that didn't set a tenant silently landed data in default — which is why the Contoso data lived there. The CLIs now fail loud on a missing tenant rather than defaulting (entra #122 / foundry #123 BREAKING CHANGE).
  • Connector .env files were seen pointing PLATFORM_TENANT_ID at enterprise-nimbus (an AWS tenant) for the Entra/Foundry connectors — wrong. Corrected under sv0-connectors#181.

Nimbus Enterprise (enterprise-nimbus) is a different demo: multi-account AWS + Entra→AWS federation. The name collision with "Entra" is the trap — enterprise-nimbus carries Entra→AWS federation edges discovered by the AWS connector, not a primary Entra/ServiceNow scan.


SV0-internal infra is filtered out of the Contoso scan

SV0 runs its own infrastructure in the same Azure subscription and Entra directory that hosts the Contoso demo, so a raw scan picks up a mix. SV0-internal assets are dropped by an explicit tag denylist — anything tagged sv0_internal=true (Azure resource tag or Entra SP tag) is excluded.

  • Single source of truth: shared/sv0_common/sv0_common/asset_filter.py
  • The filter is on by default; SV0_INTERNAL_TAG=none disables it (only when SV0 deliberately scans its own tenant).
  • Deterministic, exact-match tag check — no heuristics (platform determinism rule).

See sv0-connectors#181 / sv0-connectors#182.


Setting the tenant per run

PLATFORM_TENANT_ID in the connector's .env, or --tenant-id on the CLI (flag overrides env). The platform URL is independent (PLATFORM_URL / --platform-url).

# entra-servicenow → contoso
entra-servicenow --all --submit \
--platform-url https://dev.securityv0.com --tenant-id contoso

# azure-foundry → contoso
azure-foundry --all --submit \
--platform-url https://dev.securityv0.com --tenant-id contoso

Submitting to any *.securityv0.com target also requires CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET (Cloudflare Access service token) in the environment — the CLI fails loud without them.


Connector API keys are per-environment

The tenant slug is the same across environments, but the connector API key (PLATFORM_API_KEY) is not. Keys are minted per connector-instance and stored hashed in the platform's connector_instance_api_keys collection; the ingest route validates them in bearer-token-middleware.ts. A key minted for one deployment returns 401 INVALID_BEARER_TOKEN on another.

There are two mint paths:

1. Interactive admin API (the intended path for humans).

POST /api/v1/admin/connector-instances/:instanceId/api-keys

Gated by isSuperAdmin and blocks non-interactive / agent sessions by design (403 ADMIN_REQUIRES_INTERACTIVE_SESSION — long-lived connector keys must be attributed to a human browser session). Run it from a logged-in super-admin browser, or via scripts/cli/register-tenant-connector.ts from an interactive staff session. Plaintext is shown once — copy it into the connector .env as PLATFORM_API_KEY immediately.

2. Direct Mongo insert (CI / operator path, non-interactive). The sanctioned automated path (scripts/ci-mint-connector-key.ts). A key is just a row in connector_instance_api_keys; ingest auth resolves the tenant from the key record's tenant_id slug (bearer-token-middleware.tsfindConnectorApiKeyByHash), so the row alone is sufficient — the connector instance need not pre-exist. The row shape:

{
_id: new ObjectId(), // Mongo auto-generates if omitted
connector_instance_id: "contoso-entra-servicenow-default", // any stable id
tenant_id: "contoso", // ← binds the tenant
key_hash: sha256(plaintext), // sha256 hex of the plaintext
key_prefix: plaintext.slice(0, 12),
created_at: new Date(), created_by_user_id: "<who/why>",
revoked_at: null, last_used_at: null,
}

Plaintext format depends on the minter: ci-mint-connector-key.ts emits sv0_ci_<64hex>; the admin API emits sv0_<env>_<64hex> (env ∈ dev/staging/prod). Either is accepted — only the hash is stored, and the verifier matches on key_hash, not the prefix.

Insert into the DB the target API actually reads — MONGODB_DB overrides the MONGODB_URI path (env.ts). Per environment:

  • Dev (Hetzner): local mongo:7 container per stack. dev.securityv0.comsv0-main-apisv0-main-mongo, DB sv0_platform. ssh deploy@<hetzner> 'docker exec -i sv0-main-mongo mongosh sv0_platform --eval "…insertMany…"'
  • Staging (Azure): Mongo is Atlas (mongodb+srv://…/sv0_staging), reachable only from the VM. DB sv0_staging. sv0admin@staging-ssh.securityv0.com (Cloudflare Access SSH; cloudflared access ssh-gen), then insert via the app's own driver: sudo docker exec -i sv0-api node --input-type=module piping a script that reads process.env.MONGODB_URI.

Generate plaintext locally; insert only the hash. Multiple active keys per tenant are fine (verify matches any non-revoked hash). To rotate, insert a new key, swap .env, then set revoked_at on the old row.

If a submit returns INVALID_BEARER_TOKEN, the key is stale/wrong for that environment — re-mint and update .env; do not change the tenant slug.


References