Lesson 06 / 21
Least Privilege Practice
Granted permission is set side by side with used permission: the unused permission ratio, how many lines the narrowed policy runs to, and how much legitimate work narrowing stops are counted. The observation window is scanned and the safe narrowing threshold is given as a number.
Published Updated
In this lesson
- Compares the granted policy against real need: extracts the unused permission entry and its ratio
- Generates usage history and separates rare paths, naming by identity the legitimate path never seen in ninety days
- Scans the observation window: how the permission cut, the wrong cut, and stopped work change as the window narrows
- Computes the safe narrowing threshold and counts the miss risk remaining at the observed window
Contents
The previous lesson changed where identity comes from; what it can do was left open. When policy documents are written, permissions are usually granted not one at a time but at family level: a single line opens an entire measurement family’s permissions, because writing a line is expensive, counting a permission is cheap. This lesson’s question is how much of those lines actually gets used. The granted permission set is set side by side with usage history, and three things are counted: unused permission ratio, how many lines the narrowed policy runs to, and how much legitimate work narrowing stops. The third is as important as the first two: a path that never showed up in history does not mean it is unnecessary.
- CI31. Eight workloads, eight resource families, and eighteen permissions belong to the
fictional regional measurement network; what is measured is the model written with
node. - CI32. The actual need list was not measured, it was chosen; each path’s average usage interval is a model decision. In reality this list is unknown — the lesson’s whole point is that it is unknown.
- CI33. The usage history was generated with a linear congruential generator written by us. The seed is visible and fixed; the output stays the same from run to run.
- CI34. A permission is either used or not used. Condition statements and resource-level narrowing are not modeled; these deepen narrowing, they do not change its direction.
- CI35. The granted policy is written at family level, the narrowed policy is written at permission level. The line count is therefore a separate measure.
- CI36. The test period is the second ninety days and comes from the same generator stream. How much legitimate work the narrowed policy stops is counted there.
Granted Permission and Actual Need
The model keeps three things separate: the policy written at family level, the permission set it opens, and the paths each workload actually needs. The third is unknown in a real system; here it is made known so that narrowing’s accuracy can be measured.
// grant.mjs — granted permission, actual need, and usage history model // Fictional regional measurement network; eight workloads, eight resource families. This model is what is measured. export const FAMILY = { measurement: ['read', 'write', 'delete'], queue: ['read', 'write', 'manage'], archive: ['read', 'write', 'delete'], invoice: ['read', 'write'], 'work-order': ['read', 'write'], log: ['read', 'write'], config: ['read', 'write'], mail: ['send'], }; export const PERMISSION = Object.entries(FAMILY).flatMap(([a, e]) => e.map((x) => `${a}-${x}`)); // The granted policy is written at family level: one line grants an entire family's permissions. export const GRANTED = { 'read-collector': ['measurement', 'queue'], 'validator': ['measurement', 'queue'], 'log-shipper': ['log', 'config'], 'billing': ['invoice', 'mail', 'archive'], 'work-order': ['work-order', 'queue', 'mail'], 'nightly-batch': ['measurement', 'archive', 'invoice'], 'archiver': ['archive'], 'report-generator': ['invoice', 'archive', 'log'], }; export const SUBJECT = Object.keys(GRANTED); // Actual need: permission -> average usage interval (days). A model decision; not measured, chosen. export const ACTUAL = { 'read-collector': { 'measurement-write': 1, 'queue-write': 1, 'measurement-read': 3 }, 'validator': { 'measurement-read': 1, 'queue-read': 1, 'measurement-write': 2, 'measurement-delete': 30 }, 'log-shipper': { 'log-write': 1, 'config-read': 7 }, 'billing': { 'invoice-read': 1, 'invoice-write': 1, 'mail-send': 2, 'archive-read': 45 }, 'work-order': { 'work-order-write': 1, 'work-order-read': 1, 'queue-read': 1, 'mail-send': 5 }, 'nightly-batch': { 'measurement-read': 1, 'archive-write': 1, 'invoice-read': 7, 'measurement-delete': 20 }, 'archiver': { 'archive-read': 1, 'archive-write': 1, 'archive-delete': 60 }, 'report-generator': { 'invoice-read': 1, 'archive-read': 7, 'log-read': 30 }, }; export const PERIOD = 90; // observed number of days export const SEED = 20260802; // the generator's seed; visible and fixed export const WINDOW = [1, 7, 14, 30, 60, 90]; export const grantedPermission = (o) => GRANTED[o].flatMap((a) => FAMILY[a].map((x) => `${a}-${x}`)); export const grantedPair = SUBJECT.flatMap((o) => grantedPermission(o).map((i) => `${o}|${i}`)); export const actualPair = SUBJECT.flatMap((o) => Object.keys(ACTUAL[o]).map((i) => `${o}|${i}`)); export const interval = (c) => ACTUAL[c.split('|')[0]][c.split('|')[1]]; // Our own generator: a linear congruential generator; starts from the seed, gives the same sequence run to run. export function generator(seed) { let s = seed >>> 0; return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; }; } // History: each real day, each actual pair is used with probability 1/interval. export function historyGen(days, seed) { const r = generator(seed), h = []; for (let d = 0; d < days; d++) for (const c of actualPair) if (r() < 1 / interval(c)) h.push([d, c]); return h; }
// run1.mjs — granted permission side by side with actual need: unused permission ratio import { SUBJECT, GRANTED, ACTUAL, PERMISSION, grantedPermission, grantedPair, actualPair } from './grant.mjs'; console.log('subject lines granted actual unused'); let lines = 0; for (const o of SUBJECT) { const v = grantedPermission(o), g = Object.keys(ACTUAL[o]); lines += GRANTED[o].length; console.log(`${o.padEnd(19)}${String(GRANTED[o].length).padStart(5)}${String(v.length).padStart(9)}` + `${String(g.length).padStart(8)}${String(v.length - g.length).padStart(15)}`); } const unused = grantedPair.length - actualPair.length; console.log(''); for (const [name, v] of [ ['permission catalog', PERMISSION.length], ['granted policy lines (subject, family)', lines], ['granted (subject, permission) entries', grantedPair.length], ['actually needed entries', actualPair.length], ['unused permission entries', `${unused} (${Math.round((unused / grantedPair.length) * 100)}%)`], ]) console.log(`${name.padEnd(39)}${v}`);
subject lines granted actual unused read-collector 2 6 3 3 validator 2 6 4 2 log-shipper 2 4 2 2 billing 3 6 4 2 work-order 3 6 4 2 nightly-batch 3 8 4 4 archiver 1 3 3 0 report-generator 3 7 3 4 permission catalog 18 granted policy lines (subject, family) 19 granted (subject, permission) entries 46 actually needed entries 27 unused permission entries 19 (41%)
Nineteen lines of policy open forty-six (subject, permission) entries; twenty-seven are
actually needed. Nineteen entries, forty-one percent of granted permission, are never used at
all. These entries were not added by mistake — not one of them was written individually. A
single line written at family level brought in the whole family’s permissions together.
The lines column shows where the gap comes from. The archiver gets three permissions with one line and uses all three; its unused permission is zero. The report generator gets seven permissions with three lines and uses three; four sit idle. The nightly batch gets eight permissions with three lines, four idle. The wider a family a line touches, the bigger the surplus grows; the measurement and archive families each carry three permissions, and most of the surplus piles up in these two families’ delete permission.
Usage History
The only way to find unused permission in a real system is to look at history. The model generates a ninety-day usage history; the following ninety days are set aside as the test period.
// run2.mjs — a 90-day usage history is generated; rare paths are counted separately import { PERIOD, SEED, actualPair, interval, historyGen } from './grant.mjs'; const full = historyGen(2 * PERIOD, SEED); // first period is observation, second is the test const history = full.filter(([d]) => d < PERIOD); const tally = new Map(actualPair.map((c) => [c, 0])); for (const [, c] of history) tally.set(c, tally.get(c) + 1); console.log(`seed ${SEED}, period ${PERIOD} days, events ${history.length}, test period ${full.length - history.length} events\n`); console.log('actual paths with an interval of 7 days or more:'); console.log('pair interval in 90d first seen'); for (const c of actualPair.filter((x) => interval(x) >= 7)) { const first = history.find(([, x]) => x === c); console.log(`${c.padEnd(34)}${String(interval(c)).padStart(10)}${String(tally.get(c)).padStart(10)}` + `${(first ? `day ${first[0] + 1}` : 'not seen').padStart(13)}`); } const seen = new Set(history.map(([, c]) => c)); console.log(''); for (const [name, v] of [ ['actual path', actualPair.length], ['seen at least once in 90 days', seen.size], ['actual path never seen in 90 days', actualPair.filter((c) => !seen.has(c)).join(', ') || 'none'], ]) console.log(`${name.padEnd(36)}${v}`);
seed 20260802, period 90 days, events 1545, test period 1521 events actual paths with an interval of 7 days or more: pair interval in 90d first seen validator|measurement-delete 30 1 day 8 log-shipper|config-read 7 12 day 1 billing|archive-read 45 2 day 25 nightly-batch|invoice-read 7 15 day 3 nightly-batch|measurement-delete 20 7 day 5 archiver|archive-delete 60 0 not seen report-generator|archive-read 7 9 day 6 report-generator|log-read 30 3 day 25 actual path 27 seen at least once in 90 days 26 actual path never seen in 90 days archiver|archive-delete
Fifteen hundred forty-five events accumulate over ninety days, but that number is misleading: the overwhelming majority of events come from the fifteen paths that run daily. The table only lists the ones with an interval longer than a week. The validator’s delete permission was used once in ninety days, on day eight; billing read the archive twice, the first time on day twenty-five.
The last line is this lesson’s harsh finding. The archiver’s delete permission was not used on any of the ninety days. Twenty-six of twenty-seven actual paths were seen, one was not — and that unseen path is a real path. A narrowing that looks at history counts it as unused permission and cuts it. What happens after it is cut depends on when that path next runs; for a path that runs once every sixty days, that could be any day after the cut.
Scanning the Observation Window
The observation window is a choice: a short window cuts more, a long window is wrong less often. The trade-off between the two can be worked out with numbers.
// run3.mjs — the observation window is scanned: how much it cuts, how much it wrongly cuts import { PERIOD, SEED, WINDOW, grantedPair, actualPair, historyGen } from './grant.mjs'; const full = historyGen(2 * PERIOD, SEED); const history = full.filter(([d]) => d < PERIOD), test = full.filter(([d]) => d >= PERIOD); console.log('window narrowed unused wrong blocked in blocked'); console.log('(days) lines perm ratio cuts test period workload'); for (const p of WINDOW) { const visible = new Set(history.filter(([d]) => d >= PERIOD - p).map(([, c]) => c)); const wrongCut = actualPair.filter((c) => !visible.has(c)); const blocked = test.filter(([, c]) => !visible.has(c)); const ratio = Math.round(((grantedPair.length - visible.size) / grantedPair.length) * 100); console.log(`${String(p).padEnd(9)}${String(visible.size).padEnd(13)}${`${ratio}%`.padEnd(15)}` + `${String(wrongCut.length).padEnd(9)}${String(blocked.length).padEnd(12)}` + `${new Set(blocked.map(([, c]) => c.split('|')[0])).size}`); } console.log('\nwhich actual paths get cut as the window narrows:'); for (const p of WINDOW) { const visible = new Set(history.filter(([d]) => d >= PERIOD - p).map(([, c]) => c)); const y = actualPair.filter((c) => !visible.has(c)); console.log(` ${String(p).padStart(2)}d ${y.length ? y.slice(0, 4).join(', ') + (y.length > 4 ? ` (+${y.length - 4})` : '') : 'none'}`); }
window narrowed unused wrong blocked in blocked (days) lines perm ratio cuts test period workload 1 19 59% 8 69 7 7 22 52% 5 12 4 14 23 50% 4 10 3 30 24 48% 3 5 2 60 25 46% 2 4 1 90 26 43% 1 0 0 which actual paths get cut as the window narrows: 1d read-collector|measurement-read, validator|measurement-delete, log-shipper|config-read, billing|archive-read (+4) 7d validator|measurement-delete, billing|archive-read, nightly-batch|measurement-delete, archiver|archive-delete (+1) 14d validator|measurement-delete, billing|archive-read, nightly-batch|measurement-delete, archiver|archive-delete 30d validator|measurement-delete, billing|archive-read, archiver|archive-delete 60d validator|measurement-delete, archiver|archive-delete 90d archiver|archive-delete
The table sets two outcomes of a single choice side by side. A one-day window cuts fifty-nine percent of forty-six entries, and the narrowed policy drops to nineteen lines — the same number as the granted policy, but this time at permission level and far narrower. The cost is in the two right-hand columns: eight legitimate paths get cut, and in the test period sixty-nine jobs stall across seven separate workloads.
As the window grows, both sides soften. At the thirty-day window the cut ratio drops to forty-eight percent, wrong cuts drop to three, blocked jobs drop to five. At the ninety-day window wrong cuts drop to one, blocked jobs drop to zero. The cut-permission ratio fell from fifty-nine percent to forty-three percent; the cost of the long window is not cutting a sixteen-point slice of permission that could have been cut.
The difference between the last two lines deserves attention. At the ninety-day window, wrong cuts are one, blocked jobs are zero. The path cut is the archiver’s delete permission, and it never ran during the test period either. The bill for a wrong cut does not arrive on the day it is cut — it arrives on the day the path runs. The lower block gives the order: as the window narrows, first the rare paths get cut, then the weekly paths, and last everything that is not daily.
Safe Narrowing Threshold
If ninety observed days is enough to miss a path, how many days is not enough? This question is
answered by looking not at history but at the interval: for a path with an interval of d days,
the probability of it never appearing within a p-day window is (1 − 1/d)^p.
// run4.mjs — safe narrowing threshold: how a path's miss probability drops with the window import { PERIOD, actualPair, interval } from './grant.mjs'; const RISK = 0.01; // accepted probability of missing const missProb = (d, p) => Math.pow(1 - 1 / d, p); const required = (d) => (d === 1 ? 1 : Math.ceil(Math.log(RISK) / Math.log(1 - 1 / d))); const intervals = [...new Set(actualPair.map(interval))].sort((a, b) => a - b); console.log(' interval paths miss at 90d window for 1% risk'); for (const d of intervals) { const n = actualPair.filter((c) => interval(c) === d).length; console.log(`${String(d).padStart(10)}${String(n).padStart(7)}` + `${`${(missProb(d, PERIOD) * 100).toFixed(1)}%`.padStart(18)}${String(required(d)).padStart(20)}d`); } console.log(''); for (const [name, v] of [ ["rarest actual path's interval", `${Math.max(...intervals)}d`], ['safe narrowing threshold (1% risk)', `${Math.max(...intervals.map(required))}d`], ['observed window', `${PERIOD}d`], ['expected wrong cuts at 90d', actualPair.reduce((a, c) => a + missProb(interval(c), PERIOD), 0).toFixed(2)], ]) console.log(`${name.padEnd(37)}${v}`);
interval paths miss at 90d window for 1% risk
1 15 0.0% 1d
2 2 0.0% 7d
3 1 0.0% 12d
5 1 0.0% 21d
7 3 0.0% 30d
20 1 1.0% 90d
30 2 4.7% 136d
45 1 13.2% 205d
60 1 22.0% 275d
rarest actual path's interval 60d
safe narrowing threshold (1% risk) 275d
observed window 90d
expected wrong cuts at 90d 0.46
The table gives the threshold interval by interval. For the fifteen paths that run daily, one day is enough; for the three paths that run weekly, thirty days. At a twenty-day interval, the required window rises to ninety days — the entire observed period, for a single path. At a sixty-day interval, the required window is two hundred seventy-five days, three times the observed period.
The second column from the right gives the risk remaining at a ninety-day window. The sixty-day path’s miss probability is twenty-two percent, the forty-five-day path’s is thirteen percent, each of the two thirty-day paths five percent. Summed up, the expected number of wrong cuts at ninety days is zero point four six paths; one path actually happened in the run. Model and run are the same order of magnitude.
The safe narrowing threshold is this table’s one-line answer: two hundred seventy-five days. This number is not a system property, it is a function of the rarest legitimate path’s interval; in a system whose interval is unknown, the threshold is unknown too. Narrowing with a ninety-day window means a forty-three percent cut and half a wrong cut — whether that is acceptable depends on which workload that half lands on.
Summary
- A 19-line policy written at family level opens 46
(subject, permission)entries; 27 are actually needed. Unused permission is 19 entries, 41% of granted permission — and none of them were written one at a time, all of them came from the width of a line. - 1,545 events accumulate over a 90-day history, but only 26 of 27 actual paths are seen. The unseen path is the archiver’s delete permission; its interval is 60 days, and a narrowing that looks at history cuts it.
- The window scan gives the trade-off: a 1-day window cuts 59% but stops 8 legitimate paths, and in the test period 69 jobs are blocked across 7 workloads; a 90-day window cuts 43%, makes 1 wrong cut, blocks 0 jobs.
- The bill for a wrong cut does not arrive on the day it is cut: the one path cut at a 90-day window did not run during the test period either, and blocked jobs showed up as 0.
- Narrowing does not shrink the line count: a 19-line family-level policy turns into a 26-line permission-level policy at a 90-day window. A narrow policy is a longer policy.
- The safe narrowing threshold for a 1% miss risk is 275 days — about 4.6 times the rarest path’s 60-day interval. Over the observed 90 days, that path’s miss probability is 22% and the expected wrong cut is 0.46 paths.
Next Step
This lesson measured narrowing a policy set, and showed where narrowing stops. But every table stands on a single assumption: all these workloads sit in one place, inside the same boundary. If all forty-six entries are written in a single account, compromising one identity is a door open to that entire account, and narrowing only reshapes the key, not the width of the door. The next lesson’s question is therefore the boundary itself: when the same set of workloads is set up in a single account, in accounts split by environment, and in accounts split by workload, how many resources does the blast radius come to, how is the quota shared, does the bill split apart, and how many accounts and how many policy copies does it take to manage.
To keep your progress and take notes, Log in
My notes
Log in to take notes.