Skip to content

2026-001: Multi-Tenant Architecture & RBAC

MetadataDetails
StatusApproved
Authorjschnurr@2702rebels.com
ScopeAuthentication, Authorization, Database Schema
Created2026-01-31

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:

  1. Data Partitioning: Strict isolation of team data (/tenants/{id}) from shared global data (/metadata).
  2. Granular RBAC: A rich role system (Scout, Editor, Admin, Owner) powered by CASL.
  3. 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.

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).

  1. 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 isAdmin flag cannot capture this nuance.
  2. 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.
  3. Revocation Latency: If an admin “kicks” a rogue user, that user retains access for up to 1 hour (until their JWT expires).

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.
  • 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.
  • 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.

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”.

We selected CASL (Context Aware Security Language) for this architecture because:

  1. 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.
  2. Decoupling: It separates Authorization Logic (“Can I delete?”) from Business Logic (“Delete this record”). This makes the code cleaner and easier to test.
  3. 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.

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.

packages/database/src/dbDocumentTypes.ts
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.

  1. 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.
packages/common/src/permissions.types.ts
import { Event, EventMatch, TenantUser } from "./index";
// The "Bridge" that links Strings -> Types
export type SubjectMap = {
match: EventMatch; // 'match' string -> EventMatch interface
user: TenantUser;
event: Event;
// ...
};
export type SubjectKey = keyof SubjectMap;
  1. 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 against SubjectKey.
  • TRPC for Mutations: While the config includes write capabilities (create, update, delete), these primarily drive UI State and API Checks.
packages/common/src/permissions.config.ts
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"],
},
// ...
};

To avoid defining paths in multiple places (Client, Server, and Rules), we extract the static path segments into a shared constant.

packages/database/src/collectionPaths.ts
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.ts and dbServerRefs.ts will import COLLECTION_PATHS to construct their references.
  • Build Time (Rules): Uses COLLECTION_PATHS to generate match /... blocks.

CASL (Context Aware Security Language) is an isomorphic library. It runs on both the Client and Server.

  • In React: We use the Can component.

    // The subject 'matches' corresponds to DBMatch
    <Can
    I="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 scout tries to call this endpoint, it rejects immediately.

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:

    1. Read PERMISSIONS config.
    2. Iterate over subjects (e.g., matches).
    3. Lookup Path: Resolve matches -> ['tenants', 'matches'] via COLLECTION_PATHS.
    4. Construct Match Block: Interleave wildcards to form /tenants/{tenantId}/matches/{document}.
    5. Generate allow statements based on the roles that have that action.
  • Generated Output:

    // Auto-generated. Do not edit.
    match /tenants/{tenantId}/matches/{matchId} {
    allow create: if hasRole(tenantId, ['scout', 'editor', 'admin']);
    // ...
    }

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.ts which rules govern those entities (e.g., “Scouts can read Matches”).
    • Constraint: Must NOT import from database.
  • packages/database (Data Layer):

    • Defines DB Wrappers (e.g., DBTenantUser = DBRecord<TenantUser>).
    • Defines collectionPaths.ts mapping Domain Concepts to Firestore Paths.
    • Generates Security Rules by combining the Config (from Common) with the Paths (from Database).
    • Constraint: Can import from common.

Security is critical, so we employ a multi-layered testing strategy to ensure our “Mirror Pattern” works.

  1. 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);
  2. 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.ts script.
  3. API Authorization Tests (Integration):

    • Location: apps/trpc-api/src/routers/protected.test.ts
    • Goal: Verify that TRPC endpoints throw FORBIDDEN when accessed by a user with insufficient privileges.

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.ts to authorize actions (showing buttons, processing requests).
    • Database: Generates allow read: if ... but allow write: if false (by default).
  • 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 write rules 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.

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.

To ensure Superuser actions are explicit and safe, the application enforces structural separation:

  • Regular Mode: The user acts within a $tenantId scope (e.g., /2702/...). Permissions are governed by CASL and team-specific roles.
  • System Admin Mode: Accessible via a dedicated /system-admin route that sits outside the tenant hierarchy. No team-specific data is visible here. This mode is strictly for platform management.

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 pending state and reserves the owner role for that specific email address.

