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


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:

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

Ownership vs. Visibility

Each library asset has:

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 visibility publishes downward or account-wide
  • Configuration resolves upward