Phase 2: Production RBAC Security Rules - Architectural Design
Document Type: Architecture / Thinking
Status: Conceptual Design
Created: 2026-01-15
Target Implementation: When roles are finalized (Wave 5+)
Dependencies: Custom Claims Infrastructure, AuthzSnapshot System, Directory Graph
Executive Summary
This document defines the architectural approach for transitioning from Development Mode (authenticated-only) security rules to Production RBAC (role-based access control) security rules. The design integrates the platform's existing 4-Axis Authorization Model with Firebase Firestore security rules to create a comprehensive, relationship-backed security system.
Key Principles
- Relationship-Backed: Roles derived from Directory graph traversal, not hardcoded claims
- Context-Aware: Access determined by Hat (persona), Scope (boundary), Asset (target), and Principal (delegation)
- Snapshot-Driven: Custom claims store pre-computed AuthzSnapshot for performance
- Forensically Verified: Capabilities only granted after verification events (Standard 78)
- Delegation-Ready: First-class support for proxy voting and property management
1. Authorization Model Integration
1.1 The 4-Axis Context Model
The platform's authorization is governed by four axes (from 02_B_AUTHZ_MODEL.md):
| Axis | Field | Firebase Mapping | Security Rule Usage |
|---|---|---|---|
| Hat | activeHat |
request.auth.token.activeHat |
Primary role check |
| Scope | activeScope |
request.auth.token.activeScope |
Boundary enforcement |
| Asset | activeAssetId |
request.auth.token.activeAssetId |
Ownership verification |
| Principal | actingForPrincipalId |
request.auth.token.actingForPrincipalId |
Delegation check |
1.2 Role Hierarchy (Persona Book)
Primary Personas (Hats):
- PUBLIC (0) - Unauthenticated or unverified
- GUEST (10) - Short-term visitor (reservation-bound)
- TENANT (20) - Leaseholder
- RESIDENT (30) - Family/cohabitant
- OWNER (40) - Primary title holder
- CO_OWNER (40) - Secondary title holder
- AGENT (50) - Property manager, sales agent
- VENDOR (60) - Service provider
- LEGAL (70) - Legal counsel, notary
- GOVERNANCE (80) - Board member, vigilance
- STAFF (90) - Building staff (with sub-facets)
- ADMIN (100) - System administrator
- GOD (1000) - Superuser/root
Staff Sub-Facets:
- STAFF_MAINTENANCE - Maintenance department
- STAFF_SECURITY - Security personnel
- STAFF_FRONTDESK - Front desk/hospitality
- STAFF_ENGINEERING - Building engineer
- STAFF_ACCOUNTING - Accounting department
- STAFF_CLEANING - Cleaning services
Governance Sub-Facets:
- GOVERNANCE_PRESIDENT - Board president
- GOVERNANCE_TREASURER - Board treasurer
- GOVERNANCE_SECRETARY - Board secretary
- GOVERNANCE_VIGILANCE - Vigilance committee member
1.3 Scope Boundaries
my_units- User can only access their owned/managed unitsbuilding- User can access building-wide datacommunity- User can access community-level data (future)
2. Custom Claims Architecture
2.1 Claims Structure
Firebase Auth custom claims will store a snapshot of the user's authorization state:
interface FirebaseCustomClaims {
// Primary Context
activeHat: UserRole; // Current acting persona
activeScope: 'my_units' | 'building';
activeAssetId?: string; // Current target unit/asset
actingForPrincipalId?: string; // Delegation target
// Authorization Snapshot
authzVersion: number; // Dirty detection timestamp
availableHats: UserRole[]; // All personas user can assume
availableAssets: string[]; // All units user can access
capabilities: string[]; // Pre-computed capability IDs
// Metadata
directoryProfileId: string; // Link to Directory
isDomainUser: boolean; // @singulardream.org email
lastRoleCheck: number; // Timestamp of last graph traversal
}
2.2 Claims Lifecycle
1. Initial Authentication (Login)
// Server Action: apps/platform/src/actions/auth-context.ts
async function onUserLogin(firebaseUser: FirebaseUser) {
// 1. Resolve Directory Profile
const profile = await getDirectoryProfile(firebaseUser.email);
// 2. Traverse Relationship Graph
const snapshot = await resolveAuthzSnapshot(profile);
// 3. Set Custom Claims
await admin.auth().setCustomUserClaims(firebaseUser.uid, {
activeHat: snapshot.availableHats[0],
activeScope: deriveDefaultScope(snapshot.availableHats[0]),
authzVersion: snapshot.authzVersion,
availableHats: snapshot.availableHats,
availableAssets: snapshot.availableAssets.map(a => a.id),
capabilities: Object.keys(snapshot.capabilities).filter(k => snapshot.capabilities[k]),
directoryProfileId: profile.id,
isDomainUser: firebaseUser.email.endsWith('@singulardream.org'),
lastRoleCheck: Date.now()
});
// 4. Force token refresh
await firebaseUser.getIdToken(true);
}
2. Context Switch (Hat Change)
// Server Action: apps/platform/src/actions/context-actions.ts
async function switchHat(newHat: UserRole) {
const user = await getCurrentUser();
const claims = await user.getIdTokenResult();
// Verify user has access to this hat
if (!claims.claims.availableHats.includes(newHat)) {
throw new Error('Unauthorized hat');
}
// Update claims
await admin.auth().setCustomUserClaims(user.uid, {
...claims.claims,
activeHat: newHat,
activeScope: deriveDefaultScope(newHat),
activeAssetId: null // Reset asset on hat change
});
// Force token refresh
await user.getIdToken(true);
}
3. Dirty Detection (Relationship Change)
// Cloud Function: functions/src/onDirectoryChange.ts
export const onDirectoryChange = functions.firestore
.document('directory_profiles/{profileId}')
.onUpdate(async (change, context) => {
const before = change.before.data();
const after = change.after.data();
// Check if relationships changed
if (JSON.stringify(before.relationships) !== JSON.stringify(after.relationships)) {
// Bump authzVersion
await change.after.ref.update({
authzVersion: admin.firestore.FieldValue.serverTimestamp()
});
// Find associated Firebase user
const userRecord = await admin.auth().getUserByEmail(after.email);
// Re-resolve snapshot
const snapshot = await resolveAuthzSnapshot(after);
// Update claims
await admin.auth().setCustomUserClaims(userRecord.uid, {
...userRecord.customClaims,
authzVersion: snapshot.authzVersion,
availableHats: snapshot.availableHats,
availableAssets: snapshot.availableAssets.map(a => a.id),
capabilities: Object.keys(snapshot.capabilities).filter(k => snapshot.capabilities[k])
});
}
});
3. Firestore Security Rules Design
3.1 Helper Functions
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
// --- Authentication ---
function isAuthenticated() {
return request.auth != null;
}
function isOwner(userId) {
return request.auth.uid == userId;
}
function isDomainUser() {
return isAuthenticated() &&
request.auth.token.isDomainUser == true;
}
// --- Role Checks ---
function hasHat(hat) {
return isAuthenticated() &&
request.auth.token.activeHat == hat;
}
function hasAnyHat(hats) {
return isAuthenticated() &&
request.auth.token.activeHat in hats;
}
function canAssumeHat(hat) {
return isAuthenticated() &&
request.auth.token.availableHats != null &&
hat in request.auth.token.availableHats;
}
// --- Capability Checks ---
function hasCapability(capabilityId) {
return isAuthenticated() &&
request.auth.token.capabilities != null &&
capabilityId in request.auth.token.capabilities;
}
// --- Scope Checks ---
function hasScope(scope) {
return isAuthenticated() &&
request.auth.token.activeScope == scope;
}
function isBuildingScope() {
return hasScope('building');
}
function isMyUnitsScope() {
return hasScope('my_units');
}
// --- Asset Ownership ---
function ownsAsset(assetId) {
return isAuthenticated() &&
request.auth.token.availableAssets != null &&
assetId in request.auth.token.availableAssets;
}
function isActiveAsset(assetId) {
return isAuthenticated() &&
request.auth.token.activeAssetId == assetId;
}
// --- Delegation ---
function isActingFor(principalId) {
return isAuthenticated() &&
request.auth.token.actingForPrincipalId == principalId;
}
function isDelegated() {
return isAuthenticated() &&
request.auth.token.actingForPrincipalId != null;
}
// --- Privilege Levels ---
function isPrivileged() {
return hasAnyHat(['ADMIN', 'GOD', 'STAFF', 'GOVERNANCE']);
}
function isAdmin() {
return hasAnyHat(['ADMIN', 'GOD']);
}
function isStaff() {
return hasAnyHat([
'STAFF',
'STAFF_MAINTENANCE',
'STAFF_SECURITY',
'STAFF_FRONTDESK',
'STAFF_ENGINEERING',
'STAFF_ACCOUNTING',
'STAFF_CLEANING'
]);
}
function isGovernance() {
return hasAnyHat([
'GOVERNANCE',
'GOVERNANCE_PRESIDENT',
'GOVERNANCE_TREASURER',
'GOVERNANCE_SECRETARY',
'GOVERNANCE_VIGILANCE'
]);
}
function isOwnerOrResident() {
return hasAnyHat(['OWNER', 'CO_OWNER', 'RESIDENT']);
}
3.2 Collection-Specific Rules
Finance Collections
// --- Finance: Accounts ---
match /finance_accounts/{accountId} {
allow read: if isAdmin() ||
isStaff() ||
isGovernance();
allow write: if isAdmin() ||
(isStaff() && hasCapability('FIN-MANAGE-COA'));
}
// --- Finance: Ledger ---
match /finance_ledger/{entryId} {
allow read: if isAdmin() ||
isStaff() ||
isGovernance();
allow create: if isAdmin() ||
(isStaff() && hasCapability('FIN-POST-JOURNAL'));
allow update, delete: if isAdmin();
}
// --- Finance: Bank Statements ---
match /finance_bank_statements/{statementId} {
allow read: if isAdmin() ||
(isStaff() && hasCapability('FIN-VIEW-STATEMENTS')) ||
(isGovernance() && hasCapability('GOV-AUDIT-FINANCE'));
allow write: if isAdmin() ||
(isStaff() && hasCapability('FIN-MANAGE-STATEMENTS'));
}
// --- Finance: Invoices ---
match /finance_invoices/{invoiceId} {
allow read: if isAdmin() ||
isStaff() ||
isGovernance() ||
(isOwnerOrResident() && ownsAsset(resource.data.assetId));
allow write: if isAdmin() ||
(isStaff() && hasCapability('FIN-MANAGE-INVOICES'));
}
// --- Finance: Assessments ---
match /finance_assessments/{assessmentId} {
allow read: if isAuthenticated(); // All residents can see assessments
allow write: if isAdmin() ||
(isGovernance() && hasCapability('GOV-APPROVE-BUDGET'));
}
Operations Collections
// --- Operations: Personnel ---
match /operations_personnel/{personnelId} {
allow read: if isAdmin() || isStaff();
allow write: if isAdmin() ||
(isStaff() && hasCapability('OPS-MANAGE-PERSONNEL'));
}
// --- Operations: Work Orders ---
match /operations_work_orders/{orderId} {
allow read: if isAdmin() ||
isStaff() ||
(isOwnerOrResident() && ownsAsset(resource.data.assetId));
allow create: if isAuthenticated(); // Anyone can create work orders
allow update: if isAdmin() ||
(isStaff() && hasCapability('OPS-MANAGE-WORK-ORDERS')) ||
(isOwner(resource.data.createdBy) && resource.data.status == 'draft');
allow delete: if isAdmin();
}
// --- Operations: Amenity Bookings ---
match /operations_bookings/{bookingId} {
allow read: if isAdmin() ||
isStaff() ||
isOwner(resource.data.userId);
allow create: if isOwnerOrResident() &&
hasCapability('PROP-BOOK-AMENITY');
allow update: if isAdmin() ||
isStaff() ||
(isOwner(resource.data.userId) && resource.data.status == 'pending');
allow delete: if isAdmin() ||
(isOwner(resource.data.userId) && resource.data.status == 'pending');
}
// --- Operations: Access Logs ---
match /operations_access_logs/{logId} {
allow read: if isAdmin() ||
(isStaff() && hasAnyHat(['STAFF_SECURITY', 'STAFF_FRONTDESK']));
allow create: if isStaff(); // Security system creates logs
allow update, delete: if false; // Immutable
}
Governance Collections
// --- Governance: Assemblies ---
match /governance_assemblies/{assemblyId} {
allow read: if isOwnerOrResident() || isGovernance() || isAdmin();
allow create: if isGovernance() && hasCapability('GOV-CREATE-ASSEMBLY');
allow update: if isGovernance() && hasCapability('GOV-MANAGE-ASSEMBLY');
allow delete: if isAdmin();
}
// --- Governance: Proposals ---
match /governance_proposals/{proposalId} {
allow read: if isOwnerOrResident() || isGovernance() || isAdmin();
allow create: if (isOwnerOrResident() && hasCapability('GOV-CREATE-PROPOSAL')) ||
(isGovernance() && hasCapability('GOV-CREATE-PROPOSAL'));
allow update: if isGovernance() ||
(isOwner(resource.data.createdBy) && resource.data.status == 'draft');
allow delete: if isAdmin();
}
// --- Governance: Votes ---
match /governance_votes/{voteId} {
allow read: if isGovernance() || isAdmin();
allow create: if isAuthenticated() &&
hasCapability('GOV-VOTE') &&
ownsAsset(request.resource.data.assetId);
allow update, delete: if false; // Votes are immutable
}
// --- Governance: Proxies ---
match /governance_proxies/{proxyId} {
allow read: if isOwner(resource.data.ownerId) ||
isOwner(resource.data.proxyId) ||
isGovernance() ||
isAdmin();
allow create: if isAuthenticated() &&
isOwner(request.resource.data.ownerId) &&
hasCapability('GOV-DELEGATE-VOTE');
allow update: if isOwner(resource.data.ownerId);
allow delete: if isOwner(resource.data.ownerId) || isAdmin();
}
Knowledge Collections
// --- Knowledge: Articles ---
match /knowledge_articles/{articleId} {
allow read: if resource.data.status == 'published' ||
isStaff() ||
isGovernance() ||
isAdmin();
allow write: if isAdmin() ||
(isStaff() && hasCapability('KNOW-MANAGE-ARTICLES'));
}
// --- Knowledge: Vault (Restricted Documents) ---
match /knowledge_vault/{documentId} {
// Role-based access using audience field
allow read: if isAdmin() ||
(resource.data.audience.roles != null &&
request.auth.token.activeHat in resource.data.audience.roles);
allow write: if isAdmin() ||
(isStaff() && hasCapability('KNOW-MANAGE-VAULT'));
}
Directory Collections
// --- Directory: Profiles ---
match /directory_profiles/{profileId} {
allow read: if isAuthenticated(); // All authenticated users can view directory
allow create: if isAuthenticated() &&
request.resource.data.email == request.auth.token.email;
allow update: if isOwner(resource.data.userId) ||
isAdmin() ||
(isStaff() && hasCapability('DIR-MANAGE-PROFILES'));
allow delete: if isAdmin();
}
// --- Directory: Occupancy ---
match /directory_occupancy/{occupancyId} {
allow read: if isAuthenticated();
allow write: if isAdmin() ||
(isStaff() && hasCapability('DIR-MANAGE-OCCUPANCY'));
}
// --- Directory: Units ---
match /directory_units/{unitId} {
allow read: if isAuthenticated();
allow write: if isAdmin() ||
(isStaff() && hasCapability('DIR-MANAGE-UNITS'));
}
4. Implementation Roadmap
Phase 2.1: Custom Claims Infrastructure (Week 1)
Deliverables:
1. Cloud Function: onUserCreate - Set initial claims on signup
2. Cloud Function: onDirectoryChange - Update claims on relationship changes
3. Server Action: refreshAuthzSnapshot - Manual refresh endpoint
4. Server Action: switchContext - Hat/scope/asset switching
Testing: - Unit tests for graph traversal logic - Integration tests for claims lifecycle - Emulator tests for dirty detection
Phase 2.2: Capability Registry Integration (Week 2)
Deliverables:
1. Migrate capability definitions to CAPABILITY_REGISTRY
2. Update resolveAuthzSnapshot to pull from registry
3. Create capability seeding script for existing users
4. Document capability-to-collection mapping
Testing: - Verify all personas have correct capabilities - Test capability inheritance for sub-facets - Validate delegation scopes
Phase 2.3: Security Rules Implementation (Week 3)
Deliverables:
1. Update firestore.rules with full RBAC rules
2. Implement all helper functions
3. Add collection-specific access patterns
4. Create rules testing suite
Testing: - Emulator tests for each collection - Test matrix: (Role × Collection × Operation) - Negative tests (unauthorized access attempts) - Delegation tests (proxy voting, property management)
Phase 2.4: Migration \u0026 Deployment (Week 4)
Deliverables: 1. Backfill custom claims for all existing users 2. Deploy to staging environment 3. Run comprehensive integration tests 4. Monitor for permission denied errors 5. Deploy to production
Rollback Plan: - Keep development-mode rules as backup - Monitor error rates for 48 hours - Gradual rollout (10% → 50% → 100%)
5. Security Considerations
5.1 Privilege Escalation Prevention
Risk: User modifies client-side context to gain unauthorized access
Mitigation: - Custom claims are server-controlled (cannot be modified by client) - All server actions re-verify claims against Directory - Dirty detection prevents stale claims exploitation - Asset ownership verified server-side
5.2 Delegation Abuse
Risk: Proxy grants used beyond intended scope
Mitigation: - Time-bound delegation (validFrom/validUntil) - Scope-bound delegation (specific capabilities only) - Audit trail for all delegated actions - Revocation mechanism for grants
5.3 Cross-Asset Access
Risk: User accesses data for units they don't own
Mitigation:
- ownsAsset() check required for unit-scoped data
- availableAssets list maintained in claims
- Server-side verification of asset ownership
- Branching authorization pattern (admin vs. resident)
5.4 Stale Claims
Risk: User retains access after relationship ends
Mitigation:
- authzVersion dirty detection
- Automatic claims refresh on Directory changes
- Periodic claims re-validation (every 24 hours)
- Manual refresh endpoint for immediate updates
6. Performance Optimization
6.1 Snapshot Caching
Strategy: Pre-compute authorization snapshot to avoid N+1 queries
Implementation: - Store snapshot in custom claims (max 1000 bytes) - Compress capability list to IDs only - Cache asset list (limit to 50 assets per user) - Lazy-load full asset details on demand
6.2 Rule Complexity
Strategy: Keep security rules simple and fast
Implementation:
- Use helper functions for reusability
- Avoid complex nested conditions
- Leverage indexed fields for queries
- Minimize get() calls in rules
6.3 Token Refresh
Strategy: Minimize forced token refreshes
Implementation: - Batch claim updates (don't update on every field change) - Only refresh on significant relationship changes - Client-side token caching (1 hour TTL) - Background refresh before expiration
7. Testing Strategy
7.1 Unit Tests
Target: Helper functions and graph traversal logic
describe('resolveAuthzSnapshot', () => {
it('should grant OWNER hat for users with deeds', async () => {
const profile = createMockProfile({ deeds: ['deed-1'] });
const snapshot = await resolveAuthzSnapshot(profile);
expect(snapshot.availableHats).toContain('OWNER');
});
it('should grant building scope for STAFF hat', async () => {
const profile = createMockProfile({ staffAssignment: 'maintenance' });
const snapshot = await resolveAuthzSnapshot(profile);
expect(snapshot.availableHats).toContain('STAFF_MAINTENANCE');
});
});
7.2 Integration Tests
Target: End-to-end claim lifecycle
describe('Custom Claims Lifecycle', () => {
it('should set claims on user creation', async () => {
const user = await createTestUser('owner@example.com');
const claims = await user.getIdTokenResult();
expect(claims.claims.activeHat).toBe('OWNER');
});
it('should update claims on directory change', async () => {
const user = await createTestUser('owner@example.com');
await updateDirectoryProfile(user.uid, { deeds: [] });
await waitForClaimsUpdate();
const claims = await user.getIdTokenResult(true);
expect(claims.claims.availableHats).not.toContain('OWNER');
});
});
7.3 Security Rules Tests
Target: Firestore security rules
describe('Finance Collections', () => {
it('should allow staff to read accounts', async () => {
const db = getTestFirestore({ activeHat: 'STAFF' });
await assertSucceeds(db.collection('finance_accounts').get());
});
it('should deny residents from reading accounts', async () => {
const db = getTestFirestore({ activeHat: 'RESIDENT' });
await assertFails(db.collection('finance_accounts').get());
});
it('should allow owners to read own invoices', async () => {
const db = getTestFirestore({
activeHat: 'OWNER',
availableAssets: ['unit-101']
});
await assertSucceeds(
db.collection('finance_invoices')
.where('assetId', '==', 'unit-101')
.get()
);
});
});
8. Migration from Development Mode
8.1 Transition Plan
Step 1: Parallel Deployment - Deploy custom claims infrastructure - Keep development-mode rules active - Monitor claims generation for all users
Step 2: Gradual Rollout - Enable RBAC rules for 10% of users (feature flag) - Monitor error rates and performance - Expand to 50%, then 100%
Step 3: Full Cutover - Replace development-mode default rule with RBAC rules - Keep development-mode rules as commented backup - Monitor for 48 hours
Step 4: Cleanup - Remove development-mode fallback code - Archive old security rules - Update documentation
8.2 Rollback Procedure
If issues arise during migration:
-
Immediate Rollback:
-
Investigate Issues:
- Check Firebase Console for permission denied errors
- Review custom claims for affected users
-
Verify graph traversal logic
-
Fix and Retry:
- Fix identified issues
- Test in emulator
- Deploy to staging
- Retry gradual rollout
9. Future Enhancements
9.1 Field-Level Security
Concept: Restrict access to specific fields within documents
Example:
match /directory_profiles/{profileId} {
allow read: if isAuthenticated();
allow update: if isOwner(resource.data.userId) &&
// Prevent users from modifying their own roles
!request.resource.data.diff(resource.data).affectedKeys()
.hasAny(['roles', 'capabilities', 'authzVersion']);
}
9.2 Rate Limiting
Concept: Prevent abuse by limiting operation frequency
Implementation: - Use Cloud Functions to track operation counts - Store rate limit counters in Firestore - Enforce limits in security rules
9.3 Audit Trail Integration
Concept: Log all security rule denials for forensic analysis
Implementation:
- Cloud Function triggered on permission denied errors
- Store denial events in audit_logs collection
- Dashboard for security monitoring
9.4 Dynamic Capability Updates
Concept: Update capabilities without redeploying rules
Implementation: - Store capability definitions in Firestore - Security rules query capability collection - Admin UI for capability management
10. Success Criteria
10.1 Security
- ✅ No unauthenticated access to protected collections
- ✅ Role-based access enforced for all collections
- ✅ Asset ownership verified for unit-scoped data
- ✅ Delegation properly scoped and time-bound
- ✅ System collections remain server-only
10.2 Performance
- ✅ Custom claims generation \u003c 500ms
- ✅ Security rule evaluation \u003c 100ms
- ✅ Token refresh \u003c 200ms
- ✅ No N+1 query issues
10.3 Functionality
- ✅ All existing features work with RBAC rules
- ✅ Context switching works seamlessly
- ✅ Delegation (proxy voting) works correctly
- ✅ No false denials for legitimate access
10.4 Maintainability
- ✅ Security rules are well-documented
- ✅ Helper functions are reusable
- ✅ Test coverage \u003e 80%
- ✅ Clear migration path from dev mode
11. References
Internal Documents
02_B_AUTHZ_MODEL.md- 4-Axis Authorization Model01_B_PERSONA_BOOK.md- Persona DefinitionsPERSONA_RECONCILIATION.md- Directory Alignment
Implementation Files
packages/foundation/auth/src/types.ts- Role definitionspackages/foundation/auth/src/system.ts- Auth systemapps/platform/src/lib/context/types.ts- Context stateapps/platform/src/lib/auth/graph-resolver.ts- Graph traversal
Current Security Rules
firestore.rules- Development mode rulessecurity_audit_report.md- Current state analysis
Document Status: Ready for review when roles are finalized
Next Steps: Implement custom claims infrastructure (Phase 2.1)
Owner: Platform Architecture Team
Last Updated: 2026-01-15