Skip to content
academia.sh

Lesson 05 / 21

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.

Published Updated

In this lesson

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

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.

// 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'],
};
// 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.

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

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

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

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close