---
title: 'Least Privilege'
source: 'https://academia.sh/en/courses/introduction-to-cybersecurity/least-privilege'
course: 'Introduction to Cybersecurity'
language: en
updated: '2026-09-13T05:08:03+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Ties every step of the access graph to a permission item and derives the path count it opens'
    - 'Measures both sides of every permission item together: the path it opens and the daily legitimate request it carries'
    - 'Removes items that carry no legitimate traffic and counts the path cost-free narrowing closes'
    - 'Sweeps the stopped-request-per-path threshold and finds the point where narrowing turns expensive'
---

# Least Privilege

Every step of the same access graph is tied to a permission item, and the path it opens is counted side by side with the daily legitimate request it carries. Removing items that carry no legitimate traffic is measured as cost-free, then the stopped-request-per-path threshold is swept to find where narrowing turns expensive.

The previous lesson added five defense layers to the same graph and closed fifty-two of the
fifty-five paths. Every closed path closed because a step could not be crossed: the filter did
not let it through, the identity was not recognized, the input was not accepted. None of the
layers asked a second question — does the subject taking the step have **permission** to touch
the target item? This lesson asks that question and reads the graph again as a permission set.

**Least privilege** is granting each subject the narrowest permission set sufficient to do its
work. The Cloud Governance, Security and Cost course measured this same principle through the
ratio of granted permission to used permission; here the unit of measure is path count. When a
permission item is removed, a step drops from the graph and every path passing through that step
closes. Because legitimate work also passes through the same step, every removal produces two
numbers: the path that closes and the daily request that stops. The principle only counts as
measured when both are written down together.

- **DP7.** A permission item is a `(subject, item, action)` triple and corresponds one to one
  with a step in the graph. Twenty-two edges means twenty-two permission items.
- **DP8.** The action mapping is a model decision: each step was assigned `read`, `write`, or
  `manage`. In reality a step can require more than one action; this would grow the item count,
  not change the direction of the measure.
- **DP9.** A permission item is either granted or not. Condition expressions, record-level
  narrowing, and time-boxed permission are not modeled.
- **DP10.** The daily legitimate request counts come from the previous lesson's flow model; they
  were not measured, they were chosen.
- **DP11.** A legitimate flow stops entirely once one of its steps closes. Continuing over a
  backup path is not modeled; this shows the stop count as higher than it might be.

## The Step's Second Condition

The graph has not changed; a second reading sits on top of it. Every edge is now the name of a
permission item, and when that item is removed the edge drops with it.

```js
// network.mjs — fictional regional measurement network's access graph and legitimate flows (model)
export const ENTRY = ['field-endpoint', 'branch-network', 'public-endpoint', 'maintenance-endpoint'];
export const ASSET = ['measurement-store', 'invoice-db', 'subscriber-registry', 'configuration'];
export const EDGE = [
  ['field-endpoint', 'collector'], ['field-endpoint', 'work-order'], ['branch-network', 'collector'],
  ['branch-network', 'work-order'], ['branch-network', 'management'], ['public-endpoint', 'collector'],
  ['maintenance-endpoint', 'management'], ['maintenance-endpoint', 'queue'], ['collector', 'queue'],
  ['collector', 'measurement-store'], ['queue', 'validator'], ['queue', 'billing'],
  ['validator', 'measurement-store'], ['validator', 'invoice-db'], ['billing', 'invoice-db'],
  ['billing', 'subscriber-registry'], ['work-order', 'queue'], ['work-order', 'subscriber-registry'],
  ['management', 'collector'], ['management', 'validator'], ['management', 'work-order'],
  ['management', 'configuration'],
];
// Legitimate flow: work that runs daily; the step sequence passes through the same graph's edges.
export const FLOW = [
  ['field-reading', ['field-endpoint', 'collector', 'queue'], 1440],
  ['validation', ['queue', 'validator', 'measurement-store'], 1440],
  ['public-reading', ['public-endpoint', 'collector', 'measurement-store'], 300],
  ['branch-query', ['branch-network', 'collector', 'measurement-store'], 210],
  ['invoice-write', ['queue', 'billing', 'invoice-db'], 48],
  ['work-order-opening', ['branch-network', 'work-order', 'subscriber-registry'], 32],
  ['work-order-queuing', ['branch-network', 'work-order', 'queue'], 32],
  ['subscriber-update', ['billing', 'subscriber-registry'], 6],
  ['configuration-change', ['branch-network', 'management', 'configuration'], 2],
  ['maintenance-session', ['maintenance-endpoint', 'management', 'collector'], 1],
  ['queue-maintenance', ['maintenance-endpoint', 'queue'], 1],
  ['reconciliation', ['management', 'validator', 'invoice-db'], 1],
];
export const edgeName = (a, b) => `${a}>${b}`;
export const steps = (d) => d.slice(0, -1).map((x, i) => edgeName(x, d[i + 1]));
export function paths(closed = new Set()) {
  const neighbor = new Map();
  for (const [a, b] of EDGE) if (!closed.has(edgeName(a, b))) neighbor.set(a, [...(neighbor.get(a) || []), b]);
  const out = [];
  const walk = (d, y) => {
    if (ASSET.includes(d)) return void out.push(y);
    for (const k of neighbor.get(d) || []) if (!y.includes(k)) walk(k, [...y, k]);
  };
  for (const g of ENTRY) walk(g, [g]);
  return out;
}
export const blocked = (closed) => FLOW.filter(([, d]) => steps(d).some((k) => closed.has(k)));
```

