Turning a Permission Model Into a Guard
A permission model that lives only in the database does nothing. This is the runtime layer, a declarative decorator and a guard that turns "who can do what" into an enforced answer on every request, without scattering authorization logic through your handlers.

This is Part 4 of the series Building Multi-Tenant Systems That Match the Real World.
Part 1: Designing Multi-Tenant Backends With Both Ownership and Team Access
Table of Contents
The Layer Nobody Writes About
The first three parts of this series were all about modeling. Ownership, teams, lifecycle, roles, scoped permissions, two planes of authority. By the end of Part 3 you have a data model that can answer one question precisely:
What can this user do inside this organization right now?
But a data model is inert. It answers questions only when something asks. Most articles stop at the model and wave a hand at "and then you check permissions in your endpoints."
That hand-wave is where real systems go wrong.
If every endpoint checks permissions by hand, three things happen. The checks drift — one handler forgets the active-status gate, another forgets the owner bypass. Authorization logic tangles with business logic until you cannot read either. And the day you change how permissions resolve, you change it in forty places and miss two.
The fix is to make authorization a single enforced layer that sits between the request and your handler. The handler says what it requires. One guard decides whether the request satisfies it. The handler never touches the permission service at all.
This article is that layer.
Authorization Should Be Declarative, Not Imperative
Here is the imperative version — the one that creeps in by default:
@Post()
async createProduct(@Req() req, @Body() body) {
const userId = req.user.sub;
const organizationId = req.params.organizationId;
const allowed = await this.permissions.userHasPermissions(
userId,
organizationId,
['products.create'],
'all',
);
if (!allowed) {
throw new ForbiddenException('Cannot create products');
}
// ...the four lines that actually create a product
}
Every handler that needs protection repeats that block. The interesting part — creating a product — is buried under boilerplate, and the boilerplate is exactly the part that must never be wrong.
The declarative version states the requirement and stops:
@Post()
@RequirePermission('all', 'products.create')
async createProduct(@Body() body) {
// ...the four lines that actually create a product
}
The handler no longer mentions userId, organizationId, the permission service, or ForbiddenException. It declares one thing: this route requires products.create. Everything else is the guard's job.
That separation is the whole point. The requirement lives next to the route it guards. The enforcement lives in one place. Neither leaks into the other.
The Decorator: Describe the Requirement, Not the Check
The decorator does not check anything. It only attaches metadata to the route — a declaration the guard will read later.
import { SetMetadata } from '@nestjs/common';
export type PermissionMode = 'all' | 'any';
export const PERMISSION_KEY = 'permissions';
export const PERMISSION_MODE_KEY = 'permission_mode';
export const RequirePermission = (
mode: PermissionMode = 'all',
...permissions: string[]
) => {
return (target: any, key?: string, descriptor?: PropertyDescriptor) => {
SetMetadata(PERMISSION_KEY, permissions)(target, key, descriptor);
SetMetadata(PERMISSION_MODE_KEY, mode)(target, key, descriptor);
};
};
Two pieces of metadata: the list of required permissions, and the mode (all or any). That is the entire decorator. It runs at class-definition time, writes two keys, and never executes during a request.
This is worth pausing on, because it is the trick that makes the whole pattern clean: the decorator and the guard are decoupled in time. The decorator records intent when your app boots. The guard reads that intent when a request arrives. They communicate through metadata, never through a direct call. That is why you can protect a route with one line and never import the guard into your controller.
The Guard: One Place That Enforces Everything
The guard is where enforcement actually lives. It runs after authentication, reads the metadata the decorator left, resolves the request's context, and asks the permission service the one question that matters.
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(
private reflector: Reflector,
private permissions: PermissionService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. Read what the route declared.
const required = this.reflector.getAllAndOverride<string[]>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
// No declaration means no restriction — the route is open by design.
if (!required || required.length === 0) {
return true;
}
const mode = this.reflector.getAllAndOverride<PermissionMode>(
PERMISSION_MODE_KEY,
[context.getHandler(), context.getClass()],
) ?? 'all';
const request = context.switchToHttp().getRequest();
// 2. Identity must already be established by an auth guard upstream.
const user = request.user;
if (!user?.sub) {
throw new ForbiddenException('User not authenticated');
}
// 3. Resolve which organization this request is acting on.
const organizationId = this.resolveOrganizationId(request);
if (!organizationId) {
throw new ForbiddenException(
'Organization context is required for this request',
);
}
// 4. Ask the one question. The guard owns no permission logic itself.
const allowed = await this.permissions.userHasPermissions(
user.sub,
organizationId,
required,
mode,
);
if (!allowed) {
throw new ForbiddenException(
`Missing required permissions: ${required.join(', ')}`,
);
}
return true;
}
// ...resolveOrganizationId below
}
Notice what the guard does not do. It does not know how permissions resolve. It does not know about owners, staff, lifecycle states, wildcards, or planes. All of that lives in the permission service from the earlier parts. The guard's only jobs are:
read the declaration,
confirm identity,
resolve the organization context,
delegate the decision.
Keep the guard thin. The moment it starts making permission decisions itself, you have two sources of truth, and they will disagree.
Resolving the Organization From the Request
Step 3 deserves its own attention, because it is the bridge between Part 1's "make organization context explicit per request" and the actual HTTP layer.
A request can carry its organization in more than one place. A RESTful route puts it in the path. Some endpoints — bulk operations, dashboards — carry it in a header instead. The resolver checks both:
private resolveOrganizationId(request: any): string | undefined {
let organizationId =
request.params.organizationId || request.headers['x-organization-id'];
// Some clients send a header twice; HTTP concatenates duplicates with a comma.
// Take the first value rather than trusting a malformed string.
if (typeof organizationId === 'string' && organizationId.includes(',')) {
organizationId = organizationId.split(',')[0].trim();
}
return organizationId;
}
That comma-handling line looks like a small defensive detail. It is actually a real-world scar. Tools and proxies sometimes send the same header twice, and a naive read hands you "org_123,org_123" — an ID that matches nothing and fails every check with a confusing error. Normalizing it here, once, means no downstream code ever has to think about it.
The deeper principle: resolve the organization in exactly one place. If three different guards each parse the request their own way, they will eventually disagree about which organization a request belongs to — and a disagreement about scope is a security bug, not a formatting bug.
All vs Any: Two Honest Modes
A route sometimes needs all of a set of permissions, and sometimes any of them. Both are legitimate, and the mode should be explicit at the route.
all — every permission is required. Use it when an action genuinely needs multiple grants:
@RequirePermission('all', 'products.edit', 'products.publish')
async publishProduct() {
// must be able to BOTH edit and publish
}
any — one permission is enough. Use it when several roles should reach the same read path through different grants:
@RequirePermission('any', 'orders.view', 'orders.process')
async listOrders() {
// a viewer OR a processor can list orders
}
The matcher inside the permission service is the same one from Part 2, just branched on the mode:
if (mode === 'all') {
return required.every((p) => this.hasPermission(p, granted));
} else {
return required.some((p) => this.hasPermission(p, granted));
}
Making the mode explicit at the route keeps the intent readable. Anyone scanning the controller sees not just which permissions a route needs, but how they combine. That is one less thing to infer wrong.
Wildcards Belong in the Matcher, Not the Routes
Routes declare concrete permissions: products.create, orders.refund. They never declare wildcards. Wildcards are a grant concept, not a requirement concept — they describe what a role holds, not what a route demands.
So the wildcard logic lives entirely inside the matcher, invisible to every route:
private hasPermission(required: string, granted: string[]): boolean {
// Owner-level full wildcard.
if (granted.includes('*')) return true;
// Exact grant.
if (granted.includes(required)) return true;
// Resource wildcard: a holder of 'products.*' satisfies 'products.create'.
const [resource] = required.split('.');
return granted.includes(`${resource}.*`);
}
A route asks for products.create. Three different users satisfy it: the owner (holds *), a content role (holds products.*), and a narrow role (holds exactly products.create). The route is identical in all three cases. It never has to know which kind of grant let the user through.
This is the right division of labor. Routes express needs in specific terms. The matcher resolves those needs against grants of any breadth. Keeping wildcards out of route declarations means you can broaden or narrow a role's grants without ever touching the routes that depend on them.
What a Protected Controller Looks Like
Put the pieces together and a controller reads like a permission spec for the resource:
@Controller('organizations/:organizationId/products')
@UseGuards(JwtAuthGuard, PermissionGuard)
export class ProductsController {
@Get()
@RequirePermission('any', 'products.view', 'products.edit')
async list() { /* ... */ }
@Post()
@RequirePermission('all', 'products.create')
async create(@Body() dto: CreateProductDto) { /* ... */ }
@Put(':productId')
@RequirePermission('all', 'products.edit')
async update(@Body() dto: UpdateProductDto) { /* ... */ }
@Delete(':productId')
@RequirePermission('all', 'products.delete')
async remove() { /* ... */ }
}
Two things make this work, and both belong at the class level:
@UseGuards(JwtAuthGuard, PermissionGuard)— order matters. Authentication runs first and puts the user on the request; the permission guard runs second and trusts it is there. A permission guard that runs before authentication has nothing to check.organizations/:organizationId/...— the organization context is in the path, so the guard's resolver finds it without any per-handler wiring.
Every handler is now one line of authorization and the rest is pure business logic. You can read the whole access policy for products by skimming the decorators. No mental tracing through service calls to reconstruct who is allowed to do what.
Failures Should Be Loud and Specific
A denied request should fail in a way that helps the person debugging it, without leaking anything to an attacker.
There are three distinct failure points, and they are genuinely different conditions — collapsing them into one generic 403 makes every authorization bug harder to diagnose:
// Missing identity — an upstream wiring problem, not the user's fault.
if (!user?.sub) {
throw new ForbiddenException('User not authenticated');
}
// Missing organization context — the request did not say which org it acts on.
if (!organizationId) {
throw new ForbiddenException(
'Organization context is required for this request',
);
}
// Genuinely insufficient permissions — name what was required.
if (!allowed) {
throw new ForbiddenException(
`Missing required permissions: ${required.join(', ')}`,
);
}
For your own logs, capture the full context — user, organization, required permissions, mode — so denials are traceable. For the response body, name the permission that was missing but never enumerate what the user does have. "You need products.delete" is helpful. Dumping their entire permission set is a reconnaissance gift.
The distinction between "not authenticated," "no organization context," and "insufficient permissions" is also a maintenance gift to your future self. Three different bugs produce three different messages, and you stop guessing which one you are looking at.
What I Would Avoid
1. Checking permissions by hand in handlers
The moment authorization logic lives in handlers, it drifts. One forgets the status gate, another forgets the owner bypass. Centralize it in the guard.
2. Putting permission logic in the guard
The guard resolves context and delegates. It should not know about owners, lifecycle, or wildcards. That belongs in the permission service, so there is one source of truth.
3. Resolving the organization in more than one place
Different parsers eventually disagree about which organization a request belongs to. A disagreement about scope is a security bug. Resolve it once.
4. Putting the permission guard before the auth guard
The permission guard depends on an authenticated user being on the request. Order your guards so identity is established first.
5. Declaring wildcards at the route
Routes state concrete needs. Wildcards are a grant concept and belong in the matcher. Mixing them into route declarations couples your routes to how roles happen to be shaped today.
6. One generic error for every denial
"Not authenticated," "no organization context," and "insufficient permissions" are three different conditions. Collapsing them into one 403 makes every authorization bug harder to find.
Closing Thought
The shift in this article is the one that makes the previous three parts actually run:
The handler declares what it needs. One guard enforces it. The model decides.
Three roles, cleanly separated:
the decorator records a route's requirement as metadata, at boot time
the guard reads that requirement, resolves identity and organization context, and delegates — knowing no permission logic itself
the permission service holds all the real logic: owners, staff, lifecycle, wildcards, planes
Because they are separated, each can change without disturbing the others. You can add a new permission and protect a route with one line. You can change how permissions resolve and touch exactly one service. You can read a controller and know its entire access policy at a glance.
That is what "authorization as a layer" actually buys you: the model from Parts 1 through 3 stops being a description and starts being enforced — on every request, in one place, the same way every time.
In Part 5, I will move to a different axis entirely — not "can this user do it" but "does their plan allow it." Subscription-based feature gating sits on top of RBAC as a second, independent gate, and keeping the two apart is its own design problem.



