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

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

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

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

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

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

```js
// 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-pipeline` reaches `role-management` in 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.
