---
title: 'Workload Identities'
source: 'https://academia.sh/en/courses/cloud-governance/workload-identities'
course: 'Cloud Governance, Security and Cost'
language: en
updated: '2026-09-13T05:07:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Counts the leak surface of three identity-issuance paths: how many separate locations, how many copies, how many persistent, and how many sit in a location that keeps no access record'
    - 'Measures rotation work: how many locations are touched by hand, how many instances restart, and how many copies rotation never reaches'
    - 'Works out identity scope — how much extra permission and how many extra workloads a host-scoped identity opens'
    - 'Counts separately how identity is issued at the edge and function layer, and shows the persistent copy that remains even on the best path'
---

# Workload Identities

Three ways of giving an application an identity are measured side by side: a long-lived key sitting in a file, a secret in an environment variable, and an identity issued by the runtime. Leak surface, rotation work, and identity scope are counted; how identity is issued at the edge and function layer is worked out separately.

The previous lesson made human identities temporary: authority is obtained by assumption and
drops once the work is done. The same cannot be said for code. The measurement network's
workload instances write to the data store overnight, and while doing it they read a key that
sits somewhere. That key was written the day it was set up; nowhere does it say when it will
change. The secret's lifetime and leak window were measured in M22/K07 and are not repeated
here. This lesson's question is not lifetime but **location** and **scope**: how many separate
places the identity sits in, how many places changing it requires touching, and how much more
work opens up under that identity once a process is compromised. Three paths are set side by
side — a long-lived key sitting in a file, a secret placed in an environment variable, and an
identity issued by the runtime.

- **CI25.** Ten workloads, three hosts, three environments, and twelve edge points belong to the
  fictional regional measurement network. No real key, secret, or credential was written; the
  names are made up. What is measured is the model written with `node`.
- **CI26.** There is no randomness; the numbers are derived from the model's tables, and the
  output is the same from run to run.
- **CI27.** The identity's lifetime and leak window are not measured. Three things are counted:
  where the identity sits, where rotation touches, and the identity's scope.
- **CI28.** A location is either persistent or temporary, either keeps an access record or does
  not; partial recording is not modeled.
- **CI29.** In a host-scoped identity, every process on the host can read the host's identity;
  isolation between processes is not modeled. In reality isolation lowers the entry, it does not
  zero it.
- **CI30.** The edge layer has no runtime-issued identity; identity travels with the deployment
  package. This is a model decision; edge points were assumed not to carry a local identity
  endpoint.

## Where the Identity Sits

The model holds ten workloads, which host each runs on, and the permissions each one needs. The
seven workloads at the container and machine layers are spread across three hosts; the three at
the function and edge layers have no host. The `LOCATION` table gives, for each path, where the
identity sits, how many times each location exists in the system, whether it is persistent, and
whether it keeps an access record.

