Skip to main content

Authentication & Security Overview

The Cosailor Template implements a comprehensive authentication and authorization system built around Role-Based Access Control (RBAC) with JWT tokens and OAuth integration.

RBAC Model

The authentication system is built around these core entities:

Core Tables

  • users - User accounts with email, display name, status
  • tenants - Organizations/companies that users belong to
  • roles - Named permission sets (admin, manager, rep)
  • user_roles - Many-to-many: User ↔ Role assignments (optionally per-tenant)
  • user_assignments - Manager ↔ Rep hierarchies within a tenant

Entity Definitions

Users

  • Individual accounts identified by email and external ID (e.g., Clerk user ID)
  • Can belong to one primary tenant
  • Can have multiple roles (global or tenant-scoped)

Tenants

  • Organizations/companies
  • Users are scoped to tenants
  • Roles can be tenant-specific

Roles

  • Named permission sets: admin, manager, rep
  • Can be global or scoped to a specific tenant
  • Admins see all users in their tenant
  • Managers see only their assigned reps

UserAssignments

  • Defines manager-to-rep hierarchies
  • Used for access control: managers can only access their assigned reps' data

Token-Based Authentication

All requests to Core API require a JWT token in the Authorization: Bearer <token> header.

Token Payload Structure

{
"sub": "user@example.com",
"email": "user@example.com",
"roles": ["admin", "manager"],
"managedUserIds": ["uuid-1", "uuid-2"],
"tenantId": "uuid-tenant",
"exp": 1734567890
}

Token Fields

  • email - User's email address (identity)
  • roles - List of role names the user has
  • managedUserIds - IDs of users this user manages (for hierarchical access)
  • tenantId - Current tenant context

Authentication Flow

Token Generation

Tokens are generated by the Next.js web app (apps/web/src/lib/core-api-jwt.ts) using CORE_API_JWT_SECRET.

Token Validation

Core API validates tokens in apps/core-api/src/security.py:

  1. Verifies JWT signature
  2. Checks expiration
  3. Extracts ServiceAuthContext from payload
  4. Makes context available to endpoints via dependency injection

Minimal Token Bootstrap

When a user first logs in, we need their roles and managedUserIds to create a full token, but we need a token to fetch that data!

Solution: Minimal Token Pattern

Step 1: Create minimal token (empty permissions)

const minimalToken = await mintToken({
email: "user@example.com",
roles: [],
managedUserIds: [],
tenantId: null
});

Step 2: Fetch user access snapshot

const snapshot = await fetch(`/v1/auth/snapshot?email=user@example.com`, {
headers: { Authorization: `Bearer ${minimalToken}` }
});

The /auth/snapshot endpoint:

  • Validates the token signature (proves authenticity)
  • Checks auth.email matches requested email
  • Ignores the empty roles/managedUserIds in token
  • Fetches real data from database

Step 3: Create full token with real permissions

const fullToken = await mintToken({
email: "user@example.com",
roles: snapshot.roles.map(r => r.name),
managedUserIds: snapshot.managedUsers.map(u => u.id),
tenantId: snapshot.tenant?.id
});

Step 4: Use full token for all subsequent requests

The full token contains accurate permissions and is used for authorization checks without hitting the database on every request.

Authorization Model

Permission Rules

ActionRule
ReadHierarchical: Admins can read all users, managers can read their reps, users can read themselves
WriteSelf-only: Users can only write their own data (even admins cannot edit others)

Resource-Level Authorization

Authorization happens at the resource level, not just at the endpoint level.

Example:

from fastapi import APIRouter, Depends
from security import ServiceAuthContext, get_auth_context, check_can_read_user

@router.get("/dashboard/{user_id}")
def get_dashboard(
user_id: str,
auth: ServiceAuthContext = Depends(get_auth_context),
conn: Connection = Depends(get_db_connection)
):
# Validate access to THIS specific user's data
check_can_read_user(user_id, auth)

# Fetch and return data
return build_dashboard(conn, user_id)

User Access Snapshot

Endpoint: GET /v1/auth/snapshot?email={email}

Purpose: Returns comprehensive user context including roles, managed users, and tenant assignments.

Authentication: Requires valid JWT token (minimal or full)

Response Structure

{
"id": "user-uuid",
"email": "user@example.com",
"displayName": "John Doe",
"status": "active",
"tenant": {
"id": "tenant-uuid",
"name": "Acme Corp"
},
"roles": [
{ "name": "admin", "tenantId": null },
{ "name": "manager", "tenantId": "tenant-uuid" }
],
"managedUsers": [
{
"id": "rep-uuid",
"email": "rep@example.com",
"displayName": "Jane Rep",
"status": "active"
}
],
"metrics": []
}

Snapshot Construction Process

  1. Query user by email (repo.get_user_by_email)
  2. Fetch all roles assigned to user (repo.get_user_roles)
  3. Determine managed users:
    • If user has admin role: fetch all reps in their tenant
    • Otherwise: fetch only directly assigned users (repo.get_managed_users)
  4. Fetch user metrics (repo.get_user_metrics)
  5. Assemble into UserAccessSnapshot DTO

Implementation: apps/core-api/src/services.pybuild_user_access_snapshot()

Common Authorization Patterns

Pattern 1: Check User Access (Read)

from security import check_can_read_user

@router.get("/users/{user_id}/data")
def get_user_data(
user_id: str,
auth: ServiceAuthContext = Depends(get_auth_context)
):
check_can_read_user(user_id, auth)
return fetch_user_data(user_id)

Rules:

  • Admins can read all users
  • Managers can read their managed reps
  • Users can read themselves

Pattern 2: Check User Access (Write)

from security import check_can_write_user

@router.put("/users/{user_id}/profile")
def update_user_profile(
user_id: str,
data: ProfileUpdate,
auth: ServiceAuthContext = Depends(get_auth_context)
):
check_can_write_user(user_id, auth)
return update_profile(user_id, data)

Rules:

  • Only the user themselves can write their own data
  • Even admins cannot edit other users' data

Pattern 3: Role-Based Access

@router.get("/admin-only")
def admin_endpoint(auth: ServiceAuthContext = Depends(get_auth_context)):
if "admin" not in auth.roles:
raise HTTPException(403, detail="Admin access required")
return {"data": "admin data"}

Pattern 4: Filter by Managed Users

@router.get("/my-team")
def get_my_team(auth: ServiceAuthContext = Depends(get_auth_context)):
if not auth.managed_user_ids:
return []

# Only return users this manager manages
stmt = select(User).where(User.id.in_(auth.managed_user_ids))
return conn.execute(stmt).scalars().all()

Token Lifecycle & Refresh

Token Expiration

  • Default TTL: 30 minutes
  • Tokens are short-lived for security
  • Cookie maxAge matches token expiration

Automatic Token Refresh

The Next.js proxy (/api/v1/core/[...path]) automatically refreshes tokens:

  1. Checks if token expires in <5 minutes
  2. If yes, generates fresh token via getUserAccessContext()
  3. Updates cookie and uses fresh token for request
  4. Transparent to client - no retry needed

This prevents 401 errors for users staying on pages long-term.

Security Best Practices

  1. Always validate permissions - Never skip authorization checks
  2. Use helpers for common patterns - Don't repeat permission logic
  3. Validate at resource level - Check access to specific resources, not just endpoints
  4. Keep tokens short-lived - Current default: 30 minutes with auto-refresh
  5. Never trust client-provided IDs - Always validate user_id against auth.email or auth.managed_user_ids
  6. Log authorization failures - Track who tried to access what