---
title: 'Least Privilege Practice'
source: 'https://academia.sh/en/courses/cloud-governance/least-privilege-practice'
course: 'Cloud Governance, Security and Cost'
language: en
updated: '2026-09-13T05:07:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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.

```js
// 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;
}
```

```js
// 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.

```js
// 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.

```js
// 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`.

```js
// 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.
