Authorization

Overview

Authorization in the Refresh platform determines what a user can do after authentication proves who they are. The system follows the principle of least privilege: users receive only the minimum permissions required for their role, with granular resource:action permissions checked at every API endpoint.

Key Principle: FusionAuth proves "this is Taylor" → Your database answers "what can Taylor do?"

Evolution of the Auth System

The authorization system has evolved through three major iterations, each addressing limitations discovered in the previous approach.

Phase 1: Auth.js with Database Roles (Initial)

The original system used Auth.js (formerly NextAuth) for authentication with role columns directly on membership tables:

┌─────────────────────────────────────────────────────────────────┐
│  PHASE 1: Auth.js + Database Role Columns                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  Authentication: Auth.js (Google, Microsoft OAuth)               │
│  Session: Drizzle adapter storing sessions in Postgres           │
│                                                                  │
│  Role Storage:                                                   │
│  • tenant_users.role → 'owner' | 'admin' | 'member'             │
│  • group_users.role  → 'admin' | 'member'                       │
│  • users.role        → 'user' | 'admin' (platform level)        │
│                                                                  │
│  Permission Checking:                                            │
│  • Hardcoded role checks: if (role === 'admin') { ... }         │
│  • No granular permissions                                       │
│  • Role implies all capabilities for that level                  │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Limitations:

  • No granular permissions - "admin" meant everything or nothing

  • Hardcoded role checks scattered throughout codebase

  • No support for M2M/service authentication

  • No way to create custom roles per tenant

  • Difficult to audit what each role could actually do

Phase 2: FusionAuth with Permissions Table

Migrated to FusionAuth for authentication and introduced a permissions table mapping roles to granular permissions:

Improvements over Phase 1:

  • Granular resource:action permissions

  • FusionAuth handled identity, MFA, SSO

  • M2M support via FusionAuth Entities

  • bypass_rls flag for internal team cross-tenant access

  • Centralized permission definitions

Remaining Limitations:

  • Roles still hardcoded in tenant_users.role and group_users.role columns

  • No custom roles - tenants stuck with predefined owner/admin/member

  • Permission changes required code deployment (new rows in permissions table)

  • No admin UI for managing permissions or roles

  • JWT roles for internal team, database roles for customers - inconsistent

  • OAuth scopes not properly integrated with permission system

Phase 3: Fully Database-Managed Authorization (Current)

Complete redesign with all authorization managed in database tables, enabling runtime customization:

Improvements over Phase 2:

  • Custom roles: Tenants can create their own roles with custom permission sets

  • Admin UI: Full management interface for permissions, roles, scopes, clients

  • Consistent model: All users (internal and external) use same auth tables

  • Runtime changes: No code deployment needed for permission/role changes

  • OAuth scopes: Proper scope→permission mapping for M2M clients

  • Visibility controls: is_tenant_assignable and is_internal flags

  • Performance: Denormalized cache with automatic invalidation

Migration Summary

Aspect
Phase 1
Phase 2
Phase 3 (Current)

Auth Provider

Auth.js

FusionAuth

FusionAuth

Role Storage

Column on each table

JWT + columns

auth_assignment table

Permissions

Hardcoded checks

permissions table

auth_permission + junction

Custom Roles

No

No

Yes

Admin UI

No

No

Yes

M2M Support

No

Basic (Entities)

Full (OAuth scopes)

RLS Function

Direct role checks

get_permission()

has_permission()

Deprecated Elements

The following were removed in Phase 3:

  • tenant_users.role column → replaced by auth_assignment

  • group_users.role column → replaced by auth_assignment

  • permissions table → replaced by auth_permission + auth_role_permission

  • get_permission() function → replaced by has_permission()

  • is_tenant_admin() / is_group_admin() functions → replaced by permission checks

  • JWT roles for permission resolution → all from database now

