Lesson 07 / 21
Account and Organization Structure
The same workload set is set up in three structures — single account, account by environment, account by workload. Blast radius, quota sharing, how the bill splits apart, and management overhead are counted; what the organization-level protection band cuts and cannot cut is measured.
Published Updated
In this lesson
- Counts blast radius in three structures: how many resources open up if an identity is compromised
- Measures quota sharing and works out which account hits the limit and how much headroom remains
- Counts management overhead: how many accounts, how many policy copies, and how many lines the bill splits into
- Shows separately what the organization-level protection band cuts, the legitimate work it stops, and the blast radius it cannot cut
Contents
The previous two lessons measured where identity comes from and what it can do; both stayed within a single boundary. Even after policy is narrowed, one question remains unanswered: inside which boundary do these workloads sit? The same eight workloads and three environments can be set up in three separate structures — all in a single account, in accounts split by environment, or in accounts split by workload. This lesson sets up all three side by side and counts four things: blast radius, how many workloads share the quota, how many lines the bill splits into, and how many accounts and how many policy copies are managed. At the end, what the organization-level protection band cuts and cannot cut is measured.
- CI37. Eight workloads, three environments, eight resource families, and twenty-four
resources belong to the fictional regional measurement network; what is measured is the model
written with
node. - CI38. Blast radius is the union of two sets: the resources the identity reaches through policy, and the resources inside its account. Escalation within the account is assumed possible, crossing the account boundary is assumed impossible.
- CI39. Quota is granted per account and modeled as a single number. It is a fictional unit; it is not a real price or limit list.
- CI40. Demand is constant; spikes, backoff, and queuing are not modeled. These change the moment the limit is hit, not which account hits it.
- CI41. A workload’s bill share is counted as directly readable only if it is alone in its account; distribution through tagging is not measured in this lesson.
- CI42. The organization-level protection band is attached to the account and cannot separate what is inside it. This is a model decision; whether the band can cut by environment depends on the account boundary separating the environment.
Same Set, Three Structures
The model names resources by a family-and-environment pair, every family has an owning workload, and the three structures answer a single question differently: which account a workload falls into. Everything else — policy, resources, demand — is the same across all three structures.
// account.mjs — the same workload set is set up in three account structures; fictional measurement network model export const ENV = ['development', 'staging', 'production']; export const FAMILY = ['measurement', 'queue', 'archive', 'invoice', 'work-order', 'log', 'config', 'mail']; export const PERMISSION = { // family -> permissions the family opens measurement: ['read', 'write', 'delete'], queue: ['read', 'write', 'manage'], archive: ['read', 'write', 'delete'], invoice: ['read', 'write'], 'work-order': ['read', 'write'], log: ['read', 'write'], config: ['read', 'write'], mail: ['send'], }; export const GRANTED = { // workload -> families it has access to 'read-collector': ['measurement', 'queue'], 'validator': ['measurement', 'queue'], 'log-shipper': ['log', 'config'], 'billing': ['invoice', 'mail', 'archive'], 'work-order': ['work-order', 'queue', 'mail'], 'nightly-batch': ['measurement', 'archive', 'invoice'], 'archiver': ['archive'], 'report-generator': ['invoice', 'archive', 'log'], }; export const OWNER = { // owning workload of a resource family measurement: 'read-collector', queue: 'validator', archive: 'archiver', invoice: 'billing', 'work-order': 'work-order', log: 'log-shipper', config: 'log-shipper', mail: 'billing', }; export const ACTUAL = { // paths actually used 'read-collector': ['measurement-write', 'queue-write', 'measurement-read'], 'validator': ['measurement-read', 'queue-read', 'measurement-write', 'measurement-delete'], 'log-shipper': ['log-write', 'config-read'], 'billing': ['invoice-read', 'invoice-write', 'mail-send', 'archive-read'], 'work-order': ['work-order-write', 'work-order-read', 'queue-read', 'mail-send'], 'nightly-batch': ['measurement-read', 'archive-write', 'invoice-read', 'measurement-delete'], 'archiver': ['archive-read', 'archive-write', 'archive-delete'], 'report-generator': ['invoice-read', 'archive-read', 'log-read'], }; export const SUBJECT = Object.keys(GRANTED); export const DEMAND = { // fictional unit: requests/minute, at production scale 'read-collector': 420, 'validator': 260, 'log-shipper': 90, 'billing': 140, 'work-order': 110, 'nightly-batch': 380, 'archiver': 70, 'report-generator': 60, }; export const ENV_SCALE = { development: 0.1, staging: 0.3, production: 1 }; export const QUOTA = 1000; // quota per account, fictional unit export const RESOURCE = FAMILY.flatMap((a) => ENV.map((e) => `${a}@${e}`)); export const IDENTITY = SUBJECT.flatMap((s) => ENV.map((e) => `${s}@${e}`)); // Three structures: which account a workload falls into. export const STRUCTURE = { 'single-account': () => 'single', 'by-environment': (s, e) => e, 'by-workload': (s, e) => `${s}@${e}`, }; export const identityAccount = (y, k) => STRUCTURE[y](k.split('@')[0], k.split('@')[1]); export const resourceAccount = (y, r) => STRUCTURE[y](OWNER[r.split('@')[0]], r.split('@')[1]); // Resources reached through policy: the families granted in the identity's own environment. export const policyResource = (k) => GRANTED[k.split('@')[0]].map((a) => `${a}@${k.split('@')[1]}`); // Resources within the account: the set opened if a compromised identity escalates inside the account. export const accountResource = (y, k) => RESOURCE.filter((r) => resourceAccount(y, r) === identityAccount(y, k)); export const blastRadius = (y, k) => new Set([...accountResource(y, k), ...policyResource(k)]).size; // Organization-level permission boundary: [permission, which environment it cuts in] export const BOUNDARY = [ ['measurement-delete', (e) => e !== 'production'], ['archive-delete', (e) => e !== 'production'], ['config-write', () => true], ]; export const grantedPermission = (s) => GRANTED[s].flatMap((a) => PERMISSION[a].map((x) => `${a}-${x}`));
// run1.mjs — blast radius: how many resources open up if an identity is compromised import { RESOURCE, IDENTITY, STRUCTURE, identityAccount, accountResource, blastRadius, policyResource } from './account.mjs'; console.log(`resources ${RESOURCE.length}, identities ${IDENTITY.length}\n`); console.log('structure accts widest average (identity, resource) policy'); console.log(' blast blast entries share'); for (const y of Object.keys(STRUCTURE)) { const p = IDENTITY.map((k) => blastRadius(y, k)); const accts = new Set(IDENTITY.map((k) => identityAccount(y, k))).size; const pol = IDENTITY.reduce((a, k) => a + policyResource(k).length, 0); console.log(`${y.padEnd(18)}${String(accts).padStart(5)}${String(Math.max(...p)).padStart(10)}` + `${(p.reduce((a, x) => a + x, 0) / p.length).toFixed(1).padStart(10)}` + `${String(p.reduce((a, x) => a + x, 0)).padStart(23)}${String(pol).padStart(10)}`); } console.log('\nproduction identities in by-workload in account via policy blast'); for (const k of IDENTITY.filter((x) => x.endsWith('production'))) console.log(` ${k.padEnd(38)}${String(accountResource('by-workload', k).length).padStart(9)}` + `${String(policyResource(k).length).padStart(13)}${String(blastRadius('by-workload', k)).padStart(9)}`);
resources 24, identities 24
structure accts widest average (identity, resource) policy
blast blast entries share
single-account 1 24 24.0 576 57
by-environment 3 8 8.0 192 57
by-workload 24 3 2.4 57 57
production identities in by-workload in account via policy blast
read-collector@production 1 2 2
validator@production 1 2 2
log-shipper@production 2 2 2
billing@production 2 3 3
work-order@production 1 3 3
nightly-batch@production 0 3 3
archiver@production 1 1 1
report-generator@production 0 3 3
In the single account, every one of the twenty-four identities opens all twenty-four resources
if compromised. Split by environment, that number drops to eight; split by workload, to three at
most. The total (identity, resource) entry count drops from five hundred seventy-six to
fifty-seven — tenfold.
The last column tells where the structure stops. The access policy alone grants fifty-seven entries in all three structures; the account structure does not change this number, it only determines what gets added on top. In the single account, five hundred nineteen entries come from the account boundary; in the by-workload structure, the addition is zero. A narrowed policy is only truly narrow if the account boundary backs it up.
The lower block shows why. The nightly batch and report generator own no resources at all; their accounts hold zero resources, and their radius comes entirely from policy. The archiver owns one family and accesses one family, so its radius is one. How tightly the structure can squeeze is determined by how ownership is distributed.
Quota Sharing and What Remains
An account is not just a security boundary — it is also a quota container.
// run2.mjs — quota sharing: which account hits the limit, how much headroom remains import { IDENTITY, STRUCTURE, QUOTA, DEMAND, ENV_SCALE, identityAccount } from './account.mjs'; const demand = (k) => Math.round(DEMAND[k.split('@')[0]] * ENV_SCALE[k.split('@')[1]]); console.log(`quota per account ${QUOTA} fictional units; total demand ${IDENTITY.reduce((a, k) => a + demand(k), 0)}\n`); console.log('structure accts most sharing fullest headroom over'); console.log(' one quota account left limit'); for (const y of Object.keys(STRUCTURE)) { const load = new Map(); for (const k of IDENTITY) { const a = identityAccount(y, k); load.set(a, [(load.get(a)?.[0] ?? 0) + demand(k), (load.get(a)?.[1] ?? 0) + 1]); } const loads = [...load.values()], fullest = Math.max(...loads.map((v) => v[0])); console.log(`${y.padEnd(18)}${String(load.size).padStart(5)}${String(Math.max(...loads.map((v) => v[1]))).padStart(17)}` + `${String(fullest).padStart(10)}${String(QUOTA - fullest).padStart(10)}` + `${String(loads.filter((v) => v[0] > QUOTA).length).padStart(6)}`); } console.log('\naccount load in the by-environment structure:'); for (const e of ['development', 'staging', 'production']) { const t = IDENTITY.filter((k) => k.endsWith(e)).reduce((a, k) => a + demand(k), 0); console.log(` ${e.padEnd(12)}demand ${String(t).padStart(4)} headroom ${String(QUOTA - t).padStart(5)}`); }
quota per account 1000 fictional units; total demand 2142
structure accts most sharing fullest headroom over
one quota account left limit
single-account 1 24 2142 -1142 1
by-environment 3 8 1530 -530 1
by-workload 24 1 420 580 0
account load in the by-environment structure:
development demand 153 headroom 847
staging demand 459 headroom 541
production demand 1530 headroom -530
In the single account, twenty-four identities share the same thousand-unit quota, and total demand is two thousand one hundred forty-two. Headroom left is negative eleven hundred forty-two units — the account exceeds the quota by more than double. The result of exceeding it is not one workload slowing down but all of them — a test running in the development environment sits in the same container as the collector running in production.
Split by environment, the container splits into three, and the real information is in the lower block. Development has eight hundred forty-seven units idle, staging five hundred forty-one; production, meanwhile, exceeds by five hundred thirty. Total demand did not change, only where it lands changed — and the thirteen hundred eighty-eight idle units do not cover production’s shortfall, because quotas cannot be transferred between accounts.
In the by-workload structure, no account hits the limit; the fullest account has five hundred eighty units of headroom left. The cost is twenty-four separate quotas — each one a limit that has to be watched, raised, and can be forgotten separately. As quota sharing shrinks, the risk of hitting the limit drops, and the number of limits grows.
Billing and Management Overhead
An account boundary determines a third thing too: how many lines the bill splits into, and how many policy documents have to be maintained.
// run3.mjs — how the bill splits apart and the management overhead: how many accounts, how many policy copies import { IDENTITY, STRUCTURE, identityAccount, resourceAccount, policyResource } from './account.mjs'; console.log('structure bill directly identity resource total'); console.log(' lines readable -based -based policy'); for (const y of Object.keys(STRUCTURE)) { const byAccount = new Map(); for (const k of IDENTITY) byAccount.set(identityAccount(y, k), (byAccount.get(identityAccount(y, k)) ?? 0) + 1); const aloneCount = IDENTITY.filter((k) => byAccount.get(identityAccount(y, k)) === 1).length; const cross = IDENTITY.reduce((a, k) => a + policyResource(k).filter((r) => resourceAccount(y, r) !== identityAccount(y, k)).length, 0); console.log(`${y.padEnd(18)}${String(byAccount.size).padStart(6)}${`${aloneCount}/${IDENTITY.length}`.padStart(10)}` + `${String(IDENTITY.length).padStart(10)}${String(cross).padStart(10)}${String(IDENTITY.length + cross).padStart(10)}`); } console.log('\ncross-account access in the by-workload structure:'); for (const k of IDENTITY.filter((x) => x.endsWith('production'))) { const c = policyResource(k).filter((r) => resourceAccount('by-workload', r) !== identityAccount('by-workload', k)); console.log(` ${k.padEnd(30)}${String(c.length).padStart(2)} ${c.map((r) => r.split('@')[0]).join(', ') || '-'}`); }
structure bill directly identity resource total
lines readable -based -based policy
single-account 1 0/24 24 0 24
by-environment 3 0/24 24 0 24
by-workload 24 24/24 24 33 57
cross-account access in the by-workload structure:
read-collector@production 1 queue
validator@production 1 measurement
log-shipper@production 0 -
billing@production 1 archive
work-order@production 2 queue, mail
nightly-batch@production 3 measurement, archive, invoice
archiver@production 0 -
report-generator@production 3 invoice, archive, log
Bill lines equal the account count: one, three, twenty-four. In the first two structures, no workload’s share can be read at account level; the by-environment structure lets the environment total be read, but not a workload’s share. In the third structure, twenty-four of twenty-four can be read.
The three right-hand columns give the cost. Identity-based policy count is twenty-four in all three structures. What changes is the resource-based policy: the by-workload structure produces thirty-three cross-account accesses, each one a document that has to be written separately in the resource’s account. Total policy documents rise from twenty-four to fifty-seven.
The lower block shows where cross-account access piles up. The two workloads that own no resources carry three cross-account accesses each; the log shipper and the archiver carry none, because they own every family they access. The cost of splitting the structure is exactly the size of the gap between ownership and access.
Organization-Level Protection Band
A permission boundary can be placed on top of accounts: whatever gets written inside the account, the permission the band cuts does not pass. The band’s power depends on the account boundary, because the band is attached to the account.
// run4.mjs — organization-level protection band: what it cuts, what it cannot cut import { ENV, IDENTITY, STRUCTURE, BOUNDARY, ACTUAL, grantedPermission, identityAccount, blastRadius } from './account.mjs'; const separates = (y) => { // does the account boundary separate the environment const h = new Map(); for (const k of IDENTITY) { const a = identityAccount(y, k); h.set(a, new Set([...(h.get(a) ?? []), k.split('@')[1]])); } return [...h.values()].every((s) => s.size === 1); }; const applicable = (y) => BOUNDARY.filter(([, cond]) => separates(y) || ENV.every(cond)); const grantedTotal = IDENTITY.reduce((a, k) => a + grantedPermission(k.split('@')[0]).length, 0); console.log(`granted (identity, permission) entries ${grantedTotal}\n`); console.log('structure env cut % of stopped widest blast'); console.log(' separated entries granted legit path after band'); for (const y of Object.keys(STRUCTURE)) { let cut = 0, stopped = 0; for (const [perm, cond] of applicable(y)) for (const k of IDENTITY) { const [s, e] = k.split('@'); if (!cond(e) || !grantedPermission(s).includes(perm)) continue; cut++; if (ACTUAL[s].includes(perm)) stopped++; } console.log(`${y.padEnd(18)}${(separates(y) ? 'yes' : 'no').padEnd(11)}${String(cut).padStart(5)}` + `${`${Math.round((cut / grantedTotal) * 100)}%`.padStart(11)}${String(stopped).padStart(11)}` + `${String(Math.max(...IDENTITY.map((k) => blastRadius(y, k)))).padStart(18)}`); } console.log('\nwhat each band rule cuts individually (in an environment-separated structure):'); for (const [perm, cond] of BOUNDARY) { const k = IDENTITY.filter((x) => cond(x.split('@')[1]) && grantedPermission(x.split('@')[0]).includes(perm)); console.log(` ${perm.padEnd(18)}${String(k.length).padStart(3)} entries, legit ` + `${k.filter((x) => ACTUAL[x.split('@')[0]].includes(perm)).length}`); }
granted (identity, permission) entries 138
structure env cut % of stopped widest blast
separated entries granted legit path after band
single-account no 3 2% 0 24
by-environment yes 17 12% 6 8
by-workload yes 17 12% 6 3
what each band rule cuts individually (in an environment-separated structure):
measurement-delete 6 entries, legit 4
archive-delete 8 entries, legit 2
config-write 3 entries, legit 0
The band carries three rules: delete measurement outside production, delete archive outside production, and write config everywhere. In the two structures that separate environments, all three apply, and they cut seventeen of a hundred thirty-eight entries — twelve percent. In the single account, only the third rule applies: because the account boundary does not separate the environment, the “outside production” condition cannot be attached anywhere, and the cut stays at three.
The fourth column gives the band’s cost: six of the seventeen cut entries stop a legitimate path. The lower block breaks this down rule by rule — measurement delete cuts six entries, four legitimate; archive delete cuts eight, two legitimate; config write cuts three, none legitimate. The third rule is free, the first two are not.
The last column shows what the band cannot cut. Even after the band is applied, the widest blast radius stays at twenty-four, eight, and three. The band cuts permissions, it does not build boundaries: removing a delete permission does not change how many resources a compromised identity can touch. The only thing that changes that is which account the resources sit in.
Summary
- When the same 24 resources and 24 identities are set up in three structures, the widest blast
radius goes 24 → 8 → 3; the total
(identity, resource)entry count drops from 576 to 57. - What policy alone grants is 57 entries in all three structures; the single account adds 519 entries on top, the by-workload structure adds 0. A narrowed policy is only truly narrow if the account boundary backs it up.
- Quota is 1,000 fictional units per account: the single account exceeds the limit by 1,142 units; by environment, production exceeds by 530 while development and staging leave 1,388 units idle and non-transferable; by workload, no account exceeds, and the fullest one has 580 units of headroom.
- Bill lines go 1 → 3 → 24; a workload’s share is 0/24 in the first two structures, directly readable at 24/24 in the third. The cost is the policy count: 24 documents rise to 57, because 33 cross-account accesses arise.
- The organization-level protection band cuts 17 of 138 entries (12%) in the structures that separate environments, and stops 6 legitimate paths; in the single account, only 3 entries are cut because the environment condition cannot be attached anywhere. Its power comes not from its own text but from the account structure — and what it cannot cut is the blast radius: even after the band, the widest radius stays 24 / 8 / 3.
Next Step
This topic carried one question all the way through: who can do what. Users and roles were written, policies were evaluated from both directions, authority became temporary, workloads were given identity, granted permission was compared against used permission, and accounts were split apart. But every one of these measures was looking at a single verb: doing. Even after access is narrowed, subjects remain on the list — the blast radius did not reach zero, it reached three. The question that was never asked is this: what does a subject who can reach those three resources see when it reads the data? Is the record sitting on disk in the open, is the request crossing the wire readable, and who holds the key that decodes the record? The next lesson measures not access but what is accessed.
To keep your progress and take notes, Log in
My notes
Log in to take notes.