Skip to content

Authentication & RBAC Architecture

Firebase Authentication with granular role-based access control.


Overview

On Book Pro uses a layered authentication and authorization system:

  1. Authentication: Firebase Authentication (Google OAuth + Email/Password)
  2. Authorization: Custom RBAC with granular permissions
  3. Project Isolation: Firestore security rules per project
  4. Personnel Linking: User accounts linked to personnel records

Authentication Layer

Firebase Authentication

Users authenticate via Firebase Auth:

typescript
// src/features/auth/auth-service.ts

import { 
    getAuth, 
    signInWithPopup, 
    GoogleAuthProvider,
    signInWithEmailAndPassword,
    signOut 
} from 'firebase/auth';

const auth = getAuth();

// Google Sign-In
export async function signInWithGoogle(): Promise<User> {
    const provider = new GoogleAuthProvider();
    const result = await signInWithPopup(auth, provider);
    return result.user;
}

// Email/Password Sign-In
export async function signInWithEmail(email: string, password: string): Promise<User> {
    const result = await signInWithEmailAndPassword(auth, email, password);
    return result.user;
}

// Sign Out
export async function logout(): Promise<void> {
    await signOut(auth);
}

User Profile

After authentication, a UserProfile is created or retrieved:

typescript
// src/features/auth/types.ts

export interface UserProfile {
    uid: string;
    email: string;
    displayName: string;
    photoURL?: string;
    isSuperAdmin?: boolean;    // Global admin (billing, multi-project)
    needsSetup?: boolean;      // First-time setup wizard required
}

Super Admin

The isSuperAdmin flag grants platform-wide access:

  • View all projects (not just member projects)
  • Access billing and organization management
  • Platform administration features

This flag is set directly in Firestore, not through the Admin Portal.


Role-Based Access Control

Permission Enum

Granular permissions control feature access:

typescript
// src/features/auth/types.ts

export enum Permission {
    // View permissions
    VIEW_ALL = 'VIEW_ALL',
    
    // Edit permissions (per feature)
    EDIT_RUNSHEET = 'EDIT_RUNSHEET',
    EDIT_PROPS = 'EDIT_PROPS',
    EDIT_SETS = 'EDIT_SETS',
    EDIT_BLOCKING = 'EDIT_BLOCKING',
    EDIT_SCHEDULER = 'EDIT_SCHEDULER',
    EDIT_CAST = 'EDIT_CAST',
    EDIT_NOTES = 'EDIT_NOTES',
    EDIT_SOUND = 'EDIT_SOUND',
    EDIT_LIGHTING = 'EDIT_LIGHTING',
    EDIT_PROJECTIONS = 'EDIT_PROJECTIONS',
    VIEW_PROMPTBOOK = 'VIEW_PROMPTBOOK',
    EDIT_PROMPTBOOK = 'EDIT_PROMPTBOOK',
    EDIT_FILES = 'EDIT_FILES',
    
    // Management permissions
    MANAGE_USERS = 'MANAGE_USERS',
    MANAGE_PROJECT = 'MANAGE_PROJECT',
    DELETE_PROJECT = 'DELETE_PROJECT',
    
    // Budget permissions
    VIEW_BUDGET = 'VIEW_BUDGET',
    EDIT_BUDGET = 'EDIT_BUDGET',
    
    // Production Management
    VIEW_PRODUCTION = 'VIEW_PRODUCTION',
    EDIT_PRODUCTION = 'EDIT_PRODUCTION',
    
    // Special permissions
    SELF_CHECK_IN = 'SELF_CHECK_IN',
    UPDATE_OWN_PROFILE = 'UPDATE_OWN_PROFILE',

    // Performance permissions
    CALL_SHOW = 'CALL_SHOW',
}

Role Definition

Roles are collections of permissions:

typescript
// src/features/auth/types.ts

export interface Role {
    id: string;
    name: string;
    permissions: Permission[];
    isBuiltIn: boolean;        // True for default roles
    description?: string;
}

Default Roles

The system provides built-in roles:

RoleKey Permissions
OwnerAll permissions
Stage ManagerFull editing, user management, show calling, no budget edit
Production ManagerFull editing + budget management
DirectorBlocking, notes, prompt book, view-only
DesignerProps, sets, lighting, projections, files, view-only budget
CastView-only + self check-in + profile updates
CrewView-only
PendingNo permissions (awaiting approval)
typescript
// src/features/auth/permissions.ts

