Authorization is dual-check
The property
Every org-scoped endpoint is authorized against two independent checks:
- Session permission — does the actor (their identity, plus any impersonation overlay) hold the relevant permission?
- Target-org permission — does the actor have permission for the target organization, by direct membership or by hierarchy cascade?
Both must hold. Either one alone is insufficient.
The shape is enforced by the permission guard at libs/api/services-api/src/lib/core/auth/guards/permission.guard.ts against the @TargetOrgId() decorator at libs/api/services-api/src/lib/core/auth/decorators/target-org-id.decorator.ts. See Current state → Multi-tenant hierarchy → Dual-check authorization.
Why two checks
The two checks answer different questions:
- Session permission answers: "is this kind of action allowed for this actor?"
- Target-org permission answers: "is this actor allowed to act on this target?"
A user with permission to manage memberships at their own org should not be able to manage memberships at a sibling org. A platform admin with PLATFORM identity should be able to act on any org, but only through endpoints they have base permission for.
A single combined check can't easily express both. Splitting the check makes the two axes explicit at every controller method.
Why not target-only (skip the session permission check)
Target-only authorization is appealing — "if you can act on this org, you can take this action" — but it conflates capability with scope. A user with "view-only" permission and "manage" permission would both pass a target-only check; the system would have to encode the action type in the permission set, which is what the session permission already does.
Splitting them lets each layer own one concern: the session has the actor's capability set, the target-org binding has the scope.
Why not session-only (skip the target-org check)
Session-only authorization checks permission against whichever org the session is currently "active" on, with no signal from the request about what the actor is actually trying to act on. The failure mode is a confused deputy: the active-session org and the intended target of the action — the org id carried in the URL — can diverge, and the action succeeds against the session's org while the actor believes they are operating on a different one.
Binding the action to an explicit target via @TargetOrgId() and checking permission against that target eliminates the deputy gap. The session is no longer the authority on scope — it carries identity and capability; the URL carries scope; the guard reconciles them.
Implementation cost
The dual check costs:
- One additional permission lookup per request (mitigated by the Ancestry cache)
- A
@TargetOrgId()decoration on every org-scoped controller method (a one-line annotation)
Both costs are small relative to the deputy-gap class of bug the model prevents.
Consequences
- Every new org-scoped controller method MUST carry both a
@Permission()and a@TargetOrgId()decoration. Missing the target-org binding silently falls through to session-org scope — a confused-deputy regression. The pattern is review-enforced. - The ancestry cache is load-bearing for request throughput. An ancestry-cache outage doubles MongoDB read load on the membership collection; the cache's failure mode and TTL are correctness-shaped, not performance-shaped.
- Cross-hierarchy operations (a Portfolio acting on a descendant Brand) inherit the dual check uniformly. There is no special-case "platform admin" bypass — PLATFORM authority is expressed via the org-type discriminator (see PLATFORM as its own OrgType) and the same guard resolves it.
- Path-based URL conventions (
/organizations/:orgId/...) are load-bearing for the target-org binding. Renaming or restructuring URL segments that carry the org id requires updating@TargetOrgId()sources in lockstep; the decorator's path/body/query source is what the guard reads. - Widget endpoints with API-key auth (the embedded scheduler's flat-URL routes) are exempt from
@TargetOrgId()and live in sibling*NonOrgScopedControllerclasses. The widget contract is preserved at the controller-class boundary — a single org-scoped admin endpoint and its widget twin do not share a controller.
Related
- Current state → Multi-tenant hierarchy
- OpenSpec:
openspec/specs/rbac-hierarchy-permission-sync/spec.md,openspec/specs/session-org-context/spec.md