Skip to main content
Learn how to implement role-based access control for API endpoints using Sonamu’s Guards system.

Guards System Overview

Declarative Permissions

@api guards optionSimple permission setup

Guard Types

admin, user, queryType definitions provided

Custom Guards

Business logicFlexible extension

Hierarchical Permissions

Role-based controlRBAC implementation

Understanding Guards

What are Guards?

Guards are functions that verify permissions before an API method executes. They are used in the following situations:
  • APIs accessible only to logged-in users
  • APIs accessible only to administrators
  • APIs accessible only to users meeting specific conditions
Advantages of Guards:
  1. Declarative: Simply declare like @api({ guards: ["admin"] })
  2. Reusable: Use the same Guards across multiple APIs
  3. Testable: Test Guards independently
  4. Separation of Concerns: Permission logic separated from business logic

Guards Execution Flow

1. Client → API request
2. Sonamu → Create Context (including user info)
3. Guards → Permission verification
   ↓ Success
4. API method execution

5. Return response

   ↓ Failure
3. 403 Forbidden response

Guard Types

Sonamu has three Guard types (user, admin, query) defined. These are type-safe keys, and the actual verification logic must be implemented in guardHandler in sonamu.config.ts.

user Guard

user Guard allows only logged-in users to access. It’s the most basic authentication Guard.
class PostModel extends BaseModelClass {
  /**
   * Get my posts list
   * 
   * user Guard: Login required
   */
  @api({ httpMethod: "GET", guards: ["user"] })
  async getMyPosts(): Promise<{
    posts: Post[];
  }> {
    const context = Sonamu.getContext();
    
    // Thanks to user Guard, context.user is guaranteed to exist
    const userId = context.user!.id;
    
    const rdb = this.getPuri("r");
    const posts = await rdb
      .table("posts")
      .where("user_id", userId)
      .orderBy("created_at", "desc")
      .select("*");
    
    return { posts };
  }
  
  /**
   * Create post
   */
  @api({ httpMethod: "POST", guards: ["user"] })
  async create(params: {
    title: string;
    content: string;
  }): Promise<{ postId: number }> {
    const context = Sonamu.getContext();
    const { title, content } = params;
    
    const wdb = this.getPuri("w");
    const [post] = await wdb
      .table("posts")
      .insert({
        user_id: context.user!.id,
        title,
        content,
        created_at: new Date(),
      })
      .returning({ id: "id" });
    
    return { postId: post.id };
  }
}
When to use?
  • All APIs that require login
  • APIs handling user-specific data (my profile, my orders, etc.)
  • Content creation/modification/deletion

admin Guard

admin Guard allows only administrators to access. Used for sensitive administrative functions.
class UserModel extends BaseModelClass {
  /**
   * All users list (admin only)
   */
  @api({ httpMethod: "GET", guards: ["admin"] })
  async getAllUsers(params: {
    page?: number;
    pageSize?: number;
  }): Promise<{
    users: User[];
    total: number;
  }> {
    const { page = 1, pageSize = 20 } = params;
    
    const rdb = this.getPuri("r");
    
    const users = await rdb
      .table("users")
      .limit(pageSize)
      .offset((page - 1) * pageSize)
      .select("*");
    
    const [{ count }] = await rdb
      .table("users")
      .count("* as count");
    
    return {
      users,
      total: count,
    };
  }
  
  /**
   * Force deactivate user (admin only)
   */
  @api({ httpMethod: "POST", guards: ["admin"] })
  async deactivateUser(userId: number): Promise<{ success: boolean }> {
    const wdb = this.getPuri("w");
    
    await wdb
      .table("users")
      .where("id", userId)
      .update({
        is_active: false,
        deactivated_at: new Date(),
      });
    
    return { success: true };
  }
}
When to use?
  • System settings changes
  • Accessing all user data
  • User management (activation/deactivation, permission changes)
  • Statistics and analytics data
  • Sensitive business logic

query Guard

