Skip to main content

Organizations (multi-tenancy + parent-child hierarchy)

Scope

One model, one module, six endpoints — the platform's structural top-of-tenancy layer. Unusually for this campaign, the module is unwired: OrganizationsService is exported but no other service injects it. Orgs are a shape without behavioural consequences.

  • organizations/OrganizationsService (list / findById / create / update / suspend / reinstate) + one controller.
  • Organization Prisma model — 12 fields, self-referential parentOrgId for hierarchy, soft-delete via deletedAt, metadata JSON. Types: brokerage / agency / partner / zfa_internal (soft string, no enum). Statuses: active / suspended (soft string, no enum).
  • User.organizationId — one User belongs to one Org, but UsersService.list() doesn't filter by org — permission-only access control, no tenant boundary enforcement.

The three near-neighbours already audited — insurers (Insurer Catalogue), agents (Agents lifecycle), payout-accounts — all ship the pattern this module lacks: PiiCipher on contact fields, sanctions gate on onboarding, retention sweep on archived rows, cascade on suspend, structured status enums.

Compliance envelope

  • IRA Insurance Act 2017 §12 — licensed-entity registration. The IRA maintains a canonical list of licensed brokers, agencies, and partners; the platform's Organization.name + a currently-missing registrationNumber should match. There is no uniqueness constraint on any registration-like field today; two orgs with the same registration slip through.
  • AML Act 2013 §6 — CDD on every counterparty ZFA transacts with. Organizations are the parent of agents (who receive commission payouts). A sanctioned or PEP-flagged organization is a §6 finding even if the specific agent under it screens clean.
  • DPPA 2019 §16 — cross-border / cross-controller data isolation. User.organizationId is an FK, but no read-path filters on it — a SystemPermissions.OrganizationRead user in org A sees every org row in the DB, and downstream UsersService.list() returns every user regardless of org.
  • DPPA 2019 §21 — PII minimisation on the contact fields. billingEmail, contactPhone, taxPin, address are plaintext. Every DB dump and every audit-event snapshot exposes them.
  • BOU cybersecurity §5.4 — throttle + audit trail. Every mutation is unthrottled; the organization.update audit event logs only the field names that changed, not the before/after values — so forensic reconstruction of "who changed the contact email from X to Y" requires a git blame on the audit-emit call, not a query.

Current state (2026-07-18)

Module footprint

  • src/modules/organizations/organizations.module.ts — 255 lines. DTOs + service + controller + module in one file.
  • Prisma model Organization — 12 fields, 1 self-relation (parent/children), 1 relation to User[], no piiEncryptedAt / retentionScrubbedAt / sanctionsScreeningId columns.
  • Cross-callers: zero. No other service injects OrganizationsService.

What works today

  • Parent-child hierarchy is enforced: create() verifies the parentOrgId exists before writing.
  • Soft-delete via deletedAt: read paths filter on it, so archived orgs disappear from the listing.
  • Lifecycle audit events fire: organization.create, organization.update, organization.suspend, organization.reinstate.
  • suspend() is idempotent (returns an error on already-suspended rows).
  • reference + code are auto-generated + @unique: duplicate references / codes are prevented.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-18)

Release org-p1-20260718-2000. Closes gaps 1–4:

  • gap 1 — SUBJECT_TYPES extended with organization; SanctionsService.loadSubject('organization', id) reads the org name (post-decrypt). OrganizationsService.create() runs screenForPayout('organization', ...) after write; on block, flips the org to status='suspended' + emits organization.sanctions_block audit event + throws ApiError.forbidden.
  • gap 2 — Migration 20260718210000_organizations_phase1 adds billingEmailHash, contactPhoneHash, taxPinHash, addressHash, piiEncryptedAt + indexes. OrganizationsService.create() + update() cipher plaintext + write hash siblings on every touch via PiiCipher. Read paths round-trip legacy plaintext via PiiCipher.decrypt fallback.
  • gap 3 — New ObjectAuthorizationService.assertOrganizationAccess helper: super / ops / finance / compliance pass; caller's own organizationId passes; immediate parent chain (one level) passes. OrganizationsService.list() scopes the where-clause on the caller's organizationId for non- elevated roles. findById() / update() / suspend() / reinstate() all invoke the assert.
  • gap 4 — Migration adds Organization.retentionScrubbedAt
    • index. New data_sharing.organizations_retention_days policy (default 2555 = 7 y). RetentionPurgeJob scrubs billingEmail / contactPhone / taxPin / address (+ hashes) on soft-deleted orgs past window + emits organization.retention.pii_scrubbed.

