---
title: 'Role-Based Access Control'
source: 'https://academia.sh/en/courses/orchestration/role-based-access-control'
course: 'Container Orchestration'
language: en
updated: '2026-09-13T05:08:28+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds a subject × verb × object × namespace privilege matrix and measures the number of permitted operations'
    - "Counts least privilege's two costs separately: dropped operations and lines of role definition written"
    - 'Counts privilege escalation paths and shows why the count of permitted operations alone is not enough'
---

# Role-Based Access Control

A subject × verb × object × namespace privilege matrix is built, and the number of operations that drop going from broad privilege down to least privilege is counted: permitted operations, the line cost of role definitions, and the escalation paths by which one subject can reach another subject's privilege are all measured.

The controllers in the previous two lessons did their work in the cluster without restriction. The
deployment controller changed the deployment object, deleted pods, wrote pool membership; the
health probes killed processes. People and other automated processes use the same interface, and
none of them has run into the question "are you authorized to do this" yet. Identity and least
privilege were measured earlier; the difference here is that privilege is defined **over cluster
objects**, and every object carries the possibility of reaching other objects.

The delegated decision is this: the cluster interface decides whether to execute a request by
looking at who made it. It needs three items to do this — who, what they want to do, on what — and
a fourth is added to those three: in which namespace. The product of these items is the table the
system has to fill in. This lesson measures that table's size, the cost of filling it in, and the
price of **leaving it empty**.

- **OO13.** The model carries the fictional regional measurement network's eight subjects, seven
  verbs, eight object types, and three namespaces. No real cluster is run; what is measured is a
  model built with `node`.
- **OO14.** The operation trace is treated as if extracted from a thirty-day record, and is
  hand-written. An operation absent from the trace is taken as "not needed." Maintenance jobs that
  run rarely do not show up in a trace like this; least privilege drops them too, and they get
  blocked the first time they run.
- **OO15.** The namespace here is the **cluster's** namespace: the partition into which objects are
  grouped and privilege is bounded. It is the same word as the namespace in the sense of the
  single-machine isolation's visibility mapping, but a different object.
- **OO16.** Privilege escalation is reduced to two rules: a subject that can write a role
  definition can grant itself every privilege in that namespace; a subject that can read a secret
  object can take over a subject's identity in that namespace. In reality there are more paths, so
  the counted value is a lower bound.
- **OO17.** Role-definition lines are counted with our own small schema, defined within the
  lesson. Line count depends on formatting choices, not on the order of magnitude.
- **OO18.** Subjects with the same privilege set collapse into a single role. Role names are
  generated; readable names would not change the line count.

## Privilege Matrix

