Awesome-omni-skill authentication-authorization

Authentication and authorization patterns using Clerk and RBAC

install
source · Clone the upstream repo
git clone https://github.com/diegosouzapw/awesome-omni-skill
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/backend/authentication-authorization" ~/.claude/skills/diegosouzapw-awesome-omni-skill-authentication-authorization && rm -rf "$T"
manifest: skills/backend/authentication-authorization/SKILL.md
source content

Authentication & Authorization Skill

This skill provides guidance for implementing authentication and authorization in Splits Network.

Purpose

Help developers implement secure, role-based access control:

  • Clerk Integration: Authentication with Clerk
  • V2 Access Context: Role-based data filtering
  • API Gateway RBAC: Route-level authorization
  • Frontend Auth: Protected routes and role checks
  • User Identification: Proper ID handling across services

When to Use This Skill

Use this skill when:

  • Implementing protected API endpoints
  • Creating repository methods with role-based filtering
  • Building protected frontend pages
  • Checking user permissions
  • Debugging authorization issues

Core Principles

1. V2 Access Context Pattern (Recommended)

All V2 services use shared access context for data-level authorization:

// packages/shared-access-context/src/index.ts
import { SupabaseClient } from "@supabase/supabase-js";

export interface AccessContext {
    userId: string; // Internal UUID (users.id)
    clerkUserId: string; // Clerk user ID
    role: UserRole; // Primary role
    memberships: Membership[]; // Organization memberships
    isCompanyUser: boolean; // Has company affiliation
    isRecruiter: boolean; // Is recruiter (independent or affiliated)
    accessibleCompanyIds: string[]; // Companies user can access
}

export async function resolveAccessContext(
    clerkUserId: string,
    supabase: SupabaseClient,
): Promise<AccessContext> {
    // 1. Lookup internal user ID
    const { data: user } = await supabase
        .from("users")
        .select("id")
        .eq("clerk_user_id", clerkUserId)
        .single();

    if (!user) {
        throw new Error("User not found");
    }

    // 2. Get memberships
    const { data: memberships } = await supabase
        .from("memberships")
        .select("*, organizations(*)")
        .eq("user_id", user.id);

    // 3. Determine role and permissions
    const isPlatformAdmin = memberships?.some(
        (m) => m.role === "platform_admin",
    );
    const isCompanyAdmin = memberships?.some((m) => m.role === "company_admin");
    const isHiringManager = memberships?.some(
        (m) => m.role === "hiring_manager",
    );

    // 4. Check recruiter status
    const { data: recruiter } = await supabase
        .from("recruiters")
        .select("id, status")
        .eq("user_id", user.id)
        .single();

    const isRecruiter = !!recruiter && recruiter.status === "active";

    // 5. Get accessible company IDs
    const accessibleCompanyIds =
        memberships?.map((m) => m.organization.id).filter(Boolean) || [];

    return {
        userId: user.id,
        clerkUserId,
        role: isPlatformAdmin
            ? "platform_admin"
            : isCompanyAdmin
              ? "company_admin"
              : isHiringManager
                ? "hiring_manager"
                : isRecruiter
                  ? "recruiter"
                  : "user",
        memberships: memberships || [],
        isCompanyUser: isCompanyAdmin || isHiringManager,
        isRecruiter,
        accessibleCompanyIds,
    };
}

See examples/access-context.ts.

2. Repository with Access Context

Apply role-based filtering in repository queries:

// services/ats-service/src/v2/jobs/repository.ts
import { resolveAccessContext } from "@splits-network/shared-access-context";

export class JobRepository {
    constructor(private supabase: SupabaseClient) {}

    async list(clerkUserId: string, filters: JobFilters): Promise<Job[]> {
        const context = await resolveAccessContext(clerkUserId, this.supabase);

        const query = this.supabase.from("jobs").select("*");

        // Apply role-based filtering
        if (context.role === "platform_admin") {
            // Platform admins see all jobs (no filter)
        } else if (context.isCompanyUser) {
            // Company users see their organization's jobs
            query.in("company_id", context.accessibleCompanyIds);
        } else if (context.isRecruiter) {
            // Recruiters see jobs they're assigned to
            const { data: assignments } = await this.supabase
                .from("role_assignments")
                .select("job_id")
                .eq("recruiter_user_id", context.userId);

            query.in("id", assignments?.map((a) => a.job_id) || []);
        } else {
            // Regular users see no jobs
            query.eq("id", "impossible"); // Force empty result
        }

        // Apply additional filters
        if (filters.status) {
            query.eq("status", filters.status);
        }

        const { data, error } = await query;
        if (error) throw error;

        return data || [];
    }

