ADR 004: Return 404 for cross-tenant resource misses
Status: Accepted
Context
RiskFlow is multi-tenant: almost every business row is scoped to a Team via
teamId. When a caller requests a resource by ID that exists in another tenant's
scope, the API must choose between:
- 403 Forbidden — "You are authenticated but not allowed to access this resource."
- 404 Not Found — "No resource exists at this identifier for you."
A 403 confirms that the resource exists but the caller lacks permission. For cross-tenant misses, that confirmation is an information leak: an attacker can probe IDs and learn which resources belong to other tenants.
Decision
Default to 404 over 403 when a resource is missing from the caller's tenant scope.
Implementation guidelines:
- Repository and service methods accept the actor's scope (
teamId, parent MSP relationship, or vendor org link) and include it in the Prismawhereclause. - When the scoped query returns no row, throw
NotFoundException— notForbiddenException— even if an unscoped query would have found a row elsewhere. - Reserve 403 for cases where the resource is in scope but the principal lacks a specific permission (RBAC denial on an owned resource).
- Apply the same rule to vendor flows: if a vendor has no
VendorOrgLinkto the org that owns the resource, respond with 404.
Example pattern:
const org = await this.repository.findByIdForParent(orgId, mspTeamId);
if (!org) {
throw new NotFoundException(`Organization ${orgId} not found`);
}
The caller cannot distinguish "org does not exist" from "org exists under another MSP."
Consequences
- OpenAPI/Swagger responses for
:idroutes should document 404 for both genuine misses and cross-tenant probes. - Unit tests for tenancy-sensitive services should assert
NotFoundException, notForbiddenException, for out-of-scope IDs. - Internal admin or platform endpoints that intentionally cross tenants must be named, guarded, and audited — they are explicit exceptions, not the default CRUD path.
- Support and debugging may require server-side logs to distinguish a true missing row from a scope rejection; never expose that distinction in the HTTP response.
References
.cursor/rules/security-multi-tenancy.mdc— Authorization (RBAC)apps/backend/src/modules/organizations/organizations.service.tsapps/backend/src/modules/agreements/agreements.service.ts