Scoped permissions β
A role tells GCM what a user is allowed to do. A scope tells it where they're allowed to do it. Without a scope, a Leader can see every member in your workspace; with a scope set to East Branch β Anderson Center, that same Leader sees only members assigned to Anderson Center and any cells underneath it. The role's verbs are unchanged β they still cover view, create, edit, mark, and send β but the rows they apply to shrink to the slice of the org tree the user has been assigned.
This is how most churches build out shepherds, campus pastors, zone leaders, and any other role that should run a piece of the organisation without seeing the whole.

Assigning a unit to a user β
Open the user's row in Users & Roles, click the Units button, and pick one or more org units from the picker. The save writes to user_unit_assignments_v2 with the user's id, the org unit's id, and the organization id. Unlike role assignments β which are typically a single chip per user β a user can be assigned to multiple units (a regional overseer might cover three branches), and the scopes union.
Leave the picker empty for org-wide access. An empty assignment means no scope filter applies; the user sees everything the role permits. This is the right default for org admins, lead pastors, and the executive admin.
TIP
The Units button only appears on users whose roles include unit-scopable resources. A Viewer with only dashboard.view doesn't get a Units button β there's nothing for the scope to filter.
How the filter is computed β
When a scoped user logs in, GCM computes the set of org unit IDs they can see. The work is done by user_visible_unit_ids(org_id), a SECURITY DEFINER PL/pgSQL function in the database:
- It looks up every row in
user_unit_assignments_v2for that user in that organization. - For each assigned unit, it walks down the tree via a recursive CTE on
org_units.parent_unit_id, collecting every descendant. - The union is returned as a
uuid[]β the user's full visible set.
A shepherd assigned to Anderson Center (a child of East Branch) sees:
- Anderson Center itself.
- Every cell under Anderson Center (e.g. Anderson East Cell, Anderson West Cell).
- New cells added under Anderson Center later β automatically, because the recursion runs at query time.
They do not see:
- East Branch itself (the parent).
- Wilson Center or any other sibling under East Branch.
- Anything under West Branch.
A platform admin gets NULL from this function, which RLS treats as no filter β they see everything across every tenant.
Where the filter runs β
The scope is enforced at the database level by restrictive RLS policies. Every scopable table (members, attendances, member_unit_assignments, and more) carries a unit_scope_read policy that says: the row must belong to the current org, AND its org_unit_id must be in the user's visible set, OR the visible set must be NULL.
Because the policy is restrictive, it intersects with the permission check. A Leader with members.view and a scope of Anderson Center sees only Anderson Center members β both conditions must pass. The permission alone won't show extra rows; the scope alone won't let them view if the role lacks members.view.
The same restrictive policy applies to writes. A scoped Leader can't edit a member outside their unit, even if the row id is forged in the request. The WITH CHECK clause re-runs the visibility filter for INSERT and UPDATE.
Performance: the cached wrapper β
Calling user_visible_unit_ids() per row would be slow β the recursive CTE would re-expand for every member scanned. Postgres can't always hoist it because passing a column reference defeats the planner's invariance check.
To fix this, GCM uses a parameterless wrapper, current_user_visible_unit_ids(), that caches the result in a transaction-scoped GUC (app.uv_units). The first call expands the tree; every subsequent call within the same HTTP request reads the cached uuid[] string and returns instantly. This took member list queries from 4.8 seconds down to under 200ms on a 1,400-member workspace.
You don't have to think about this β every RLS policy uses the wrapper, and every page load gets the cached value for free.
Scoping for write vs. read β
Scope applies to every verb the role grants on a scopable resource. A Leader with a scope of Anderson Center:
- Sees only Anderson Center members and attendances.
- Can edit only Anderson Center members.
- Can mark attendance only for Anderson Center events.
- Can record donations only for Anderson Center members (the donation's member is checked against their visible set).
There is no way to grant read on the whole org and write on a slice β the scope is uniform across the role's verbs. If you need that split, create two roles (one read-only, org-wide; one read-write, scoped) and assign both to the user.
What's not scopable β
Some resources are inherently org-wide and ignore the unit filter:
- Billing β there's no such thing as a unit-scoped invoice; a workspace has one subscription.
- Settings, channels, modules β these are workspace-level configurations.
- The role catalogue itself β roles are workspace-wide.
- Funds β a giving fund applies to the whole org, not a single unit.
If a permission gates one of these resources (billing.manage, config.manage, users.manage, giving.manage), the scope is ignored when the check runs. A Leader scoped to Anderson Center who somehow also holds billing.manage would see the whole billing page β but you shouldn't grant those keys to a scoped user in the first place.
Removing a scope β
Open the Units dialog and clear the picker. The rows in user_unit_assignments_v2 are deleted; the user is back to org-wide access (subject to whatever their role still permits). The change takes effect on their next page load.
Suspending a user does not clear their unit assignments β when you reactivate them, they pick up exactly where they left off.
Next β
- Default roles β what the seeded roles include before you scope them.
- Granting permissions β pair scope with the right verbs.
- Audit log β see who scoped whom.
- Org structure β how the tree is built in the first place.