How We Built Inherited File Permissions Without Storing Effective ACLs Everywhere
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:
-
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. -
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-Idheader 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
READat Root - Finance Team might get
READ, WRITEat Finance - Bob, in both groups, ends up with
READ ∪ WRITEunder 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:
- Filter ACLs to that principal
- Split into ALLOW / RESTRICT (union within mode)
- 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
- Explainability is a feature, not a debug dump. If you cannot show the path, admins will not trust the model.
- Principals must be separate until the end. Early merging of user + group ACLs creates subtle escalation bugs.
- Store boundaries, compute leaves. Millions of files do not need millions of ACL documents.
- UI and API must agree. Sorting folders first, filtering trees by READ, and allowing empty RESTRICT all had to land in both layers.
- Demo auth is fine if the principal abstraction is real. Swap
X-User-Idfor JWT/OIDC later without rewritingPermissionService.
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.