```js
// permission.mjs — the permission item each step requires: (subject, item, action)
export const ACTION = {
  'field-endpoint>collector': 'write', 'field-endpoint>work-order': 'write', 'branch-network>collector': 'read',
  'branch-network>work-order': 'write', 'branch-network>management': 'manage', 'public-endpoint>collector': 'read',
  'maintenance-endpoint>management': 'manage', 'maintenance-endpoint>queue': 'manage', 'collector>queue': 'write',
  'collector>measurement-store': 'write', 'queue>validator': 'read', 'queue>billing': 'read',
  'validator>measurement-store': 'write', 'validator>invoice-db': 'write', 'billing>invoice-db': 'write',
  'billing>subscriber-registry': 'write', 'work-order>queue': 'write', 'work-order>subscriber-registry': 'write',
  'management>collector': 'manage', 'management>validator': 'manage', 'management>work-order': 'manage',
  'management>configuration': 'write',
};
export const itemName = (k) => `${k.split('>')[0]}.${k.split('>')[1]}.${ACTION[k]}`;
```

```js
// run1.mjs — permission matrix: how many paths each item opens, how many daily legitimate requests it carries
import { FLOW, EDGE, edgeName, steps, paths } from './network.mjs';
import { itemName } from './permission.mjs';

const y = paths(), item = EDGE.map(([a, b]) => edgeName(a, b));
const opens = (k) => y.filter((p) => steps(p).includes(k)).length;
const request = (k) => FLOW.filter(([, d]) => steps(d).includes(k)).reduce((s, [, , n]) => s + n, 0);
console.log(`permission item ${item.length}, path ${y.length}, daily legitimate request ${FLOW.reduce((s, [, , n]) => s + n, 0)}`);
console.log('\npermission item                         opens path  daily legitimate request');
for (const [k, a, i] of item.map((k) => [k, opens(k), request(k)]).sort((p, q) => q[1] - p[1]))
  console.log(`${itemName(k).padEnd(40)}${String(a).padEnd(12)}${i}`);
const idle = item.filter((k) => request(k) === 0);
console.log(`\nitem carrying no legitimate traffic ${idle.length}: ${idle.map(itemName).join(', ')}`);
```

```
permission item 22, path 55, daily legitimate request 3513

permission item                         opens path  daily legitimate request
collector.queue.write                   20          1440
queue.validator.read                    20          1440
queue.billing.read                      20          48
work-order.queue.write                  16          32
branch-network.management.manage        13          2
maintenance-endpoint.management.manage  13          1
validator.measurement-store.write       12          1440
validator.invoice-db.write              12          1
billing.invoice-db.write                10          48
billing.subscriber-registry.write       10          6
management.collector.manage             10          1
management.work-order.manage            10          0
field-endpoint.collector.write          5           1440
field-endpoint.work-order.write         5           0
branch-network.collector.read           5           210
branch-network.work-order.write         5           64
public-endpoint.collector.read          5           300
collector.measurement-store.write       5           510
maintenance-endpoint.queue.manage       4           1
work-order.subscriber-registry.write    4           32
management.validator.manage             4           1
management.configuration.write          2           2

item carrying no legitimate traffic 2: field-endpoint.work-order.write, management.work-order.manage
```