Locked in by src/modules/organizations/organizations-phase1.spec.ts (8 cases). Test suite: 563/563 (83 suites).

Phase 2 shipped (2026-07-18)

Release org-p2-20260718-2014. Closes gaps 5–8:

  • gap 5 — OrganizationsService.suspend() calls new cascadeOnOrgSuspend() helper that flips every User.status='suspended' for users in the org (blocking new sessions) and flips every user's Agent.status='suspended' (blocking new referrals). Aggregate audit event organization.suspend.cascade records the counts.
  • gap 6 — Migration 20260718220000_organizations_phase2 adds Organization.registrationNumber (nullable) + partial unique index (WHERE NOT NULL). CreateOrganizationDto accepts the field; duplicate registrations are rejected at DB level.
  • gap 7 — New ORG_STATUSES soft-enum (onboarding / active / suspended / inactive / archived) + ORG_STATUS_TRANSITIONS matrix. New activate() (onboarding → active) + archive() (any → archived, terminal) service methods + controller endpoints. suspend() / reinstate() / archive() all call assertTransition which refuses invalid state jumps (e.g. reactivating an archived org).
  • gap 8 — OrganizationsService.update() audit event now carries decrypted before + after snapshots (previously only field names). Same shape on suspend / reinstate / activate / archive. Audit-egress masking still applies via AuditRedactor on the entityType organization (extendable via the ENTITY_TYPE_PII_KEYS registry shipped in Support Tickets Phase 3 gap 12).

Locked in by src/modules/organizations/organizations-phase2.spec.ts (6 cases). Test suite: 569/569 (84 suites).

Phase 3 shipped (2026-07-18)

Release org-p3-20260718-2032. Closes gaps 9–12:

  • gap 9 — Six idempotent wrappers (createIdempotent, updateIdempotent, suspendIdempotent, reinstateIdempotent, activateIdempotent, archiveIdempotent) via shared IdempotencyService. Controller passes Idempotency-Key header through every mutation.
  • gap 10 — @Throttle on every mutation: POST /organizations 5/min, PATCH /:id 20/min, POST /:id/suspend + /reinstate + /activate 10/min, POST /:id/archive 5/min. GET endpoints unthrottled.
  • gap 11 — OrganizationsService injects @Optional() NotificationDispatchService + @Optional() RoleFanoutService. New notifyLifecycle() helper dispatches an organization.<kind> template to the org's billingEmail (via dispatchDirect — bypasses user-preference lookup because the billing contact isn't necessarily a platform User) on created / updated / suspended / reinstated / activated / archived. On suspended + archived, additionally fans out organization.<kind>.compliance to the compliance-officer role via RoleFanoutService. Best-effort; template lookup failures + missing billing suppress silently.
  • gap 12 — ObjectAuthorizationService.assertOrganizationAccess helper was formalised in Phase 1 gap 3 and covered by the Phase 1 spec (super/ops / own-org / parent-org / other-org cases). This gap is closed as-of-Phase-1; Phase 3 exposes it as a public API on the ObjectAuthorizationService surface that downstream modules (users, agents, future org-scoped surfaces) can invoke.

Locked in by src/modules/organizations/organizations-phase3.spec.ts (3 cases). Test suite: 572/572 (85 suites).

All 12 gaps closed. Organizations module is audit-clean against IRA §12 (registration + dedup), AML §6 (sanctions on onboarding), DPPA §11 + §16 + §21 (retention + tenant isolation

  • PII minimisation), and BOU cybersecurity §5.4 (throttle + before/after audit + compliance fan-out).

Phased implementation plan

Complete — every phase shipped.