query Guard implements simple authentication via query string parameters. Mainly used for public APIs or temporary links.
class FileModel extends BaseModelClass {
  /**
   * File download (query token authentication)
   * 
   * Usage: GET /api/file/download?fileId=123&token=abc...
   */
  @api({ httpMethod: "GET", guards: ["query"] })
  async download(params: {
    fileId: number;
    token: string;
  }): Promise<{
    url: string;
  }> {
    const { fileId, token } = params;
    
    // Verify token
    const rdb = this.getPuri("r");
    const downloadToken = await rdb
      .table("download_tokens")
      .where("file_id", fileId)
      .where("token", token)
      .where("expires_at", ">", new Date())
      .first();
    
    if (!downloadToken) {
      throw new Error("Invalid or expired download token");
    }
    
    // Query file info
    const file = await rdb
      .table("files")
      .where("id", fileId)
      .first();
    
    if (!file) {
      throw new Error("File not found");
    }
    
    // Generate Signed URL
    const disk = Sonamu.storage.use(file.disk_name);
    const url = await disk.getSignedUrl(file.key, 3600);
    
    return { url };
  }
}
When to use?
  • Temporary download links sent via email
  • Shareable public APIs
  • Webhook callbacks (API key verification)
  • Cases requiring simple authentication
The specific implementation of query Guard may vary by project. It can be extended with various methods like API keys, tokens, signatures, etc.

Combining Multiple Guards

You can apply multiple Guards to a single API. All Guards must pass for the API to execute.
class ReportModel extends BaseModelClass {
  /**
   * Generate sensitive report (admin + additional verification)
   */
  @api({ httpMethod: "POST", guards: ["admin", "superAdmin"] })
  async generateSensitiveReport(): Promise<{ reportId: number }> {
    // Executes only after passing both admin Guard and superAdmin Guard
    // ...
  }
}

Implementing Guards

Guards are implemented in guardHandler in sonamu.config.ts. Sonamu automatically executes guardHandler for each API call.

Implementing guardHandler

// sonamu.config.ts
import type { GuardKey } from "sonamu";
import type { FastifyRequest } from "fastify";
import { Sonamu } from "sonamu";

export default {
  // ... other settings

  api: {
    route: {
      prefix: "/api",
    },
    server: {
      custom: (server) => {
        // Fastify server customization
      },
    },
    apiConfig: {
      contextProvider: (defaultContext, request) => ({
        ...defaultContext,
        // Context extension
      }),

      /**
       * Guards verification logic
       *
       * @param guard - Guard key ("user", "admin", "query", etc.)
       * @param request - Fastify Request object
       * @param api - API metadata
       * @returns true to pass, false or throw exception to block
       */
      guardHandler: (guard: GuardKey, request: FastifyRequest, api) => {
        // Get user information from Sonamu Context
        const context = Sonamu.getContext();

        switch (guard) {
          case "user":
            // Only logged-in users can access
            if (!context.user) {
              throw new Error("Authentication required");
            }
            return true;

          case "admin":
            // Only administrators can access
            if (!context.user || context.user.role !== "admin") {
              throw new Error("Admin access required");
            }
            return true;

          case "query":
            // Verify API key from query parameter
            const apiKey = request.query.apiKey;
            if (apiKey !== process.env.API_KEY) {
              throw new Error("Invalid API key");
            }
            return true;

          default:
            // Block unknown Guards
            throw new Error(`Unknown guard: ${guard}`);
        }
      },
    },
  },
};

Adding Custom Guard Types

To add project-specific Guards, extend the GuardKeys interface.
// types/guards.ts
declare module "sonamu" {
  interface GuardKeys {
    verified: true;      // Email verified users
    premium: true;       // Premium members
    moderator: true;     // Moderators
  }
}
Now implement new Guards in guardHandler.
// sonamu.config.ts
guardHandler: (guard, request, api) => {
  const context = Sonamu.getContext();

  switch (guard) {
    case "user":
      if (!context.user) {
        throw new Error("Authentication required");
      }
      return true;

    case "admin":
      if (!context.user || context.user.role !== "admin") {
        throw new Error("Admin access required");
      }
      return true;

    case "verified":
      // Email verified users only
      if (!context.user || !context.user.email_verified) {
        throw new Error("Email verification required");
      }
      return true;

    case "premium":
      // Premium members only
      if (!context.user || context.user.subscription_tier !== "premium") {
        throw new Error("Premium subscription required");
      }
      return true;

    case "moderator":
      // Moderators or administrators
      if (!context.user ||
          (context.user.role !== "moderator" && context.user.role !== "admin")) {
        throw new Error("Moderator access required");
      }
      return true;

    default:
      throw new Error(`Unknown guard: ${guard}`);
  }
},

