2026-001: Multi-Tenant Architecture & RBAC
| Metadata | Details |
|---|---|
| Status | Approved |
| Author | jschnurr@2702rebels.com |
| Scope | Authentication, Authorization, Database Schema |
| Created | 2026-01-31 |
1. Executive Summary
Section titled “1. Executive Summary”This document proposes a comprehensive re-architecture of the FRC Scouting App’s identity and data layer. We are transitioning from a Legacy Single-Tenant Model (where all data implicitly belongs to Team 2702 and admin access is a binary flag) to a modern Database-First Multi-Tenant Architecture.
This new system introduces:
- Data Partitioning: Strict isolation of team data (
/tenants/{id}) from shared global data (/metadata). - Granular RBAC: A rich role system (Scout, Editor, Admin, Owner) powered by CASL.
- Automated Security: A “Mirror Pattern” where a single TypeScript configuration drives both the Frontend UI logic and the Backend Database Security Rules, eliminating the risk of permission drift.
2. Context & Problem Statement
Section titled “2. Context & Problem Statement”The Current State
Section titled “The Current State”Currently, the application uses Firebase Custom Claims to store user roles.
A user is either an admin or a user. This is stored in their JWT (JSON Web
Token).
The Limitations
Section titled “The Limitations”- Binary Security is Insufficient: FRC teams are hierarchical. A “Lead
Scout” needs to edit match data (to fix mistakes) but should not be able
to delete the entire team’s database or kick other users. The current binary
isAdminflag cannot capture this nuance. - Scalability of Claims: Custom Claims have a hard size limit of 1000 bytes. As we allow users to join multiple teams (e.g., a mentor helping 3 teams), storing this map in the token becomes impossible.
- Revocation Latency: If an admin “kicks” a rogue user, that user retains access for up to 1 hour (until their JWT expires).
Part 1: Proposed Architecture
Section titled “Part 1: Proposed Architecture”3. System Overview: The Hybrid Data Model
Section titled “3. System Overview: The Hybrid Data Model”To support multi-tenancy without duplicating massive amounts of FRC schedule data, we are adopting a Hybrid Partitioning Strategy.
3.1 The Tenant Partition (/tenants/{tenantId}/...)
Section titled “3.1 The Tenant Partition (/tenants/{tenantId}/...)”- Purpose: Stores data that is private and unique to a specific FRC Team.
- Isolation: Strict. A user from Team 2702 cannot read/write data in Team 2056’s partition.
Contents (Collection Names):
matches(DBMatch): The core value. Detailed scouting reports collected by students.picklist(DBPicklistTeam): The team’s strategic ranking of other robots. This is highly sensitive competitive intelligence.users(DBTenantUser): The roster of who belongs to this team and what their role is.surveys(DBSurvey): Match Scouting Data. Observations collected by scouts during a match.pits(DBPitSurvey): Pit Scouting Data. Robot specs and structural info collected in the pits.pictures(DBPicture): Robot photos.comments(DBComment): Qualitative notes on teams.
3.2 The Global Partition (/metadata/...)
Section titled “3.2 The Global Partition (/metadata/...)”- Purpose: Stores public data sourced from the FRC API / The Blue Alliance.
- Isolation: None (Read-Only). All authenticated users can read this data. No user can write to it (system-only).
Contents:
seasons(DBSeason): Definitions of games (e.g., “Reefscape 2025”).events(DBEvent): Schedules, match lists, and venue info.teams(DBTeam): Basic info (Number, Name, Rookie Year) of all FRC teams.
3.3 The System Partition (/system/...)
Section titled “3.3 The System Partition (/system/...)”- Purpose: Stores global application state and platform-level authorization.
- Isolation: Restricted to Superusers. Not scoped to any specific FRC team.
Contents:
admins: A collection of documents where the ID is a User UID. Presence in this collection grants “Superuser” status.
3.4 Why Partition?
Section titled “3.4 Why Partition?”By physically separating these data types in the NoSQL structure, we gain:
- Security: We can write a simple, blanket security rule:
match /tenants/{tenantId}/{document=**} { allow read: if isMember(tenantId); }. - Performance: Queries are naturally scoped. Fetching “All Matches” automatically means “All Matches for this team”.
3.4 Technology Choice: Why CASL?
Section titled “3.4 Technology Choice: Why CASL?”We selected CASL (Context Aware Security Language) for this architecture because:
- Isomorphic Security: It runs on both the Browser (hiding UI buttons) and the Node.js Server (rejecting API calls), ensuring a single source of truth.
- Decoupling: It separates Authorization Logic (“Can I delete?”) from Business Logic (“Delete this record”). This makes the code cleaner and easier to test.
- Future-Proof: Unlike simple role checks (
if role == 'admin'), CASL supports Attribute-Based Access Control (ABAC). This allows rules like “A Scout can edit a match report ONLY IF they were the original author,” which is a planned feature for Phase 2.
4. Identity & Access Management (IAM)
Section titled “4. Identity & Access Management (IAM)”4.1 Database-First Identity
Section titled “4.1 Database-First Identity”We are moving the “Source of Truth” for identity out of the Auth Token and into the Database.
The Membership Document: Located at tenants/{tenantId}/users/{uid}, this
document is the definitive record of a user’s access.
export type TenantRole = | "owner" // Team Captain/Lead Mentor: Can manage billing, delete team. | "admin" // Strategy Lead: Can manage roster, config, and all data. | "editor" // Lead Scout: Can fix bad data entries (Update/Delete). | "scout" // Student Scout: Can submit new data (Create). | "viewer" // Guest/Parent: Read-only access. | "pending"; // New Sign-up: No access until approved.
export interface DBTenantUser { uid: string; role: TenantRole; displayName: string; // Authoritative name synced from Auth nickname: string | null; // User-specified override for this team email: string | null; // Synced from Auth photoURL: string | null; // Synced from Auth creationTime: string; // ISO timestamp approvedBy?: string; // Audit trail: UID of the admin who approved them}4.2 The “Mirror Pattern” Security Architecture
Section titled “4.2 The “Mirror Pattern” Security Architecture”A common pitfall in Firebase apps is Permission Drift.
- The UI hides the “Delete” button.
- The Database forgets to block the delete operation.
- Result: A savvy user can manually invoke the API to delete data.
To solve this, we define permissions once in TypeScript, and generate both the UI logic and the Database Rules from that single definition.
Layer 1: The Configuration (Source of Truth)
Section titled “Layer 1: The Configuration (Source of Truth)”We define a matrix of Roles and Capabilities. We define permissions
against Domain Entities (in common), not Database wrappers.
- The CASL Bridge (Type Mapping): We do not re-define our domain. Instead, we create a “Bridge Type” that tells CASL which string key corresponds to which existing TypeScript interface. This ensures strict type safety.
import { Event, EventMatch, TenantUser } from "./index";
// The "Bridge" that links Strings -> Typesexport type SubjectMap = { match: EventMatch; // 'match' string -> EventMatch interface user: TenantUser; event: Event; // ...};
export type SubjectKey = keyof SubjectMap;- Define the Permissions: This file lives in
packages/common.
Design Principle: This configuration serves as the single source of truth for logical permissions.
- Domain Subjects: Keys (e.g.,
match) are checked againstSubjectKey. - TRPC for Mutations: While the config includes write capabilities
(
create,update,delete), these primarily drive UI State and API Checks.
import { SubjectKey } from "./permissions.types";
export const PERMISSIONS: Record< Role, Partial<Record<SubjectKey, Action[]>>> = { // --- The Data Collector (Scout) --- scout: { // TypeScript autocompletes 'match', 'event', 'user' match: ["create", "read"], event: ["read"], user: ["read"], }, // ...};Layer 2: Single Source of Truth for Paths
Section titled “Layer 2: Single Source of Truth for Paths”To avoid defining paths in multiple places (Client, Server, and Rules), we extract the static path segments into a shared constant.
export const COLLECTION_PATHS = { // Key matches the Subject in permissions.config.ts matches: ["tenants", "matches"], // -> /tenants/{tenantId}/matches/{doc} users: ["tenants", "users"], // -> /tenants/{tenantId}/users/{doc} events: ["metadata", "events"], // -> /metadata/events/{doc}} as const;- Runtime (Client & Server): Both
dbClientRefs.tsanddbServerRefs.tswill importCOLLECTION_PATHSto construct their references. - Build Time (Rules): Uses
COLLECTION_PATHSto generatematch /...blocks.
Layer 3: The Application (CASL)
Section titled “Layer 3: The Application (CASL)”CASL (Context Aware Security Language) is an isomorphic library. It runs on both the Client and Server.
-
In React: We use the
Cancomponent.// The subject 'matches' corresponds to DBMatch<CanI="delete"a="matches"><Button onClick={deleteMatch}>Delete Report</Button></Can>Result: If the user is a
scout, the button is not rendered. -
In TRPC (Node.js): We check permissions in the API handler.
if (ctx.ability.cannot("delete", "matches")) {throw new TRPCError({ code: "FORBIDDEN" });}Result: If a
scouttries to call this endpoint, it rejects immediately.
Layer 4: The Database (Firestore Rules)
Section titled “Layer 4: The Database (Firestore Rules)”We assume the client code is compromised. The database must defend itself. We
use a build script (scripts/gen-rules.ts) to translate the CASL config into
Firestore CEL (Common Expression Language).
-
Logic:
- Read
PERMISSIONSconfig. - Iterate over subjects (e.g.,
matches). - Lookup Path: Resolve
matches->['tenants', 'matches']viaCOLLECTION_PATHS. - Construct Match Block: Interleave wildcards to form
/tenants/{tenantId}/matches/{document}. - Generate
allowstatements based on the roles that have that action.
- Read
-
Generated Output:
// Auto-generated. Do not edit.match /tenants/{tenantId}/matches/{matchId} {allow create: if hasRole(tenantId, ['scout', 'editor', 'admin']);// ...}
4.3 Package Architecture
Section titled “4.3 Package Architecture”To support the shared permissions logic while avoiding circular dependencies, we enforce a strict separation of Domain vs Database types.
-
packages/common(Domain Layer):- Defines pure domain entities (e.g.,
TenantUser,EventMatch). - Defines
permissions.config.tswhich rules govern those entities (e.g., “Scouts can read Matches”). - Constraint: Must NOT import from
database.
- Defines pure domain entities (e.g.,
-
packages/database(Data Layer):- Defines DB Wrappers (e.g.,
DBTenantUser = DBRecord<TenantUser>). - Defines
collectionPaths.tsmapping Domain Concepts to Firestore Paths. - Generates Security Rules by combining the Config (from Common) with the Paths (from Database).
- Constraint: Can import from
common.
- Defines DB Wrappers (e.g.,
4.4 Testing Strategy
Section titled “4.4 Testing Strategy”Security is critical, so we employ a multi-layered testing strategy to ensure our “Mirror Pattern” works.
-
Permissions Logic Tests (Unit):
- Location:
packages/common/src/permissions.test.ts - Goal: Verify that the CASL configuration correctly allows/denies actions based on roles.
- Example:
expect(scoutAbility.can('delete', 'Match')).toBe(false);
- Location:
-
Firestore Rules Tests (Integration):
- Location:
packages/database/test/rules.test.ts - Tool: Firebase Emulator Suite +
@firebase/rules-unit-testing. - Goal: Verify that the generated Firestore Rules actually block
unauthorized writes in the database engine. This validates the
gen-rules.tsscript.
- Location:
-
API Authorization Tests (Integration):
- Location:
apps/trpc-api/src/routers/protected.test.ts - Goal: Verify that TRPC endpoints throw
FORBIDDENwhen accessed by a user with insufficient privileges.
- Location:
4.5 Hybrid Access & TRPC
Section titled “4.5 Hybrid Access & TRPC”To prevent users from bypassing API-side validation (Zod, Karma logic), the
gen-rules.ts script will be configured to generate Read-Only rules for the
Client SDK by default.
- Logic:
- UI/API: Uses
permissions.config.tsto authorize actions (showing buttons, processing requests). - Database: Generates
allow read: if ...butallow write: if false(by default).
- UI/API: Uses
- Result: All mutations are forced through the TRPC API (which uses the Admin SDK to bypass rules), ensuring business logic is never skipped.
- Extensibility: If specific features require direct write access (e.g.,
“Offline Photos”), the generator script can be configured to “opt-in” specific
collections to receive
allow writerules based on the permission config.
4.6 Global Platform Administration (Superusers)
Section titled “4.6 Global Platform Administration (Superusers)”To manage the lifecycle of tenants, we introduce a Global Authorization Layer that sits above individual team memberships.
1. The Superuser Concept
Section titled “1. The Superuser Concept”A Superuser is a user whose UID exists in the /system/superusers
collection. This status is independent of team roles. A user can be an owner
of Team 2702 and simultaneously a Superuser for the entire platform.
2. Structural Separation
Section titled “2. Structural Separation”To ensure Superuser actions are explicit and safe, the application enforces structural separation:
- Regular Mode: The user acts within a
$tenantIdscope (e.g.,/2702/...). Permissions are governed by CASL and team-specific roles. - System Admin Mode: Accessible via a dedicated
/system-adminroute that sits outside the tenant hierarchy. No team-specific data is visible here. This mode is strictly for platform management.
3. Tenant Creation Workflow
Section titled “3. Tenant Creation Workflow”Superusers bootstrap new team environments by designating an owner:
- Designated Owner: Specify the email address of the team’s primary contact
(e.g., Lead Mentor). The system creates the tenant in a
pendingstate and reserves theownerrole for that specific email address.
4.7 Tenant Lifecycle & Visibility
Section titled “4.7 Tenant Lifecycle & Visibility”To ensure security and prevent unauthorized team takeovers, tenants follow a strict activation lifecycle:
- Status:
pending: A newly created tenant is invisible to the general public. It will NOT appear in the team directory for regular users. - Ownership Reservation: The Superuser specifies a
reservedOwnerEmail. A placeholder membership record is created for this email. - Owner Discovery: When a user logs in whose email matches a reserved owner slot, the system explicitly identifies the pending tenant for them.
- Activation: Once the designated owner selects the tenant for the first
time, the system:
- Binds their
uidto the membership record. - Sets the tenant status to
active. - Makes the tenant visible in the public team directory for other users to find and “Request to Join”.
- Binds their
5. User Experience (UX) & Workflows
Section titled “5. User Experience (UX) & Workflows”5.1 Onboarding: The “Request to Join” Flow
Section titled “5.1 Onboarding: The “Request to Join” Flow”Requesting access is a “Self-Service Request” model.
- User Action: User logs in via Google. They have no teams.
- User Action: They search “Team 2702” and click Join.
- System: Creates a membership document:
tenants/2702/users/{uid}withrole: 'pending'. - Admin View: The “Team Management” dashboard shows a badge: “1 Pending Request”.
- Admin Action: Admin clicks Approve and selects a role (e.g., “Scout”).
- System: Updates role to
scout. The user now has access.
5.2 Switching Contexts
Section titled “5.2 Switching Contexts”When a user belongs to multiple teams (e.g., a Mentor), they need to switch contexts safely.
- URL Strategy: The Tenant ID is always in the URL:
/tenant/2702/dashboard. - Deep Linking: If the mentor sends a link (
/tenant/2702/matches/qm42) to a student, the app parses the ID from the URL and immediately loads the correct tenant context. - Data Safety: When switching from Team 2702 to Team 2056, the frontend
triggers a Hard Cache Reset (
queryClient.resetQueries()). This ensures that no confidential data (like 2702’s Pick List) leaks into the view while 2056’s data is loading.
5.3 Platform Management
Section titled “5.3 Platform Management”- Discovery: Superusers see a “System Administration” button on the Team Selection screen.
- Navigation: Clicking this button redirects to
/system-admin. - Action: The Superuser creates a new tenant
2056. - Verification: The new tenant immediately appears in the Global Tenant Directory and is available for users to “Request to Join”.
Part 2: Migration Strategy
Section titled “Part 2: Migration Strategy”6. Migration Plan
Section titled “6. Migration Plan”This is a Schema-Breaking Migration. We are renaming the root collection. This cannot be done “live”.
6.1 Deprecations
Section titled “6.1 Deprecations”The following legacy types and patterns are strictly deprecated in the new architecture:
DBUser(Type):- Current: Mixes identity (email) with single-tenant permissions
(
isAdmin,isUser). - Replacement: Use
DBTenantUserfor permissions/roles. Enrich withemail,displayName, andphotoURLsynced from Auth.
- Current: Mixes identity (email) with single-tenant permissions
(
DBFirebaseUserCache(Type):- Current: Caches display names to avoid joining.
- Replacement: Integrated into
DBTenantUser.displayNameis synced from global Auth, whilenicknameis a team-specific override. The UI should prefernicknameif present.
- Custom Claims (
isAdmin,isUser):- Current: Stored on the JWT.
- Replacement: Database lookups against
tenants/{id}/users/{uid}. The new Security Rules will ignore these claims entirely.
- Legacy Date Formats:
- Current: Mix of local strings and RFC 2822 dates.
- Replacement: Strict ISO 8601 normalization for all
creationTimeandtimestampfields.
- Auto-Admin Logic (TRPC Context):
- Current: Sets
isAdminclaim if 1 user exists in the system. - Replacement: Explicit Provisioning. Superusers designate an owner email during tenant creation. The system creates a reserved membership slot that is claimed when that user logs in.
- Current: Sets
Phase 1: Preparation
Section titled “Phase 1: Preparation”- Dependencies: Install
@casl/abilityand@casl/reactinapps/appandapps/trpc-api. - Code Complete:
- Domain Types: Implement
TenantUserinpackages/commonandDBTenantUserinpackages/database. - Implement
packages/common/src/permissions.config.tsusing Domain Subjects. - Create
packages/database/src/collectionPaths.tsfor shared path definitions. - Refactor
dbClientRefs.tsanddbServerRefs.tsto useCOLLECTION_PATHS. - TRPC Context: Update
createContextto fetchDBTenantUser(replacing Claims) and inject the CASLabilityobject. - Indexes: Create
firestore.indexes.jsonto define the Collection Group Index onusers.uid. Updatefirebase.jsonto include it. - Implement the Rule Generator script.
- Domain Types: Implement
- Verify Rules: Run unit tests against the generated
firestore.rulesto ensure they block unauthorized access as expected. - Backup: Export the entire Firestore database to Google Cloud Storage.
Phase 2: Schema Migration (/tenant -> /tenants)
Section titled “Phase 2: Schema Migration (/tenant -> /tenants)”Goal: Move data to the new pluralized root.
- Maintenance Mode: Deploy a temporary Firestore Rule blocking ALL writes. The app is effectively Read-Only.
- Migration Script:
- Virtual Document Discovery: Use
listDocuments()instead ofget()to ensure documents that only exist as parents of sub-collections (virtual documents) are discovered. - Iterate
db.collection('tenant'). - For each document (Tenant):
- Create
db.collection('tenants').doc(id). - Recursively Copy Sub-Collections:
matches,events,users,surveys. - Note: The
/metadataand/schemascollections are global and do not move.
- Create
- Virtual Document Discovery: Use
- Validation:
- Script:
count(tenant/{id}/matches) === count(tenants/{id}/matches). - If counts mismatch, ABORT.
- Script:
Phase 3: Identity Backfill
Section titled “Phase 3: Identity Backfill”Goal: Convert legacy Claims to Documents and enrich with Auth metadata.
- Script: Iterate all users via Firebase Admin Auth.
- Logic:
if (customClaims.isAdmin)-> Createtenants/2702/users/{uid}with roleadmin.if (customClaims.isUser)-> Createtenants/2702/users/{uid}with rolescout.- Metadata Enrichment:
- Email & Avatar: Sync
emailandphotoURLfrom the Auth record. - Authoritative History: Use
userRecord.metadata.creationTimeas the primary source forcreationTime, normalized to ISO 8601. Fallback to the legacytimestamponly if Auth data is missing.
- Email & Avatar: Sync
- Defaulting to Team 2702: All current users are implicitly 2702 members.
Phase 4: Application Rollout
Section titled “Phase 4: Application Rollout”- Deploy Backend: TRPC API pointing to
/tenants.- Optimization: Increase Firebase Function timeout (e.g., 540s) to ensure long-running migration tasks complete successfully.
- Deploy Frontend: React App with new Routing.
- Important: Ensure
blobStoreis cleared/reset when switching tenants to avoid data leakage.
- Important: Ensure
- Deploy Rules: The new generated rules.
- Live Test: Log in as a Scout. Verify access. Log in as Admin. Verify access.
- Disable Maintenance Mode.
Phase 5: Cleanup (One Week Later)
Section titled “Phase 5: Cleanup (One Week Later)”- Verify system stability.
- Run script to delete the old
/tenantcollection. - Run script to clear
customClaimsfrom all users. - Revert Firebase Function timeout to standard value (60s) to prevent unintended long-running execution costs.