The table reads differently from how it looks, because it places two columns side by side. A
high left column does not mean dangerous, and a high right column does not mean indispensable;
the decision depends on the **ratio** of the two columns. Write permission on the queue opens
twenty paths but also carries one thousand four hundred forty legitimate requests a day. The
validator's write permission on the invoice database also opens twelve paths and carries only
one legitimate request a day. Two items open a similar number of paths, but removing one stops
the network, while removing the other stops one reconciliation job a day.

Comparing the same pair of rows makes the principle concrete. The branch network's manage
permission on the management station opens thirteen paths and carries two requests a day; the
maintenance endpoint's opens thirteen paths too and carries one. Nine of the twenty-two items
carry fewer than five requests a day, and together these are the items that open most of the
paths. This is exactly where least privilege pays off: items that are rarely used but open wide.

The action column explains the table even though it is not the one being read. Six items carry
the `manage` action, and together the six carry six daily legitimate requests; against that,
four of them open ten or more paths. Manage permission is wide by its nature: a subject that can
manage a node can also use all of that node's outgoing steps. In the table, the management
station's three separate manage permissions sit as separate rows, but all three sit in the same
subject's hands. Counting permission items one by one hides this pileup; counting paths does
not, because the same subject's permissions merge on the same paths.

The last row gives the cheapest part of the principle. Two items carry no legitimate request at
all: the field endpoint's write permission on the work-order service, and the management
station's manage permission on the work-order service. Both were granted, neither is used.

## The Limit of Narrowing

The order of removal matters. Cost-free items are removed first, then items ranked by how many
requests they stop per path removed. The threshold is the lowest `opened path / carried request`
value at which removing an item is accepted.

```js
// run2.mjs — narrowing: cost-free items first, then the path/request threshold is swept
import { FLOW, EDGE, edgeName, steps, paths } from './network.mjs';

const y = paths(), total = y.length, item = EDGE.map(([a, b]) => edgeName(a, b));
const opens = (k) => y.filter((p) => steps(p).includes(k)).length;
const request = (k) => FLOW.filter(([, d]) => steps(d).includes(k)).reduce((s, [, , n]) => s + n, 0);
const idle = item.filter((k) => request(k) === 0);
console.log(`cost-free narrowing: ${idle.length} items removed, remaining path ${paths(new Set(idle)).length}, stopped legitimate request 0`);
console.log('\nthreshold  removed  left  stopped  stopped  stopped request');
console.log('           item     path  flow     daily    per closed path');
for (const t of [Infinity, 10, 4, 1, 0.4, 0.1, 0]) {
  const closed = new Set([...idle, ...item.filter((k) => request(k) > 0 && opens(k) / request(k) >= t)]);
  const left = paths(closed).length;
  const cut = FLOW.filter(([, d]) => steps(d).some((k) => closed.has(k)));
  const stopped = cut.reduce((s, [, , n]) => s + n, 0);
  console.log(`${(t === Infinity ? '-' : String(t)).padEnd(11)}${String(closed.size).padEnd(12)}${String(left).padEnd(7)}` +
    `${String(cut.length).padEnd(9)}${String(stopped).padEnd(9)}${(stopped / (total - left)).toFixed(1)}`);
}
```

```
cost-free narrowing: 2 items removed, remaining path 40, stopped legitimate request 0

threshold  removed  left  stopped  stopped  stopped request
           item     path  flow     daily    per closed path
-          2           40     0        0        0.0
10         5           21     2        2        0.1
4          8           16     4        5        0.1
1          10          12     5        11       0.3
0.4        12          7      7        91       1.9
0.1        14          6      8        123      2.5
0          22          0      12       3513     63.9
```
The first row is the principle's free part. Removing the two unused items closes fifteen of the
fifty-five paths and stops no legitimate work. Twenty-seven percent of the paths went for zero
cost; these two items were the kind nobody notices, and since nobody uses them, nobody ever
will.

The second and third rows are the principle's cheap part. At a threshold of ten, five items are
removed, the remaining path drops from forty to twenty-one, and in exchange two requests a day
stop. At a threshold of four, the remaining path is sixteen, the stopped requests five.
Thirty-nine paths closed for the cost of five daily requests — one-tenth of a request per closed
path.