Dynamic Verification

When you need to verify permissions dynamically based on API parameters, you can use the api parameter in guardHandler.
guardHandler: (guard, request, api) => {
  const context = Sonamu.getContext();

  if (guard === "owner") {
    // Dynamic verification using API metadata
    // Example: Check if post ID exists in parameters
    const postId = request.query.postId || request.body?.postId;

    if (!postId) {
      throw new Error("Resource ID required");
    }

    // Only perform simple verification here
    // Complex ownership verification should be done inside API methods
    if (!context.user) {
      throw new Error("Authentication required");
    }

    return true;
  }

  // ... other Guards
},
Note: For permission verification requiring complex business logic or database queries, it’s better to handle it inside API methods rather than in Guards. Guards should only handle simple authentication/authorization checks.

Role-Based Access Control (RBAC)

Use RBAC (Role-Based Access Control) for more complex permission systems.

Defining Roles and Permissions

// types/permissions.ts
export enum Permission {
  // User management
  USER_READ = "user:read",
  USER_WRITE = "user:write",
  USER_DELETE = "user:delete",
  
  // Post management
  POST_READ = "post:read",
  POST_WRITE = "post:write",
  POST_DELETE = "post:delete",
  
  // Administration
  ADMIN_DASHBOARD = "admin:dashboard",
  ADMIN_SETTINGS = "admin:settings",
}

export const rolePermissions: Record<string, Permission[]> = {
  admin: [
    Permission.USER_READ,
    Permission.USER_WRITE,
    Permission.USER_DELETE,
    Permission.POST_READ,
    Permission.POST_WRITE,
    Permission.POST_DELETE,
    Permission.ADMIN_DASHBOARD,
    Permission.ADMIN_SETTINGS,
  ],
  moderator: [
    Permission.POST_READ,
    Permission.POST_WRITE,
    Permission.POST_DELETE,
  ],
  user: [
    Permission.POST_READ,
    Permission.POST_WRITE,
  ],
  guest: [
    Permission.POST_READ,
  ],
};

/**
 * Check if user has specific permission
 */
export function hasPermission(
  user: User | null,
  permission: Permission
): boolean {
  if (!user) return false;
  
  const permissions = rolePermissions[user.role] || [];
  return permissions.includes(permission);
}

Implementing Permission-Based Guards

To integrate the RBAC system with Guards, verify permissions in guardHandler.
// sonamu.config.ts
import { Permission, hasPermission } from "./types/permissions";

// Add permission-based Guards to GuardKeys
declare module "sonamu" {
  interface GuardKeys {
    "user:write": true;
    "user:delete": true;
    "post:delete": true;
    "admin:settings": true;
  }
}

export default {
  // ... settings
  api: {
    apiConfig: {
      guardHandler: (guard, request, api) => {
        const context = Sonamu.getContext();

        // Basic Guards
        switch (guard) {
          case "user":
            if (!context.user) {
              throw new Error("Authentication required");
            }
            return true;

          case "admin":
            if (!context.user || context.user.role !== "admin") {
              throw new Error("Admin access required");
            }
            return true;

          // Permission-based Guards
          case "user:write":
            if (!hasPermission(context.user, Permission.USER_WRITE)) {
              throw new Error("USER_WRITE permission required");
            }
            return true;

          case "user:delete":
            if (!hasPermission(context.user, Permission.USER_DELETE)) {
              throw new Error("USER_DELETE permission required");
            }
            return true;

          case "post:delete":
            if (!hasPermission(context.user, Permission.POST_DELETE)) {
              throw new Error("POST_DELETE permission required");
            }
            return true;

          case "admin:settings":
            if (!hasPermission(context.user, Permission.ADMIN_SETTINGS)) {
              throw new Error("ADMIN_SETTINGS permission required");
            }
            return true;

          default:
            throw new Error(`Unknown guard: ${guard}`);
        }
      },
    },
  },
};

Using Permission Guard

