Skip to main content

Multi-tenant hierarchy

Wrkbelt is multi-tenant by construction. Every request is authenticated and org-scoped: the session identifies who is acting, and a separate signal identifies which organization they are acting on. Authorization is checked against both.

This page covers the org model, the dual-check authorization pattern, session org context, impersonation, and the ancestry cache.

Org types and hierarchy

Organizations are a four-level hierarchy. The type lives in libs/shared/types-data/src/lib/core/organization/organization.enum.ts and the discriminated entity variants live in libs/shared/types-data/src/lib/core/organization/organization.entity.ts.

TypePurposeCan parentDiscriminator
PLATFORMWrkbelt's own org. Identity-based authority.n/a (root)PlatformOrganization
PORTFOLIOHolding company / aggregatorBRANDPortfolioOrganization
PARTNERFranchise or agencyBRANDPartnerOrganization
BRANDOperating service businessn/a (leaf)BrandOrganization

A BRAND can have a PORTFOLIO parent, a PARTNER parent, or both. Type-specific invariants (e.g. BRAND must have an industry) are enforced by guards at libs/shared/types-data/src/lib/core/organization/organization.guards.ts and validated at the DTO layer (organization.dtos.ts).

OpenSpec contract: openspec/specs/organization-hierarchy/spec.md, openspec/specs/org-validation-enforcement/spec.md.

Ancestry path

Each organization stores its full lineage as a materialized ancestry_path array — the ordered list of organization IDs from the root to the current org. This lets cascade and inheritance queries hit a single document instead of walking parent pointers.

Mutation rules:

  • ancestry_path is set at creation from the parent's ancestry_path + [parent_id].
  • A reparent operation rewrites ancestry_path for the moved org and all descendants. The cascade is owned by the organization service.
  • An invalidation event (ancestry-invalidate) is broadcast cluster-wide whenever ancestry changes — see Ancestry cache below.

OpenSpec contract: openspec/specs/organization-hierarchy/spec.md, openspec/specs/org-hierarchy-backfill/spec.md.

Dual-check authorization

The platform splits "who is the actor" from "what org are they targeting" and requires permission on both. This is the dual check. It is enforced by the permission guard at libs/api/services-api/src/lib/core/auth/guards/permission.guard.ts against:

  1. The session identity — who is logged in, plus any active impersonation overlay. Drives base permission checks.
  2. The target org — the org the action is scoped to, derived from @TargetOrgId() on the controller method. Drives hierarchy-scoped permission checks.

Both must hold. A platform admin acting on a brand passes (1) trivially (PLATFORM identity authority) and (2) via hierarchy cascade. A Brand owner acting on a sibling brand fails (2) regardless of (1).

@TargetOrgId() decorator

Controllers declare which request field carries the target organization id via the @TargetOrgId() decorator at libs/api/services-api/src/lib/core/auth/decorators/target-org-id.decorator.ts. The guard reads this annotation and pulls the target org id from the request body, params, or query.

Two examples from real controllers:

  • libs/api/services-api/src/lib/core/membership/membership.controller.ts — membership operations target the org being managed.
  • libs/api/services-api/src/lib/core/user/user-admin.controller.ts — admin operations against a user target the user's home org.

Decorating every org-scoped controller method means there is exactly one place per endpoint where the target-org binding is declared, and the guard never has to guess.

OpenSpec contract: openspec/specs/rbac-hierarchy-permission-sync/spec.md, openspec/specs/session-org-context/spec.md.

Session org context

Beyond identity, each session carries an org context — cached org-membership and per-org permission state — so the permission guard doesn't have to round-trip MongoDB on every request. The shape is owned by libs/shared/types-data/src/lib/core/auth and consumed by the auth module at libs/api/services-api/src/lib/core/auth/.

Highlights:

  • Persisted by RedisSessionStore, which extends Express's Store and delegates to RedisCacheService for the underlying TTL-bounded reads and writes. See Cluster coordination pattern for why RedisCacheService is the right primitive here.
  • Invalidated when org membership or roles change.
  • Re-populated lazily on the next request.

OpenSpec contract: openspec/specs/session-org-context/spec.md, openspec/specs/ui-session-envelope/spec.md.

Impersonation

Support engineers can act as another user via the impersonation service. Impersonation is dual-identity: the audit trail always records who actually performed the action (the support engineer) AND who they were acting as (the impersonated user). The session overlay does not erase the real identity.

Constraints:

  • Maximum duration is bounded (4 hours per the spec).
  • Permission checks run against the impersonated user's permission set, not the support engineer's — so an impersonation session cannot grant escalated capabilities.
  • All audit emits during impersonation include both identities. See Audit and observability.

Implementation: see the audit and impersonation services in libs/api/services-api/src/lib/core/audit/ and libs/api/services-api/src/lib/core/auth/ plus the spec.

OpenSpec contract: openspec/specs/impersonation-service/spec.md.

Ancestry cache

The org hierarchy is read on every authorization check. Walking the database for that read at request rate is unworkable, so org-ancestry data is cached per process with a short TTL and a cluster-wide invalidation channel.

Implementation: libs/api/services-api/src/lib/core/organization/ancestry-cache.service.ts.

  • Backing primitive: in-process map with a 60-second TTL.
  • Invalidation: on any ancestry-affecting mutation (membership change, role change, reparent), the service publishes on the ancestry-invalidate Redis channel. Every instance subscribes and evicts the affected entry locally.
  • Broadcast primitive: RedisBroadcastChannelService — pub/sub with auto-resubscribe on TCP reconnect. See Cluster coordination pattern → BroadcastChannel.
  • Failure mode: during a Redis outage, the local cache continues to serve from its TTL; staleness is bounded by the 60-second TTL and recovered via redis.subscription.resubscribed once the connection returns.

The trade is bounded staleness in exchange for sub-millisecond reads. The 60-second TTL is the floor; the broadcast tightens it to ~milliseconds in steady state.

Request lifecycle

In sequence: SessionAuthGuard resolves the session and attaches the identity. PermissionsGuard reads the controller's @Permission() and @TargetOrgId() decorators, derives the target org id from the request, looks up membership + ancestry via AncestryCache, and runs the dual check.