How We Built Inherited File Permissions Without Storing Effective ACLs Everywhere

How We Built Inherited File Permissions Without Storing Effective ACLs Everywhere
Photo by Growtika / Unsplash

A practical walkthrough of hierarchical folder permissions, restriction-only inheritance, multi-principal evaluation, and explainable authorization — built with Spring WebFlux and React.


The problem

Most people understand file permissions intuitively:

If I can read Finance, I should be able to read what’s inside it — unless someone deliberately locked a subfolder down.

That sounds simple until you try to implement it.

Naive approaches fall into two traps:

  1. Store effective permissions on every file
    Then every ACL change forces a cascade update across thousands (or millions) of descendants. Group membership changes make it worse.

  2. Recompute from scratch with recursive parent walks
    Every authorization check becomes a chain of database round-trips. Latency and load explode under concurrency.

We wanted a third path: store only explicit ACL changes, calculate effective permissions on demand, and make the result explainable in the UI so developers and admins can see why a user has (or lost) DELETE.

This post describes the model and algorithm we implemented in a full-stack proof of concept:

  • Backend: Spring Boot 4 + WebFlux + Reactive MongoDB
  • Frontend: React + Ant Design + Tailwind
  • Auth for the demo: X-User-Id header with user switching

The permission model in one sentence

Effective permissions flow from parent to child; a child may restrict what it inherited, but must never broaden it.

Permissions used:

READ · WRITE · DELETE · SHARE

ACL modes:

Mode Meaning
ALLOW Grants a permission set (usually the first grant on a principal chain)
RESTRICT effective = inherited ∩ restriction

No explicit DENY in v1. Empty RESTRICT (permissions: []) means: clear all permissions for that subject on this branch.


A concrete example

Hierarchy:

Root
 └── Finance
      └── Payroll
           └── salaries-2026.xlsx

ACLs for Alice:

Node Mode Permissions
Root ALLOW READ, WRITE, DELETE, SHARE
Finance RESTRICT READ, WRITE, DELETE
Payroll RESTRICT READ, WRITE
salaries-2026.xlsx (none) inherits

Result on the file:

Alice → salaries-2026.xlsx
  READ  ✓
  WRITE ✓
  DELETE ✕  (removed at Payroll)
  SHARE  ✕  (removed at Finance)

That is the entire product story in four rows of ACL data.


What we store (and what we don’t)

Stored

  • Nodes — folders/files with parentId, ancestors[], depth, permissionBoundary, permissionVersion
  • ACL entries — only where permissions change
  • Users / groups — users reference groupIds

Not stored

  • Effective permissions per user per file
  • “Inherited copy” of parent ACLs on every descendant

Why? Effective rights depend on who is asking. Alice and Bob looking at the same Payroll folder can get different answers. Caching a single effective set on the node would be wrong.

Permission boundaries look like this:

Root        ← ACL
Finance     ← ACL
Reports     ← no ACL (inherits Finance)
2026        ← no ACL
report.pdf  ← no ACL

Only Root → Finance are boundaries for Alice’s chain. Everything under Finance inherits until the next explicit ACL.


Users, groups, and principals

An ACL targets either a USER or a GROUP.

When evaluating Alice, we expand:

USER:user-alice
GROUP:group-employees
GROUP:group-finance
GROUP:group-managers

Each principal is evaluated independently along the path. The final answer is the union of those results.

That matters:

  • Employees might only have READ at Root
  • Finance Team might get READ, WRITE at Finance
  • Bob, in both groups, ends up with READ ∪ WRITE under Finance

If we merged all ACLs into one running set too early, the Employees READ at Root could incorrectly cap Bob’s Finance Team grant.


Materialized paths instead of recursive lookups

Every node stores:

parentId
ancestors[]
depth

Example:

Payroll.ancestors = [Root, Finance]

Authorization builds the evaluation path in one shot:

path = ancestors + [target]
     = Root → Finance → Payroll → salaries-2026.xlsx

No $graphLookup. No “fetch parent, then parent’s parent” loop on the hot path.

Subtree moves are more expensive (rewrite descendants’ ancestors), but reads stay cheap — the right trade-off for a permission-heavy system.


The ACL calculation algorithm

Here is the algorithm, step by step, as implemented in PermissionService — walked against dummy MongoDB documents.

Request we will evaluate:

GET /api/v1/nodes/file-salary-2026/permissions
X-User-Id: user-alice
X-Tenant-Id: tenant-1

Question: What is Alice’s effective permission set on salaries-2026.xlsx?


Dummy data (MongoDB collections)

users

_id tenantId username displayName email groupIds admin active
user-alice tenant-1 alice Alice Smith alice@example.com group-employees, group-finance, group-managers true true
user-bob tenant-1 bob Bob Jones bob@example.com group-employees, group-finance false true
user-charlie tenant-1 charlie Charlie Brown charlie@example.com group-employees, group-hr false true

groups

_id tenantId name description
group-employees tenant-1 Employees All employees
group-finance tenant-1 Finance Team Finance department
group-hr tenant-1 HR Team Human resources
group-managers tenant-1 Managers Management

nodes (subset used for this evaluation)

_id type name parentId ancestors depth permissionBoundary permissionVersion
folder-root FOLDER Root null [] 0 true 1
folder-finance FOLDER Finance folder-root [folder-root] 1 true 1
folder-payroll FOLDER Payroll folder-finance [folder-root, folder-finance] 2 true 1
file-salary-2026 FILE salaries-2026.xlsx folder-payroll [folder-root, folder-finance, folder-payroll] 3 false 1

acl_entries (subset relevant to Alice / this path)

_id nodeId subjectType subjectId permissions mode
acl-root-alice folder-root USER user-alice READ, WRITE, DELETE, SHARE ALLOW
acl-root-employees folder-root GROUP group-employees READ ALLOW
acl-finance-alice folder-finance USER user-alice READ, WRITE, DELETE RESTRICT
acl-finance-employees folder-finance GROUP group-employees (empty) RESTRICT
acl-finance-group folder-finance GROUP group-finance READ, WRITE ALLOW
acl-payroll-alice folder-payroll USER user-alice READ, WRITE RESTRICT

(No ACL on file-salary-2026 — it inherits.)


Step 1 — Load the target node

Query mapping

nodes.find({ tenantId: "tenant-1", _id: "file-salary-2026" })

Output (from dummy nodes)

Field Value
_id file-salary-2026
name salaries-2026.xlsx
ancestors [folder-root, folder-finance, folder-payroll]
permissionVersion 1

Cache key candidate

tenant-1:user-alice:file-salary-2026:1

Assume cache miss → continue evaluation.


Step 2 — Resolve principals

Input: users row user-alice

Field Value
_id user-alice
groupIds group-employees, group-finance, group-managers

Principal expansion

principals = [
  USER:user-alice,
  GROUP:group-employees,
  GROUP:group-finance,
  GROUP:group-managers
]

Running map initialized

Principal key running
USER:user-alice
GROUP:group-employees
GROUP:group-finance
GROUP:group-managers

Step 3 — Build the evaluation path

Mapping from target node

path = ancestors + [target]
     = [folder-root, folder-finance, folder-payroll] + [file-salary-2026]

Output path (ordered root → leaf)

# nodeId name
1 folder-root Root
2 folder-finance Finance
3 folder-payroll Payroll
4 file-salary-2026 salaries-2026.xlsx

Step 4 — Prefetch ACLs for path nodes

Query mapping

acl_entries.find({
  tenantId: "tenant-1",
  nodeId: { $in: [
    "folder-root",
    "folder-finance",
    "folder-payroll",
    "file-salary-2026"
  ]}
})

Grouped output (aclByNode)

nodeId Matching ACL ids
folder-root acl-root-alice, acl-root-employees
folder-finance acl-finance-alice, acl-finance-employees, acl-finance-group
folder-payroll acl-payroll-alice
file-salary-2026 (none)

Step 5 — Walk the path per principal

At each node, for each principal:

  1. Filter ACLs to that principal
  2. Split into ALLOW / RESTRICT (union within mode)
  3. Apply first-grant or intersect rules

Node 1 — folder-root (Root)

ACLs at this node vs Alice’s principals

Principal Matching ACL Mode Permissions
USER:user-alice acl-root-alice ALLOW R W D S
GROUP:group-employees acl-root-employees ALLOW R
GROUP:group-finance inherit
GROUP:group-managers inherit

Per-principal evaluation

Principal before Rule after
USER:user-alice first ALLOW → set {R,W,D,S}
GROUP:group-employees first ALLOW → set {R}
GROUP:group-finance no ACL → inherit
GROUP:group-managers no ACL → inherit