```js
// identity.mjs — workload, host, and identity-issuance path model
// Fictional regional measurement network; no real key, secret, or credential, names are made up.
export const ENV = ['development', 'staging', 'production'];
export const WORKLOAD = {
  'read-collector':    { layer: 'container', host: 'k1', permission: ['measurement-write', 'queue-write'] },
  'validator':         { layer: 'container', host: 'k1', permission: ['measurement-read', 'queue-read', 'measurement-write'] },
  'log-shipper':       { layer: 'container', host: 'k1', permission: ['log-write', 'config-read'] },
  'billing':           { layer: 'container', host: 'k2', permission: ['invoice-read', 'invoice-write', 'mail-send'] },
  'work-order':        { layer: 'container', host: 'k2', permission: ['work-order-write', 'queue-read', 'mail-send'] },
  'nightly-batch':     { layer: 'machine',   host: 'm1', permission: ['measurement-read', 'archive-write', 'invoice-read'] },
  'archiver':          { layer: 'machine',   host: 'm1', permission: ['archive-read', 'archive-write'] },
  'metric-summary':    { layer: 'function',  host: null, permission: ['measurement-read'] },
  'report-generator':  { layer: 'function',  host: null, permission: ['invoice-read', 'archive-read'] },
  'edge-validator':    { layer: 'edge',      host: null, permission: ['measurement-write'] },
};
export const NAME = Object.keys(WORKLOAD);
export const HOST = ['k1', 'k2', 'm1'];
export const EDGE_POINT = 12;                      // points the edge layer is deployed to
// Scale names: how many times a location exists in the system.
export const SCALE = {
  instance: NAME.length * ENV.length,              // (workload, env) pairs
  host: HOST.length * ENV.length,
  single: 1,
};
// Where the identity sits: [name, scale, persistent?, keeps an access record?, how rotation reaches it]
export const LOCATION = {
  'key-in-file': [
    ['instance-disk', 'instance', true, false, 'manual'], ['machine-image', 'host', true, false, 'manual'],
    ['config-store', 'instance', true, true, 'manual'], ['backup', 'host', true, false, 'unreachable'],
    ['developer-laptop', 'single', true, false, 'unreachable']],
  'env-variable': [
    ['deployment-spec', 'instance', true, true, 'manual'], ['process-env', 'instance', false, false, 'restart'],
    ['crash-dump', 'host', true, false, 'unreachable']],
  'runtime-issued': [
    ['process-memory', 'instance', false, true, 'automatic']],
};
export const PATH = Object.keys(LOCATION);
// Is the identity read when the process starts: if so, rotation needs a restart.
export const READ_AT_START = { 'key-in-file': true, 'env-variable': true, 'runtime-issued': false };
export const scaleOf = (y) => SCALE[y[1]];
export const sumBy = (path, pick) => LOCATION[path].filter(pick).reduce((a, y) => a + scaleOf(y), 0);
// In a host-scoped identity, the permission a host carries is the union of the workloads on it.
export const hostPermission = (k) =>
  [...new Set(NAME.filter((a) => WORKLOAD[a].host === k).flatMap((a) => WORKLOAD[a].permission))];
export const onHost = (k) => NAME.filter((a) => WORKLOAD[a].host === k);
// Per layer: whether a runtime-issued identity exists, and its narrowest scope.
export const LAYER = {
  machine: [true, 'host'], container: [true, 'instance'], function: [true, 'call'], edge: [false, 'deployment'],
};
```

```js
// run1.mjs — the leak surface and rotation work of all three paths in one table
import { NAME, ENV, SCALE, LOCATION, PATH, sumBy, READ_AT_START } from './identity.mjs';

const G = [11, 8, 12, 11, 8, 10, 13];               // column widths
const row = (name, v) => name.padEnd(18) + v.map((x, i) => String(x).padStart(G[i])).join('');
const countBy = (path, mode) => sumBy(path, (y) => y[4] === mode);
console.log(`workloads ${NAME.length}, envs ${ENV.length}, instances ${SCALE.instance}, host items ${SCALE.host}\n`);
console.log(row('', ['', '', '', '', 'manual', 'restart', 'unreachable']));
console.log(row('path', ['locations', 'copies', 'persistent', 'no record', 'touch', 'restarts', 'copies']));
for (const path of PATH)
  console.log(row(path, [LOCATION[path].length, sumBy(path, () => true), sumBy(path, (y) => y[2]),
    sumBy(path, (y) => !y[3]), countBy(path, 'manual'), READ_AT_START[path] ? SCALE.instance : 0,
    countBy(path, 'unreachable')]));
```

```
workloads 10, envs 3, instances 30, host items 9

                                                              manual   restart  unreachable
path                locations  copies  persistent  no record   touch  restarts       copies
key-in-file                 5      79          79         49      69        30           10
env-variable                3      69          39         39      30        30            9
runtime-issued              1      30           0          0       0         0            0
```

The key sitting in a file lives in five separate locations, seventy-nine copies in total, and
all of them are persistent. Forty-nine copies sit in locations that keep no access record. The
environment-variable path brings the location count down to three but only drops the total
copies to sixty-nine; the real difference is in the persistent and no-record columns, both of
which drop to thirty-nine.

