Short answer: Deny by default, express the actor-action-resource policy on the server, scope every object lookup to the current owner or tenant, and prove enforcement with negative tests that swap identities, roles and identifiers.
1. Model the decision, not the screen
Write the policy as three inputs: who is acting, what action is attempted and which resource is targeted. Add context such as tenant, object state and relationship. Authenticated is rarely enough: a member may read a project but not change billing; an administrator for workspace A has no authority in workspace B.
Inventory every entry point, including REST or GraphQL endpoints, Server Actions, export jobs, object storage URLs, background workers and administrative tools.
2. Enforce close to the data
Central policy helpers improve consistency, but the data query must also include the boundary. Loading a document by global ID and checking later creates a dangerous gap whenever one caller forgets the second step.
Return a consistent not-found or forbidden response according to the API contract; do not leak private object existence through detailed errors.
return db.invoice.findFirst({
where: {
id: invoiceId,
workspaceId: actor.workspaceId,
workspace: { members: { some: { userId: actor.userId } } }
},
select: { id: true, status: true, total: true }
});3. Cover actions and state transitions
Object ownership does not imply every action. Check explicit permissions for invite, export, impersonate, refund, delete, change role and transfer ownership. Validate the current state inside the same transaction as the write so concurrent requests cannot bypass a pre-check.
Signed URLs need short expiries, an intended operation and the correct object scope. Unpredictable identifiers reduce guessing but never replace authorization.
4. Build a negative authorization matrix
For each sensitive action, test anonymous, expired session, normal member, privileged member, owner, same role in another tenant and removed member. Swap both route identifiers and nested body identifiers. Test bulk endpoints and indirect references, not just the single-object endpoint.
for (const actor of [anonymous, memberA, adminB, removedMember]) {
const res = await updateProject({ actor, projectId: projectA.id });
expect(res.status).toBe(actor === adminA ? 200 : 404);
}Common mistakes
- Relying on UI visibility or route middleware as the only control.
- Checking a global role without the tenant relationship.
- Protecting reads but forgetting exports, attachments or mutations.
- Using UUIDs as proof of permission.
- Authorizing before a write but not constraining the write transaction.
- Testing only allowed cases.
Checklist
- Actor, action, resource and context are explicit.
- The default outcome is deny.
- Every data lookup includes owner or tenant scope.
- Sensitive actions have separate permissions.
- Nested and bulk identifiers receive the same checks.
- Signed object links expire and are scoped to one operation.
- Negative tests cross roles, tenants and object states.
- Authorization failures are logged without private response details.
Limits
A role matrix alone may miss relationship- and attribute-based rules. Distributed systems also need consistent identity propagation and service-level enforcement. Review storage policies, queues, caches and analytics exports where data can bypass the primary API.
Primary sources
Related resources
Review tenant and role boundaries
Apply these controls to the actual code and product boundaries.