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
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) 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) with nimbus-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 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