zenstack-cascading-access-control-bug
ZenStack Cascading Access Control Security Bug
Problem
ZenStack access control policies using check(relation, 'read') can create cascading
permission chains that grant unintended access to private data. Users who can read a
public profile may gain access to ALL related private data (availability, bookings, etc.)
through chained policies.
Context / Trigger Conditions
Symptoms:
- Users can see other users' private data when they shouldn't
- Access control works correctly for User/Profile models but fails for related data
- Testing with multiple users reveals data leakage
- Calendar or dashboard shows overlapping data from multiple users
When this occurs:
- Using ZenStack with
@@allow('read', check(relation, 'read'))patterns - Models have public read access (e.g., approved instructor profiles)
- Related models inherit access through relationship checks
- No explicit filtering in WHERE clauses
Root Cause
The cascading chain works like this:
// Profile - Public read for approved instructors
@@allow('read', isActive && type.name == 'Instructor' && verificationStatus == APPROVED)
// User - Inherits from profile
@@allow('read', check(profile, 'read'))
// Availability - Inherits from user
@@allow('read', check(user, 'read')) // ❌ BUG!
What happens:
- Emily Chen (instructor) can read other instructors' public profiles ✓ (intended)
- Therefore Emily can read those instructors' User records (via
check(profile, 'read')) - Therefore Emily can read those instructors' Availability records (via
check(user, 'read')) ❌ (BUG!)
Solution
Fix 1: Replace Cascading Checks with Explicit Conditions
BEFORE (Vulnerable):
model Availability {
// ...
@@allow('read', check(user, 'read')) // Too permissive!
@@allow('all', userId == auth().id)
}
AFTER (Secure):
model Availability {
// ...
// Public read ONLY for FREE slots of approved instructors (for booking flow)
@@allow('read',
status == FREE &&
user.profile.type.name == 'Instructor' &&
user.profile.verificationStatus == APPROVED &&
user.profile.isActive == true
)
// Users can manage their own availability
@@allow('all', userId == auth().id)
// Admins have full access
@@allow('all', auth().role == 'ADMIN')
}
Fix 2: Add Defense in Depth - Explicit Query Filtering
Even with corrected access control, add explicit filtering in queries:
BEFORE (Relies only on access control):
const blocks = await db.$setAuth({ id: user.id, role: user.role })
.availability.findMany({
where: {
status: AvailabilityStatusEnum.FREE,
// Missing: userId filter
},
});
AFTER (Defense in depth):
const blocks = await db.$setAuth({ id: user.id, role: user.role })
.availability.findMany({
where: {
userId: user.id, // Explicit filtering
status: AvailabilityStatusEnum.FREE,
},
});
Benefits:
- Performance: Uses indexed
userIdfield - Clarity: Code is self-documenting
- Safety: Works even if access control policies change
- Resilience: Multiple layers prevent bugs
Verification
-
Test with Multiple Users:
# Create two instructor accounts # Set up availability for both # Log in as instructor A # Navigate to calendar/dashboard # Verify ONLY instructor A's data appears (no instructor B data) -
Check Access Control Output:
// In development, log query results console.log('Availability count:', blocks.length); console.log('User IDs:', blocks.map(b => b.userId)); // Should only show current user's ID -
Review All
check()Policies:# Search for potentially problematic patterns grep -r "check(.*'read')" packages/database/zenstack/schema.zmodel # Review each instance for cascading issues
Example
Real-world bug from RoadDux driving instructor platform:
Symptom: Emily Chen's calendar showed availability blocks from ALL instructors, not just her own.
Root Cause:
model Availability {
@@allow('read', check(user, 'read')) // Cascades from public profile access
@@allow('all', userId == auth().id)
}
Fix:
model Availability {
// Explicit conditions instead of cascading check
@@allow('read',
status == FREE && // Only public availability
user.profile.type.name == 'Instructor' &&
user.profile.verificationStatus == APPROVED &&
user.profile.isActive == true
)
@@allow('all', userId == auth().id)
@@allow('all', auth().role == 'ADMIN')
}
Plus added explicit filtering:
const blocks = await db.$setAuth({ id: user.id, role: user.role })
.availability.findMany({
where: {
userId: user.id, // Explicit user scoping
status: AvailabilityStatusEnum.FREE,
// ... other conditions
},
});
Notes
When check() is Safe
The check() function is safe when checking relationships in the SAME direction as data ownership:
model Booking {
// Safe: Checking if I can read MY OWN booking's instructor
@@allow('read', learnerUserId == auth().id && check(instructor, 'read'))
}
When check() is Dangerous
Dangerous when it cascades UPWARD through public data:
model PrivateData {
// Dangerous: Anyone who can read my public profile can read this!
@@allow('read', check(user.profile, 'read'))
}
Security Principles
- Principle of Least Privilege: Start restrictive, explicitly allow public access where needed
- Defense in Depth: Use both access control AND explicit query filtering
- Test with Multiple Users: Access bugs only appear with multi-user testing
- Separate Public and Private: Different policies for public-facing vs private data
Other Models to Review
Check these patterns in your schema:
- ✅ Booking:
@@allow('read', instructorUserId == auth().id || learnerUserId == auth().id)(Correct - explicit IDs) - ✅ Payment:
@@allow('read', booking.instructorUserId == auth().id || booking.learnerUserId == auth().id)(Correct - through explicit ownership) - ❌ Any model with:
@@allow('read', check(user, 'read'))without additional constraints (Review carefully!)