export const DEFAULT_ROLES: Role[] = [
    {
        id: 'owner',
        name: 'Owner',
        permissions: Object.values(Permission),
        isBuiltIn: true,
        description: 'Full access to all project features and settings'
    },
    {
        id: 'stage-manager',
        name: 'Stage Manager',
        permissions: [
            Permission.VIEW_ALL,
            Permission.EDIT_RUNSHEET,
            Permission.EDIT_PROPS,
            // ... other editing permissions
            Permission.MANAGE_USERS,
            Permission.VIEW_BUDGET,
            Permission.VIEW_PRODUCTION,
        ],
        isBuiltIn: true,
        description: 'Full editing access, cannot delete project'
    },
    // ... other roles
];

Permission Checking

typescript
// src/features/auth/permissions.ts

export function hasPermission(role: Role | null | undefined, permission: Permission): boolean {
    if (!role) return false;
    return role.permissions.includes(permission);
}

// Usage in components
import { hasPermission } from '@/features/auth';
import { useAuthStore } from '@/store';

function ProtectedButton() {
    const { userRole } = useAuthStore();
    
    if (!hasPermission(userRole, Permission.EDIT_RUNSHEET)) {
        return null; // Hide button if no permission
    }
    
    return <Button onClick={handleEdit}>Edit</Button>;
}

Project Membership

ProjectMember Type

Each project tracks its members:

typescript
// src/features/auth/types.ts

export interface ProjectMember {
    userId: string;
    roleId: string;
    linkedContactId?: string;  // Link to CastMember.id
    status: 'active' | 'pending';
    joinedAt: number;
}

Membership Flow

1. Owner creates project → becomes Owner member
2. Owner generates invite link
3. New user clicks link → signs in with Google/Email
4. User added as "pending" member
5. Owner approves → status becomes "active" with assigned role

Personnel Linking

Users can be linked to personnel records:

ProjectMember.linkedContactId → CastMember.id

This enables:

  • Profile updates flowing to personnel data
  • Costume measurements appearing in My Profile
  • Automatic identification in attendance tracking

Firestore Security Rules

Rule Structure

javascript
// firestore.rules

rules_version = '2';
service cloud.firestore {
    match /databases/{database}/documents {
        
        // User profiles
        match /users/{userId} {
            allow read: if request.auth != null;
            allow write: if request.auth.uid == userId;
        }
        
        // Projects
        match /projects/{projectId} {
            // Project-level access
            allow read: if isProjectMember(projectId);
            allow write: if isProjectOwner(projectId);
            
            // Nested collections
            match /{subcollection}/{document=**} {
                allow read: if isProjectMember(projectId);
                allow write: if hasEditPermission(projectId, subcollection);
            }
        }
        
        // Helper functions
        function isProjectMember(projectId) {
            return exists(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid))
                && get(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid)).data.status == 'active';
        }
        
        function isProjectOwner(projectId) {
            let member = get(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid));
            return member.data.roleId == 'owner';
        }
        
        function hasEditPermission(projectId, collection) {
            let member = get(/databases/$(database)/documents/projects/$(projectId)/members/$(request.auth.uid));
            let role = get(/databases/$(database)/documents/projects/$(projectId)/roles/$(member.data.roleId));
            
            // Map collection to required permission
            let permissionMap = {
                'runsheet': 'EDIT_RUNSHEET',
                'props': 'EDIT_PROPS',
                'sets': 'EDIT_SETS',
                // ... etc
            };
            
            return permissionMap[collection] in role.data.permissions;
        }
    }
}

Key Security Principles

  1. Authentication Required: All operations require request.auth != null
  2. Project Isolation: Users can only access projects they're members of
  3. Role-Based Writes: Write access requires appropriate permissions
  4. Status Validation: Pending members cannot access project data

Permission Patterns in UI

Feature Gating

typescript
// Hide entire features based on permission
function WorkspaceSidebar() {
    const { userRole } = useAuthStore();
    
    const tools = TOOLS.filter(tool => {
        if (tool.requiredPermission) {
            return hasPermission(userRole, tool.requiredPermission);
        }
        return true;
    });
    
    return <nav>{tools.map(tool => <ToolLink key={tool.id} tool={tool} />)}</nav>;
}

Button/Action Gating

typescript
// Disable or hide actions
function EditButton({ onEdit, permission }) {
    const { userRole } = useAuthStore();
    const canEdit = hasPermission(userRole, permission);
    
    return (
        <Button 
            onClick={onEdit} 
            disabled={!canEdit}
            title={canEdit ? 'Edit' : 'You do not have permission to edit'}
        >
            Edit
        </Button>
    );
}

Read-Only Mode

