Skip to content

Firestore Security Rules - Phase 1 Implementation

Status: ✅ Implemented (January 31, 2026)
Purpose: Establish single source of truth for role-based access control
Reference: AUTHORIZATION_ARCHITECTURE.md


Overview

Firestore security rules have been refactored from a hybrid model (mixing Auth claims and Firestore data) to a single source of truth model where all role and tenant information is read from the /users/{uid} collection.

Key Changes

Aspect Before After
Role source request.auth.token.role (Auth claims) getUserRole() reads from /users/{uid}
Tenant source request.auth.token.tenant_id getUserTenant() reads from /users/{uid}
User storage /tenants/{id}/users/{uid} (tenant-scoped) /users/{uid} (global)
Data writes Allowed for authenticated users Denied for clients (backend only)
Tenant isolation Checked at collection level Checked at every access

Helper Functions

All authorization is built on these core helpers that read from /users/{uid}:

// Get user's role (SUPER_ADMIN, ADMIN, CLUB_ADMIN, KIOSK_ADMIN, END_USER)
function getUserRole() {
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.get('role', 'END_USER');
}

// Get user's tenant (e.g., "73", "9999")
function getUserTenant() {
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.tenant_id;
}

// Check if user completed onboarding
function isOnboarded() {
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.get('onboardingCompleted', false) == true;
}

// Check if user is in specific tenant AND onboarded
function inTenant(tenantId) {
  return isSignedIn() && getUserTenant() == tenantId;
}

// Check if user is SUPER_ADMIN (cross-tenant access)
function isSuperAdmin() {
  return isSignedIn() && getUserRole() == 'SUPER_ADMIN';
}

// Check if user is admin-level (ADMIN, CLUB_ADMIN, or SUPER_ADMIN)
function isAdmin() {
  return isSignedIn() && (getUserRole() == 'ADMIN' || getUserRole() == 'CLUB_ADMIN' || isSuperAdmin());
}

// Check if user can access tenant
function canAccessTenant(tenantId) {
  return isSignedIn() && (isSuperAdmin() || (inTenant(tenantId) && isOnboarded()));
}

Collection Structure & Rules

/users/{uid} - Global User Collection (SINGLE SOURCE OF TRUTH)

Purpose: Store all user profiles with roles and tenant assignments

Read: User can read own document

match /users/{uid} {
  allow read: if request.auth.uid == uid;
}

Write: User can only update safe fields (not role/tenant)

allow write: if request.auth.uid == uid &&
             !request.resource.data.keys().hasAny(['role', 'tenant_id', 'memberID', 'user_wallet_id', 'onboardingCompleted']) &&
             request.writeFields.hasOnly(['profile', 'preferences', 'lastLogin', 'displayName', 'photoURL']);

Document Structure:

/users/alice123 {
  email: "alice@club.no"
  role: "ADMIN"               Read by rules helpers
  tenant_id: "73"             Read by rules helpers
  memberID: "000002"
  user_wallet_id: "73-000002"
  onboardingCompleted: true   Enforced by rules
  profile: { displayName: "Alice", ... }
  preferences: { language: "no", ... }
  lastLogin: timestamp
}

/tenants/{tenantId}/{document=**} - Tenant Data (MULTI-TENANT ISOLATED)

Read: User can read if in correct tenant and onboarded

allow read: if canAccessTenant(tenantId);

Write: Only admin-level users in this tenant

allow write: if isSignedIn() && isAdmin() && inTenant(tenantId);

Collections under /tenants/{tenantId}: - wallet_items/ - User's giftcards (read-only for clients) - ledger_transactions/ - Wallet transaction history (read-only) - wallet_projections/ - Cached wallet balances (read-only) - redemptions/ - Redemption records (read-only) - giftcard_codes/ - Code database (admin-only read) - configuration/ - Club settings (admin write) - admin_audit_log/ - Admin actions (admin-only read)


Data Collection Rules (Immutable from Client)

wallet_items, ledger_transactions, wallet_projections, redemptions, giftcard_codes:

match /tenants/{tenantId}/wallet_items/{itemId} {
  allow read: if canAccessTenant(tenantId);
  allow write: if false;  //  NO CLIENT WRITES - Backend only
}

Why: Wallet data is immutable from client perspective. Only Cloud Functions can write (via service account): - ✅ Prevents accidental data corruption - ✅ Enforces ledger pattern (all writes via functions) - ✅ Audit trail guaranteed


Admin Operations (/admin collection)

SUPER_ADMIN only:

match /admin/{document=**} {
  allow read, write: if isSuperAdmin();
}

Use for: - Cross-tenant admin operations - System configuration - Audit trails - User promotion/demotion


Authorization Flow

Read Request: "Can user read /tenants/73/wallet_items/?"

1. User sends request with ID token
   
