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¶
- Resolver: add a small
resolveTenantContext(orgId)(either onVelocityTenantServiceor a tiny new provider) returning{ tenantId, organizationDid }from the org row, throwing a cleanBadRequestExceptionwhentenant_status !== 'ACTIVE'orvelocity_registrar_idis null ("Organization has no active Velocity tenant — enable issuer capabilities first"). - Replace call sites (the org entity is already loaded in most of
these paths, e.g. issuance fetches it for
legalEmployer): manual-credential.service.ts— allstaging.tenantIdusescredential.service.tsrevoke —staging.tenantIdclaim.service.ts+notification.service.ts— issuer DID for deep links / claim manifests →organization.organization_did(offers carryorg_id, so the org is one lookup away)vnf-sdk.service.ts— audit each use; most are POC/mock-era paths- Cache:
resolveIssuerServiceIdinmanual-credential.servicealready caches per-tenantId (issuerServiceIdByTenantmap) — verify the map key stays the per-org tenant id, then no change needed. - Keep as global env (legitimately platform-level):
VNF_CAO_DID(staging.caoDid) — the platform's Credential Agent Operator identity, shared across tenants by designVNF_CIH_API_URL/VNF_CIH_BEARER_TOKEN— one CIH account operates all tenants- keys.json fallback in
resolveTenantKeys(velocity-tenant.service:211) — only applies whenorganization_did === staging.organizationDid, i.e. the platform's own org; harmless to keep - Deprecate:
VNF_TENANT_ID,VNF_ISSUER_DID— remove from code;.env.examplecommentVNF_ORGANIZATION_DIDas "platform's own org only (keys.json fallback), NOT used for issuance". - Backfill/ops note: orgs whose tenant was created before
velocity_registrar_idexisted (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_idper org - Issue one credential from each org → CIH
credentials/getper 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_DIDremain
Cross-references¶
- 26-claim-webhook-registration.md — where the fragility surfaced (tenant recreation → stale env pin)
- 25-phase-a-pending-tasks.md — Phase A checklist
app/backend/src/admin/config/velocity-staging.config.ts— the config being slimmed