Combined after Root (UNION)

{R,W,D,S} ∪ {R} ∪ ∅ ∪ ∅ = {R, W, D, S}

Step snapshot

Field Value
localGrant R W D S
localMode ALLOW
effectiveAfter R W D S

Node 2 — folder-finance (Finance)

ACLs at this node vs Alice’s principals

Principal Matching ACL Mode Permissions
USER:user-alice acl-finance-alice RESTRICT R W D
GROUP:group-employees acl-finance-employees RESTRICT [] (empty)
GROUP:group-finance acl-finance-group ALLOW R W
GROUP:group-managers inherit

Per-principal evaluation

Principal before Rule Computation after
USER:user-alice {R,W,D,S} RESTRICT ∩ {R,W,D,S} ∩ {R,W,D} {R,W,D}
GROUP:group-employees {R} RESTRICT ∩ empty {R} ∩ ∅
GROUP:group-finance first ALLOW → set ALLOW {R,W} {R,W}
GROUP:group-managers no ACL inherit

Combined after Finance

{R,W,D} ∪ ∅ ∪ {R,W} ∪ ∅ = {R, W, D}

SHARE is gone (Alice USER restricted). Employees principal cleared. Finance group still contributes R+W.

Step snapshot

Field Value
effectiveAfter R W D

Node 3 — folder-payroll (Payroll)

ACLs at this node vs Alice’s principals

Principal Matching ACL Mode Permissions
USER:user-alice acl-payroll-alice RESTRICT R W
others inherit

Per-principal evaluation

Principal before Rule Computation after
USER:user-alice {R,W,D} RESTRICT ∩ {R,W,D} ∩ {R,W} {R,W}
GROUP:group-employees no ACL inherit
GROUP:group-finance {R,W} no ACL inherit {R,W}
GROUP:group-managers no ACL inherit

Combined after Payroll

{R,W} ∪ ∅ ∪ {R,W} ∪ ∅ = {R, W}

DELETE is gone (Alice USER restricted at Payroll).

Step snapshot

Field Value
effectiveAfter R W

Node 4 — file-salary-2026 (salaries-2026.xlsx)

ACLs at this node: none for any principal.

Per-principal evaluation

Principal before Rule after
USER:user-alice {R,W} inherit {R,W}
GROUP:group-employees inherit
GROUP:group-finance {R,W} inherit {R,W}
GROUP:group-managers inherit

Combined after file

{R,W} ∪ ∅ ∪ {R,W} ∪ ∅ = {R, W}

Step snapshot

Field Value
localGrant
localMode null
effectiveAfter R W

Step 6 — Union all principals (final)

Final running map

Principal Final set
USER:user-alice {READ, WRITE}
GROUP:group-employees
GROUP:group-finance {READ, WRITE}
GROUP:group-managers

Effective permission calculation

effective(Alice, file-salary-2026)
  = {R,W} ∪ ∅ ∪ {R,W} ∪ ∅
  = { READ, WRITE }

API-shaped output

{
  "nodeId": "file-salary-2026",
  "effectivePermissions": ["READ", "WRITE"],
  "evaluationPath": [
    { "nodeId": "folder-root",    "nodeName": "Root",               "effectiveAfter": ["READ","WRITE","DELETE","SHARE"] },
    { "nodeId": "folder-finance", "nodeName": "Finance",            "effectiveAfter": ["READ","WRITE","DELETE"] },
    { "nodeId": "folder-payroll", "nodeName": "Payroll",            "effectiveAfter": ["READ","WRITE"] },
    { "nodeId": "file-salary-2026","nodeName": "salaries-2026.xlsx","effectiveAfter": ["READ","WRITE"] }
  ]
}

Human reading

Alice → salaries-2026.xlsx
  READ  ✓
  WRITE ✓
  DELETE ✕  (removed at Payroll on USER:user-alice)
  SHARE  ✕  (removed at Finance on USER:user-alice)

Step 7 — Cache

Put

key   = tenant-1:user-alice:file-salary-2026:1
value = { READ, WRITE }

Next identical request hits cache until an ACL change bumps permissionVersion on the boundary (and descendants), which changes the key and forces recompute.


Algorithm rules (quick reference)

if running is empty:
    if ALLOW exists:   running = ALLOW
    else if RESTRICT:  running = RESTRICT
