WakaStart
Security

Access control — Guards & Hooks

Full implementation: useAppRights hook, Protected component, NestJS AppRightGuard.

Version v1.04 min read

Access control — Guards & Hooks

This chapter covers the implementation code on the frontend side (React hooks, the <Protected> component) and the backend side (NestJS guards). Read Rights model first.


Frontend implementation

useAppRights hook

typescript
// hooks/use-app-rights.ts import { useAuth } from "@/lib/auth-context"; export function useAppRights() { const { user } = useAuth(); const appRights = user?.appRights ?? []; return { // Check a single right hasRight: (code: string) => appRights.includes(code), // At least one of the rights present hasAnyRight: (...codes: string[]) => codes.some(c => appRights.includes(c)), // All rights present hasAllRights: (...codes: string[]) => codes.every(c => appRights.includes(c)), // Admin level check 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; }, }; }

No hasFeature/hasRole in this hook: feature activation is not a synchronous field of /me, it is checked via a dedicated call (GET /api/config/app-features/check/{customerWid}/{featureCode}, see Feature flags and cascade); and the wakaRoles/cross-cutting business role concept has been decommissioned (see Rights model).

<Protected> component

typescript
// components/protected.tsx import { useAppRights } from "@/hooks/use-app-rights"; interface ProtectedProps { right?: string; anyRight?: string[]; allRights?: string[]; adminLevel?: string; fallback?: React.ReactNode; children: React.ReactNode; } export function Protected({ right, anyRight, allRights, adminLevel, fallback = null, children, }: ProtectedProps) { const { hasRight, hasAnyRight, hasAllRights, isAdminAtLeast } = useAppRights(); const allowed = (!right || hasRight(right)) && (!anyRight || hasAnyRight(...anyRight)) && (!allRights || hasAllRights(...allRights)) && (!adminLevel || isAdminAtLeast(adminLevel)); return allowed ? <>{children}</> : <>{fallback}</>; }

The feature check (app-features/check/...) is asynchronous — combine it with <Protected> by resolving it before render (e.g. in a route loader), not as a synchronous prop of this component.

Usage in a component:

tsx
function UserManagementPage() { return ( <div> {/* Visible only if the user can create users */} <Protected right="users.create"> <CreateUserButton /> </Protected> {/* Visible if deploy OR write on infra */} <Protected anyRight={["infra.deploy", "infra.write"]}> <DeployButton /> </Protected> {/* The invitations.create right is enough here; if the invitations feature can be disabled for this customer, check it upstream via GET /api/config/app-features/check/{customerWid}/invitations and condition the render of this whole block on that result. */} <Protected right="invitations.create"> <InviteUserButton /> </Protected> {/* Visible if at least CustomerAdmin level */} <Protected adminLevel="CustomerAdmin"> <CustomerSettingsPanel /> </Protected> </div> ); }

Backend implementation with NestJS (in your Wakapp)

If your Wakapp has its own backend, check rights by delegating to WakaStart's authorization API: POST /api/token/authorize.

Do not do this — Never verify the x-enriched-token signature locally. The wakaToken is a platform implementation detail; only the response from /api/token/authorize is contractual.

@RequireAppRight decorator

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);

AppRightGuard — delegation to /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("Missing enriched token"); } 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("Invalid token"); } const { authorized } = (await res.json()) as { authorized: boolean }; if (!authorized) { throw new ForbiddenException(`Required right: ${requiredRight}`); } return true; } }

To limit latency under heavy traffic, cache the authorized response for 30 to 60 seconds on the Wakapp side, indexed by (token, requiredRight). Beyond that, the token may have been revoked.

Usage in a NestJS controller

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) { // ... } }

Best practices

  • Log denials: log 403s with the missing right (but never the full token).
  • Cache invalidation: after a profile change by an admin, the user must re-login or call /me again.

Go further