---
title: 'Identity and Access Management'
source: 'https://academia.sh/en/courses/cloud-security/identity-and-access-management'
course: 'Cloud and Virtualization Security'
language: en
updated: '2026-10-21T05:02:55+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Runs the walk that starts from a single leaked identity in the access graph and turns the radius into a number'
    - 'Counts directly reached items, items reached through role chaining, and never-reached items separately'
    - 'Runs four audits on the same graph and reports the radius drop together with the legitimate work it stops'
---

# Identity and Access Management

Turning the blast radius into a number: in an access graph of 10 identities and 18 items, a walk is run from a single leaked service account — against 2 directly reached items, 7 more are added through chaining, reaching a total of 9 items, with a 4.5x factor and a depth of 3. Without the assumption edge at all, the widest radius would be 4 instead of 9. Four audits are run on the same graph: removing one trust edge brings the radius down to 4 and stops 4 legitimate jobs a day, while multi-step verification does not lower the radius at all.

The previous lesson classified the twenty-one items that can be misconfigured and gave each one a
seed weight. That weight sat there as a number; it was never computed. This lesson computes it and
sets up the course's unit of measure: the access graph is written down, a walk is run from a single
leaked identity, and **the radius turns into a number**.

The Cloud Governance, Security and Cost course measured this same surface through a governance
lens — who can do what, which policy covers what, which permission goes unused. Here, no policy
text is ever written; what gets measured is access itself, and only one question is asked: if an
identity leaks, how many items can be reached from it. The walk itself is a graph traversal;
**no leak step, call sequence, or credential-harvesting method is ever written**. In a real
organization's access graph, a walk like this cannot be carried out without written authorization
and a defined scope; done without authorization, it is both illegal and unprofessional.

**CM7.** The graph's nodes are of two kinds: an **identity** (a service account or a role) and an
**item** (an accessed resource). An identity-to-item edge shows direct access; an
identity-to-identity edge shows a **role chain**.

**CM8.** The leaked identity is a node of the graph, and the walk starts from there. How the
identity leaks is not modeled in this lesson; what gets measured is the access that follows the
leak.

**CM9.** The **blast radius** is the number of **items** the walk reaches. It is the item count
that is counted, not the identity count, because the damage occurs in the item.

**CM10.** The **direct radius** consists of the leaked identity's own items; an **item reached
through chaining** is only seen after one or more assumption steps.

**CM11.** Assumption edges are transitive, and the graph is acyclic. How many legitimate jobs pass
over each edge per day is a fictional number.

**CM12.** The graph has ten identities and eighteen items; it is the fictional regional
measurement network's counterpart in rented infrastructure.

## The Graph and the Walk

```js
// cloud/graph.mjs — this is a MODEL of an access graph in rented infrastructure. Fictional
// regional measurement network; no real provider, service, or identity name. Contains no leak
// step or call sequence.

// Identity -> the items it directly accesses.
export const ACCESS = {
  'account-collector': ['measurement-store', 'measurement-queue'],
  'account-validator': ['measurement-store', 'measurement-queue', 'raw-archive'],
  'account-portal': ['subscriber-database', 'portal-asset-store', 'work-order-queue'],
  'account-report': ['report-store', 'billing-output-store', 'log-store'],
  'role-billing': ['billing-database', 'billing-output-store', 'mail-dispatch', 'subscriber-database'],
  'role-archive': ['raw-archive', 'backup-store'],
  'role-backup': ['backup-store', 'billing-database', 'subscriber-database', 'measurement-store'],
  'role-distribution': ['distribution-configuration', 'template-store', 'instance-management'],
  'role-log': ['log-store', 'audit-log'],
  'role-management': ['instance-management', 'key-management', 'secret-store'],
};
// Assumption edge: [source identity, target identity, how many legitimate jobs pass over this edge per day]
export const ASSUME = [
  ['account-collector', 'role-archive', 96],
  ['account-validator', 'role-archive', 24],
  ['role-archive', 'role-backup', 4],
  ['role-backup', 'role-management', 1],
  ['account-portal', 'role-billing', 310],
  ['role-billing', 'role-log', 12],
  ['account-report', 'role-log', 8],
  ['role-distribution', 'role-management', 6],
];
export const IDENTITY = Object.keys(ACCESS);
export const ITEM = [...new Set(Object.values(ACCESS).flat())].sort();

// Radius walk: the set of items reached by expanding outward from the starting identity.
// Step 0 = the leaked identity's own items (direct radius).
export function walk(start, edge = ASSUME) {
  const seen = new Set([start]);
  const step = new Map([[start, 0]]);
  const item = new Map();                        // item -> the step it was first seen at
  let queue = [start];
  while (queue.length) {
    const next = [];
    for (const k of queue) {
      for (const r of ACCESS[k]) if (!item.has(r)) item.set(r, step.get(k));
      for (const [a, b] of edge)
        if (a === k && !seen.has(b)) { seen.add(b); step.set(b, step.get(k) + 1); next.push(b); }
    }
    queue = next;
  }
  const direct = [...item.values()].filter((d) => d === 0).length;
  return { identity: seen, item, radius: item.size, direct,
    chained: item.size - direct, depth: Math.max(...step.values()) };
}
```

