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¶
| 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¶
- Backend: When updating role/tenant:
- Always update
/users/{uid}document - NO need to set Auth custom claims
-
Rules will immediately enforce new role
-
Frontend: Never trust Auth claims for authorization:
- Always read role from
/users/{uid}(viauseUserRole()hook) - Cache with 5-minute revalidation
-
Use
ProtectedRoutecomponent for access control -
Backward Compatibility:
- Old tenant-scoped user collections still exist in some docs
- Should be phased out over time
- 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:
-
Immediate: Disable new rules, restore old rules
bash firebase deploy --only firestore:rules # (with old rules file) -
Quick Fix: Adjust specific rule (minutes)
- 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