```js
// privilege.mjs — subject x verb x object x namespace privilege matrix; a model, not a real cluster
export const SUBJECT = ['reading-collector', 'verifier', 'billing', 'work-order',
  'nightly-batch-job', 'release-controller', 'on-call', 'dashboard-reader'];
export const VERB = ['read', 'list', 'watch', 'create', 'update', 'delete', 'authorize'];
export const OBJECT = ['pod', 'deployment', 'service-object', 'config-object',
  'secret-object', 'quota', 'role-definition', 'node'];
export const NAMESPACE = ['development', 'staging', 'production'];
const P = ['production'], S = ['staging', 'production'], T = NAMESPACE;

// Thirty-day operation trace; hand-written, no randomness.
export const TRACE = [
  ['reading-collector', 'read', 'config-object', P], ['reading-collector', 'list', 'pod', P],
  ['verifier', 'read', 'config-object', S], ['verifier', 'read', 'secret-object', S],
  ['verifier', 'list', 'service-object', S],
  ['billing', 'read', 'config-object', P], ['billing', 'read', 'secret-object', P],
  ['work-order', 'read', 'config-object', P], ['work-order', 'list', 'service-object', P],
  ['nightly-batch-job', 'read', 'secret-object', P], ['nightly-batch-job', 'create', 'pod', P],
  ['nightly-batch-job', 'list', 'pod', P],
  ['release-controller', 'update', 'deployment', T], ['release-controller', 'list', 'pod', T],
  ['release-controller', 'delete', 'pod', T], ['release-controller', 'watch', 'pod', T],
  ['on-call', 'read', 'pod', T], ['on-call', 'list', 'pod', T],
  ['on-call', 'watch', 'pod', T], ['on-call', 'read', 'secret-object', P],
  ['dashboard-reader', 'list', 'pod', T], ['dashboard-reader', 'list', 'service-object', T],
];

export const key = (s, v, o, n) => `${s}|${v}|${o}|${n}`;
export function leastPrivilege() {
  const set = new Set();
  for (const [s, v, o, namespaces] of TRACE) for (const n of namespaces) set.add(key(s, v, o, n));
  return set;
}
export function broadPrivilege() {                     // every subject gets every verb, object, namespace
  const set = new Set();
  for (const s of SUBJECT) for (const v of VERB) for (const o of OBJECT) for (const n of NAMESPACE) set.add(key(s, v, o, n));
  return set;
}
// Escalation: a subject that can write a role definition can grant itself any privilege in that
// namespace; a subject that can read a secret object can take over a subject's identity in that namespace.
export function escalation(granted) {
  const has = (s, v, o, n) => granted.has(key(s, v, o, n));
  const namespaceSubjects = (n) => SUBJECT.filter((s) => [...granted].some((k) => k.startsWith(`${s}|`) && k.endsWith(`|${n}`)));
  const edge = new Set();
  for (const s of SUBJECT) for (const n of NAMESPACE) {
    const writesRole = ['create', 'update', 'authorize'].some((v) => has(s, v, 'role-definition', n));
    const readsSecret = has(s, 'read', 'secret-object', n);
    if (!writesRole && !readsSecret) continue;
    for (const t of namespaceSubjects(n)) if (t !== s) edge.add(`${s}->${t}`);
  }
  let n = edge.size, prev;                              // transitive closure
  do {
    prev = n;
    for (const k1 of [...edge]) for (const k2 of [...edge]) {
      const [a1, b1] = k1.split('->'), [a2, b2] = k2.split('->');
      if (b1 === a2 && a1 !== b2) edge.add(`${a1}->${b2}`);
    }
    n = edge.size;
  } while (n !== prev);
  return edge;
}
```

The difference between a grant list and a matrix is this: the matrix carries every cell, the trace
only the filled ones. The `broadPrivilege` function fills every cell — it hands every subject
everything, without asking a single question. `leastPrivilege` fills in only what appears in the
trace. The entire difference between the two is the cost of looking at the thirty-day record.

## From Broad Privilege to Least Privilege

```js
// run5.mjs — broad privilege is compared with least privilege
import { SUBJECT, VERB, OBJECT, NAMESPACE, TRACE, leastPrivilege, broadPrivilege } from './privilege.mjs';

const g = broadPrivilege(), e = leastPrivilege();
console.log(`subject ${SUBJECT.length} x verb ${VERB.length} x object ${OBJECT.length} x namespace ${NAMESPACE.length}` +
  ` = ${SUBJECT.length * VERB.length * OBJECT.length * NAMESPACE.length} possible operations`);
console.log(`observed trace: ${TRACE.length} lines, ${e.size} distinct operations once opened across namespaces\n`);
console.log('metric                             broad privilege  least privilege  diff');
const row = (name, x, y) => console.log(`${name.padEnd(35)} ${String(x).padEnd(17)} ${String(y).padEnd(17)} ${x - y}`);
row('permitted operations', g.size, e.size);
row('privileged subjects', SUBJECT.length, new Set([...e].map((k) => k.split('|')[0])).size);
const sv = (s) => new Set([...s].map((k) => k.split('|').slice(0, 2).join('|'))).size;
row('subject-verb pairs', sv(g), sv(e));
const oo = (s) => new Set([...s].map((k) => k.split('|')[2])).size;
row('object types touched', oo(g), oo(e));
const sec = (s) => [...s].filter((k) => k.split('|')[1] === 'read' && k.split('|')[2] === 'secret-object').length;
row('secret-object read grants', sec(g), sec(e));
const role = (s) => [...s].filter((k) => k.split('|')[2] === 'role-definition').length;
row('grants touching role-definition', role(g), role(e));
```

```
subject 8 x verb 7 x object 8 x namespace 3 = 1344 possible operations
observed trace: 22 lines, 43 distinct operations once opened across namespaces

metric                             broad privilege  least privilege  diff
permitted operations                1344              43                1301
privileged subjects                 8                 8                 0
subject-verb pairs                  56                18                38
object types touched                8                 5                 3
secret-object read grants           24                5                 19
grants touching role-definition     168               0                 168
```

