ROOTVIELROOTVIEL
← Academy

Module 7 · Labs & experiments

Lab — verify scope attenuation

A guided, self-contained lab on synthetic data. You model a capability as a set of operations plus a path prefix, define a subset test, and enforce it at each hop so every child capability is contained in its parent. Then you craft a widening attempt and confirm the verifier rejects it. The lesson: signatures prove who delegated; attenuation checks prove the grant did not grow.

Learning objective

Write a verifier that enforces child ⊆ parent at every delegation hop, then construct an attempted privilege-widening delegation and watch the verifier reject it — making the "a delegate can only narrow, never grow, authority" rule concrete.

Track
Engineering
Module
7 · Labs & experiments
Prerequisites
Delegation chains · Scope attenuation · Node.js 16+ (built-in crypto)

Lab goal

This teaching exercise, offline and synthetic, isolates one rule from the delegation-chain lab: each hop may only keep or shrink authority. A signed link can still be honestly signed while trying to hand out more than its holder had — so a separate containment check (child ⊆ parent) is required. You will implement that check and prove it rejects widening.

Note

Attenuation is about bounding what was granted, not about whether exercising the grant is correct. A perfectly attenuated capability can still authorize a bad decision.

The containment model

Here a capability has two dimensions: a set of operations (read, write, delete) and a path prefix the holder may act under. Child ⊆ parent means the child ops are a subset of the parent ops AND the child path prefix is at least as specific as the parent (the parent prefix is a prefix of the child prefix). Narrowing either dimension is allowed; broadening either is forbidden.

Ops subset
Every operation in the child capability must also appear in the parent. The child may drop operations, never add them.
Path containment
The child may only reach into a sub-path of the parent. The parent prefix must be a prefix of the child prefix (child is equal or more specific).
Attenuation
The invariant that each delegation keeps the authority within its parent; the chain-wide property is that authority is monotonically non-increasing from root to leaf.

Step by step

  1. 1Represent a capability as { ops: [...], path: "a/b/c" }.
  2. 2Write subsetOf(child, parent): true only if child ops are contained in parent ops and the parent path is a prefix of the child path.
  3. 3Walk a chain of capabilities and require subsetOf(child, parent) at every adjacent pair.
  4. 4Validate an honest attenuating chain: it should pass.
  5. 5Insert a widening hop (adds an op, or escapes to a broader path) and confirm the verifier rejects the chain and reports where.
lab3.js — synthetic attenuation check
// Capability: { ops: string[], path: string } (path uses '/' segments) function pathContains(parentPath, childPath) { // parent must be a prefix of child at segment boundaries (child is narrower/equal) const p = parentPath.split('/'), c = childPath.split('/'); if (c.length < p.length) return false; return p.every((seg, i) => seg === c[i]); } function subsetOf(child, parent) { const opsOk = child.ops.every((op) => parent.ops.includes(op)); const pathOk = pathContains(parent.path, child.path); return opsOk && pathOk; } function checkChain(caps) { for (let i = 1; i < caps.length; i++) { if (!subsetOf(caps[i], caps[i - 1])) { return { ok: false, brokeAtHop: i }; // child i is not contained in parent i-1 } } return { ok: true }; } // Honest: each hop drops ops and/or narrows the path. const honest = [ { ops: ['read', 'write', 'delete'], path: 'vault/lab' }, { ops: ['read', 'write'], path: 'vault/lab/reports' }, { ops: ['read'], path: 'vault/lab/reports/q3' }, ]; // Widening attempt: hop 2 re-adds 'write' that hop 1 had dropped. const widening = [ { ops: ['read', 'write', 'delete'], path: 'vault/lab' }, { ops: ['read'], path: 'vault/lab/reports' }, { ops: ['read', 'write'], path: 'vault/lab/reports/q3' }, ]; console.log('honest :', checkChain(honest)); console.log('widening :', checkChain(widening));

Expected result

expected console output
honest : { ok: true } widening : { ok: false, brokeAtHop: 2 }

The honest chain passes because each capability stays inside its parent on both dimensions. The widening chain fails at hop 2: that child re-adds the write operation its parent had already dropped, so child ops are no longer a subset of parent ops. The verifier does not care that each link might be correctly signed — attenuation is a separate, additional gate.

Common mistakes

Note

Comparing paths with plain string startsWith lets "vault/lab" match "vault/labs-public". Split on the separator and compare whole segments, or a widening attempt can slip through a prefix trick.

  • ▸Checking only signatures and assuming narrowing. A signed link can honestly request more than the signer holds; you must verify containment independently.
  • ▸Comparing a child against the root instead of against its immediate parent — attenuation must hold at every hop so the property composes along the whole chain.
  • ▸Allowing unknown operations through because they are "not listed as forbidden". Use subset-of-parent (allow-list), never a deny-list.
  • ▸Confusing attenuation with correctness: a tightly bounded capability can still authorize an action that is wrong for the situation.

Exercise

Note

Add a third dimension to the capability: a numeric rate limit maxCalls. Define what child ⊆ parent means for it, update subsetOf, and give one widening example that your new rule must reject.

Expected direction: For a rate limit, child ⊆ parent means the child maxCalls must be less than or equal to the parent maxCalls (a delegate may only be granted the same or fewer calls). Update subsetOf to also require child.maxCalls <= parent.maxCalls. A widening example to reject: parent maxCalls 100, child maxCalls 1000 — the child tries to grant itself more calls than its parent had, which must fail.

Check your understanding

What does child ⊆ parent require in this two-dimension model?
The child operation set must be contained in the parent operation set, and the child path must sit inside the parent path (parent prefix is a segment-wise prefix of the child). Either dimension may narrow; neither may grow.
Why is a valid signature on a delegation link not enough?
A link can be honestly signed yet request more authority than the signer holds; attenuation is a separate containment check that the grant did not widen.
Why compare each hop to its immediate parent rather than to the root?
So the non-increasing property composes at every step; checking only against the root could miss a mid-chain hop that widened relative to what it actually received.

References

  1. [1]Hardt, Zeilenga et al. — macaroons (contextual caveats / attenuation), USENIX
  2. [2]NIST SP 800-162 — Guide to Attribute Based Access Control (ABAC)
  3. [3]Saltzer & Schroeder — The Protection of Information in Computer Systems (least privilege)

References point to public, well-known technical sources for further study.

Related articles