Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close