The leaked identity is the service account for the meter-collection workload. This account sits at
the outer edge of the graph: it writes to two items and assumes a single role.

```js
// cloud/run1.mjs
import { ITEM, IDENTITY, walk } from './graph.mjs';

const r = walk('account-collector');
console.log(`graph: ${IDENTITY.length} identities, ${ITEM.length} items`);
console.log('leaked identity: account-collector');
console.log(`  identities reached ${r.identity.size}: ${[...r.identity].join(' -> ')}`);
console.log(`  radius ${r.radius}/${ITEM.length}  direct ${r.direct}  ` +
  `chained ${r.chained}  depth ${r.depth}`);
console.log(`  chain factor ${(r.radius / r.direct).toFixed(1)}x` +
  `, unreached ${ITEM.length - r.radius}\n`);
console.log('step  items reached');
for (let d = 0; d <= r.depth; d++)
  console.log(`${String(d).padStart(4)}  ` +
    ITEM.filter((k) => r.item.get(k) === d).join(', '));
const outside = ITEM.filter((k) => !r.item.has(k));
console.log('   -  ' + outside.slice(0, 5).join(', '));
console.log('      ' + outside.slice(5).join(', '));
```

```
graph: 10 identities, 18 items
leaked identity: account-collector
  identities reached 4: account-collector -> role-archive -> role-backup -> role-management
  radius 9/18  direct 2  chained 7  depth 3
  chain factor 4.5x, unreached 9

step  items reached
   0  measurement-queue, measurement-store
   1  backup-store, raw-archive
   2  billing-database, subscriber-database
   3  instance-management, key-management, secret-store
   -  audit-log, billing-output-store, distribution-configuration, log-store, mail-dispatch
      portal-asset-store, report-store, template-store, work-order-queue
```

The radius is nine. The leaked account's own authorization is two items; the remaining seven items
come after three assumption steps. The factor is four and a half times, and half of the eighteen
items fall outside the walk.

The step distribution carries the real information. At step zero there are the measurement items — the
two items the meter-collection workload actually needs. At step one, the archive; at step two,
**the subscriber and billing data**; at step three, key management and the secret store appear.
There is no relationship at all between the leaked account's job and the items of these last two
steps: a workload that collects meter readings touches neither the billing database nor key
management. Even so, a few steps later, both are inside the walk.

This shows that the radius arises not from **per-item permission** but from **trust between
identities**. None of the nine items is explicitly granted to the leaked account.

## The Radius Changes from Identity to Identity

Measuring a single identity is not enough; which identity will be the one that leaks is not known.

```js
// cloud/run2.mjs — radius for each identity: how much does chaining multiply the radius by
import { ITEM, IDENTITY, walk } from './graph.mjs';

const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-19, 10, 10, 10, 9, 9];
print(A, 'leaked identity', 'direct', 'chained', 'radius', 'factor', 'depth');
const R = IDENTITY.map((k) => [k, walk(k)]);
for (const [k, r] of R)
  print(A, k, r.direct, r.chained, `${r.radius}/${ITEM.length}`,
    `${(r.radius / r.direct).toFixed(1)}x`, r.depth);
const y = R.map(([, r]) => r.radius);
console.log(`\nwidest radius ${Math.max(...y)}, average ` +
  `${(y.reduce((a, x) => a + x, 0) / y.length).toFixed(1)}, ` +
  `identities with a radius over 8: ${y.filter((x) => x > 8).length}/${IDENTITY.length}`);
const unchained = IDENTITY.map((k) => walk(k, []).radius);
console.log(`without the assumption edge at all: widest ${Math.max(...unchained)}, average ` +
  `${(unchained.reduce((a, x) => a + x, 0) / unchained.length).toFixed(1)}`);
```