typescript
// Render read-only versions of forms
function PropEditor({ prop }) {
    const { userRole } = useAuthStore();
    const canEdit = hasPermission(userRole, Permission.EDIT_PROPS);
    
    if (!canEdit) {
        return <PropViewer prop={prop} />;
    }
    
    return <PropForm prop={prop} onSave={handleSave} />;
}

Adding New Permissions

Step 1: Add to Enum

typescript
// src/features/auth/types.ts
export enum Permission {
    // ... existing
    EDIT_NEW_FEATURE = 'EDIT_NEW_FEATURE',
}

Step 2: Assign to Roles

typescript
// src/features/auth/permissions.ts
export const DEFAULT_ROLES: Role[] = [
    {
        id: 'stage-manager',
        permissions: [
            // ... existing
            Permission.EDIT_NEW_FEATURE,
        ],
    },
    // Add to other appropriate roles
];

Step 3: Update Security Rules

javascript
// firestore.rules
function hasEditPermission(projectId, collection) {
    let permissionMap = {
        // ... existing
        'new-feature': 'EDIT_NEW_FEATURE',
    };
}

Step 4: Gate UI Components

typescript
// In your feature component
const canEdit = hasPermission(userRole, Permission.EDIT_NEW_FEATURE);

Session Management

Auth State Persistence

Firebase Auth handles session persistence:

typescript
import { getAuth, onAuthStateChanged } from 'firebase/auth';

const auth = getAuth();

// Listen for auth state changes
onAuthStateChanged(auth, (user) => {
    if (user) {
        // User is signed in
        loadUserProfile(user.uid);
        loadProjectMemberships(user.uid);
    } else {
        // User is signed out
        clearAuthState();
    }
});

Token Refresh

Firebase handles token refresh automatically. The ID token is refreshed approximately every hour.

Logout Flow

Critical: Ordering Matters

The logout flow must follow a strict sequence to prevent data leakage. Zustand's persist middleware re-serializes state to localStorage on every state change. If localStorage is cleared too early, any subsequent state mutation (e.g., from the onAuthStateChanged listener) causes the persist middleware to re-write stale data before the page reloads.

Correct ordering:

typescript
// src/features/layout/components/MainLayout.tsx

const handleLogout = async () => {
    const confirmed = await confirmDialog('Are you sure you want to log out?');
    if (confirmed) {
        // 1. Sign out from Firebase (triggers onAuthStateChanged)
        await logout();

        // 2. Clear localStorage LAST — synchronously before reload
        //    This is the definitive nuke. No async gap between clear and reload.
        localStorage.removeItem('onbook-storage');
        sessionStorage.removeItem('SKIP_ADMIN_REDIRECT');

        // 3. Reload immediately
        window.location.reload();
    }
};

Why this ordering?

StepWhat happensRisk if misordered
await logout()Firebase signOut completes; auth listener fires clearCloudSession()None — safe to do first
localStorage.removeItem()Nukes all persisted Zustand stateIf done earlier, persist middleware re-writes it
window.location.reload()Immediate reload from clean stateMust be synchronous with step 2

clearCloudSession (Defense in Depth)

The clearCloudSession() action in meta-slice.ts comprehensively resets all data slices when triggered by the auth listener on sign-out. This serves as a defense-in-depth layer — even if the localStorage clear is somehow missed, the in-memory state is clean:

  • Cloud metadata (projectId, member, sync flags)
  • All 14+ data slices (showStructure, cast, props, runSheet, scheduler, sound, costumes, notes, feed, production, lighting, promptBook, attendance, activityLog)
  • Project metadata (title, director, logos)
  • UI state (activeTab, activeWorkspace)

Tool Permission Hardening (March 2026)

As of March 2026, all tools require VIEW_ALL at minimum. Previously, several tools (Feed, Files, Notes, Settings, Dev) had no requiredPermissions set in workspace-config.ts, which meant they were technically accessible without authentication. This security gap was closed by ensuring every tool in the workspace config specifies at least [Permission.VIEW_ALL].

What Changed

ToolBeforeAfter
FeedNo permissions requiredVIEW_ALL
FilesNo permissions requiredVIEW_ALL
NotesNo permissions requiredVIEW_ALL
SettingsNo permissions requiredVIEW_ALL
DevNo permissions requiredVIEW_ALL
ScenesNo permissions requiredVIEW_ALL

Impact on Tests

All workspace and permission E2E tests were updated to reflect the new baseline. Tests that previously expected unauthenticated tool access were updated to verify the VIEW_ALL requirement.


Further Reading


Last updated: March 22, 2026 (Tool permission hardening, new permissions: EDIT_PROJECTIONS, VIEW_PROMPTBOOK, EDIT_PROMPTBOOK, CALL_SHOW)