    async getById(id: string, clerkUserId: string): Promise<Job | null> {
        const context = await resolveAccessContext(clerkUserId, this.supabase);

        const { data, error } = await this.supabase
            .from("jobs")
            .select("*")
            .eq("id", id)
            .single();

        if (error || !data) return null;

        // Check access
        if (context.role === "platform_admin") {
            return data; // Admins can see all
        } else if (context.isCompanyUser) {
            // Check company ownership
            if (!context.accessibleCompanyIds.includes(data.company_id)) {
                throw new ForbiddenError("Access denied to this job");
            }
        } else if (context.isRecruiter) {
            // Check assignment
            const { data: assignment } = await this.supabase
                .from("role_assignments")
                .select("id")
                .eq("job_id", id)
                .eq("recruiter_user_id", context.userId)
                .single();

            if (!assignment) {
                throw new ForbiddenError("Access denied to this job");
            }
        } else {
            throw new ForbiddenError("Access denied");
        }

        return data;
    }
}

Key Rules:

  • ✅ Always call
    resolveAccessContext
    at start of repository method
  • ✅ Apply role-based filtering to ALL queries
  • ✅ Platform admins see everything (no filter)
  • ✅ Company users see their organization's data
  • ✅ Recruiters see assigned data
  • ✅ Regular users see minimal data (or none)
  • ❌ Never skip access context checks

See examples/repository-with-access-context.ts.

3. API Gateway RBAC (V1 Pattern - Legacy)

API Gateway enforces route-level authorization:

// services/api-gateway/src/rbac.ts
import { FastifyRequest, FastifyReply } from "fastify";

export type UserRole =
    | "platform_admin"
    | "company_admin"
    | "hiring_manager"
    | "recruiter"
    | "candidate"
    | "user";

export function requireRoles(
    allowedRoles: UserRole[],
    services?: ServiceRegistry,
) {
    return async (request: FastifyRequest, reply: FastifyReply) => {
        const clerkUserId = request.auth?.clerkUserId;

        if (!clerkUserId) {
            return reply.code(401).send({
                error: {
                    code: "UNAUTHORIZED",
                    message: "Authentication required",
                },
            });
        }

        // Check memberships first (fast path)
        const memberships = request.auth.memberships || [];

        // Platform admin always allowed
        if (isPlatformAdmin(memberships)) {
            return;
        }

        // Check company roles
        if (
            allowedRoles.includes("company_admin") &&
            isCompanyAdmin(memberships)
        ) {
            return;
        }

        if (
            allowedRoles.includes("hiring_manager") &&
            isHiringManager(memberships)
        ) {
            return;
        }

        // Check recruiter status (requires network service call)
        if (allowedRoles.includes("recruiter") && services) {
            const isRecruiterActive = await isRecruiter(
                memberships,
                clerkUserId,
                services.network,
            );

            if (isRecruiterActive) {
                return;
            }
        }

        // Check candidate status (requires ATS service call)
        if (allowedRoles.includes("candidate") && services) {
            const { data: candidates } = await services.ats.get(
                `/api/v2/candidates?limit=1`,
                { headers: { "x-clerk-user-id": clerkUserId } },
            );

            if (candidates?.data?.length > 0) {
                return;
            }
        }

        // No matching roles
        return reply.code(403).send({
            error: {
                code: "FORBIDDEN",
                message: "You do not have permission to access this resource",
            },
        });
    };
}

// Helper functions
export function isPlatformAdmin(memberships: Membership[]): boolean {
    return memberships.some((m) => m.role === "platform_admin");
}

export function isCompanyAdmin(
    memberships: Membership[],
    orgId?: string,
): boolean {
    return memberships.some(
        (m) =>
            m.role === "company_admin" &&
            (!orgId || m.organization_id === orgId),
    );
}

export async function isRecruiter(
    memberships: Membership[],
    userId: string,
    networkService: NetworkServiceClient,
): Promise<boolean> {
    // Check memberships first
    if (memberships.some((m) => m.role === "recruiter")) {
        return true;
    }

    // Check independent recruiter status
    try {
        const recruiter = await networkService.getRecruiterByUserId(userId);
        return recruiter?.status === "active";
    } catch (error) {
        return false;
    }
}

