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 |
| 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) is the Contoso demo and goes to contoso. The
Nimbus tenants (enterprise-nimbus, nimbus-cloud) are AWS-only. Never
submit Entra/ServiceNow/Foundry 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).
⚠️ 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.
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