Back to list
splits-network

authentication-authorization

by splits-network

0🍴 0📅 Jan 25, 2026

SKILL.md


name: authentication-authorization description: Authentication and authorization patterns using Clerk and RBAC alwaysApply: false applyTo:

  • "services/api-gateway/src/rbac.ts"
  • "services/**/v2/shared/access.ts"
  • "apps/**/middleware.ts"
  • "apps//src/lib/auth/"

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

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(
        `/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('/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('/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('/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

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

Score

Total Score

40/100

Based on repository quality metrics

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

0/10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

Reviews

💬

Reviews coming soon