Gateway RBAC Rules:

  • ✅ Gateway enforces route-level authorization
  • ✅ Backend services apply data-level filtering
  • ✅ Always pass
    services
    parameter when allowing recruiters/candidates
  • ✅ Check memberships first (fast), then external services
  • ❌ Backend services should NOT duplicate authorization checks

See examples/gateway-rbac.ts.

4. Protected API Routes

Use

requireRoles
middleware on protected endpoints:

// services/api-gateway/src/routes/jobs/routes.ts
import { requireRoles } from "../../rbac";

export async function jobsRoutes(
    app: FastifyInstance,
    services: ServiceRegistry,
) {
    // Public endpoint - no auth required
    app.get("/api/v2/jobs/public", async (request, reply) => {
        // Return public job listings
    });

    // Protected endpoint - requires recruiter or company user
    app.get(
        "/api/v2/jobs",
        {
            preHandler: requireRoles(
                [
                    "recruiter",
                    "company_admin",
                    "hiring_manager",
                    "platform_admin",
                ],
                services,
            ),
        },
        async (request, reply) => {
            const clerkUserId = request.auth.clerkUserId;

            // Forward to ATS service with auth headers
            const response = await services.ats.get("/api/v2/jobs", {
                headers: buildAuthHeaders(request),
            });

            return reply.send(response.data);
        },
    );

    // Admin-only endpoint
    app.delete(
        "/api/v2/jobs/:id",
        {
            preHandler: requireRoles(["platform_admin"]),
        },
        async (request, reply) => {
            // Only platform admins can delete
        },
    );
}