```
leaked identity        direct   chained    radius   factor    depth
account-collector           2         7      9/18     4.5x        3
account-validator           3         6      9/18     3.0x        3
account-portal              3         5      8/18     2.7x        2
account-report              3         1      4/18     1.3x        1
role-billing                4         2      6/18     1.5x        1
role-archive                2         6      8/18     4.0x        2
role-backup                 4         3      7/18     1.8x        1
role-distribution           3         2      5/18     1.7x        1
role-log                    2         0      2/18     1.0x        0
role-management             3         0      3/18     1.0x        0

widest radius 9, average 6.1, identities with a radius over 8: 2/10
without the assumption edge at all: widest 4, average 2.9
```

The table says the opposite of intuition. The widest radius is not in the management role, but in
the two service accounts at the outermost edge of the graph: the collector and the validator each
reach nine items. `role-management` stays at three items, because it cannot assume any role — the
walk ends there. An identity's **own authorization** does not determine the number of items it
opens up when it leaks.

The last two lines give the chain's share. Without the assumption edge at all, the widest radius
would be four and the average two point nine. With the edges added, these rise to nine and six
point one — the widest radius grows two point twenty-five times over, the average two point one
times over. Eight assumption edges double the average radius in an eighteen-item graph.

## The Inverse Direction: In How Many Walks Does an Item Show Up

The previous section asked "how far can this identity go." This section asks the reverse question:
"how many identities can reach this item."

```js
// cloud/run4.mjs — inverse direction: how many identities' walks does each item appear in
import { ITEM, IDENTITY, walk } from './graph.mjs';

const R = IDENTITY.map((k) => walk(k));
const count = (k, f) => R.filter((r) => r.item.has(k) && f(r.item.get(k))).length;
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-24, 10, 10, 10];
print(A, 'item', 'reached', 'direct', 'chain');
print(A, '', 'identity', 'only', 'only');
const sorted = [...ITEM].sort((a, b) => count(b, () => true) - count(a, () => true));
for (const k of sorted.slice(0, 6))
  print(A, k, count(k, () => true), count(k, (d) => d === 0), count(k, (d) => d > 0));
console.log('  ...');
for (const k of sorted.slice(-3))
  print(A, k, count(k, () => true), count(k, (d) => d === 0), count(k, (d) => d > 0));
const pair = (f) => ITEM.reduce((a, k) => a + count(k, f), 0);
console.log(`\ntotal (identity, item) access pairs ${pair(() => true)}: ` +
  `direct ${pair((d) => d === 0)}, chained ${pair((d) => d > 0)}`);
console.log(`on average, an item appears in ${(pair(() => true) / ITEM.length).toFixed(1)} ` +
  `identities' walks`);
```

```
item                       reached    direct     chain
                          identity      only      only
billing-database                 6         2         4
instance-management              6         2         4
key-management                   6         1         5
secret-store                     6         1         5
subscriber-database              6         3         3
audit-log                        4         1         3
  ...
report-store                     1         1         0
template-store                   1         1         0
work-order-queue                 1         1         0

total (identity, item) access pairs 61: direct 29, chained 32
on average, an item appears in 3.4 identities' walks
```

Five items appear in six of the ten identities' walks. `secret-store` and `key-management` sit at
the top of this list, and both are granted directly to only a **single** identity; the remaining
five paths come through chaining. The same row splits three-to-three for `subscriber-database` —
that item is genuinely the work of three separate workloads.

The last line reduces the whole graph to a single number: of sixty-one access pairs, twenty-nine
are direct and thirty-two are chained. In other words, in this graph, **more than half** of the
ways an item can be seen by an identity are never explicitly granted anywhere. Twenty-nine can be
counted by looking at a permission list; the remaining thirty-two can only be seen by running the
walk.

## The Audits Run on the Same Graph

Four audits are run over the same walk. Each one's cost is the legitimate work that passes over the
edge.

```js
// cloud/run3.mjs — four audits on the same graph: how far does the radius drop, at what cost in legitimate jobs per day
import { ITEM, IDENTITY, ASSUME, walk } from './graph.mjs';

const target = new Set(ASSUME.map(([, b]) => b));
const AUDIT = [                                    // [name, filter selecting the edges that remain]
  ['no audit', () => true],
  ['archive-backup trust removed', ([a, b]) => !(a === 'role-archive' && b === 'role-backup')],
  ['backup-management trust removed', ([a, b]) => !(a === 'role-backup' && b === 'role-management')],
  ['assumption reduced to a single step', ([a]) => !target.has(a)],
  ['multi-step verification for human users', () => true],
];
const TOTAL_WORK = ASSUME.reduce((a, [, , m]) => a + m, 0);
const print = (g, ...s) => console.log(s.map((v, i) =>
  g[i] < 0 ? String(v).padEnd(-g[i]) : String(v).padStart(g[i])).join(''));
