Skip to main content
Your progress
0 of 7 complete0%
T4 / M4.1 / L5 OF 6 / Architect LEVEL / 9 min

The three-state scope model

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.


PrerequisitesM4.1.L1-L4 (policy table through team scoping)
Time9 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.

Terminal window
STATE CANONICAL VALUE SQL EQUIVALENT
──────────────────────────────────────────────────────────────────
1. nil null / undefined no WHERE clause
2. 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)

Terminal window
SCOPE: nil
SQL: 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 automations

nil 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)

Terminal window
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)

Terminal window
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:

Terminal window
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 on
context need remove existing role; no role change

The three-state model is the cleaner abstraction.

How services consume the scope

Every backend service that returns resource-scoped data follows this pattern:

Terminal window
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:

Terminal window
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 assigned

Most “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.

Terminal window
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 scope

The 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:

Terminal window
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 team

Three 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:

Terminal window
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.



Start with the bill.

Foundations takes about five hours. The first lesson is nine minutes.

Free curriculum. No paywall. 290 lessons across 7 courses, plus an optional 24-lesson beginner course. Browse every course without an account; a free account opens the lessons, saves your progress and opens the exams for three publicly verifiable credentials. Read it on the train, take the exam on a Saturday, list the credential on your résumé Monday.

5h median time to finish Foundations
free account, every course, no paywall
3 certifications, each with a public verifier
Multi-cloud automation· Production-ready in 30 min· SOC 2 · ISO 27001· 20–60% off the bill, first month· 4 platforms · 1 console· Multi-cloud automation· Production-ready in 30 min· SOC 2 · ISO 27001· 20–60% off the bill, first month· 4 platforms · 1 console·