Route Protection Rules:

  • ✅ Use
    requireRoles
    on all protected endpoints
  • ✅ Pass
    services
    when allowing recruiters/candidates
  • ✅ Forward auth headers to backend services
  • ⚠️ Use minimum required roles (don't over-restrict)

See examples/protected-routes.ts.

5. Frontend Protected Routes

Protect Next.js pages with Clerk:

// apps/portal/src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";

const isPublicRoute = createRouteMatcher([
    "/sign-in(.*)",
    "/sign-up(.*)",
    "/",
    "/about",
]);

export default clerkMiddleware((auth, req) => {
    if (!isPublicRoute(req)) {
        auth().protect(); // Require authentication
    }
});

export const config = {
    matcher: [
        "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
        "/(api|trpc)(.*)",
    ],
};
// apps/portal/app/portal/jobs/page.tsx
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";

export default async function JobsPage() {
    const { userId } = await auth();

    if (!userId) {
        redirect("/sign-in");
    }

    // Page content...
}

Frontend Auth Rules:

  • ✅ Use Clerk middleware for route protection
  • ✅ Check auth in page components
  • ✅ Redirect unauthenticated users to sign-in
  • ✅ Show loading states during auth checks

See examples/protected-frontend-routes.tsx.

6. Role-Based UI

Show/hide UI based on user role:

'use client';

import { useUser } from '@clerk/nextjs';
import { useEffect, useState } from 'react';
import { apiClient } from '@/lib/api-client';

export function RoleBasedActions() {
  const { user } = useUser();
  const [userRole, setUserRole] = useState<string | null>(null);

  useEffect(() => {
    async function fetchRole() {
      const { data } = await apiClient.get('/users/me/role');
      setUserRole(data.role);
    }

    if (user) {
      fetchRole();
    }
  }, [user]);

  if (!userRole) return null;

  return (
    <div className="flex gap-2">
      {/* All authenticated users */}
      <button className="btn">View Jobs</button>

      {/* Recruiters only */}
      {(userRole === 'recruiter' || userRole === 'platform_admin') && (
        <button className="btn btn-primary">Submit Candidate</button>
      )}

      {/* Company admins only */}
      {(userRole === 'company_admin' || userRole === 'platform_admin') && (
        <button className="btn btn-primary">Create Job</button>
      )}

      {/* Platform admins only */}
      {userRole === 'platform_admin' && (
        <button className="btn btn-error">Delete</button>
      )}
    </div>
  );
}

UI Role Rules:

  • ✅ Fetch user role from API
  • ✅ Show/hide actions based on role
  • ✅ Always enforce permissions on backend (UI is not security)
  • ⚠️ Handle loading states gracefully

See examples/role-based-ui.tsx.

7. User Identification Standards

Critical: Proper user ID handling across the stack:

// ❌ WRONG - Frontend setting user ID header
fetch("/api/jobs", {
    headers: {
        "x-clerk-user-id": userId, // NEVER do this!
    },
});

// ✅ CORRECT - Frontend sends only auth token
fetch("/api/jobs", {
    headers: {
        Authorization: `Bearer ${token}`, // Clerk JWT
    },
});

// ✅ API Gateway extracts user ID from verified JWT
app.addHook("onRequest", async (request, reply) => {
    const token = request.headers.authorization?.replace("Bearer ", "");
    const verified = await verifyClerkJWT(token);
    request.auth = {
        clerkUserId: verified.sub, // ← From verified token, not header
        userId: verified.userId,
    };
});

// ✅ Gateway forwards to backend services
const response = await services.ats.get("/api/v2/jobs", {
    headers: {
        "x-clerk-user-id": request.auth.clerkUserId, // ← From verified JWT
    },
});

// ✅ Backend service uses header (trusts gateway)
export async function jobsRoutes(app: FastifyInstance) {
    app.get("/api/v2/jobs", async (request, reply) => {
        const clerkUserId = request.headers["x-clerk-user-id"] as string;
        const jobs = await repository.list(clerkUserId, filters);
        return reply.send({ data: jobs });
    });
}

User ID Flow:

  1. Frontend: Send
    Authorization: Bearer <token>
    (Clerk JWT)
  2. Gateway: Extract
    clerkUserId
    from verified JWT
  3. Gateway: Forward
    x-clerk-user-id: <clerkUserId>
    to services
  4. Service: Use
    x-clerk-user-id
    header (trust gateway)

See references/user-identification-flow.md.

Role Hierarchy

platform_admin (highest)
  ↓
company_admin
  ↓
hiring_manager
  ↓
recruiter
  ↓
candidate
  ↓
user (lowest)

Role Capabilities:

  • platform_admin: Full system access, manage all organizations
  • company_admin: Manage organization, create jobs, view all org data
  • hiring_manager: Create jobs, view applications for org jobs
  • recruiter: Submit candidates, view assigned jobs
  • candidate: View own profile, manage applications
  • user: Authenticated user with no special permissions

See references/role-capabilities.md.

Testing Authorization

Test access control:

describe("JobRepository", () => {
    it("should filter jobs by role", async () => {
        // Mock access context for recruiter
        vi.mocked(resolveAccessContext).mockResolvedValue({
            userId: "user_123",
            clerkUserId: "clerk_123",
            role: "recruiter",
            isRecruiter: true,
            accessibleCompanyIds: [],
        });

        const jobs = await repository.list("clerk_123", {});

        // Should only see assigned jobs
        expect(jobs).toHaveLength(2);
    });

    it("should throw ForbiddenError for unauthorized access", async () => {
        vi.mocked(resolveAccessContext).mockResolvedValue({
            userId: "user_123",
            role: "user",
            isRecruiter: false,
        });

        await expect(
            repository.getById("job_456", "clerk_123"),
        ).rejects.toThrow(ForbiddenError);
    });
});

describe("API Gateway", () => {
    it("should require authentication", async () => {
        const response = await app.inject({
            method: "GET",
            url: "/api/v2/jobs",
            // No auth headers
        });

        expect(response.statusCode).toBe(401);
    });

    it("should enforce role-based access", async () => {
        const response = await app.inject({
            method: "DELETE",
            url: "/api/v2/jobs/123",
            headers: {
                Authorization: "Bearer recruiter_token", // Not admin
            },
        });

        expect(response.statusCode).toBe(403);
    });
});

See examples/authorization-testing.ts.

Anti-Patterns to Avoid

❌ Client-Side Only Auth

// WRONG - No backend verification
if (user.role === "admin") {
    await deleteJob(id); // Anyone can call this!
}

// CORRECT - Backend enforces auth
await apiClient.delete(`/jobs/${id}`);
// Backend checks role and throws 403 if not authorized

❌ Skipping Access Context

// WRONG - No role filtering
async list(): Promise<Job[]> {
  return await this.supabase.from('jobs').select('*');
}

// CORRECT - Role-based filtering
async list(clerkUserId: string): Promise<Job[]> {
  const context = await resolveAccessContext(clerkUserId, this.supabase);
  // Apply role-based filtering...
}

❌ Hardcoding Permissions

// WRONG - Hardcoded user IDs
if (userId === "specific-user-id") {
    // Grant special access
}

// CORRECT - Role-based
if (context.role === "platform_admin") {
    // Grant access
}

References

Related Skills

  • api-specifications
    - V2 API patterns with access context
  • database-patterns
    - Repository with role-based filtering
  • error-handling
    - Auth error responses (401, 403)