WakaStart

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.

Version v1.07 min de lecture

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 AppRight assigné à 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.

text
WakaAdmin → 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ôlePérimètre
CONFIGConfiguration de la plateforme (apps, networks, customers)
EXPLOITExploitation / opérations
DPOProtection des données personnelles (RGPD)
AUDITConsultation des logs d'audit
BILLINGFacturation et crédits
CYBERCybersé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 :

text
partners.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 :

text
AppFeature.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 :

tsx
function 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 :

http
POST /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 403 avec 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 userLevel et adminLevel : userLevel (ADMIN, MEMBER, etc.) est le niveau au sein d'une équipe ; adminLevel est la portée administrative sur la hiérarchie.
  • appRights vide = aucun accès : un utilisateur sans profil assigné a appRights: [] et ne voit rien. Il n'y a pas de "droit par défaut".
  • Feature désactivée → 403 : si invitations est absent de me.features et que votre code appelle quand même l'API invitations, vous obtiendrez un 403 Feature désactivée.
  • Droits cumulatifs : si l'utilisateur a deux profils, ses appRights sont l'union des deux. Ce n'est pas le profil le plus permissif qui gagne, c'est l'union totale.

Aller plus loin

Cette page vous a-t-elle été utile ?