Skip to content

Authorization = Policies, not decorators sprinkled across the code

Authorization lives in testable policy functions — not scattered `@RequireRole` decorators on controllers.

nestjs-auth-policies-not-decorators

Why it matters

Failure modes if this rule is ignored
StakeIf ignored
Agent gets lost
  • Decorators scatter permissions across dozens of places. A policy change = grep across the project.
Data leak
  • Permission duplicated in a decorator and a UI if — update one, forget the other, ship a hole.
Hidden coupling
  • A decorator gets the user but not the resource. Most real permissions are about a specific resource.

How to fix

Not @RequireRole('admin'), not @CanAccess('sites:write') on the controller. A permission is a question about a user and a resource, and the answer lives in a policy function you can test and reuse.

Examples

Bad
ts
@Controller('sites')
export class SitesController {
  @Patch(':id')
  @RequireRole('admin')                          // ← admin of what?
  async update(@Param('id') id: string) { /* ... */ }

  @Delete(':id')
  @RequirePermission('sites:delete')             // ← but the owner can also delete
  async delete(@Param('id') id: string) { /* ... */ }
}
Good
ts
// libs/sites-management/backend-feature/src/lib/sites.policy.ts
@Injectable()
export class SitesPolicy {
  canRead(user: User, site: Site): boolean {
    return site.isPublic || site.ownerId === user.id || user.role === 'admin';
  }
  canUpdate(user: User, site: Site): boolean {
    return site.ownerId === user.id || user.role === 'admin';
  }
  canDelete(user: User, site: Site): boolean {
    if (site.protected) return user.role === 'admin';
    return site.ownerId === user.id;
  }
}

// in the service
async update(siteId: string, user: User, dto: UpdateSiteDto) {
  const site = await this.repo.findById(siteId);
  if (!this.policy.canUpdate(user, site)) throw new ForbiddenException();
  return this.repo.update(siteId, dto);
}

Contribute

Released under the MIT License.

esc