The third row puts the measure in its place. For the runtime-issued identity, persistent copies
are zero, no-record copies are zero. There are thirty copies — one in every instance's memory —
but every one of them gets recorded as it is fetched from the identity endpoint. The gain is not
the copy count going down; it is the persistent copy count dropping from seventy-nine to zero,
and forty-nine silent reads becoming recorded ones.

## Rotation Work

The table's right three columns are the cost side. Seeing where these columns come from requires
listing the locations out one by one.

```js
// run2.mjs — where the identity sits and how rotation reaches those places
import { LOCATION, PATH, scaleOf } from './identity.mjs';

for (const path of PATH) {
  console.log(path);
  for (const y of LOCATION[path])
    console.log(`  ${y[0].padEnd(21)}${String(scaleOf(y)).padStart(3)}  ${(y[2] ? 'persistent' : 'temporary')}` +
      `  record ${(y[3] ? 'yes' : 'no').padEnd(4)} rotation: ${y[4]}`);
}
```

```
key-in-file
  instance-disk         30  persistent  record no   rotation: manual
  machine-image          9  persistent  record no   rotation: manual
  config-store          30  persistent  record yes  rotation: manual
  backup                 9  persistent  record no   rotation: unreachable
  developer-laptop       1  persistent  record no   rotation: unreachable
env-variable
  deployment-spec       30  persistent  record yes  rotation: manual
  process-env           30  temporary  record no   rotation: restart
  crash-dump             9  persistent  record no   rotation: unreachable
runtime-issued
  process-memory        30  temporary  record yes  rotation: automatic
```

Changing the key in the file requires manually touching sixty-nine locations and restarting
thirty instances; ninety-nine touches in total. For the environment variable, the manual-touch
count drops to thirty, because the process environment renews itself automatically on restart.
On the third path both columns are zero — there is no separate rotation job at all.

The real finding is in the last column. On the file path, rotation never reaches ten copies at
all: nine backups and one developer laptop. On the environment-variable path, nine crash dumps
are in the same situation, and none of the three carry a record. Revoking the old one is a
separate step, and whether that revocation can be done safely while unreachable copies exist is
unknown: a machine restored from a backup boots with a revoked key. At the end of a
ninety-nine-touch job, the item that never closes is not zero — it is ten.

## Identity Scope

The third measure is scope: is the identity issued to an instance, or to the host that instance
runs on. In a host-scoped identity, every process on the host can read the same identity, so the
identity's permission is the union of the permissions of every workload on that host.

```js
// run3.mjs — identity scope: how much extra permission and how many extra workloads a host-scoped identity opens
import { NAME, ENV, WORKLOAD, HOST, hostPermission, onHost } from './identity.mjs';

console.log('host   workloads    union perms  permissions');
for (const k of HOST) {
  const u = hostPermission(k);
  console.log(`${k.padEnd(7)}${String(onHost(k).length).padStart(7)}${String(u.length).padStart(15)}  ${u.join(', ')}`);
}
console.log('\nworkload            scope    own    host   extra    extra');
console.log('                            perm   perm   perm     workload');
let own = 0, hostScoped = 0, extraWork = 0;
for (const a of NAME) {
  const w = WORKLOAD[a], k = w.host;
  const hostN = k ? hostPermission(k).length : w.permission.length;
  const extraWorkY = k ? onHost(k).length - 1 : 0;
  own += w.permission.length; hostScoped += hostN; extraWork += extraWorkY;
  console.log(`${a.padEnd(19)}${(k ?? 'no-host').padEnd(9)}${String(w.permission.length).padStart(5)}` +
    `${String(hostN).padStart(7)}${String(hostN - w.permission.length).padStart(9)}${String(extraWorkY).padStart(10)}`);
}
const O = ENV.length;
console.log('');
for (const [name, v] of [
  ['instance-scoped identity (instance, perm)', own * O],
  ['host-scoped identity (instance, perm)', hostScoped * O],
  ['extra permission entries granted', `${(hostScoped - own) * O}  (${Math.round(((hostScoped - own) / own) * 100)}%)`],
  ['extra (instance, workload) entries reached', extraWork * O],
  ['most extra work opened from a single instance', Math.max(...HOST.map((k) => onHost(k).length - 1))],
  ['instances that open no extra work at all', `${NAME.filter((a) => !WORKLOAD[a].host).length * O} / ${NAME.length * O}`],
]) console.log(`${name.padEnd(47)}${v}`);
```

