Authentication & RBAC Architecture
Firebase Authentication with granular role-based access control.
Overview
On Book Pro uses a layered authentication and authorization system:
- Authentication: Firebase Authentication (Google OAuth + Email/Password)
- Authorization: Custom RBAC with granular permissions
- Project Isolation: Firestore security rules per project
- Personnel Linking: User accounts linked to personnel records
Authentication Layer
Firebase Authentication
Users authenticate via Firebase Auth:
// 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:
// 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:
// 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:
// 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:
| Role | Key Permissions |
|---|---|
| Owner | All permissions |
| Stage Manager | Full editing, user management, show calling, no budget edit |
| Production Manager | Full editing + budget management |
| Director | Blocking, notes, prompt book, view-only |
| Designer | Props, sets, lighting, projections, files, view-only budget |
| Cast | View-only + self check-in + profile updates |
| Crew | View-only |
| Pending | No permissions (awaiting approval) |
// 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
// 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:
// 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 rolePersonnel Linking
Users can be linked to personnel records:
ProjectMember.linkedContactId → CastMember.idThis enables:
- Profile updates flowing to personnel data
- Costume measurements appearing in My Profile
- Automatic identification in attendance tracking
Firestore Security Rules
Rule Structure
// 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
- Authentication Required: All operations require
request.auth != null - Project Isolation: Users can only access projects they're members of
- Role-Based Writes: Write access requires appropriate permissions
- Status Validation: Pending members cannot access project data
Permission Patterns in UI
Feature Gating
// 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
// 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
// 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
// src/features/auth/types.ts
export enum Permission {
// ... existing
EDIT_NEW_FEATURE = 'EDIT_NEW_FEATURE',
}Step 2: Assign to Roles
// 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
// firestore.rules
function hasEditPermission(projectId, collection) {
let permissionMap = {
// ... existing
'new-feature': 'EDIT_NEW_FEATURE',
};
}Step 4: Gate UI Components
// In your feature component
const canEdit = hasPermission(userRole, Permission.EDIT_NEW_FEATURE);Session Management
Auth State Persistence
Firebase Auth handles session persistence:
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:
// 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?
| Step | What happens | Risk if misordered |
|---|---|---|
await logout() | Firebase signOut completes; auth listener fires clearCloudSession() | None — safe to do first |
localStorage.removeItem() | Nukes all persisted Zustand state | If done earlier, persist middleware re-writes it |
window.location.reload() | Immediate reload from clean state | Must 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
| Tool | Before | After |
|---|---|---|
| Feed | No permissions required | VIEW_ALL |
| Files | No permissions required | VIEW_ALL |
| Notes | No permissions required | VIEW_ALL |
| Settings | No permissions required | VIEW_ALL |
| Dev | No permissions required | VIEW_ALL |
| Scenes | No permissions required | VIEW_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
- Workspace Navigation — Tool filtering by permission
- File Repository — File-level visibility permissions
- State Management — Auth slice architecture
Last updated: March 22, 2026 (Tool permission hardening, new permissions: EDIT_PROJECTIONS, VIEW_PROMPTBOOK, EDIT_PROMPTBOOK, CALL_SHOW)