One thousand three hundred one operations dropped out — ninety-seven percent of the matrix. The
remaining forty-three operations are everything the eight subjects actually did over thirty days.
The second row shows **which axis this drop did not happen on**: the number of privileged subjects
went from eight to eight — unchanged. Least privilege does not remove anyone from the cluster; it
narrows what everyone **can do**.

The narrowing is sharpest on the object axis. Only five of eight object types are touched; three
types — quota, role definition, and node — were used by no subject at all over thirty days. Two of
these belong to the cluster's own operation, and there is no reason for workload subjects to reach
them. The last row gives the count: broad privilege had one hundred sixty-eight grants touching
role definitions, least privilege has zero. All one hundred sixty-eight of those grants sat open
unused, and every one of them could have been used to widen itself.

The secret-object read row, on the other hand, shows where the drop stops: not from twenty-four to
zero, but to **five**. Four subjects genuinely access secrets — the verifier across two
namespaces, billing, the nightly batch job, and on-call in production. These five grants are in
the trace, so least privilege cannot drop them.

## Writing Cost and Escalation Paths

Least privilege's second cost does not show up in the first table: the matrix's unfilled cells are
free, but every filled one has to be written. To count this, the privilege set is written out to
text in our own schema.

```js
// run6.mjs — role definitions for both models are written in our own schema, and lines are counted
import { SUBJECT, leastPrivilege, broadPrivilege, escalation } from './privilege.mjs';

// Schema: "role <name>" / "  <object>: <verb>,<verb>" / "bind <role> -> <subject> @ <namespace>"
function write(grants) {
  const grouped = new Map();                            // (subject, namespace) -> object:verbs
  for (const k of grants) {
    const [s, v, o, n] = k.split('|');
    const m = grouped.get(`${s}|${n}`) ?? new Map();
    m.set(o, (m.get(o) ?? new Set()).add(v));
    grouped.set(`${s}|${n}`, m);
  }
  const role = new Map(), bind = [];                    // the same privilege set collapses to one role
  for (const [sn, m] of grouped) {
    const body = [...m].sort().map(([o, vs]) => `  ${o}: ${[...vs].sort().join(',')}`).join('\n');
    if (!role.has(body)) role.set(body, `role-${role.size + 1}`);
    const [s, n] = sn.split('|');
    bind.push(`bind ${role.get(body)} -> ${s} @ ${n}`);
  }
  const text = [...role].map(([b, name]) => `role ${name}\n${b}`).concat(bind).join('\n');
  return { text, roles: role.size, binds: bind.length, lines: text.split('\n').length };
}
const g = write(broadPrivilege()), e = write(leastPrivilege());
console.log('model             roles  binds  total lines  grants  grants/line');
for (const [name, y, grants] of [['broad privilege', g, broadPrivilege()], ['least privilege', e, leastPrivilege()]]) {
  console.log(`${name.padEnd(17)} ${String(y.roles).padEnd(6)} ${String(y.binds).padEnd(6)} ` +
    `${String(y.lines).padEnd(12)} ${String(grants.size).padEnd(7)} ${(grants.size / y.lines).toFixed(1)}`);
}
console.log('\ntwo roles and two bindings from the least-privilege definition:');
console.log(e.text.split('\n').slice(0, 6).join('\n'));
console.log(e.text.split('\n').filter((l) => l.startsWith('bind')).slice(0, 2).join('\n'));

// what happens to escalation paths if secret-object read is removed
const trimmed = new Set([...leastPrivilege()].filter((k) => !(k.split('|')[1] === 'read' && k.split('|')[2] === 'secret-object')));
console.log('\nescalation path count and subjects reaching every subject:');
for (const [name, s] of [['broad privilege', broadPrivilege()], ['least privilege', leastPrivilege()], ['secret read removed', trimmed]]) {
  const k = escalation(s);
  const full = SUBJECT.filter((o) => [...k].filter((x) => x.startsWith(`${o}->`)).length === SUBJECT.length - 1).length;
  console.log(`  ${name.padEnd(20)} paths ${String(k.size).padEnd(4)} reach every subject ${full}  grants ${s.size}`);
}
```

```
model             roles  binds  total lines  grants  grants/line
broad privilege   1      24     33           1344    40.7
least privilege   9      15     42           43      1.0

two roles and two bindings from the least-privilege definition:
role role-1
  config-object: read
  pod: list
role role-2
  config-object: read
  secret-object: read
bind role-1 -> reading-collector @ production
bind role-2 -> verifier @ staging

escalation path count and subjects reaching every subject:
  broad privilege      paths 56   reach every subject 8  grants 1344
  least privilege      paths 28   reach every subject 4  grants 43
  secret read removed  paths 0    reach every subject 0  grants 38
```

