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
| Connector | Source system(s) | Platform tenant | Tenant class |
|---|---|---|---|
| entra-servicenow | Azure Entra ID + ServiceNow, on SV0's shared Azure subscription 2a25bc41-c1ce-4d04-9cb6-a62deccc3bcc | contoso | demo_real |
| azure-foundry | Microsoft AI Foundry, on the same Azure subscription | contoso | demo_real |
| azure-sentinel-soc | Microsoft Sentinel / Logic App playbooks / Defender·Intune action paths, on the same Azure subscription (demo lab sv0-demo-labs/labs/x2-sentinel-soc) | contoso | demo_real |
| aws (multi-account) | SV0-owned AWS accounts (workloads/security/data) with Entra→AWS federation | enterprise-nimbus | demo_real |
| aws (single account, lab-1) | SV0-owned AWS account (sv0-demo-labs lab-1) | nimbus-cloud | demo_real |
| jira-cloud | Atlassian Jira Cloud — Jira Service Management webhooks → AWS Lambda audit motion | demo-jira-aws | demo_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) vsdemo-microsoft-soc(synthetic). The azure-sentinel-soc connector scanning SV0's real Azure estate goes tocontoso. Do not confuse this withdemo-microsoft-soc— ademo_seedsandbox tenant the platform'sscripts/seed-demo-sentinel.tscreates 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 todemo-microsoft-soc(the seed wipes it and fails if it already holds data).
⚠️ Do not confuse
enterprise-nimbus(the canonical multi-account AWS slug) withnimbus-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).
| Tenant | Class | Connectors to run for a FULL refresh |
|---|---|---|
contoso | demo_real | entra-servicenow + azure-foundry + azure-sentinel-soc (all three) |
enterprise-nimbus | demo_real | aws (multi-account) + entra-servicenow (Entra→AWS federation) |
nimbus-cloud | demo_real | aws (single account, lab-1) |
demo-jira-aws | demo_seed | jira-cloud (seed-built) |
demo-w1 | demo_seed | seed-demo-w1.ts (synthetic — not a connector) |
demo-microsoft-soc | demo_seed | seed-demo-sentinel.ts (synthetic — not a connector) |
The trap (sv0-platform#1669): "refresh contoso" by running only
azure-foundry(skippingazure-sentinel-soc) succeeds with no error but the SOC agent's blast radius to Defender silently disappears.contosoandenterprise-nimbusare 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 rule | Connector A ↔ Connector B | Matches on | Affected tenant(s) |
|---|---|---|---|
azure-arm-resource-adjacency | azure-foundry ↔ azure-sentinel-soc | ARM resource id | contoso |
aws-oidc-federation-to-entra-sp / aws-saml-federation-to-entra-sp | aws ↔ entra-servicenow | federation trust subject ↔ Entra SP | enterprise-nimbus |
connection-endpoint-bridge | any ↔ any | normalized endpoint URI | e.g. demo-jira-aws (webhook→Lambda) |
human-identity-by-email | any ↔ any | any 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
PlatformClientdefaulted totenant_id="default"(shared/sv0_common/sv0_common/platform_client.py). Any connector run that didn't set a tenant silently landed data indefault— 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
.envfiles were seen pointingPLATFORM_TENANT_IDatenterprise-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=nonedisables 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.ts →
findConnectorApiKeyByHash), 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:7container per stack.dev.securityv0.com→sv0-main-api→sv0-main-mongo, DBsv0_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. DBsv0_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=modulepiping a script that readsprocess.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
sv0-platform/src/domain/tenants/canonical-tenants.ts— canonical registrysv0-platform/src/domain/tenants/types.ts—SV0_DEMO_REAL_SLUGS, tenant classes- runbooks/07-entra-servicenow-environment.md
- runbooks/08-azure-foundry-environment.md
- sv0-connectors#181 — tenant pinning + this mapping
- sv0-platform#1203 —
default→contosorename