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, vérifiez les droits en déléguant à l'API d'autorisation WakaStart : POST /api/token/authorize. Cette méthode est la seule supportée.

À ne pas faire — Ne vérifiez jamais la signature du x-enriched-token localement (pas de jwt.verify avec un secret partagé, pas de manipulation de la clé). Le wakaToken est un détail d'implémentation de la plateforme : seule la réponse de /api/token/authorize est contractuelle.

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 — délégation à /api/token/authorize

typescript
// guards/app-right.guard.ts import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { REQUIRED_RIGHT_KEY } from "../decorators/require-app-right.decorator"; @Injectable() export class AppRightGuard implements CanActivate { constructor(private reflector: Reflector) {} async canActivate(context: ExecutionContext): Promise<boolean> { const requiredRight = this.reflector.getAllAndOverride<string>( REQUIRED_RIGHT_KEY, [context.getHandler(), context.getClass()], ); if (!requiredRight) return true; const request = context.switchToHttp().getRequest(); const enrichedToken = request.headers["x-enriched-token"]; if (!enrichedToken) { throw new ForbiddenException("Token enrichi manquant"); } const res = await fetch(`${process.env.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: enrichedToken, checks: { appRights: { rights: [requiredRight], mode: "all" }, }, }), }); if (!res.ok) { throw new ForbiddenException("Token invalide"); } const { authorized } = (await res.json()) as { authorized: boolean }; if (!authorized) { throw new ForbiddenException(`Droit requis : ${requiredRight}`); } return true; } }

Pour limiter la latence en cas de trafic élevé, cachez la réponse authorized pendant 30 à 60 secondes côté Wakapp, indexée par (token, requiredRight). Au-delà, le token risque d'avoir été révoqué.

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 ?