Writing cost went from thirty-three lines to forty-two — a twenty-seven percent increase, nine
lines. This is the entire cost of dropping one thousand three hundred one operations. The last
column reduces the relationship between the two to a single number: every line written under broad
privilege opens forty operations, under least privilege exactly one. Broad privilege's cheapness
comes from the lines it **does not write**; under least privilege, every permitted operation has
to be written out individually, because no rule generalizes. This is also where the maintenance
burden of forty-two lines comes from: when the trace changes, the definition has to change.

The bottom table is this lesson's real result. Under broad privilege there are fifty-six escalation
paths, and all eight subjects reach every other subject — the expected result. Under least
privilege, permitted operations dropped to forty-three, but escalation paths dropped only by half,
to twenty-eight, and **four subjects still reach every other subject**. Dropping ninety-seven
percent of permitted operations dropped only fifty percent of escalation paths. The two numbers do
not measure the same thing.

The third row gives the reason. When secret-object read is removed, permitted operations drop from
forty-three to thirty-eight — five operations — and escalation paths drop from twenty-eight to
**zero**. Those five grants alone were carrying all twenty-eight paths. None of the remaining
thirty-eight grants lets one subject stand in for another.

These five grants were not unnecessary; they were in the trace, meaning those processes genuinely
accessed secrets. What the number says is not that the grant should be removed, but that **the
secret should reach the process by some route other than being read from the cluster**. The
decision now becomes tied to a measure: removing one grant costs five operations and buys back
twenty-eight escalation paths. The same calculation done for quota, role definition, and node would
have returned zero gain, because those grants were already closed.

## What the System Does Not Know

When the cluster interface evaluates a request, it looks for the answer to a single question: is
this in the four-column table. It does not know intent, context, time, or sequence. On-call
reading a secret object in production falls into the same cell whether it happens during an outage
or on an ordinary Tuesday morning; the table cannot tell the two apart, because it carries no item
that would let it. For the same reason, the question "when was this grant last used" cannot be
answered from the table either — which of the forty-three grants are still live is known only by
re-gathering the trace. This means the delegated decision is an information item that has to be
fed continuously: the table does not stay correct once it is built correctly; it ages as the trace
changes.

Aging has two directions, and their costs are not symmetric. When the trace expands — a new
service, a new maintenance job — the table stays narrow and the system **rejects a legitimate
operation**; this shows up immediately, because someone reports being blocked. When the trace
narrows — a process retires, a verb falls out of use — the table stays broad and the system **keeps
accepting an unnecessary operation**; this never shows up, because no one complains. The one
hundred sixty-eight role-definition grants under broad privilege sat exactly like that: unused,
bothering no one, and carrying most of the fifty-six escalation paths. The value of least privilege
derived from a thirty-day trace is that it closes this second direction **once**; keeping it closed
requires the trace measurement to continue.

## Summary

- Eight subjects, seven verbs, eight object types, and three namespaces build a 1344-cell matrix;
  the thirty-day trace fills 43 of those, and dropping to least privilege removes 1301 operations.
- The drop is not on the subject axis: the number of privileged subjects stayed fixed at eight;
  what narrows is what each subject can do.
- 168 grants touching role definitions drop to zero under least privilege; none of them had been
  used in thirty days.
- Least privilege's writing cost goes from 33 lines to 42, a twenty-seven percent increase; under
  broad privilege each written line opens 40.7 operations, under least privilege exactly 1.
- The count of permitted operations and the count of escalation paths do not measure the same
  thing: when ninety-seven percent of operations dropped, paths fell only by half, and four
  subjects still reached every other subject.
- The remaining 28 paths were carried entirely by five secret-read grants; once those five close,
  the path count drops to zero. The cost and the gain of removing a grant can be measured
  separately.

## Next Step

This lesson's matrix bounds what a subject can do **through the cluster interface**. Traffic
between two pods does not pass through this matrix: the verifier connects directly to the billing
service's endpoint and never touches the cluster interface at all. Two subjects that cannot reach
each other in the privilege matrix can reach each other freely over the network. The next lesson
builds the reachability matrix with policy, and counts the difference between default-open and
default-closed: how many paths close, how many legitimate paths close by mistake, how many rules
the policy needs, and how many rules get updated when a new service is added.
