Skip to content

Task 27: Per-Organization Tenant Resolution (remove single-tenant env pins)

Status: ⬜ PENDING — deliberately deferred (found 2026-07-16, during Task 26 testing) Priority: High — blocker for running more than one issuing organization (multi-org Beta) Effort: ~½–1 day code + a two-org end-to-end test pass

Problem

Organization onboarding is multi-tenant, but credential issuance is single-tenant. The two halves of the system disagree about where the Velocity identity comes from.

What the org-creation flow does (correct, per-org)

Each organization row already stores its own network identity:

Data Column Written by
Organization DID (uploaded at creation) organizations.organization_did org creation Step 3
Velocity keys (keys.json, encrypted) organizations.velocity_tenant_keys org creation Step 3
CIH tenant ID organizations.velocity_registrar_id velocity-tenant.service.ts:160 at enable-issuer

What the issuance/claim/revoke flows do (wrong, global env pins)

They ignore the org record and read one global identity from env:

Env var Config accessor Used by (verified 2026-07-16)
VNF_TENANT_ID staging.tenantId manual-credential.service.ts:669 (issuance: issuer-service, depots, create credential, issue links) · credential.service.ts:270 (revocation) · vnf-sdk.service.ts:96,312,317
VNF_ORGANIZATION_DID staging.organizationDid vnf-sdk.service.ts:180,271,293 (issuer identity in manifests/descriptors) · velocity-tenant.service.ts:211 (legitimate — "is this our own org" keys.json fallback)
VNF_ISSUER_DID raw configService.get notification.service.ts:280 (wallet deep links) · claim.service.ts:293,344 (claim manifest issuer id)

Note VNF_ORGANIZATION_DID and VNF_ISSUER_DID are two separate pins of what is conceptually the same value — they can drift apart silently.

Why it "works" today

There is exactly one organization, and the env var happens to point at its tenant. Proof of fragility: during Task 26 (2026-07-16) the tenant was recreated and got a new CIH id — every issuance/revocation would have silently failed (or worse, targeted a dead tenant) until VNF_TENANT_ID was hand-edited and the backend container recreated.

Failure scenario with two orgs

Org B is onboarded with its own DID/keys/tenant. An Org B admin issues a credential → manual-credential.service uses staging.tenantId → the credential is created under Org A's CIH tenant and presented to the wallet with Org A's issuer identity. Wrong issuer on a verifiable credential = correctness + trust failure, and Org A's tenant accumulates Org B's PII in its depots.

Fix design

  1. Resolver: add a small resolveTenantContext(orgId) (either on VelocityTenantService or a tiny new provider) returning { tenantId, organizationDid } from the org row, throwing a clean BadRequestException when tenant_status !== 'ACTIVE' or velocity_registrar_id is null ("Organization has no active Velocity tenant — enable issuer capabilities first").
  2. Replace call sites (the org entity is already loaded in most of these paths, e.g. issuance fetches it for legalEmployer):
  3. manual-credential.service.ts — all staging.tenantId uses
  4. credential.service.ts revoke — staging.tenantId
  5. claim.service.ts + notification.service.ts — issuer DID for deep links / claim manifests → organization.organization_did (offers carry org_id, so the org is one lookup away)
  6. vnf-sdk.service.ts — audit each use; most are POC/mock-era paths
  7. Cache: resolveIssuerServiceId in manual-credential.service already caches per-tenantId (issuerServiceIdByTenant map) — verify the map key stays the per-org tenant id, then no change needed.
  8. Keep as global env (legitimately platform-level):
  9. VNF_CAO_DID (staging.caoDid) — the platform's Credential Agent Operator identity, shared across tenants by design
  10. VNF_CIH_API_URL / VNF_CIH_BEARER_TOKEN — one CIH account operates all tenants
  11. keys.json fallback in resolveTenantKeys (velocity-tenant.service:211) — only applies when organization_did === staging.organizationDid, i.e. the platform's own org; harmless to keep
  12. Deprecate: VNF_TENANT_ID, VNF_ISSUER_DID — remove from code; .env.example comment VNF_ORGANIZATION_DID as "platform's own org only (keys.json fallback), NOT used for issuance".
  13. Backfill/ops note: orgs whose tenant was created before velocity_registrar_id existed (or whose tenant was re-created manually on CIH) need a one-time retry-tenant-creation or manual UPDATE to populate the column. Verify the single existing org row is correct at rollout.

Test plan (acceptance)

  • Unit: resolver throws for org with no/inactive tenant
  • Two-org E2E on staging: onboard Org B (own DID + keys.json), enable issuer → distinct velocity_registrar_id per org
  • Issue one credential from each org → CIH credentials/get per tenant shows each credential under its own tenant only
  • Wallet shows the correct issuer name/DID for each org's credential
  • Revoke works from each org and hits the right tenant
  • Deep links / claim manifests carry the issuing org's DID (not env)
  • Grep proves no runtime reads of VNF_TENANT_ID / VNF_ISSUER_DID remain

Cross-references