hitaji-erp-api/src/.
The three scoping identifiers
Accounting isolation is a layered model, not a single tenant column:Naming trap.@CurrentTenant()is a legacy alias for@TenantId()— it returns the validated tenant id, not a workspace id, even though many call-sites bind it to a parameter namedworkspaceId. Prefer@TenantId()in new code. (Seetenant/decorators/current-tenant.decorator.ts.)
How tenant id is resolved (and why the header is not enough)
extractBusinessContext() (common/business-context/business-context.ts) and the
accounting guard’s extractTenantId() resolve the tenant id in this order:
- JWT
tenant_idclaim (snake_case, legacy) - JWT
tenantIdclaim (camelCase, newer issuers) request.tenantIdset byAdminApiKeyGuardafter it validates the admin API key — the only path where a rawX-Tenant-Idheader is honored- (admin-key callers only) raw
X-Tenant-Idheader
X-Tenant-Id header is ignored for such callers — honoring it
would let any user impersonate another tenant simply by setting a header (the
claim is signed; the header is not). The accounting guard’s extractTenantId()
deliberately returns null for a missing claim rather than falling back to the
JWT sub (user id), so a missing tenant claim is treated as “unknown tenant,”
not “this user’s id.”
The AccountingWorkspaceGuard (the front door)
Every workspace-routed accounting endpoint sits behind AccountingWorkspaceGuard
(accounting/guards/accounting-workspace.guard.ts), alongside TenantAuthGuard
and the @RequirePermissions permission gate. Its job:
Validate thatKey behaviors:workspace_idresolves to an active accounting book owned by the caller’s tenant, and — whenX-Business-Idis supplied — that the book belongs to that business.
- Reads and writes are covered. It extracts
workspaceIdfrom both the query and the body (extractWorkspaceId). Reading the query alone would let a body-routed write (POST/PUT) moveworkspaceIdinto the JSON body and skip the consistency check entirely. X-Business-Idis required when a workspace is present. The guard callsWorkspaceBusinessScopeService.assertWorkspaceInBusiness({ …, requireLinkedBusiness: true }). WithrequireLinkedBusiness: true, an absentX-Business-Idheader is a deny (ForbiddenException), not “any business of the tenant.” This closes the old hole where an unscoped finance role could read across every business by omitting the header.- No workspace → pass. Endpoints that don’t route by
workspaceId(bridge, settings, some list endpoints) returntruefrom the guard; those controllers enforce business-id consistency themselves. - No tenant → pass to downstream. A missing tenant id means a pre-auth state; the guard defers to the auth layers rather than guessing.
The fail-closed core: WorkspaceBusinessScopeService
assertWorkspaceInBusiness() (common/business-context/workspace-business-scope.service.ts)
is the shared workspace↔business consistency check. It loads the book by either
id = workspaceId or workspaceId = workspaceId (both scoped to tenant_id,
is_deleted = false) and then:
The cross-business mismatch (last-but-one row) is the most dangerous routing
error — a client pointing at a book from a different business than the UI context
claims — and it is rejected with 403, not silently allowed.
Per-business books: AccountingRoutingService
Inbound product postings (payroll, retail, SACCO, etc.) don’t carry a
workspaceId — they carry a businessId and rely on
AccountingRoutingService (services/accounting-routing.service.ts) as the
single authority for tenant + business → book → workspace:
resolve({ tenantId, businessId, productKey?, bookId? })— validates the business belongs to the tenant, then picks the target book:- an explicit
bookId(must belong to that business, elseNotFoundException) - the business’s
defaultBookId - exactly one active book (auto-adopt with a warning)
- zero active books →
NotFoundException; more than one active book with no default →BadRequestExceptioncode: 'BOOK_ROUTING_AMBIGUOUS'(“Refusing to guess”).
- an explicit
resolveBusinessIdForWorkspace({ tenantId, workspaceId })— the reverse direction, used by modules that already hold aworkspaceId(e.g.JournalService.postWithCurrentPipeline). Throws if the book is unlinked to a business.resolveByProduct(...)is intentionally a hard rejection — after the multi-business cutover every adapter must pass an explicitbusinessId.resolveContextByProduct(...)is the narrow exception for products with no businessId at all (e.g. personal-finance reimbursements): it routes only when exactly one business is enrolled in the product, otherwise returnsnullso the caller degrades (skips GL) instead of mis-routing money.
The @BusinessCtx() decorator (the cross-module pattern)
For controllers that take business context directly (the HR/payroll modules, and
the accounting bridge/settings endpoints that don’t route by workspaceId), the
canonical accessor is the @BusinessCtx() parameter decorator
(common/business-context/business-context.decorator.ts), which yields a
normalized RequestBusinessContext:
null, forcing the
handler to make an explicit decision. Two narrowing helpers enforce that decision:
requireTenantId(ctx)→UnauthorizedException('Tenant context required')if null.requireBusinessId(ctx)→UnauthorizedException('X-Business-Id header is required …')if null — use this on routes that are always scoped to a single business.
Native accounting CRUD controllers (invoices,bills,payments, …) instead composeTenantAuthGuard+AccountingWorkspaceGuard+@CurrentTenant()(=@TenantId()) and route byworkspaceId. The two patterns coexist: workspace-routed endpoints get their business check fromAccountingWorkspaceGuard; bridge/settings/HR endpoints get theirs from@BusinessCtx()+requireBusinessId().
The gotcha: reads, lists and mutations must all be scoped
Tenant/business isolation is not a single guard at the door — every repository query must also carry the scoping predicates, because a guard only proves the book is in-tenant/in-business, not that a given row is. The consistent pattern across services (e.g.PeriodCloseService,
JournalService, AccountMergeService) is to filter every query by
tenant_id AND workspace_id:
businessId/tenantId stamped on create but ignored on
read/list/mutate/delete lets one business read or mutate another’s money, GL,
and PII (a suite-wide cross-business IDOR). The rule:
If you add a handler, scope the read, the list, the update, and the delete — not just the insert. A row lookup byidalone (findOne({ where: { id } })) is a cross-tenant/cross-business leak waiting to happen; always includetenantId(andworkspaceId/businessIdwhere the entity carries it).
Quick checklist for a new accounting endpoint
- Guard with
TenantAuthGuard+AccountingWorkspaceGuard(workspace-routed) or read@BusinessCtx()+requireBusinessId()(business-routed). - Take the tenant from
@CurrentTenant()/@TenantId()(the signed claim) — never from a header for a normal user. - Pass
tenantId+workspaceId(andbusinessIdwhere applicable) into every repository call —find,findOne,update,softDelete. - For inbound product postings, resolve the book via
AccountingRoutingServicewith an explicitbusinessId; letBOOK_ROUTING_AMBIGUOUSsurface rather than guessing a book.
Gaps / notes for reviewers
business_idis a real column on a subset of master/internal tables (tax_codes,withholding_codes,withholding_balances,payment_terms,journal_entry_templates, and the posting-internalsaccounting_intent_outbox/accounting_source_links/accounting_posting_profiles). Transactional rows (invoices, bills, payments, journal entries, ledger entries) do not carrybusiness_id; their owning business is derived from the book viaAccountingRoutingService. Confirm the entity’s columns before assuming abusinessIdfilter is available on a given table.- Row-level scoping is enforced by convention in service/repository code, not by a Postgres RLS policy. There is no database-level tenant guarantee — the application predicates are the guarantee.