To ensure security and prevent unauthorized team takeovers, tenants follow a strict activation lifecycle:

  1. Status: pending: A newly created tenant is invisible to the general public. It will NOT appear in the team directory for regular users.
  2. Ownership Reservation: The Superuser specifies a reservedOwnerEmail. A placeholder membership record is created for this email.
  3. Owner Discovery: When a user logs in whose email matches a reserved owner slot, the system explicitly identifies the pending tenant for them.
  4. Activation: Once the designated owner selects the tenant for the first time, the system:
    • Binds their uid to 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”.

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.

  1. User Action: User logs in via Google. They have no teams.
  2. User Action: They search “Team 2702” and click Join.
  3. System: Creates a membership document: tenants/2702/users/{uid} with role: 'pending'.
  4. Admin View: The “Team Management” dashboard shows a badge: “1 Pending Request”.
  5. Admin Action: Admin clicks Approve and selects a role (e.g., “Scout”).
  6. System: Updates role to scout. The user now has access.

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.
  1. Discovery: Superusers see a “System Administration” button on the Team Selection screen.
  2. Navigation: Clicking this button redirects to /system-admin.
  3. Action: The Superuser creates a new tenant 2056.
  4. Verification: The new tenant immediately appears in the Global Tenant Directory and is available for users to “Request to Join”.

This is a Schema-Breaking Migration. We are renaming the root collection. This cannot be done “live”.

The following legacy types and patterns are strictly deprecated in the new architecture:

  1. DBUser (Type):
    • Current: Mixes identity (email) with single-tenant permissions (isAdmin, isUser).
    • Replacement: Use DBTenantUser for permissions/roles. Enrich with email, displayName, and photoURL synced from Auth.
  2. DBFirebaseUserCache (Type):
    • Current: Caches display names to avoid joining.
    • Replacement: Integrated into DBTenantUser. displayName is synced from global Auth, while nickname is a team-specific override. The UI should prefer nickname if present.
  3. 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.
  4. Legacy Date Formats:
    • Current: Mix of local strings and RFC 2822 dates.
    • Replacement: Strict ISO 8601 normalization for all creationTime and timestamp fields.
  5. Auto-Admin Logic (TRPC Context):
    • Current: Sets isAdmin claim 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.
  1. Dependencies: Install @casl/ability and @casl/react in apps/app and apps/trpc-api.
  2. Code Complete:
    • Domain Types: Implement TenantUser in packages/common and DBTenantUser in packages/database.
    • Implement packages/common/src/permissions.config.ts using Domain Subjects.
    • Create packages/database/src/collectionPaths.ts for shared path definitions.
    • Refactor dbClientRefs.ts and dbServerRefs.ts to use COLLECTION_PATHS.
    • TRPC Context: Update createContext to fetch DBTenantUser (replacing Claims) and inject the CASL ability object.
    • Indexes: Create firestore.indexes.json to define the Collection Group Index on users.uid. Update firebase.json to include it.
    • Implement the Rule Generator script.
  3. Verify Rules: Run unit tests against the generated firestore.rules to ensure they block unauthorized access as expected.
  4. 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.

  1. Maintenance Mode: Deploy a temporary Firestore Rule blocking ALL writes. The app is effectively Read-Only.
  2. Migration Script:
    • Virtual Document Discovery: Use listDocuments() instead of get() 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 /metadata and /schemas collections are global and do not move.
  3. Validation:
    • Script: count(tenant/{id}/matches) === count(tenants/{id}/matches).
    • If counts mismatch, ABORT.

Goal: Convert legacy Claims to Documents and enrich with Auth metadata.

  1. Script: Iterate all users via Firebase Admin Auth.
  2. Logic:
    • if (customClaims.isAdmin) -> Create tenants/2702/users/{uid} with role admin.
    • if (customClaims.isUser) -> Create tenants/2702/users/{uid} with role scout.
    • Metadata Enrichment:
      • Email & Avatar: Sync email and photoURL from the Auth record.
      • Authoritative History: Use userRecord.metadata.creationTime as the primary source for creationTime, normalized to ISO 8601. Fallback to the legacy timestamp only if Auth data is missing.
    • Defaulting to Team 2702: All current users are implicitly 2702 members.
  1. Deploy Backend: TRPC API pointing to /tenants.
    • Optimization: Increase Firebase Function timeout (e.g., 540s) to ensure long-running migration tasks complete successfully.
  2. Deploy Frontend: React App with new Routing.
    • Important: Ensure blobStore is cleared/reset when switching tenants to avoid data leakage.
  3. Deploy Rules: The new generated rules.
  4. Live Test: Log in as a Scout. Verify access. Log in as Admin. Verify access.
  5. Disable Maintenance Mode.
  1. Verify system stability.
  2. Run script to delete the old /tenant collection.
  3. Run script to clear customClaims from all users.
  4. Revert Firebase Function timeout to standard value (60s) to prevent unintended long-running execution costs.