Lessons Learned

  1. Don't embed roles in membership tables - Use a separate assignment table for flexibility

  2. Separate permissions from roles - Allows role customization without changing permissions

  3. Plan for multi-tenancy from the start - Custom roles per tenant is a common requirement

  4. Build admin UI early - Manual database edits don't scale

  5. Cache strategically - RLS performance matters; use denormalized cache with triggers

  6. OAuth scopes ≠ permissions - Scopes are coarse-grained bundles for API clients

Defense-in-Depth Architecture

Authorization is enforced at multiple layers, with the API layer serving as the primary guard and database RLS as a backup safety net:

Why Defense-in-Depth?

  • If a new endpoint is added without proper middleware, RLS still blocks unauthorized access

  • If RLS is misconfigured on a new table, API middleware still enforces access control

  • Both layers use the same permission logic via has_permission(), providing consistent enforcement

Authorization Architecture

Security Model: RLS vs API Enforcement

Two Layers of Defense

Layer
Enforces
Purpose

API

Global, Tenant, Group, User

Primary enforcement - full business logic

RLS

Global, Tenant, User

Backup safety net - prevents data leakage if API has bugs

Why RLS Doesn't Enforce Groups

  1. Groups are organizational, not security boundaries - They're for internal structure (teams, departments), not isolating sensitive data between untrusted parties.

  2. All users in a tenant work for the same company - A team lead seeing another team's compliance data is an internal policy issue, not a data breach.

  3. Simpler = more secure in practice - Fewer moving parts means fewer bugs.

Security Boundaries

Role Classification System

Roles are classified based on two key fields: tenant_id and bypass_rls. This determines who can see the role and where it can be assigned.

tenant_id
bypass_rls
Category
Who Sees It
Who Can Use It
Example

NULL

true

Platform

Platform admins only

Internal team only

Super Admin, Developer

NULL

false

System

All tenants

Any tenant admin

Account Owner, Employee

{refresh_tenant}

true

Internal

Refresh tenant only

Internal team (with bypass)

Sales, Customer Success

{tenant_uuid}

false

Custom

That tenant only

That tenant's admin

Acme's "Regional Manager"

Role Scope Types

Each role has a scope_type that determines where it can be assigned:

scope_type
Description
Assignment

global

Platform-wide access

No scope_id needed

tenant

Tenant-scoped access

Requires tenant_id as scope_id

group

Group-scoped access

Requires group_id as scope_id

bypass_rls Behavior

Important: bypass_rls means "skip tenant isolation for permissions you have" - NOT "superuser access to everything".

A user with bypass_rls and tenant:list can list ALL tenants, but cannot access permission:create if they don't have that permission.

Database Constraint

Platform Roles (Internal Team)

These roles are for Refresh OS internal staff only. They have bypass_rls = true which allows access across all tenants.

Super Admin

  • Scope: global

  • bypass_rls: true

  • Description: Full platform access with all permissions

Platform Admin

  • Scope: global

  • bypass_rls: true

  • Description: Broad read access for business visibility, limited write access

Developer

  • Scope: global

  • bypass_rls: true

  • Description: Development-focused access for debugging and maintenance

Support

  • Scope: global

  • bypass_rls: true

  • Description: Read-only access for troubleshooting customer issues

System Roles (Tenant Scope)

These roles are available to all tenants as default options. They have tenant_id = NULL and bypass_rls = false.

Account Owner

  • Scope: tenant

  • Description: Highest authority within a tenant. Full control including billing.

Organization Admin

  • Scope: tenant

  • Description: Full administrative access without billing control.

Compliance Manager

  • Scope: tenant

  • Description: Focused on compliance and audit functionality.

HR Administrator

  • Scope: tenant

  • Description: Manages employee data and HR functions.

Manager

  • Scope: tenant

  • Description: Limited to managing direct reports.

Employee

  • Scope: tenant

  • Description: Basic access for regular employees.

Permission System

Permission Naming Convention

Permissions follow the pattern [category.]resource:action:

  • Category (optional): Groups related permissions (e.g., auth., compliance.)

  • Resource: The entity being accessed (e.g., tenant, user, role)

  • Action: The operation being performed (e.g., list, get, create, update, delete)

Standard Actions

Action
Description

list

Query/list multiple records

get

Get single record by ID

