← Terug naar blog
AI Engineering
4 november 2025

Row-level security in NestJS via het strategy-pattern

Hoe je filtervragen en toegangscontrole van elkaar scheidt — zonder privilege-escalation en zonder business-logica in je repositories
Aron Heesakkers
Aron HeesakkersFullstack Developer & AI Engineer

De simpele aanpak voor row-level security: een WHERE user_id = ? in elke query. Dat werkt totdat:

  • Een manager medewerkers uit zijn eigen afdeling mag zien, maar niet die van andere afdelingen
  • Een admin alles mag zien, maar een planner alleen zijn eigen planningen
  • Een API-call namens een organisatie ook de kinderorganisaties moet includeren

Dan wordt die WHERE user_id = ? een WHERE (user_id = ? OR department_id IN (?) OR organization_id IN (?)) met vertakkingen per rol — en die logica wordt per tabel herhaald.

Het strategy-pattern biedt een uitweg: één interface per entity-type, met één implementatie per toegangsregel.

De interfaces

Twee interfaces die samenhangen: een voor lijstoperaties en een voor enkelvoudige ophaling:

export interface FilterAccessStrategy<T> {
  // Geeft een Prisma where-clause terug voor lijstoperaties
  createAccessFilter(
    user: AuthUser,
    baseFilter?: Prisma.Args<T, "findMany">["where"],
  ): Prisma.Args<T, "findMany">["where"];

  // Geeft een where-clause terug die exact één rij matcht én toegang controleert
  createSingleAccessFilter(
    user: AuthUser,
    id: string,
  ): Prisma.Args<T, "findUnique">["where"];
}

export interface CreateAccessStrategy<T> {
  // Valideert of de user de create-operatie mag uitvoeren
  validateCreateAccess(user: AuthUser, data: unknown): Promise<void>;
}

createAccessFilter retourneert een Prisma where-object dat gecombineerd wordt met andere filters. createSingleAccessFilter geeft een where-object dat zowel de ID als de toegangscheck combineert — zodat een findUniqueOrThrow automatisch een 404 geeft als de rij niet bestaat én de user er geen toegang toe heeft.

Een strategie implementeren

Een planning is alleen zichtbaar voor medewerkers van de eigen organisatie, maar planners zien alleen planningen van hun eigen afdeling:

@Injectable()
export class PlanningAccessStrategy implements FilterAccessStrategy<Prisma.PlanningDelegate> {
  createAccessFilter(user: AuthUser, baseFilter?: Prisma.PlanningWhereInput) {
    const accessFilter: Prisma.PlanningWhereInput =
      user.role === 'ADMIN'
        ? { organizationId: user.organizationId }
        : {
            organizationId: user.organizationId,
            departmentId: { in: user.departmentIds },
          }

    return baseFilter
      ? { AND: [accessFilter, baseFilter] }
      : accessFilter
  }

  createSingleAccessFilter(user: AuthUser, id: string) {
    return {
      id,
      ...this.createAccessFilter(user) as Prisma.PlanningWhereInput,
    }
  }
}

De strategie kent alleen de toegangsregel. De repository kent alleen de databaseaanroep. Ze zijn gescheiden.

Gebruik in de service

@Injectable()
export class PlanningService {
  constructor(
    private readonly db: PrismaService,
    private readonly access: PlanningAccessStrategy,
  ) {}

  async listPlannings(user: AuthUser, filter?: PlanningFilterDto) {
    const where = this.access.createAccessFilter(user, {
      status: filter?.status,
      startDate: filter?.from ? { gte: filter.from } : undefined,
    })

    return this.db.planning.findMany({ where, orderBy: { startDate: 'asc' } })
  }

  async getPlanning(user: AuthUser, id: string) {
    return this.db.planning.findUniqueOrThrow({
      where: this.access.createSingleAccessFilter(user, id),
    })
  }
}

getPlanning gooit automatisch een PrismaClientKnownRequestError (code P2025) als de planning niet bestaat of de user er geen toegang toe heeft. In een NestJS exception filter vertaal je dat naar een 404 — de gebruiker ziet geen verschil tussen "bestaat niet" en "geen toegang", wat correct is voor security.

Het Permission-systeem

Naast row-level access is er operation-level access: mag deze user überhaupt een shift aanmaken, een versie goedkeuren, een campagne starten? Dat wordt bijgehouden in een Permission-enum met meer dan 100 entries:

export enum Permission {
  ORGANIZATION_READ = "organization.read",
  PROJECT_CREATE = "project.create",
  SHIFT_UPDATE = "shift.update",
  PLANNING_APPROVE = "planning.approve",
  CAMPAIGN_CREATE = "campaign.create",
  WAGE_READ = "wage.read",
  // ...
}

Permissions zijn gegroepeerd per domein (ORGANIZATION, PROJECT, SHIFT, PLANNING, CAMPAIGN, WAGE). Rollen zijn collecties van permissions. Een admin heeft alles; een planner heeft SHIFT_*, PLANNING_*, maar niet WAGE_*.

De check in een guard:

@Injectable()
export class PermissionGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.get<Permission[]>('permissions', context.getHandler())
    const user = context.switchToHttp().getRequest().user as AuthUser

    return required.every((perm) => user.permissions.includes(perm))
  }
}

// Gebruik in een controller:
@Post('plannings/:id/approve')
@RequirePermissions(Permission.PLANNING_APPROVE)
async approvePlanning(@Param('id') id: string, @CurrentUser() user: AuthUser) {
  return this.planningService.approveVersion(id, user)
}

@RequirePermissions is een custom decorator die de guard-metadata zet. De combinatie van PermissionGuard (operation-level) en FilterAccessStrategy (row-level) geeft een tweelaags model: eerst "mag je dit type operatie uitvoeren?", dan "op welke rijen?"

Privilege escalation voorkomen

De meest voorkomende fout: een endpoint dat een ID accepteert en alleen de PermissionGuard gebruikt, maar niet de FilterAccessStrategy. Een planner met PLANNING_READ kan dan elke planning opvragen als hij het ID weet — ook die van andere afdelingen.

De oplossing: createSingleAccessFilter altijd gebruiken bij ophalen op ID, nooit alleen { where: { id } }. Een code review checklist helpt hierbij:

□ Gebruikt findUnique/findUniqueOrThrow een access-filter?
□ Gebruikt findMany een access-filter?
□ Is de access-filter gecombineerd met de business-filter (AND, niet vervanging)?

De strategie-interface maakt die checklist afdwingbaar: als een service de strategie injecteert, is de impliciete verwachting dat hij hem ook gebruikt. Een linting-regel op this.db.planning.findMany({ where: { id } zonder strategie-aanroep in dezelfde methode maakt dat mechanisch controleerbaar.


Project bespreken?

Heb je een use case waar je over twijfelt?

We praten graag over of een AI-aanpak past — en zeggen het eerlijk als het beter zonder kan. Vaste prijs, afgesproken scope.
Plan een gesprek
Probeer AI