Outcome
By the end of this lesson, you will be able to predict the query behavior of each scope state (nil / empty / list), explain why the empty state is a distinct concept rather than a degenerate case, and identify which state any given user is in.
| Prerequisites | M4.1.L1-L4 (policy table through team scoping) |
| Time | 9 minutes |
1. Concept
What any user can see comes down to one field, called the scope, and it has exactly three possible values.
Each value means something specific in the database and something predictable on screen. Nearly every “why can I not see this?” question is answered by checking which of the three a user is in, before anybody needs to look at roles at all.
STATE CANONICAL VALUE SQL EQUIVALENT──────────────────────────────────────────────────────────────────1. nil null / undefined no WHERE clause2. empty [] WHERE id IN ()3. filtered list [uid1, uid2, …] WHERE id IN (uid1, …)The three states emerged from production debugging. Many RBAC systems collapse “no scope” and “empty scope” into a single “no access” or single “everything.” That collapse is what causes the classic edge cases; a member of a brand-new team with all data visible, an Admin who is mysteriously filtered, a compliance auditor who can see things they should not. The three-state model makes each of these scenarios distinguishable.
State 1: nil (unfiltered)
SCOPE: nilSQL: SELECT * FROM resources (no WHERE on resource_uid)RESULT: returns every resource in the org
WHO HAS IT: - Admin role (which implies allResources=true) - Any role assigned to the user directly, org-wide, rather than through a team (including a newly invited user) - Custom role explicitly created with allResources=true - Service accounts running org-wide automationsnil is the “trust this user with everything” state. The backend skips the filter and runs an unscoped query. This is fast (no scope-list to compute, no IN clause to evaluate). Note how easy it is to reach: any directly assigned role produces it, so check who holds one.
State 2: empty (zero visibility)
SCOPE: []SQL: SELECT * FROM resources WHERE resource_uid IN () (or equivalent; many DBs short-circuit at the planner)RESULT: zero rows
WHO HAS IT: - Member of a team that has no resources or groups yet - User whose team has been deleted but user not yet reassigned - Compliance scenarios requiring total data isolation (e.g., suspended user during investigation)Empty is intentional, not degenerate. The user can authenticate, see the product UI, see their profile, see their assigned role: but no business data leaks. This is the safe default during the gap between creating a team and assigning it resources. Without an explicit empty state, you would have to either grant unscoped access during the gap (insecure) or fail logins (bad onboarding UX).
To the user, the empty state looks like empty pages. Tell people in this state why, so they do not file a ticket thinking the product is broken.
State 3: filtered list (team-scoped)
SCOPE: ['eks-cluster-1', 'rds-prod', 'eks-cluster-2', ...]SQL: SELECT * FROM resources WHERE resource_uid IN ('eks-cluster-1', ...)RESULT: matching rows only
WHO HAS IT: - Standard team-scoped user (most engineers) - Multi-team user (the union of all assigned teams' resources) - Resource-group member (the union of all group memberships)This is the most common state in mid-size customer orgs. The resourceIds list is computed at request time from the team’s assigned resources and the members of its assigned resource groups. The user does not see the list directly.
The performance characteristic matters at scale. A user scoped to 5,000 resource UIDs produces a 5,000-element IN clause, and the scope is resolved per request. Keep team scopes to what the team actually owns.
Why three states, not two
A first-draft system might collapse to two states: “nil” (admin-y, sees all) and “list” (everyone else, sees a list). What that collapse loses:
SCENARIO TWO-STATE PROBLEM THREE-STATE SOLUTION─────────────────────────────────────────────────────────────────────────────────────Team created, nothing assigned yet Sees everything Empty list → sees nothing (over-permissioned)
Suspended pending investigation Must remove role Set scope to empty; entirely (over-correction) role intact for audit
Admin during a temporary Must add Admin then Set allResources=true oncontext need remove existing role; no role changeThe three-state model is the cleaner abstraction.
How services consume the scope
Every backend service that returns resource-scoped data follows this pattern:
def get_resources(user_scope: ResourceScope): # Short-circuit on Admin / allResources if user_scope.allResources: return db.query("SELECT * FROM resources WHERE org_id=:org", org=user_scope.org_id)
# Short-circuit on empty: avoid the IN () round-trip if user_scope.resourceIds == []: return []
# Standard filtered query return db.query( "SELECT * FROM resources WHERE resource_uid IN :ids", ids=user_scope.resourceIds )The two short-circuits (allResources and empty list) avoid unnecessary database work for the common edge cases. The middle path is the actual filtered query.
Debugging “why can’t I see X”
The first question to ask: which scope state is this user in? No screen labels the state directly, so work it out from the facts. A role assigned directly gives nil; a role held only through a team gives that team’s resource list, or [] if the team owns nothing yet. Check the member list in Settings → Organization for direct roles and the team’s page under Teams for memberships and assignments. The diagnosis flow:
SYMPTOM STATE FIX─────────────────────────────────────────────────────────────────"I see nothing" empty Assign team"I see everything (uh-oh)" nil Verify allResources=true is intentional; if not, add team scope"I see some but not all expected" list Check team membership + team assignments; the missing resources are likely not assignedMost “missing resource” tickets resolve at the assignment step. The resource exists; the user is correctly scoped; the resource just isn’t assigned to the team (directly or through one of its groups).
How ZopNight uses the model
The scope is not fixed at login. Services resolve it on each request, and the gateway caches permission decisions for about 30 seconds. When a user is added to or removed from a team, the change takes effect within about half a minute, with no re-login.
TIMELINE: T+0 User assigned to new team (admin action) T+0 Audit log records the assignment T+0..30s Some requests may still use the cached decision T+30s Every request uses the new scopeThe short cache is the trade for performance. During an incident, wait half a minute after a team change before asking the user to retry; the web app also refetches permissions when its tab regains focus.
2. Demo
Three users in the same org, three distinct scope states:
USER A: Admin (org owner) Role: Admin (system) Teams: none required Scope: nil Sees: all 12,000 resources in the org
USER B: Junior engineer (team just created) Role: Editor (system), as her role on the new payments team Teams: [payments], nothing assigned to it yet Scope: [] Sees: 0 resources
USER C: Platform engineer (steady state) Role: Editor (system) Teams: [platform] Scope: ['eks-cluster-1', 'rds-prod', 'eks-cluster-2', ... 47 UIDs] Sees: 47 resources scoped to the platform teamThree users, three roles assigned, three different visibility profiles: all expressible cleanly through the scope state.
3. Hands-on (6 min)
For three specific users on your team, identify their scope state:
USER 1: __________ Expected state (nil / empty / list): __________ Facts (direct role? teams? team assignments?): __________ Derived state: __________ Match? __________ (if not, investigate)
USER 2: __________ Expected state: __________ Facts (direct role? teams? team assignments?): __________ Derived state: __________ Match? __________
USER 3: __________ Expected state: __________ Facts (direct role? teams? team assignments?): __________ Derived state: __________ Match? __________If any expected ≠ actual, that user is in the wrong state. Common causes: team assignment missing, resources not assigned to the team, a role assigned directly instead of through the team, custom role with unexpected allResources=true.
4. Knowledge check
Q1
A user is reported as “sees no resources.” Their scope is []. The right diagnosis:
A. A bug somewhere in the way the scope is computed for them
B. The user has no team assignment yet (or team has no resources)
C. Their assigned role is quite simply the wrong one for them
D. Their cloud account has been disconnected from the org
Show answer
Correct: B. Make sure the team has resources or groups assigned to it; within about 30 seconds they will see data. Role is unrelated: the role defines actions, not visibility.
Q2
A user has scope = nil. What does that mean?
A. They are restricted, so they can see no resources listed
B. It is a bug somewhere in the scope resolver code
C. Their resources are being filtered out somewhere else
D. No filter applied; the user sees every resource in the org
Show answer
Correct: D. nil means no scope filter. Admins have it, and so does anyone whose role was assigned directly rather than through a team. Verify the grant is intentional: nil over-grants if assigned by mistake.
Q3
A new user is invited, role assigned, but no team yet. Their initial scope state:
A. [] (empty list: sees nothing)
B. They cannot log in at all yet
C. nil, so they see everything
D. They default to the Admin role
Show answer
Correct: C. A role assigned directly to the user grants its policies org-wide, so the scope is nil and they see everything. [] only arises for a member of a team with nothing assigned. To keep a new hire scoped, give them their role through a team instead of directly.
5. Apply
Derive any user’s scope state from their direct role in Settings → Organization and their teams under Teams: direct role means nil, team-only means the team’s list or []. For ongoing hygiene, run a quarterly report of users in nil state (over-permissioned) and [] state (under-onboarded). Both should be small intentional populations.
Related lessons
- L4: Team-scoped roles
- L6: Frontend gating with usePermission (next)
- T4.M4.3.L1: Audit log: what gets recorded
- T4.M4.4.L3: Cross-account discovery