Skip to content
academia.sh

Lesson 23 / 30

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.

Published Updated

In this lesson

  • 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
Contents

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

// 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

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close