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.OrganizationPrisma model — 12 fields, self-referentialparentOrgIdfor hierarchy, soft-delete viadeletedAt,metadataJSON. 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, butUsersService.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-missingregistrationNumbershould 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.organizationIdis an FK, but no read-path filters on it — aSystemPermissions.OrganizationReaduser in org A sees every org row in the DB, and downstreamUsersService.list()returns every user regardless of org. - DPPA 2019 §21 — PII minimisation on the contact fields.
billingEmail,contactPhone,taxPin,addressare plaintext. Every DB dump and every audit-event snapshot exposes them. - BOU cybersecurity §5.4 — throttle + audit trail. Every
mutation is unthrottled; the
organization.updateaudit 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 toUser[], nopiiEncryptedAt/retentionScrubbedAt/sanctionsScreeningIdcolumns. - Cross-callers: zero. No other service injects
OrganizationsService.
What works today
- Parent-child hierarchy is enforced:
create()verifies theparentOrgIdexists 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+codeare auto-generated +@unique: duplicate references / codes are prevented.
Gaps
All 12 gaps closed. See shipped-notes below.
Release org-p1-20260718-2000. Closes gaps 1–4:
- gap 1 —
SUBJECT_TYPESextended withorganization;SanctionsService.loadSubject('organization', id)reads the org name (post-decrypt).OrganizationsService.create()runsscreenForPayout('organization', ...)after write; on block, flips the org tostatus='suspended'+ emitsorganization.sanctions_blockaudit event + throwsApiError.forbidden. - gap 2 — Migration
20260718210000_organizations_phase1addsbillingEmailHash,contactPhoneHash,taxPinHash,addressHash,piiEncryptedAt+ indexes.OrganizationsService.create()+update()cipher plaintext + write hash siblings on every touch viaPiiCipher. Read paths round-trip legacy plaintext viaPiiCipher.decryptfallback. - gap 3 — New
ObjectAuthorizationService.assertOrganizationAccesshelper: super / ops / finance / compliance pass; caller's ownorganizationIdpasses; immediate parent chain (one level) passes.OrganizationsService.list()scopes the where-clause on the caller'sorganizationIdfor non- elevated roles.findById()/update()/suspend()/reinstate()all invoke the assert. - gap 4 — Migration adds
Organization.retentionScrubbedAt- index. New
data_sharing.organizations_retention_dayspolicy (default 2555 = 7 y).RetentionPurgeJobscrubsbillingEmail/contactPhone/taxPin/address(+ hashes) on soft-deleted orgs past window + emitsorganization.retention.pii_scrubbed.
- index. New
Locked in by src/modules/organizations/organizations-phase1.spec.ts
(8 cases). Test suite: 563/563 (83 suites).
Release org-p2-20260718-2014. Closes gaps 5–8:
- gap 5 —
OrganizationsService.suspend()calls newcascadeOnOrgSuspend()helper that flips everyUser.status='suspended'for users in the org (blocking new sessions) and flips every user'sAgent.status='suspended'(blocking new referrals). Aggregate audit eventorganization.suspend.cascaderecords the counts. - gap 6 — Migration
20260718220000_organizations_phase2addsOrganization.registrationNumber(nullable) + partial unique index (WHERE NOT NULL).CreateOrganizationDtoaccepts the field; duplicate registrations are rejected at DB level. - gap 7 — New
ORG_STATUSESsoft-enum (onboarding/active/suspended/inactive/archived) +ORG_STATUS_TRANSITIONSmatrix. Newactivate()(onboarding → active) +archive()(any → archived, terminal) service methods + controller endpoints.suspend()/reinstate()/archive()all callassertTransitionwhich refuses invalid state jumps (e.g. reactivating an archived org). - gap 8 —
OrganizationsService.update()audit event now carries decryptedbefore+aftersnapshots (previously only field names). Same shape onsuspend/reinstate/activate/archive. Audit-egress masking still applies viaAuditRedactoron the entityTypeorganization(extendable via theENTITY_TYPE_PII_KEYSregistry 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).
Release org-p3-20260718-2032. Closes gaps 9–12:
- gap 9 — Six idempotent wrappers (
createIdempotent,updateIdempotent,suspendIdempotent,reinstateIdempotent,activateIdempotent,archiveIdempotent) via sharedIdempotencyService. Controller passesIdempotency-Keyheader through every mutation. - gap 10 —
@Throttleon every mutation:POST /organizations5/min,PATCH /:id20/min,POST /:id/suspend+/reinstate+/activate10/min,POST /:id/archive5/min. GET endpoints unthrottled. - gap 11 —
OrganizationsServiceinjects@Optional() NotificationDispatchService+@Optional() RoleFanoutService. NewnotifyLifecycle()helper dispatches anorganization.<kind>template to the org'sbillingEmail(viadispatchDirect— bypasses user-preference lookup because the billing contact isn't necessarily a platform User) oncreated / updated / suspended / reinstated / activated / archived. Onsuspended+archived, additionally fans outorganization.<kind>.complianceto the compliance-officer role viaRoleFanoutService. Best-effort; template lookup failures + missing billing suppress silently. - gap 12 —
ObjectAuthorizationService.assertOrganizationAccesshelper 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.