2. Rules evaluate: canAccessTenant("73")
   ├─ isSignedIn()? 
   ├─ isSuperAdmin()? 
     └─ getUserRole() == "SUPER_ADMIN"?  YES  ALLOW
   └─ OR (inTenant("73") && isOnboarded())?
      ├─ getUserTenant() == "73"? 
      ├─ isOnboarded() == true? 
      └─ YES  ALLOW
   
3.  READ ALLOWED

Write Request: "Can user write to /tenants/73/configuration/?"

1. User sends write request
   
2. Rules evaluate: isAdmin() && inTenant("73")
   ├─ isAdmin()?
     └─ getUserRole() in ['ADMIN', 'CLUB_ADMIN', 'SUPER_ADMIN']? 
   ├─ inTenant("73")?
     ├─ getUserTenant() == "73"? 
     └─ isOnboarded()? 
   
3.  WRITE ALLOWED
   (Actually updates Firestore)

Denied Request: "User tries to write role field"

1. User sends: { role: "ADMIN", profile: { ... } }
   
2. Rules evaluate write condition:
   request.writeFields.hasOnly(['profile', 'preferences', ...])

   'role' is in writeFields?  YES
   'role' in allowedFields?  NO
   
3.  WRITE DENIED
   Firestore rejects update

Testing

Test Users

Email Role Tenant Onboarded Expected Access
super-admin@testgolf.no SUPER_ADMIN 9999 All data (admin)
admin@testgolf.no CLUB_ADMIN 73 Tenant 73 (admin)
kristin@testgolf.no END_USER 73 Tenant 73 (read)
new-user@testgolf.no END_USER null Onboarding only

Manual Test Cases

Test 1: Non-onboarded user blocked

# User: new-user@testgolf.no (tenant=null, onboarded=false)
# Try: Read /tenants/73/wallet_items/...
# Expected: ❌ PERMISSION DENIED

# Because: canAccessTenant("73") checks isOnboarded()
#          which returns false

Test 2: User cannot escalate own role

# User: kristin@testgolf.no (role=END_USER)
# Try: Update own /users/kristin_uid with role="ADMIN"
# Expected: ❌ PERMISSION DENIED

# Because: !request.resource.data.keys().hasAny(['role', ...])
#          prevents writing sensitive fields

Test 3: Cross-tenant access denied

# User: admin@testgolf.no (role=CLUB_ADMIN, tenant=73)
# Try: Write /tenants/9999/configuration/...
# Expected: ❌ PERMISSION DENIED

# Because: inTenant("73") != true when accessing tenant "9999"

Test 4: SUPER_ADMIN cross-tenant access

# User: super-admin@testgolf.no (role=SUPER_ADMIN, tenant=9999)
# Try: Write /admin/system_config/...
# Expected: ✅ ALLOWED

# Because: isSuperAdmin() bypasses tenant check

Security Properties

Property How Enforced
Single Source of Truth All role/tenant read from /users/{uid}
Tenant Isolation Every access checks getUserTenant()
Role Escalation Prevention Rules forbid writing role/tenant fields
Onboarding Enforcement isOnboarded() checked for data access
Immutable Audit Trail Client write = false for wallet data
Backend Authority Service account can bypass rules
No Stale Claims Never reads from Auth custom claims

Migration Notes

For Developers

  1. Backend: When updating role/tenant:
  2. Always update /users/{uid} document
  3. NO need to set Auth custom claims
  4. Rules will immediately enforce new role

  5. Frontend: Never trust Auth claims for authorization:

  6. Always read role from /users/{uid} (via useUserRole() hook)
  7. Cache with 5-minute revalidation
  8. Use ProtectedRoute component for access control

  9. Backward Compatibility:

  10. Old tenant-scoped user collections still exist in some docs
  11. Should be phased out over time
  12. Rules are backward compatible but discourage use

Performance Considerations

Database Reads

Helper functions use get() which counts as a read operation:

function getUserRole() {
  return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.get('role', 'END_USER');
  //  This is a Firestore read
}

Cost Implications: - Every database access triggers helper function evaluation - Each evaluation reads /users/{uid} document - Potential cost increase: ~5-10% (acceptable for consistency)

Mitigation: - Frontend caches role locally (Phase 3) - Most reads cached for 5 minutes - Only write operations require fresh role check


Rollback Plan

If issues discovered:

  1. Immediate: Disable new rules, restore old rules bash firebase deploy --only firestore:rules # (with old rules file)

  2. Quick Fix: Adjust specific rule (minutes)

  3. Full Revert: Restore from git (seconds)

Next Phase

Phase 2: Cloud Functions Authorization - Update onAuthCreate function - Implement completeOnboarding function - All backend functions read role from /users/{uid} - Set Auth claims: tenant_id only

Timeline: 2-3 hours


Status: ✅ Ready for deployment to Firebase Console

Last Updated: January 31, 2026