Contrôle d'accès
Implémenter le contrôle d'accès côté frontend et backend : niveaux admin, rôles métier, AppRights et cascade de features.
Contrôle d'accès
Le contrôle d'accès est double : le backend bloque toujours (source d'autorité), le frontend masque (UX). Ne faites jamais confiance au frontend seul pour sécuriser une action.
Pourquoi cette étape
Comprendre le modèle de droits Wakastart est indispensable pour implémenter des guards backend corrects et une UI conditionnelle cohérente. Le modèle comporte trois vecteurs orthogonaux (adminLevel, wakaRoles, appRights) et une cascade de features qui peut désactiver des fonctionnalités entières.
Concepts clés
- adminLevel : niveau hiérarchique, détermine la portée des opérations CRUD.
- wakaRoles : rôles métier transversaux (CONFIG, EXPLOIT, etc.), indépendants de la hiérarchie.
- appRights : droits fins sur des ressources spécifiques (
users.create,infra.deploy…). - features : activation en cascade App ∧ Network ∧ Customer. Si une feature est désactivée à n'importe quel niveau, elle est indisponible.
- Profile : ensemble de
AppRightassigné à un utilisateur. Un utilisateur peut avoir plusieurs profils.
Les 3 vecteurs de droits
1. adminLevel — portée hiérarchique
Contrôle qui peut voir et modifier quoi selon la position dans la hiérarchie multi-tenant.
textWakaAdmin → Tout (plateforme complète, tous les Partners) OwnerAdmin → Tout (propriétaire du Partner) AppsAdmin → Applications + Profils + Droits (dans son Network) NetworkAdmin → Réseaux + Customers + Apps (dans son Network) CustomerAdmin → Customers + Users + Teams (dans son Customer) User → Son propre profil uniquement None → Aucun accès admin
Règle de hiérarchie : un NetworkAdmin possède implicitement les droits de CustomerAdmin et User. La vérification côté backend utilise une comparaison ordinale.
2. wakaRoles — rôles métier
Rôles transversaux indépendants de la hiérarchie. Un utilisateur peut avoir plusieurs rôles.
| Rôle | Périmètre |
|---|---|
CONFIG | Configuration de la plateforme (apps, networks, customers) |
EXPLOIT | Exploitation / opérations |
DPO | Protection des données personnelles (RGPD) |
AUDIT | Consultation des logs d'audit |
BILLING | Facturation et crédits |
CYBER | Cybersécurité |
3. appRights — droits applicatifs fins
Droits de type {resource}.{action} accumulés depuis les profils de l'utilisateur.
Format : resource.action ou resource.sub-resource.action
Exemples :
textpartners.read users.create infra.deploy partners.write users.update infra.services.scale apps.read users.delete audit.read apps.create teams.read billing.manage apps.delete teams.members.add api-keys.read profiles.read teams.members.remove api-keys.create profiles.assign invitations.create api-keys.revoke profiles.rights.write invitations.read media.upload
Cascade de features
Une feature doit être activée à trois niveaux pour être disponible :
textAppFeature.isEnabled = true (au niveau de l'App) ∧ NetworkAppFeature.isEnabled = true (au niveau du Network) ∧ CustomerAppFeature.isEnabled = true (au niveau du Customer) → Feature disponible pour l'utilisateur
Si l'une des trois conditions est false, la feature est désactivée même si les autres sont actives. Le payload /me agrège ce résultat dans le champ features: string[].
Exemple concret :
typescript// Si "invitations" n'est pas dans me.features → masquer tout le module const hasInvitations = me.features.includes("invitations");
Implémentation côté frontend
Hook useAppRights
typescript// hooks/use-app-rights.ts import { useAuth } from "@/lib/auth-context"; export function useAppRights() { const { user } = useAuth(); const appRights = user?.appRights ?? []; const features = user?.features ?? []; return { // Vérification d'un droit unique hasRight: (code: string) => appRights.includes(code), // Au moins un des droits présent hasAnyRight: (...codes: string[]) => codes.some(c => appRights.includes(c)), // Tous les droits présents hasAllRights: (...codes: string[]) => codes.every(c => appRights.includes(c)), // Vérification du niveau admin isAdminAtLeast: (level: string) => { const levels = ["None", "User", "CustomerAdmin", "NetworkAdmin", "AppsAdmin", "OwnerAdmin", "WakaAdmin"]; const current = levels.indexOf(user?.adminLevel ?? "None"); const required = levels.indexOf(level); return current >= required; }, // Feature activée en cascade hasFeature: (feature: string) => features.includes(feature), // Rôle métier hasRole: (role: string) => (user?.wakaRoles ?? []).includes(role), }; }
Composant <Protected>
typescript// components/protected.tsx import { useAppRights } from "@/hooks/use-app-rights"; interface ProtectedProps { right?: string; anyRight?: string[]; allRights?: string[]; adminLevel?: string; feature?: string; role?: string; fallback?: React.ReactNode; children: React.ReactNode; } export function Protected({ right, anyRight, allRights, adminLevel, feature, role, fallback = null, children, }: ProtectedProps) { const { hasRight, hasAnyRight, hasAllRights, isAdminAtLeast, hasFeature, hasRole } = useAppRights(); const allowed = (!right || hasRight(right)) && (!anyRight || hasAnyRight(...anyRight)) && (!allRights || hasAllRights(...allRights)) && (!adminLevel || isAdminAtLeast(adminLevel)) && (!feature || hasFeature(feature)) && (!role || hasRole(role)); return allowed ? <>{children}</> : <>{fallback}</>; }
Usage dans un composant :
tsxfunction UserManagementPage() { return ( <div> {/* Visible uniquement si l'utilisateur peut créer des users */} <Protected right="users.create"> <CreateUserButton /> </Protected> {/* Visible si deploy OU write sur infra */} <Protected anyRight={["infra.deploy", "infra.write"]}> <DeployButton /> </Protected> {/* Visible uniquement si la feature invitations est activée */} <Protected feature="invitations" right="invitations.create"> <InviteUserButton /> </Protected> {/* Visible si niveau CustomerAdmin minimum */} <Protected adminLevel="CustomerAdmin"> <CustomerSettingsPanel /> </Protected> </div> ); }
Implémentation côté backend NestJS (dans votre Wakapp)
Si votre Wakapp a son propre backend, vous devez vérifier les droits en décodant le wakaToken enrichi reçu dans x-enriched-token.
Décorateur @RequireAppRight
typescript// decorators/require-app-right.decorator.ts import { SetMetadata } from "@nestjs/common"; export const REQUIRED_RIGHT_KEY = "requiredRight"; export const RequireAppRight = (right: string) => SetMetadata(REQUIRED_RIGHT_KEY, right);
Guard AppRightGuard
typescript// guards/app-right.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import * as jwt from "jsonwebtoken"; import { REQUIRED_RIGHT_KEY } from "../decorators/require-app-right.decorator"; @Injectable() export class AppRightGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { const requiredRight = this.reflector.getAllAndOverride<string>( REQUIRED_RIGHT_KEY, [context.getHandler(), context.getClass()] ); if (!requiredRight) return true; // Pas de restriction const request = context.switchToHttp().getRequest(); const enrichedToken = request.headers["x-enriched-token"]; if (!enrichedToken) { throw new ForbiddenException("Token enrichi manquant"); } try { // Vérifier avec JWT_SECRET de votre Wakapp // (vous devez contacter l'admin Wakastellar pour obtenir la clé publique) // Alternative recommandée : POST /api/token/authorize avec votre API key const payload = jwt.verify(enrichedToken, process.env.WAKASTART_JWT_SECRET!) as any; const appRights: string[] = payload.appRights ?? []; if (!appRights.includes(requiredRight)) { throw new ForbiddenException(`Droit requis : ${requiredRight}`); } return true; } catch (err) { throw new ForbiddenException("Token invalide"); } } }
Alternative recommandée : déléguer au service token
typescript// Votre backend délègue la vérification à la Public API async function verifyRight(token: string, right: string): Promise<boolean> { const res = await fetch(`${WAKASTART_API_URL}/api/token/authorize`, { method: "POST", headers: { "x-api-key": process.env.WAKASTART_API_KEY!, "Content-Type": "application/json", }, body: JSON.stringify({ token, checks: { appRights: { rights: [right], mode: "all" }, }, }), }); const { authorized } = await res.json(); return authorized; }
Usage dans un contrôleur NestJS
typescript@Controller("users") @UseGuards(AppRightGuard) export class UsersController { @Post() @RequireAppRight("users.create") async createUser(@Body() dto: CreateUserDto) { // ... } @Delete(":id") @RequireAppRight("users.delete") async deleteUser(@Param("id") id: string) { // ... } }
Vérification combinée via /token/authorize
Pour des vérifications complexes (admin level + rôle + droit) en un seul appel :
httpPOST /api/token/authorize x-api-key: sk_live_... Content-Type: application/json { "token": "eyJ...", "checks": { "adminLevel": { "requiredLevel": "CustomerAdmin" }, "roles": { "roles": ["CONFIG", "AUDIT"], "mode": "any" }, "appRights": { "rights": ["users.create", "users.update"], "mode": "all" } } }
json{ "authorized": true, "details": { "adminLevel": { "hasLevel": true, "actualLevel": "NetworkAdmin" }, "roles": { "hasRoles": true, "matchedRoles": ["CONFIG"] }, "appRights": { "hasRights": true, "missingRights": [] } } }
Bonnes pratiques
- Principe du moindre privilège : n'accordez que les droits strictement nécessaires dans les profils.
- Backend = autorité : vérifiez toujours les droits côté serveur. Le frontend ne fait que masquer.
- Granularité fine : préférez
users.createàusers.*— les wildcards n'existent pas dans le modèle. - Logging des refus : loggez les
403avec le droit manquant (mais jamais le token complet). - Invalidation du cache : après une modification de profil par un admin, l'utilisateur doit se re-loguer ou appeler
/meà nouveau. - Features en premier : avant de vérifier un droit, vérifiez si la feature est activée — cela évite d'afficher des messages d'erreur sur des features non provisionnées.
Pièges classiques
- Confondre
userLeveletadminLevel:userLevel(ADMIN, MEMBER, etc.) est le niveau au sein d'une équipe ;adminLevelest la portée administrative sur la hiérarchie. appRightsvide = aucun accès : un utilisateur sans profil assigné aappRights: []et ne voit rien. Il n'y a pas de "droit par défaut".- Feature désactivée → 403 : si
invitationsest absent deme.featureset que votre code appelle quand même l'API invitations, vous obtiendrez un403 Feature désactivée. - Droits cumulatifs : si l'utilisateur a deux profils, ses
appRightssont l'union des deux. Ce n'est pas le profil le plus permissif qui gagne, c'est l'union totale.
Aller plus loin
- Contexte utilisateur /me : source des droits
- Utilisation de l'API : comment passer les tokens dans vos requêtes
- Référence des endpoints : endpoint
/token/authorize