class UserModel extends BaseModelClass {
  /**
   * Update user info (admin or self)
   */
  @api({ httpMethod: "PUT", guards: ["user:write"] })
  async updateUser(params: {
    userId: number;
    username?: string;
    email?: string;
  }): Promise<{ user: User }> {
    const context = Sonamu.getContext();
    const { userId, username, email } = params;
    
    // Deny if not self and not admin
    if (
      context.user!.id !== userId &&
      !hasPermission(context.user, Permission.USER_WRITE)
    ) {
      throw new Error("Permission denied");
    }
    
    const wdb = this.getPuri("w");
    await wdb
      .table("users")
      .where("id", userId)
      .update({
        username,
        email,
        updated_at: new Date(),
      });
    
    const rdb = this.getPuri("r");
    const user = await rdb
      .table("users")
      .where("id", userId)
      .first();
    
    return { user };
  }
}

Resource-Based Permission Control

Pattern for verifying ownership of specific resources.
class PostModel extends BaseModelClass {
  /**
   * Update post (author or admin only)
   */
  @api({ httpMethod: "PUT", guards: ["user"] })
  async update(params: {
    postId: number;
    title?: string;
    content?: string;
  }): Promise<{ post: Post }> {
    const context = Sonamu.getContext();
    const { postId, title, content } = params;
    
    // Query post
    const rdb = this.getPuri("r");
    const post = await rdb
      .table("posts")
      .where("id", postId)
      .first();
    
    if (!post) {
      throw new Error("Post not found");
    }
    
    // Verify ownership
    const isOwner = post.user_id === context.user!.id;
    const isAdmin = context.user!.role === "admin";
    
    if (!isOwner && !isAdmin) {
      throw new Error("You can only edit your own posts");
    }
    
    // Update
    const wdb = this.getPuri("w");
    await wdb
      .table("posts")
      .where("id", postId)
      .update({
        title,
        content,
        updated_at: new Date(),
      });
    
    const updatedPost = await rdb
      .table("posts")
      .where("id", postId)
      .first();
    
    return { post: updatedPost };
  }
  
  /**
   * Delete post (author or admin only)
   */
  @api({ httpMethod: "DELETE", guards: ["user"] })
  async remove(postId: number): Promise<{ success: boolean }> {
    const context = Sonamu.getContext();
    
    const rdb = this.getPuri("r");
    const post = await rdb
      .table("posts")
      .where("id", postId)
      .first();
    
    if (!post) {
      throw new Error("Post not found");
    }
    
    // Verify ownership
    if (
      post.user_id !== context.user!.id &&
      context.user!.role !== "admin"
    ) {
      throw new Error("Permission denied");
    }
    
    const wdb = this.getPuri("w");
    await wdb.table("posts").where("id", postId).delete();
    
    return { success: true };
  }
}

Testing Guards

You can test Guards independently.
// guards/__tests__/guards.test.ts
import { describe, it, expect } from "vitest";
import { guards } from "../index";
import type { AppContext } from "../../types/context";

describe("Guards", () => {
  describe("user guard", () => {
    it("passes logged in user", () => {
      const context: Partial<AppContext> = {
        user: {
          id: 1,
          email: "test@example.com",
          role: "user",
        } as any,
      };
      
      expect(guards.user(context as AppContext)).toBe(true);
    });
    
    it("blocks non-logged in user", () => {
      const context: Partial<AppContext> = {
        user: null,
      };
      
      expect(guards.user(context as AppContext)).toBe(false);
    });
  });
  
  describe("admin guard", () => {
    it("passes admin", () => {
      const context: Partial<AppContext> = {
        user: {
          id: 1,
          email: "admin@example.com",
          role: "admin",
        } as any,
      };
      
      expect(guards.admin(context as AppContext)).toBe(true);
    });
    
    it("blocks regular user", () => {
      const context: Partial<AppContext> = {
        user: {
          id: 2,
          email: "user@example.com",
          role: "user",
        } as any,
      };
      
      expect(guards.admin(context as AppContext)).toBe(false);
    });
  });
});

Cautions

Cautions when using Guards:
  1. Guards only handle Authentication, Authorization should be handled in business logic
  2. Additional verification required for sensitive data even after passing Guards
  3. Return 403 Forbidden on Guard failure (not 401 Unauthorized)
  4. Consider order when using multiple Guards (general to specific)
  5. Avoid complex business logic in Guards (simple permission checks only)
  6. Resource ownership verification should be handled inside API methods

Next Steps