Org Hierarchy & Permissions
How to model accounts, teams, roles, content ownership, and shared resources.
01The Org Hierarchy
An account is the billing and tenancy root — one per customer. Below it, any number of
nested nodes representing teams, offices, regions, or divisions.
Every node is a full org. It can own content, carry config, and scope permissions. There is no
distinction between "grouping node" and "data node."
The full tree lives on a single account document. Node IDs are org IDs — existing
orgId foreign keys across the product don't change.
What the account document looks like
{
"id": "acct-jll",
"name": "JLL",
"rootNodeId": "acct-jll",
"nodes": {
"acct-jll": { type: "account", parentId: null, childIds: ["denver","nyc","sf"] },
"denver": { type: "team", parentId: "acct-jll", childIds: ["denver-is","denver-mtg"] },
"denver-is": { type: "team", parentId: "denver", childIds: [] },
"denver-mtg": { type: "team", parentId: "denver", childIds: [] },
"nyc": { type: "team", parentId: "acct-jll", childIds: ["nyc-is"] },
"nyc-is": { type: "team", parentId: "nyc", childIds: [] },
"sf": { type: "team", parentId: "acct-jll", childIds: [] }
}
}
Every customer shape is just a tree depth
| Scenario |
Tree shape |
| Enterprise with regions |
account → regions → teams (JLL above) |
| Mid-market, few teams |
account → 2–3 leaf teams |
| SMB, single team |
account → one team |
Structural operations — cheap metadata edits
| Operation |
What happens |
Artifacts move? |
| New region added later |
Insert a node, re-parent existing children under it |
No |
| Region dissolved |
Delete the node, re-parent its children up |
No |
| Two existing orgs become one account |
Create account doc, attach both as children |
No |
| Re-parent a team under a different region |
One atomic write to the account doc |
No |
The only operation that moves artifacts is literal merges or splits — combining two
nodes into one shared data pool, or splitting a team into two. That must be performed async, since it
involves updating the owner node id on all artifacts/assets involved.
02Users, Roles & Auth
Users belong to an account via
accountId. Canonical role assignments live on user documents, keyed by node ID.
The cast
// Sarah — corporate admin for all of JLL
{ accountId: "acct-jll", roleAssignments: { "acct-jll": ["account_admin"] } }
// Mike — Denver regional manager
{ accountId: "acct-jll", roleAssignments: { "denver": ["admin"] } }
// Lisa — Denver IS analyst
{ accountId: "acct-jll", roleAssignments: { "denver-is": ["user"] } }
// Tom — cross-branch: viewer on Denver, user on SF
{ accountId: "acct-jll", roleAssignments: { "denver": ["viewer"], "sf": ["user"] } }
Role definitions
Roles are global constants. Each maps to a flat array of action verbs.
| Role |
Actions |
account_admin |
billing:manage, artifact:read, artifact:write, library:manage, config:manage, user:add,
user:remove, user:edit |
admin |
artifact:read, artifact:write, library:manage, config:manage, user:add, user:remove, user:edit
|
user |
artifact:read, artifact:write |
viewer |
artifact:read |
As we add products, we add action verbs (deal:read,
underwriting:write). The schema shape doesn't change.
Roles inherit downward. A role on a node grants permissions on that node and everything
below it.
Auth algorithm
Walk from the target node to the root. At each stop, check if the user has a role. Union all matched
action arrays. Allow if the requested action is present.
function can(user, action, targetOrgId, accountTree) {
const path = getPathToRoot(accountTree, targetOrgId);
for (const nodeId of path) {
for (const role of user.roleAssignments[nodeId] ?? []) {
if (roleDefinitions[role].includes(action)) return true;
}
}
return false;
}
Cost: two reads — the user doc and the account doc. Both are already hot in every
request. No per-artifact ACL lookup.
Authorization examples
| # |
Who |
Action |
Target |
Path walked |
Matched role |
Result |
| 1 |
Sarah |
artifact:read |
Any deck anywhere |
→ … → acct-jll |
account_admin on acct-jll |
Granted |
| 2 |
Mike |
artifact:write |
Deck in Denver IS |
denver-is → denver |
admin on denver |
Granted |
| 3 |
Mike |
artifact:write |
Deck in SF |
sf → acct-jll |
— |
Denied |
| 4 |
Lisa |
artifact:write |
Deck in Denver IS |
denver-is |
user on denver-is |
Granted |
| 5 |
Lisa |
artifact:read |
Deck in Denver MTG |
denver-mtg → denver → acct-jll |
— |
Denied |
| 6 |
Tom |
artifact:read |
Deck in Denver IS |
denver-is → denver |
viewer on denver |
Granted |
| 7 |
Tom |
artifact:write |
Deck in Denver IS |
denver-is → denver |
viewer on denver |
Denied |
| 8 |
Tom |
artifact:write |
Deck in SF |
sf |
user on sf |
Granted |
| 9 |
Mike |
user:add |
Denver MTG |
denver-mtg → denver |
admin on denver |
Granted |
| 10 |
Mike |
billing:manage |
JLL account |
acct-jll |
— (admin ≠ account_admin) |
Denied |
03Content Keying & List Queries
Every artifact has orgId (the owning node) and accountId (the billing root).
Access is derived entirely from roles on the owning node's ancestry path. No ACLs are stored per
artifact.
Artifacts live on nodes
Both leaf and non-leaf nodes can own content. For ex, if an artifact naturally belongs to the Denver
region (not
specifically
IS or MTG), it lives there.
Example artifacts
| Artifact |
orgId |
Why |
| Denver IS client deck |
denver-is |
Created by Lisa, belongs to her team |
| Denver regional summary |
denver |
Created by Mike, scoped to the region — not either leaf team |
| JLL corporate overview |
acct-jll |
Created by Sarah, account-wide |
Deriving the accessible list for each user
Start from the user's role assignments. Expand each downward through all descendants. Collect the full
set of accessible node IDs. Fan out the query.
| User |
Role assignments |
Accessible nodes |
Firestore query |
| Sarah |
account_admin on acct-jll |
acct-jll, denver, denver-is, denver-mtg, nyc, nyc-is, sf |
WHERE orgId IN [all 7] |
| Mike |
admin on denver |
denver, denver-is, denver-mtg |
WHERE orgId IN [3 nodes] |
| Lisa |
user on denver-is |
denver-is |
WHERE orgId IN ["denver-is"] |
| Tom |
viewer on denver, user on sf |
denver, denver-is, denver-mtg, sf |
WHERE orgId IN [4 nodes] |
What each user sees — concrete results
Given the three artifacts above
| User |
Corporate overview orgId: acct-jll |
Denver summary orgId:
denver |
Denver IS deck orgId:
denver-is |
| Sarah |
Read/Write |
Read/Write |
Read/Write |
| Mike |
Not visible |
Read/Write |
Read/Write |
| Lisa |
Not visible |
Not visible |
Read/Write |
| Tom |
Not visible |
Read only |
Read only |
Note that Mike cannot see the corporate overview — it lives on acct-jll and Mike only has a
role on denver. Tom can see Denver-branch decks but only as read-only (viewer), while he
can read/write in SF.
Workspace switcher UX
- Users with access to multiple nodes get a switcher.
- The active context determines where new artifacts are created.
- Admins can toggle between "just this team" (filter by active node) and "everything I can see"
(fan out across all accessible nodes).
- The query shape remains the same — only the input set of node IDs changes.
Why not account-scoped artifacts?
The alternative: store all artifacts at the account level and filter by permissions.
✓ Node-owned (this model)
Create a deck: write with orgId.
List Mike's
decks:
WHERE orgId IN ["denver", "denver-is", "denver-mtg"]
3
indexed lookups, composable.
Re-parent Denver: edit the tree. Mike's access adjusts automatically. Decks
are untouched.
"Who can see this deck?" Walk from owning node to root, and gather all users who
have roles on the path.
✗ Account-scoped
Create a deck: write with accountId. Also decide and persist who
can see it — ACL, tags, or owner field.
List Mike's decks:
WHERE accountId = "jll" returns everything,
then filter in-app by permission intersection. Or maintain a per-artifact ACL index.
Re-parent Denver: same tree edit, but if artifacts had ACLs referencing
"Denver", those may need updating too.
"Who can see this deck?" Read the artifact's ACL — could be a mix of direct
shares, inherited rules, exceptions.
The indirection is unavoidable
Without per-artifact ACLs or per-user artifact lists, something has to mediate access. There are
two options:
- Group users, attach access state to each artifact — ACLs, sharing lists, tags.
- Group artifacts by owning node, attach permissions to users on those nodes.
Account-scoping doesn't remove this choice. It defaults to option 1, where the complexity scales
with artifact count instead of being handled once in the org structure.
04Library Assets & Config
- Artifacts are created at the team level — a deck belongs to the team that made it.
Access comes from above: a Denver admin sees Denver IS decks because their role inherits
down to where the content lives.
- Library assets work the other way around — a corporate color palette or regional
boilerplate slides are defined higher up and published downward to the teams that need them.
- Because they flow in opposite directions, library assets need their own visibility
model — decoupling "who can use this resource" from "who can access this node's
content."
Ownership vs. Visibility
Each library asset has:
orgId — who owns it (and can edit it)
visibility — who can use it: local, descendants, or
account
Publishing more broadly changes availability, not ownership. Three levels keep it simple — no need for
arbitrary sharing lists.
Library visibility examples
| # |
Asset |
Owner |
Visibility |
Who can use it |
| 1 |
Denver brand kit |
denver |
local |
Denver only. Not Denver IS, not Denver MTG. |
| 2 |
Denver brand kit |
denver |
descendants |
Denver, Denver IS, Denver MTG. |
| 3 |
JLL disclaimer theme |
acct-jll |
account |
Every node in JLL. |
| 4 |
Denver MTG custom palette |
denver-mtg |
local |
Denver MTG only. Mike (admin on Denver) can manage it via inherited role, but a Denver
IS user doesn't see it. |
| 5 |
Denver brand kit (after Denver IS is re-parented under NYC) |
denver |
descendants |
Denver and Denver MTG. Denver IS no longer sees it — visibility follows the tree. |
Resolving available library assets
User working in Denver IS — what library assets are available?
- Assets owned by Denver IS (any visibility) → local stuff
- Assets owned by Denver with
descendants or account →
regional stuff
- Assets owned by JLL with
account → corporate stuff
- NOT assets from NYC, SF, or Denver MTG with
local or
descendants — wrong branch
Why library assets can't just be artifacts
| If we treated brand kits as regular artifacts… |
Why it doesn't work |
Denver's brand kit with orgId: "denver" |
Mike can see it (admin on Denver). But Denver IS and Denver MTG users can't — they only have
roles on their own leaf nodes. |
Give everyone artifact:read on Denver to fix it |
Now they can also read Denver's decks, which might be confidential regional summaries.
|
| Add per-artifact ACLs to share selectively |
This reintroduces the per-artifact access complexity we're trying to avoid. |
Config Resolution
Configuration lives on org node documents. Resolution: read the key on the current node. If missing, walk
up to
the root.
Config examples
| # |
Setting |
Set on |
Effect |
| 1 |
defaultThemeId: "denver-dark" |
Denver IS |
Decks created in Denver IS get that theme. Denver MTG unaffected. |
| 2 |
notifyOnSubmit: "admins" |
Denver |
Denver IS and Denver MTG inherit this, unless they override locally. |
| 3 |
notifyOnSubmit: "creator_only" |
Denver IS (overrides #2) |
Denver IS uses its own value. Denver MTG still inherits Denver's. |
| 4 |
approvalRouting: "manager_chain" |
JLL (account root) |
Every node without a local override gets this. Account-wide default. |
The org-hierarchy tree is the single organizing principle
- Roles inherit downward
- Artifacts belong to their owning node
- Assets library is published locally, downward or account-wide
- Configuration resolves upward