```
host   workloads    union perms  permissions
k1           3              6  measurement-write, queue-write, measurement-read, queue-read, log-write, config-read
k2           2              5  invoice-read, invoice-write, mail-send, work-order-write, queue-read
m1           2              4  measurement-read, archive-write, invoice-read, archive-read

workload            scope    own    host   extra    extra
                            perm   perm   perm     workload
read-collector     k1           2      6        4         2
validator          k1           3      6        3         2
log-shipper        k1           2      6        4         2
billing            k2           3      5        2         1
work-order         k2           3      5        2         1
nightly-batch      m1           3      4        1         1
archiver           m1           2      4        2         1
metric-summary     no-host      1      1        0         0
report-generator   no-host      2      2        0         0
edge-validator     no-host      1      1        0         0

instance-scoped identity (instance, perm)      66
host-scoped identity (instance, perm)          120
extra permission entries granted               54  (82%)
extra (instance, workload) entries reached     30
most extra work opened from a single instance  2
instances that open no extra work at all       9 / 30
```

The upper block shows how much the union grows. `k1` has three workloads on it, none of them
needing more than three permissions, and the host's union permission is six. The log shipper
needs two permissions but carries six under the host identity; two of the four extra ones are
measurement and queue writes — two data paths the log shipper never touches at all.

The lower block is the system total. The instance-scoped identity has sixty-six `(instance,
permission)` entries, the host-scoped identity a hundred twenty. The fifty-four extra entries
granted amount to eighty-two percent of the real need. Compromising a single instance opens
extra access in thirty `(instance, workload)` entries; in the worst case a single process opens
two more workloads.

The last line states the limit of the scope measure: nine of the thirty instances share no
host, so the scope question never even arises for them. The scope entry is the entry of host
sharing. Moving the identity from a file to the runtime zeroes out the location and rotation
columns, but it does not zero out this one — a runtime-issued identity can still be host-scoped.

## Edge and Function Layer

Scope changes by layer: the runtime does not issue an identity the same way at every layer, and
at some it does not issue one at all.

```js
// run4.mjs — how identity is issued at the edge and function layer: scope and persistent copies counted separately
import { NAME, ENV, WORKLOAD, LAYER, EDGE_POINT } from './identity.mjs';

const O = ENV.length;
const onLayer = (lt) => NAME.filter((a) => WORKLOAD[a].layer === lt);
const persistentCopies = (lt) => (LAYER[lt][0] ? 0 : onLayer(lt).length * EDGE_POINT * O);
console.log('layer      workloads  instances  runtime-issued  narrowest   persistent  rotation');
console.log('                                 identity        scope       copies      touch');
for (const lt of Object.keys(LAYER)) {
  const n = onLayer(lt).length;
  console.log(`${lt.padEnd(11)}${String(n).padStart(7)}${String(n * O).padStart(7)}  ` +
    `${(LAYER[lt][0] ? 'yes' : 'no').padEnd(17)}${LAYER[lt][1].padEnd(10)}` +
    `${String(persistentCopies(lt)).padStart(6)}${String(persistentCopies(lt)).padStart(10)}`);
}
const canIssue = Object.keys(LAYER).filter((k) => LAYER[k][0]);
const narrower = canIssue.filter((k) => LAYER[k][1] !== 'host');
const countAt = (ks) => ks.reduce((a, k) => a + onLayer(k).length * O, 0);
console.log('');
for (const [name, v] of [
  ['instances that can receive a runtime identity', `${countAt(canIssue)} / ${NAME.length * O}`],
  ['instances that can get instance-or-narrower scope', `${countAt(narrower)} / ${NAME.length * O}`],
  ['instances that remain host-scoped', `${countAt(canIssue.filter((k) => LAYER[k][1] === 'host'))} / ${NAME.length * O}`],
  ['instances that get none at all', `${countAt(Object.keys(LAYER).filter((k) => !LAYER[k][0]))} / ${NAME.length * O}`],
  ['persistent copies left in the system on the best path', Object.keys(LAYER).reduce((a, k) => a + persistentCopies(k), 0)],
]) console.log(`${name.padEnd(56)}${v}`);
```

