Files
stack/apps/api/src/common
Jason Woltje b4f4de6f7a
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix(api): remove noisy CSRF guard debug log (#631)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 21:13:00 +00:00
..

Common Guards and Decorators

This directory contains shared guards and decorators for workspace-based permission management in the Mosaic Stack API.

Overview

The permission system provides:

  • Workspace isolation via Row-Level Security (RLS)
  • Role-based access control (RBAC) using workspace member roles
  • Declarative permission requirements using decorators

Guards

AuthGuard

Located in ../auth/guards/auth.guard.ts

Verifies user authentication and attaches user data to the request.

Sets on request:

  • request.user - Authenticated user object
  • request.session - User session data

WorkspaceGuard

Validates workspace access and sets up RLS context.

Responsibilities:

  1. Extracts workspace ID from request (header, param, or body)
  2. Verifies user is a member of the workspace
  3. Sets the current user context for RLS policies
  4. Attaches workspace context to the request

Sets on request:

  • request.workspace.id - Validated workspace ID
  • request.user.workspaceId - Workspace ID (for backward compatibility)

Workspace ID Sources (in priority order):

  1. X-Workspace-Id header
  2. :workspaceId URL parameter
  3. workspaceId in request body

Example:

@Controller("tasks")
@UseGuards(AuthGuard, WorkspaceGuard)
export class TasksController {
  @Get()
  async getTasks(@Workspace() workspaceId: string) {
    // workspaceId is validated and RLS context is set
  }
}

PermissionGuard

Enforces role-based access control using workspace member roles.

Responsibilities:

  1. Reads required permission from @RequirePermission() decorator
  2. Fetches user's role in the workspace
  3. Checks if role satisfies the required permission
  4. Attaches role to request for convenience

Sets on request:

  • request.user.workspaceRole - User's role in the workspace

Must be used after AuthGuard and WorkspaceGuard.

Example:

@Controller("admin")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class AdminController {
  @RequirePermission(Permission.WORKSPACE_ADMIN)
  @Delete("data")
  async deleteData() {
    // Only ADMIN or OWNER can execute
  }
}

Decorators

@RequirePermission(permission: Permission)

Specifies the minimum permission level required for a route.

Permission Levels:

Permission Allowed Roles Use Case
WORKSPACE_OWNER OWNER Critical operations (delete workspace, transfer ownership)
WORKSPACE_ADMIN OWNER, ADMIN Administrative functions (manage members, settings)
WORKSPACE_MEMBER OWNER, ADMIN, MEMBER Standard operations (create/edit content)
WORKSPACE_ANY All roles including GUEST Read-only or basic access

Example:

@RequirePermission(Permission.WORKSPACE_ADMIN)
@Post('invite')
async inviteMember(@Body() inviteDto: InviteDto) {
  // Only admins can invite members
}

@Workspace()

Parameter decorator to extract the validated workspace ID.

Example:

@Get()
async getTasks(@Workspace() workspaceId: string) {
  // workspaceId is guaranteed to be valid
}

@WorkspaceContext()

Parameter decorator to extract the full workspace context.

Example:

@Get()
async getTasks(@WorkspaceContext() workspace: { id: string }) {
  console.log(workspace.id);
}

@CurrentUser()

Located in ../auth/decorators/current-user.decorator.ts

Extracts the authenticated user from the request.

Example:

@Post()
async create(@CurrentUser() user: any, @Body() dto: CreateDto) {
  // user contains authenticated user data
}

Usage Patterns

Basic Controller Setup

import { Controller, Get, Post, UseGuards } from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { CurrentUser } from "../auth/decorators/current-user.decorator";

@Controller("resources")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class ResourcesController {
  @Get()
  @RequirePermission(Permission.WORKSPACE_ANY)
  async list(@Workspace() workspaceId: string) {
    // All members can list
  }

  @Post()
  @RequirePermission(Permission.WORKSPACE_MEMBER)
  async create(@Workspace() workspaceId: string, @CurrentUser() user: any, @Body() dto: CreateDto) {
    // Members and above can create
  }

  @Delete(":id")
  @RequirePermission(Permission.WORKSPACE_ADMIN)
  async delete(@Param("id") id: string) {
    // Only admins can delete
  }
}

Mixed Permissions

Different endpoints can have different permission requirements:

@Controller("projects")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class ProjectsController {
  @Get()
  @RequirePermission(Permission.WORKSPACE_ANY)
  async list() {
    /* Anyone can view */
  }

  @Post()
  @RequirePermission(Permission.WORKSPACE_MEMBER)
  async create() {
    /* Members can create */
  }

  @Patch("settings")
  @RequirePermission(Permission.WORKSPACE_ADMIN)
  async updateSettings() {
    /* Only admins */
  }

  @Delete()
  @RequirePermission(Permission.WORKSPACE_OWNER)
  async deleteProject() {
    /* Only owner */
  }
}

Workspace ID in Request

The workspace ID can be provided in multiple ways:

Via Header (Recommended for SPAs):

// Frontend
fetch("/api/tasks", {
  headers: {
    Authorization: "Bearer <token>",
    "X-Workspace-Id": "workspace-uuid",
  },
});

Via URL Parameter:

@Get(':workspaceId/tasks')
async getTasks(@Param('workspaceId') workspaceId: string) {
  // workspaceId extracted from URL
}

Via Request Body:

@Post()
async create(@Body() dto: { workspaceId: string; name: string }) {
  // workspaceId extracted from body
}

Row-Level Security (RLS)

When WorkspaceGuard is applied, it automatically:

  1. Calls setCurrentUser(userId) to set the RLS context
  2. All subsequent database queries are automatically filtered by RLS policies
  3. Users can only access data in workspaces they're members of

See: docs/design/multi-tenant-rls.md for full RLS documentation.

Testing

Tests are provided for both guards:

  • workspace.guard.spec.ts - WorkspaceGuard tests
  • permission.guard.spec.ts - PermissionGuard tests

Run tests:

npm test -- workspace.guard.spec
npm test -- permission.guard.spec

Error Handling

WorkspaceGuard Errors

  • ForbiddenException("User not authenticated") - No authenticated user
  • BadRequestException("Workspace ID is required...") - No workspace ID provided
  • ForbiddenException("You do not have access to this workspace") - User is not a workspace member

PermissionGuard Errors

  • ForbiddenException("Authentication and workspace context required") - Missing user or workspace context
  • ForbiddenException("You are not a member of this workspace") - User not found in workspace
  • ForbiddenException("Insufficient permissions. Required: ...") - User role doesn't meet requirements

Migration Guide

Before (Manual Checks):

@Get()
async getTasks(@Request() req: any) {
  const workspaceId = req.user?.workspaceId;
  if (!workspaceId) {
    throw new UnauthorizedException("Authentication required");
  }
  return this.tasksService.findAll(workspaceId);
}

After (Guard-Based):

@Get()
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
@RequirePermission(Permission.WORKSPACE_ANY)
async getTasks(@Workspace() workspaceId: string) {
  return this.tasksService.findAll(workspaceId);
}

Benefits

Declarative - Permission requirements are clear from decorators DRY - No repetitive auth/workspace checks in every handler Type-safe - Workspace ID is guaranteed to exist when using @Workspace() Secure - RLS context automatically set, defense in depth Testable - Guards are independently testable Maintainable - Permission changes in one place

  • apps/api/src/lib/db-context.ts - RLS utility functions
  • docs/design/multi-tenant-rls.md - RLS architecture documentation
  • apps/api/prisma/schema.prisma - Database schema with role definitions