create

Create a new record

update

Modify an existing record

delete

Archive/soft delete a record

publish

Publish draft (versioned tables)

Permission Visibility

The is_tenant_assignable flag on auth_permission controls which permissions tenant admins can assign to custom roles:

is_tenant_assignable

Available To

Examples

true (default)

All roles (system + custom)

user:list, compliance.control:update

false

Platform/Internal roles only

auth.permission:create, auth.oauth-scope:update

Permissions that should have is_tenant_assignable = false:

  • All auth.* permissions (auth system management)

  • All auth.oauth-scope* permissions (OAuth scope management)

  • redaction:* permissions (internal data operations)

Database Schema

Core Auth Tables

auth_permission - Atomic Capabilities

auth_role - Permission Groups

auth_role_permission - Role → Permission Mapping

auth_assignment - User → Role in Scope

auth_permission_cache - Denormalized for RLS Performance

RLS Permission Function

The has_permission() function is used by all RLS policies:

RLS Policy Examples

OAuth Scopes (M2M Authentication)

OAuth scopes bundle permissions for M2M API clients. Scopes use a resource:action naming pattern separate from the permission system.

Scope vs Permission

Concept
Granularity
Example
Used By

Permission

Fine-grained

user:get, user:list, user:create

Users via roles

OAuth Scope

Coarse-grained

employees:read, employees:write

API clients

A single OAuth scope maps to multiple permissions:

  • employees:readuser:get, user:list

  • employees:writeuser:create, user:update

OAuth Scope Tables

oauth_scope - Versioned Scopes

oauth_scope_permission - Scope → Permission Mapping

Internal vs External Scopes

is_internal

Visible To

Use Case

false

All admins

External API scopes (employees:read, surveys:write)

true

Platform admins only

Internal service scopes (platform:admin)

The is_internal flag ensures tenant admins cannot see or assign platform-level scopes that grant cross-tenant access.

Scope Naming Convention

Scopes use simplified resource:action patterns:

  • employees:read - Read employee data

  • employees:write - Create/update employees

  • surveys:manage - Full survey access

  • platform:admin - Internal platform access (is_internal = true)

M2M Client Types

Type

bypass_rls

tenant_id

Access

Internal system (api-core)

true

NULL

All tenants

External tenant-bound

false

<tenant-uuid>

Only that tenant

External unbound

false

NULL

No tenant access (personal/global only)

M2M Token Flow

AuthContext Structure

The JWT authorizer resolves ALL access upfront and returns an AuthContext:

Frontend Permission Structure

The frontend receives permissions via locals.permissions:

UI Permission Guards

PermissionGuard Component

Use PermissionGuard to conditionally render UI based on permissions:

usePermissions Hook

For programmatic permission checks:

Important: UI gating is for UX only. Always enforce permissions server-side via RLS and API checks.

Cache Invalidation

Triggers automatically invalidate auth_permission_cache when:

  • auth_assignment changes (user role assignment)

  • auth_role changes (role archived, bypass_rls changed)

  • auth_role_permission changes (permission added/removed from role)

  • auth_permission changes (permission renamed/deleted)

  • api_client_scope changes (M2M client scopes)

  • oauth_scope_permission changes (scope permissions)

Custom Roles

Tenants can create custom roles for their specific needs:

  • tenant_id: Set to the creating tenant's ID

  • bypass_rls: Always false (enforced by database constraint)

  • scope_type: Can be tenant or group

  • Permissions: Limited to is_tenant_assignable = true permissions

Example Custom Roles

  • "Regional Manager" - Access to specific groups only

  • "Compliance Reviewer" - Read-only compliance without employee data

  • "HR Assistant" - Limited HR functions

Best Practices

  1. Always check server-side - UI gating is for UX only; never trust client-side permission checks

  2. Use specific permissions - Prefer document:create over broad patterns

  3. Fail closed - Deny by default if permission isn't explicitly granted

  4. Least privilege - Assign minimum permissions needed for each role

  5. Audit role changes - Track who assigned which roles and when


Last updated: December 2025

Last updated