```
layer      workloads  instances  runtime-issued  narrowest   persistent  rotation
                                 identity        scope       copies      touch
machine          2      6  yes              host           0         0
container        5     15  yes              instance       0         0
function         2      6  yes              call           0         0
edge             1      3  no               deployment    36        36

instances that can receive a runtime identity           27 / 30
instances that can get instance-or-narrower scope       21 / 30
instances that remain host-scoped                       6 / 30
instances that get none at all                          3 / 30
persistent copies left in the system on the best path   36
```

At the machine layer an identity exists, but its narrowest scope is the host: everything running
on that machine can read the identity issued to it. At the container layer, instance scope is
possible — each workload can get its own identity. The function layer is the narrowest of all —
the identity is issued per call, disappears once the call ends, and persistent copies are zero.

The edge row stands apart from the rest. Edge points have no local identity endpoint; the
identity travels inside the deployment package, and twelve points times three environments makes
thirty-six persistent copies. Rotation touches the same thirty-six locations. The layer that
carries identity under the worst conditions is the one facing outward.

The lower block gives the total. Twenty-seven of thirty instances can receive a runtime-issued
identity, but only twenty-one of them can get instance-or-narrower scope; six remain
host-scoped, and three get none at all. Even choosing the best path, thirty-six persistent
copies remain in the system, all in a single layer. Dropping from seventy-nine to thirty-six is
a real gain, but it does not reach zero; what remains is left there by what the layer makes
impossible.

## Summary

- The key sitting in a file lives in 5 locations, 79 copies, all persistent; 49 of them are in a
  location that keeps no access record. The environment variable drops persistent copies to 39.
  The runtime-issued identity has 30 copies but 0 persistent, 0 without a record — the gain is
  not in the copy count, it is in the copy's persistence and recordability.
- Rotation requires 69 manual touches and 30 restarts on the file path, 30 touches and 30
  restarts on the environment variable, 0 on the runtime-issued identity.
- Copies rotation never reaches: 10 on the file path (9 backups, 1 laptop), 9 on the environment
  variable (crash dumps); these leave whether revocation can be done safely unresolved.
- A host-scoped identity raises the `(instance, permission)` entry count from 66 to 120: 54 extra
  permissions, 82% of the real need. Compromising a single instance opens extra access in 30
  `(instance, workload)` entries; 9 of the 30 instances share no host, so this entry never arises
  for them.
- The layer determines identity: 27 of 30 instances can receive a runtime-issued identity, but
  only 21 can get instance-or-narrower scope, 6 remain host-scoped, 3 get none at all.
- Even on the best path 36 persistent copies remain, all at the edge layer; with no local
  identity endpoint there, the identity multiplies to 12 points through the deployment package.

## Next Step

This lesson changed where identity comes from, but never looked at what that identity can do.
The number that came out of measuring scope already says it: identity `k1` carries six
permissions, and none of the three workloads on it use all six. Even once instance scope is
reached, the same question stands — is the permission set written for a workload the same set
that workload actually uses? That is the next lesson's question. Once usage history and the
granted policy are set side by side, what does the unused-permission ratio come out to, how many
lines does the narrowed policy run to, and how much legitimate work does narrowing stop — the
rare path that never showed up in history but is still necessary is the cost of narrowing.
