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.
| Type | Purpose | Can parent | Discriminator |
|---|---|---|---|
PLATFORM | Wrkbelt's own org. Identity-based authority. | n/a (root) | PlatformOrganization |
PORTFOLIO | Holding company / aggregator | BRAND | PortfolioOrganization |
PARTNER | Franchise or agency | BRAND | PartnerOrganization |
BRAND | Operating service business | n/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_pathis set at creation from the parent'sancestry_path + [parent_id].- A reparent operation rewrites
ancestry_pathfor 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 — seeAncestry cachebelow.
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:
- The session identity — who is logged in, plus any active impersonation overlay. Drives base permission checks.
- 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'sStoreand delegates toRedisCacheServicefor the underlying TTL-bounded reads and writes. See Cluster coordination pattern for whyRedisCacheServiceis 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-invalidateRedis 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.resubscribedonce 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.
Related
- Why dual-check? See Decisions → Shipped → Dual-check authorization for the rationale.
- Why PLATFORM as its own org type? See Decisions → Shipped → PLATFORM as org type.
- Why impersonation is dual-identity? See Decisions → Shipped → Impersonation dual identity.
- OpenSpec contracts:
openspec/specs/organization-hierarchy/spec.md,openspec/specs/rbac-hierarchy-permission-sync/spec.md,openspec/specs/session-org-context/spec.md,openspec/specs/impersonation-service/spec.md,openspec/specs/org-validation-enforcement/spec.md.