Lesson 04 / 21
Role Assumption
Access with temporary credentials is compared against a long-lived key: the leak window, the length of the assumption chain, how the trust relationship determines who can assume it, and how many steps it takes before the true subject disappears from the audit record.
Published Updated
In this lesson
- Measures the leak window for two credential types: the long-lived key and the temporary credential
- Builds the assumption chain that follows from the trust relationship and counts the roles reached through it
- Measures how traceable the true subject is in the audit record, based on session naming
Contents
The previous three lessons narrowed permission and taught how to read both directions together, but every subject was still a persistent identity, and every persistent identity had a long-lived credential. The first lesson counted the size of that surface: twenty-nine storage locations, the oldest eight hundred eighty days old. However narrowly a policy document is written, whoever gets hold of the key gets everything that document grants.
Role assumption separates access from persistent identity. A subject authenticates with its own identity, then assumes a role and receives a temporary credential that carries that role’s authority. Dynamically generating a credential and shortening its lifetime was already measured on the secret-management axis; that is not what is measured here. Three things are counted here: how far the window narrows, the chaining of assumption, and the trace the chain leaves in the audit record.
- CI19. The model carries five persistent identities, six roles, and nine assumption chains.
No real account is run; what is measured is the trust graph built with
node. - CI20. Credential lifetimes and the hourly assumption limit are fictional and defined within the lesson. They are not a provider’s values.
- CI21. The leak’s detection time was set at twenty-one days. The long-lived key’s window was capped at this duration; in reality an undetected leak has a longer window, so the measured factor is a lower bound.
- CI22. The leak was assumed to be uniformly distributed across the credential’s validity interval; the expected window is therefore half the lifetime.
- CI23. The thirty-day trace was written by hand and consists of nine chains. The code checks that the chains match the trust relationship.
- CI24. The audit record is treated as carrying two fields: the final role and the session name. The assumption record additionally carries the caller’s session name. Record retention duration did not enter the model.
Trust Graph and Lifetimes
// assumption.mjs — trust relationship and assumption chain; not a real account, only a model built with node export const IDENTITY = ['ingest-service', 'analytics-job', 'field-maintenance', 'release-pipeline', 'on-call-human']; export const TRUST = { // role -> who can assume it (identity or role) 'role-read': ['ingest-service', 'analytics-job', 'role-bridge'], 'role-release': ['release-pipeline'], 'role-maintenance': ['field-maintenance', 'role-release'], 'role-management': ['on-call-human', 'role-maintenance'], 'role-audit': ['*'], 'role-bridge': ['on-call-human', 'release-pipeline'], }; export const ROLE = Object.keys(TRUST); // Thirty-day trace: which chain produced how many operations; written by hand export const TRACE = [ ['ingest-service', ['role-read'], 1200], ['analytics-job', ['role-read'], 800], ['field-maintenance', ['role-maintenance'], 260], ['release-pipeline', ['role-release', 'role-maintenance'], 340], ['on-call-human', ['role-bridge', 'role-read'], 90], ['on-call-human', ['role-management'], 40], ['release-pipeline', ['role-release', 'role-maintenance', 'role-management'], 25], ['field-maintenance', ['role-audit'], 60], ['analytics-job', ['role-audit'], 30], ]; export const trusts = (role, assumer, g = TRUST) => g[role].includes('*') || g[role].includes(assumer); export function chainValid(start, steps, g = TRUST) { let cur = start; for (const r of steps) { if (!trusts(r, cur, g)) return false; cur = r; } return true; } // Roles reachable from an identity and the shortest chain length (breadth-first search) export function reach(start, g = TRUST) { const dist = new Map(); let frontier = [start]; for (let d = 1; frontier.length; d++) { const next = []; for (const r of Object.keys(g)) { if (dist.has(r)) continue; if (frontier.some((s) => trusts(r, s, g))) { dist.set(r, d); next.push(r); } } frontier = next; } return dist; } export const LIFETIME = [ // name, validity duration (minutes) ['long-lived key', null], ['role assumption 12h', 720], ['role assumption 1h', 60], ['role assumption 15min', 15], ]; export const DETECT_MIN = 21 * 24 * 60; // leak detection time, fictional export const LIMIT_HOUR = 500; // max assumption calls per hour, fictional
The trust relationship sits on the role itself and carries a list: who can assume this role. The
list can hold a persistent identity as well as another role — that is where the chain is born.
The * on the role-audit row is this list’s widest form, and is itself a subject of
measurement.
Leak Window
// run1.mjs — leak window: long-lived key versus role assumption import { IDENTITY, LIFETIME, DETECT_MIN, LIMIT_HOUR } from './assumption.mjs'; const MONTH = 30 * 24 * 60; // thirty-day window, minutes const longLived = DETECT_MIN; // for long-lived, window = detection time console.log(`thirty days ${MONTH} min; leak detection time ${DETECT_MIN} min (fictional)`); console.log(`${IDENTITY.length} persistent identities, hourly assumption limit ${LIMIT_HOUR} calls (fictional)\n`); console.log('credential type lifetime (min) count in 30d expected window worst window factor'); for (const [name, t] of LIFETIME) { const count = t ? MONTH / t : 1; const expected = t ? Math.min(t / 2, longLived) : longLived; const worst = t ? Math.min(t, longLived) : longLived; console.log(`${name.padEnd(25)} ${String(t ?? 'unlimited').padEnd(15)} ${String(count).padEnd(16)} ` + `${String(expected).padEnd(17)} ${String(worst).padEnd(16)} ${Math.round(longLived / expected)}`); } console.log('\nrenewal cost and limit:'); for (const [name, t] of LIFETIME.filter(([, t]) => t)) { const hourly = (60 / t) * IDENTITY.length; console.log(` ${name.padEnd(21)} calls/hour ${hourly.toFixed(2).padEnd(6)} ` + `left to limit ${(LIMIT_HOUR - hourly).toFixed(2).padEnd(7)} identities at same rate ${Math.floor(LIMIT_HOUR / (60 / t))}`); }
thirty days 43200 min; leak detection time 30240 min (fictional) 5 persistent identities, hourly assumption limit 500 calls (fictional) credential type lifetime (min) count in 30d expected window worst window factor long-lived key unlimited 1 30240 30240 1 role assumption 12h 720 60 360 720 84 role assumption 1h 60 720 30 60 1008 role assumption 15min 15 2880 7.5 15 4032 renewal cost and limit: role assumption 12h calls/hour 0.42 left to limit 499.58 identities at same rate 6000 role assumption 1h calls/hour 5.00 left to limit 495.00 identities at same rate 500 role assumption 15min calls/hour 20.00 left to limit 480.00 identities at same rate 125
The long-lived key’s window comes not from its lifetime but from when the leak is detected: thirty thousand two hundred forty minutes, or three weeks. Throughout this time the key is still valid and nobody knows. For a one-hour temporary credential, the expected window drops to thirty minutes — a thousand eight times shorter — because the lifetime of what leaked is independent of how long it takes to notice. Even if nobody does anything, the credential invalidates itself.
The cost is in the second column. A one-hour lifetime means seven hundred twenty separate credentials over thirty days; a fifteen-minute lifetime means two thousand eight hundred eighty. Each of these is an assumption call, and the call count runs into the quota. With five persistent identities, the fifteen-minute lifetime makes twenty calls an hour, leaving four hundred eighty calls of the fictional five-hundred limit; a hundred twenty-five identities running at the same rate would fit. The cost of narrowing the window fourfold is that the number of identities that fit drops to a quarter.
Chain and Trust List
// run2.mjs — the trust relationship determines who can assume; how far the chain reaches import { IDENTITY, ROLE, TRUST, TRACE, reach, chainValid } from './assumption.mjs'; console.log(`${IDENTITY.length} persistent identities, ${ROLE.length} roles; chains in trace ${TRACE.length}`); console.log(`every chain in the trace matches the trust relationship: ${TRACE.every(([b, a]) => chainValid(b, a))}\n`); console.log('persistent identity direct via chain total longest chain used in trace'); let directTotal = 0, total = 0; for (const k of IDENTITY) { const u = reach(k); const d = [...u.values()].filter((x) => x === 1).length; const used = new Set(TRACE.filter(([b]) => b === k).flatMap(([, a]) => a)).size; directTotal += d; total += u.size; console.log(`${k.padEnd(21)} ${String(d).padEnd(9)} ${String(u.size - d).padEnd(10)} ${String(u.size).padEnd(7)} ` + `${String(Math.max(...u.values())).padEnd(15)} ${used}`); } console.log(`total ${String(directTotal).padEnd(9)} ${String(total - directTotal).padEnd(10)} ${total}`); // If role-audit's wide trust were narrowed to what the trace actually assumes const assumedInTrace = (role) => IDENTITY.filter((k) => TRACE.some(([b, a]) => b === k && a.includes(role))); const narrow = { ...TRUST, 'role-audit': assumedInTrace('role-audit') }; const pairCount = (g) => IDENTITY.reduce((a, k) => a + reach(k, g).size, 0); const legitimate = (g) => TRACE.filter(([b, a]) => chainValid(b, a, g)).reduce((s, [, , n]) => s + n, 0); console.log('\n' + 'trust list'.padEnd(47) + ' (identity, role) pairs stalled chains stalled ops/day'); for (const [name, g] of [['role-audit: *', TRUST], [`role-audit: ${narrow['role-audit'].join(', ')}`, narrow], ['role-audit removed', { ...TRUST, 'role-audit': [] }]]) { const stalled = TRACE.filter(([b, a]) => !chainValid(b, a, g)).length; console.log(`${name.padEnd(47)} ${String(pairCount(g)).padEnd(24)} ${String(stalled).padEnd(15)} ${legitimate(TRUST) - legitimate(g)}`); }
5 persistent identities, 6 roles; chains in trace 9 every chain in the trace matches the trust relationship: true persistent identity direct via chain total longest chain used in trace ingest-service 2 0 2 1 1 analytics-job 2 0 2 1 2 field-maintenance 2 1 3 2 2 release-pipeline 3 3 6 3 3 on-call-human 3 1 4 2 3 total 12 5 17 trust list (identity, role) pairs stalled chains stalled ops/day role-audit: * 17 0 0 role-audit: analytics-job, field-maintenance 14 0 0 role-audit removed 12 2 90
Five identities and six roles form thirty possible pairs; the trust lists open seventeen of them.
Twelve are direct assumption, five go through a chain. The chain does the most work for
release-pipeline: it can directly assume three roles, reaches three more through the chain, and
its longest chain is three steps. This identity is never granted role-management authority
anywhere; it reaches role-maintenance through role-release, and from there
role-management. Someone looking at the trust lists one at a time cannot see this, because no
list writes release-pipeline and role-management side by side.
The lower table measures the cost of a wide trust list. Because role-audit is open to every
identity, five pairs come from it; replacing the list with the two names that actually assume it
in the trace drops the pair count from seventeen to fourteen and stops zero legitimate work.
Three pairs were assumption rights that sat open without ever being used. Going one stage
further and closing the role entirely stops two chains and loses ninety operations a day.
Narrowing’s first stage is free, the second is not — the same result that came out of the
resource-based document in the previous lesson, on a different axis.
The True Subject in the Audit Record
// run3.mjs — does the true subject appear in the audit record, and after how many steps does it vanish import { ROLE, TRACE } from './assumption.mjs'; function records(name) { // records are produced from the thirty-day trace const assumption = [], operation = []; for (const [trueSubject, steps, daily] of TRACE) { let cur = trueSubject, prev = null, session = null; for (const [n, r] of steps.entries()) { session = name(trueSubject, r, n); assumption.push({ from: cur, role: r, session, caller: prev }); prev = session; cur = r; } operation.push({ role: steps.at(-1), session, daily, trueSubject, length: steps.length }); } return { assumption, operation }; } // Backtracking: possible true subjects from a (role, session) key function possibleTrueSubjects(assumption, role, session) { const set = new Set(); for (const u of assumption.filter((x) => x.role === role && x.session === session)) { if (!ROLE.includes(u.from)) set.add(u.from); else for (const a of possibleTrueSubjects(assumption, u.from, u.caller)) set.add(a); } return set; } const NAMING = [ // name, readable from last record, generator ['named by role', false, (a, r) => `${r}-session`], ['true subject only in first step', false, (a, r, n) => (n === 0 ? `${a}@${r}` : `${r}-session`)], ['true subject in every step', true, (a, r) => `${a}@${r}`], ]; const totalOps = TRACE.reduce((a, [, , n]) => a + n, 0); console.log(`the thirty-day trace has ${TRACE.length} chains, ${totalOps} operation records per day\n`); console.log('session naming subject in record resolved ops/day percent records to merge max candidates'); const results = new Map(); for (const [name, readable, f] of NAMING) { const { assumption, operation } = records(f); const candidates = operation.map((k) => ({ ...k, candidates: possibleTrueSubjects(assumption, k.role, k.session).size })); results.set(name, candidates); const resolved = candidates.filter((k) => k.candidates === 1); const resolvedOps = resolved.reduce((a, k) => a + k.daily, 0); const toMerge = readable ? '0' : (resolvedOps ? (resolved.reduce((a, k) => a + k.daily * k.length, 0) / resolvedOps).toFixed(2) : '-'); console.log(`${name.padEnd(34)} ${(readable ? 'yes' : 'no').padEnd(19)} ${String(resolvedOps).padEnd(18)} ` + `${(((100 * resolvedOps) / totalOps).toFixed(1) + '%').padEnd(7)} ${toMerge.padEnd(18)} ${Math.max(...candidates.map((k) => k.candidates))}`); } console.log('\nchain length chains ops/day resolved by role name resolved by first-step subject'); for (const u of [1, 2, 3]) { const selected = (name) => results.get(name).filter((k) => k.length === u); const resolvedAt = (name) => selected(name).filter((k) => k.candidates === 1).reduce((a, k) => a + k.daily, 0); console.log(`${String(u).padEnd(16)} ${String(selected('named by role').length).padEnd(7)} ` + `${String(selected('named by role').reduce((a, k) => a + k.daily, 0)).padEnd(10)} ` + `${String(resolvedAt('named by role')).padEnd(23)} ${resolvedAt('true subject only in first step')}`); } const steps = TRACE.reduce((a, [, s]) => a + s.length, 0); console.log(`\nassumption steps ${steps}; with a one-hour temporary credential, ${steps * 24} assumption records per day`);
the thirty-day trace has 9 chains, 2845 operation records per day session naming subject in record resolved ops/day percent records to merge max candidates named by role no 0 0.0% - 3 true subject only in first step no 2845 100.0% 1.17 1 true subject in every step yes 2845 100.0% 0 1 chain length chains ops/day resolved by role name resolved by first-step subject 1 6 2390 0 2390 2 2 430 0 430 3 1 25 0 25 assumption steps 13; with a one-hour temporary credential, 312 assumption records per day
The operation record carries two fields: which role and which session. In a session named by
role, the true subject never appears anywhere, and backtracking does not rescue it either — zero
of the two thousand eight hundred forty-five records a day resolve to a single true subject. The
reason is in the second table: the result is zero even for one-step chains. Three separate
identities reach role-read, and because all three use the same session name, there is no way to
tell which one a given read record came from. In the worst case there are three candidates.
When the session name carries the true subject only in the first step, every record resolves, but the record itself still does not show the true subject: merging an average of one point one seven assumption records is required, and in a three-step chain that number rises to three. When the true subject is carried in every step, merging drops to zero — the question is answered directly from the record itself.
The difference is measured during an incident. Merging three record sets requires having all three relevant records on hand and their time ranges overlapping; the choice of session name, meanwhile, is in the hands of whoever performs the assumption. The last line gives the volume: thirteen assumption steps, three hundred twelve assumption records a day with a one-hour lifetime. Narrowing the window also grows the audit record.
Summary
- The long-lived key’s leak window equals the detection time (30,240 min); with a one-hour temporary credential the expected window is 30 min, 1,008 times shorter.
- The window’s cost is production volume: a 15-minute lifetime requires 2,880 credentials over thirty days and 20 calls an hour; that leaves 480 calls of the fictional 500 limit, and 125 identities fit at the same rate.
- Five identities and six roles form 30 pairs, the trust lists open 17 of them: 12 direct, 5 via
chain.
release-pipelinereachesrole-managementin three steps even though no list ever writes them together. - Writing
role-audit’s*trust list from the trace drops the pair count from 17 to 14 and stops no legitimate work; closing the role entirely stops 2 chains and 90 operations a day. - When the session name is given by role, zero of 2,845 operation records a day resolve to a single true subject; the result is zero even for one-step chains, and in the worst case there are 3 candidates.
- When the true subject is carried only in the first step, every record resolves, but merging an average of 1.17 assumption records is required; carrying it in every step drops the merge to zero.
Next Step
Four of this lesson’s five subjects were a program, one was a human, and all of them were handled the same way. The difference between persistent identity and temporary credential was measured, but one question went unanswered: where does a program get its first identity from. Assuming a role requires first authenticating with an identity, and if that first identity sits somewhere, there is still a long-lived secret at the start of the chain. The next lesson measures this first step: how many separate ways there are to give a workload an identity, how many secrets each way requires storing, and what item takes over once the number of stored secrets drops to zero.
To keep your progress and take notes, Log in
My notes
Log in to take notes.