The stopped-flow column shows which jobs fall in what order, and the order is not random. At a
threshold of ten, the two stopped flows are the maintenance session and the reconciliation,
carrying one request a day each; at a threshold of four, queue maintenance and the
configuration change are added. Because the items removed at a high threshold are the rarely
used ones, the first jobs to stop are the rarely run ones too: maintenance, reconciliation,
manual configuration. These are invisible in daily volume, but they are needed exactly when they
are needed, and that moment is usually an incident. The remaining eight flows — reading uploads,
validation, queries, billing — only start falling once the threshold drops below zero point
four.

## The Price of Forty-Three Paths

The table draws a curve, and that curve's shape is least privilege's true shape. Forty-three of
the fifty-five paths can be closed for the cost of eleven daily requests; the remaining twelve
paths cost three thousand five hundred two requests. Seventy-eight percent of the paths close
for zero point three percent of the cost.

The same curve also explains why least privilege is usually left half done. Once the easy part
is finished, what remains requires accepting that a legitimate flow will stop at every step;
that is not a security decision, it is a business decision, and one the security team cannot
make alone. Where the threshold ends up is therefore decided not by measurement but by which
work people agree to stop.

This ratio also says how the principle should be applied. Starting narrowing from the question
"which permission is excessive" is the wrong order; the right order is "which permission opens
many paths while carrying little work." The two questions do not give the same answer: write
permission on the queue sits at the top of the list with twenty paths, but because it carries
one thousand four hundred forty requests a day, it is among the last items to be removed. The
management station's manage permission on the work-order service sits in the middle of the list
with ten paths and is the first item to go, because it carries no work at all.

Why the remaining twelve paths are expensive is also readable from the table. Those paths are
made of edges that legitimate flows pass over: field endpoint to collector, collector to queue,
queue to validator. An attack path and a work flow share the same edge here, and a permission
item cannot tell them apart, because a permission item only answers "who can do what to which
item." This is the limit of least privilege. Narrowing brings paths down to the width of the
legitimate channel and stops there — the same place the five layers stopped in the previous
lesson, reached by a different route.

Two things the table does not show are also part of the decision. First, narrowing is not a
one-time job: when a service is added to the network, the edge count grows, the paths the new
edges open are given wide by default on day one, and stay open until the threshold is swept
again. The measurement is a snapshot, not a steady state. Second, every item removed is a
configuration change, and that change itself can be written wrong. The first two items that look
free really are free, but once the threshold drops below one, most of the items removed belong
to rarely run jobs — when an item is removed by mistake, finding out has to wait for that job's
next run. The path count drops instantly, the stoppage shows up weeks later.

## Summary

- The graph's twenty-two edges correspond to twenty-two permission items. Every item carries two
  numbers: the path it opens and the daily legitimate request it carries; the decision is made
  from their ratio.
- Two items opening a similar number of paths can be very different: write permission on the
  queue opens 20 paths and carries 1,440 requests a day, the validator's invoice-write permission
  opens 12 paths and carries 1 request a day.
- Two permission items carry no legitimate request at all. Removing them closes 15 of the 55
  paths for zero cost — 27% of the paths came from two items nobody had noticed.
- The threshold sweep gives the point where the curve breaks: 43 paths close for the cost of 11
  daily requests, the remaining 12 paths need 3,502. 78% of the paths close for 0.3% of the cost.
- When every permission is removed, the remaining path is 0 and the stopped daily requests are
  3,513: all twelve legitimate flows. Full narrowing is the same thing as removing the network's
  function.
- Narrowing order is set not by "which permission is excessive" but by "which permission opens
  many paths while carrying little work"; the two questions produce different rankings.

## Next Step

This lesson removed permission items one by one and counted the cost of every removal. But the
whole measurement rested on one assumption: when a step closes, only that step closes, the nodes
themselves stay in place. In a real incident, what closes is not a step but a node — a service
gets compromised, and every permission that service holds passes into the attacker's hands. The
narrowing that closes forty-three paths does not close the paths the compromised node opens with
its own permissions. The next lesson's question is therefore how nodes are grouped: when the
network is divided into compartments, how many items are reached when one compartment falls, how
that number shrinks as the compartment count grows, and how many gates the legitimate traffic
running between compartments costs.