const A = [-40, 9, 10, 10, 10];
print(A, 'audit', 'radius', 'widest', 'average', 'stopped');
for (const [name, filt] of AUDIT) {
  const edge = ASSUME.filter(filt);
  const y = IDENTITY.map((k) => walk(k, edge).radius);
  print(A, name, walk('account-collector', edge).radius, Math.max(...y),
    (y.reduce((a, x) => a + x, 0) / y.length).toFixed(1),
    TOTAL_WORK - edge.reduce((a, [, , m]) => a + m, 0));
}
console.log(`\n(a total of ${TOTAL_WORK} legitimate jobs a day pass over the assumption edges)\n`);
console.log('if a single assumption edge is removed (leaked identity: account-collector):');
const B = [-19, -14, 9, 11];
print(B, 'edge', '', 'radius', 'stopped');
print(B, '', '', 'drop', 'per day');
for (const [a, b, m] of ASSUME)
  print(B, a, `-> ${b}`,
    9 - walk('account-collector', ASSUME.filter((e) => e[0] !== a || e[1] !== b)).radius, m);
```

```
audit                                      radius    widest   average   stopped
no audit                                        9         9       6.1         0
archive-backup trust removed                    4         8       4.5         4
backup-management trust removed                 6         8       4.9         1
assumption reduced to a single step             4         6       3.8        17
multi-step verification for human users         9         9       6.1         0

(a total of 461 legitimate jobs a day pass over the assumption edges)

if a single assumption edge is removed (leaked identity: account-collector):
edge                                radius    stopped
                                      drop    per day
account-collector  -> role-archive        7         96
account-validator  -> role-archive        0         24
role-archive       -> role-backup        5          4
role-backup        -> role-management        3          1
account-portal     -> role-billing        0        310
role-billing       -> role-log           0         12
account-report     -> role-log           0          8
role-distribution  -> role-management        0          6
```

The upper table puts the four audits side by side. Removing the archive role's assumption of the
backup role brings the radius down from nine to four and stops four legitimate jobs a day. Removing
the backup role's assumption of the management role brings it down from nine to six and stops a
single job a day. Reducing assumption to a single step — an already-assumed role cannot assume
another role — pulls the radius to four, the widest radius to six, and the average to three point
eight; its cost is seventeen jobs a day.

The last row carries this lesson's second finding. When multi-step verification is made mandatory
for human users, the radius stays at nine, the average stays at six point one, and the legitimate
work stopped is zero. The audit is not wrong, and it does close its own path — but the leaked
identity is a service account, and that account never passes through any verification step. **An
audit that does not lower the radius looks effective right up until it is run.**

The lower table ranks the audit edge by edge and shows how the choice should be made. Five of the
eight edges do not lower the radius at all for this leaked identity; among them is also the busiest
edge, carrying three hundred ten jobs a day. The biggest drop comes from removing the leaked
account's own assumption edge — seven items — but its cost is ninety-six jobs a day.
`role-archive -> role-backup` drops five items and stops four jobs a day. The cost per item is
seventeen times higher for the first one.

## Summary

- The blast radius is the number of items reached by a walk that starts from a leaked identity.
  From the meter-collection account, the radius is 9/18; direct 2, chained 7, depth 3, factor 4.5x.
- The radius does not come from per-item permissions but from trust between identities: of a total
  of 61 `(identity, item)` access pairs, 29 are direct and 32 are chained. 6 identities reach
  `secret-store` and `key-management`, and both are granted directly to only a single identity.
- The widest radius is not in the management role but in the two service accounts at the outer
  edge of the graph (9/18); `role-management` stays at 3/18, because it cannot assume any role.
- Eight assumption edges raise the widest radius from 4 to 9 and the average from 2.9 to 6.1:
  chaining grows the average radius by close to double.
- Removing one trust edge brings the radius down from 9 to 4 and stops 4 legitimate jobs a day;
  reducing assumption to a single step brings it down to 4 and stops 17 jobs a day.
- Multi-step verification leaves the radius at 9 and stops no legitimate work; removing five of the
  eight edges also does not lower the radius at all — one of them is the busiest edge, carrying 310
  jobs a day.

## Next Step

One thing went unquestioned throughout the walk: does an identity having access authorization to
an item mean it can actually **reach** that item. The graph had an edge, and the walk passed over
the edge; whether the network the item sits on is reachable from where that identity is located was
never looked at. The next lesson adds a second layer to the same graph: each item's network path.
The radius does not come out the same when the leaked identity is used from outside versus from
inside the network.
