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, statustenants- Organizations/companies that users belong toroles- 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 hasmanagedUserIds- 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:
- Verifies JWT signature
- Checks expiration
- Extracts
ServiceAuthContextfrom payload - 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.emailmatches 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
| Action | Rule |
|---|---|
| Read | Hierarchical: Admins can read all users, managers can read their reps, users can read themselves |
| Write | Self-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
- Query user by email (
repo.get_user_by_email) - Fetch all roles assigned to user (
repo.get_user_roles) - Determine managed users:
- If user has
adminrole: fetch all reps in their tenant - Otherwise: fetch only directly assigned users (
repo.get_managed_users)
- If user has
- Fetch user metrics (
repo.get_user_metrics) - Assemble into
UserAccessSnapshotDTO
Implementation: apps/core-api/src/services.py → build_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
maxAgematches token expiration
Automatic Token Refresh
The Next.js proxy (/api/v1/core/[...path]) automatically refreshes tokens:
- Checks if token expires in <5 minutes
- If yes, generates fresh token via
getUserAccessContext() - Updates cookie and uses fresh token for request
- Transparent to client - no retry needed
This prevents 401 errors for users staying on pages long-term.
Security Best Practices
- Always validate permissions - Never skip authorization checks
- Use helpers for common patterns - Don't repeat permission logic
- Validate at resource level - Check access to specific resources, not just endpoints
- Keep tokens short-lived - Current default: 30 minutes with auto-refresh
- Never trust client-provided IDs - Always validate user_id against
auth.emailorauth.managed_user_ids - Log authorization failures - Track who tried to access what