else:
    if RESTRICT exists: running = running ∩ RESTRICT
    else if ALLOW:      running = running ∩ ALLOW
else:
    inherit (no change)

effective(user, node) = ⋃ running[principal]

Empty RESTRICT (permissions: []) → intersect with → that principal is cleared on the branch (see Employees at Finance above).


Diagram of the flow

HTTP request (X-User-Id)
        │
        ▼
 CurrentUserProvider → UserPrincipal
        │
        ▼
 AuthorizationService.canAccess(READ|WRITE|DELETE|SHARE)
        │
        ▼
 PermissionService.getEffectivePermissions
        │
        ├── cache hit? → return
        │
        ├── resolve principals
        ├── load path from ancestors[]
        ├── load ACLs for path
        ├── per-principal walk (ALLOW / RESTRICT / inherit)
        ├── UNION
        └── cache put
        │
        ▼
 allow or 403

Controllers never embed permission logic. There is one calculation path.


Making permissions explainable

A boolean canDelete is not enough for a demo — or for debugging production ACLs.

We return:

  • Effective set — what the user has now
  • Sources — which node/subject/mode contributed each permission
  • Evaluation path — node-by-node effective snapshot

The UI renders this as:

✓ READ     inherited from Root
✓ WRITE    restricted at Payroll
✕ DELETE   removed at Payroll
✕ SHARE    removed at Finance

Root      R W D S
  ↓
Finance   R W D
  ↓
Payroll   R W
  ↓
file      R W

When you switch users in the header, the same file tells a different story. That is the point of the product demo.


Frontend: permission-aware browsing

The left folder tree is not a static org chart. It calls a tree API that filters by READ. Branches you cannot see disappear.

The file list only shows children you can read.
The right-hand accordion shows effective permissions + ACL entries for the selected node.
Admins can create users/groups and assign memberships; ACL edits require WRITE on the node.

Uploaded files land on a configurable local path:

filesystem:
  storage:
    local-path: ./data/files

Metadata stays in MongoDB; bytes stay on disk under {tenantId}/{nodeId}/{filename}.


Design choices worth calling out

Why restrictions instead of arbitrary overrides?

If a child could invent DELETE without the parent having it, “folder security” becomes theater. Restriction-only inheritance matches how people think about shared drives: lock down as you go deeper, don’t escalate.

Why not DENY yet?

RESTRICT with an empty set covers “this subject gets nothing here.” Explicit DENY (override even a wider group grant) is a future mode that would apply as a final mask and must also appear in the explanation path.

Why versioned cache keys?

Invalidating “everything under Finance” in Redis is painful. Versioning the node (and descendants) means old keys simply stop matching. Local cache today; Redis tomorrow behind the same interface.

Why reactive end-to-end?

Authorization fans out into many small I/O ops (node, ACLs, cache). WebFlux + Reactor keeps that composition explicit without blocking threads on every check. No .block() on the request path.


Lessons from building the POC

  1. Explainability is a feature, not a debug dump. If you cannot show the path, admins will not trust the model.
  2. Principals must be separate until the end. Early merging of user + group ACLs creates subtle escalation bugs.
  3. Store boundaries, compute leaves. Millions of files do not need millions of ACL documents.
  4. UI and API must agree. Sorting folders first, filtering trees by READ, and allowing empty RESTRICT all had to land in both layers.
  5. Demo auth is fine if the principal abstraction is real. Swap X-User-Id for JWT/OIDC later without rewriting PermissionService.

Closing

Hierarchical permissions are less about clever data structures and more about a crisp rule:

Parent effective
      │
      ▼
Child restriction   →   INTERSECT
      │
      ▼
Descendant inherits

Add multi-principal union, materialized paths, and versioned caching, and you get a system that is fast enough to query, cheap enough to update, and clear enough to debug.

If you are designing a drive, DMS, or multi-tenant workspace, start with that rule — then make the evaluation path visible. The algorithm only earns trust when the UI can show its work.


Stack reference

Layer Choice
API Spring Boot 4, WebFlux, Reactive MongoDB
UI React, TypeScript, Vite, Ant Design, Tailwind
Store MongoDB collections: nodes, acl_entries, users, groups
Files Local filesystem path from filesystem.storage.local-path

Built as a runnable proof of concept for inherited, restriction-only, explainable file permissions.

Read more