| name | zenstack-cascading-access-control-bug |
| description | Fix ZenStack access control bug where cascading check(relation, 'read') policies
allow unauthorized data access. Use when: (1) Users can see other users' private
data they shouldn't access, (2) Access control works at User/Profile level but
fails for related models, (3) Public profile access unintentionally grants access
to private related data. Covers ZenStack @@allow policies with check() function
and defense-in-depth patterns.
|
| author | Claude Code |
| version | 1.0.0 |
| date | "2026-02-05T00:00:00.000Z" |
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,
},
});
AFTER (Defense in depth):
const blocks = await db.$setAuth({ id: user.id, role: user.role })
.availability.findMany({
where: {
userId: user.id,
status: AvailabilityStatusEnum.FREE,
},
});
Benefits:
- Performance: Uses indexed
userId field
- Clarity: Code is self-documenting
- Safety: Works even if access control policies change
- Resilience: Multiple layers prevent bugs
Verification
-
Test with Multiple Users:
-
Check Access Control Output:
console.log('Availability count:', blocks.length);
console.log('User IDs:', blocks.map(b => b.userId));
-
Review All check() Policies:
grep -r "check(.*'read')" packages/database/zenstack/schema.zmodel
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,
status: AvailabilityStatusEnum.FREE,
},
});
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!)
References