Lesson 01 / 21
Users, Groups, and Roles
The needs of five teams on the fictional regional measurement network are built and compared using three identity types: the connection count, excess permission, new-member cost, and permission left behind on departure for permission granted directly to a user, to a group, and to a role.
Published Updated
In this lesson
- Builds the same need as a direct user, a group, and a role, and measures the permission connection count
- Counts where the group's cheapness comes from: the connection count falling while excess permission rises
- Separately counts the connection deleted, the permission left behind, and the legitimate work stalled when a person leaves
Contents
Across four courses, every layer got rented: the machine, the network, the store, the function, and the edge. Each rental handed a decision to the provider and got an item back in return. The one thing never asked was whose name all that renting was done under. This lesson’s question is who owns the rented thing, and how many connections that ownership is written with.
There are three ways: a permission can be written directly to a person, to a group people are collected into, or to a role independent of any person. All three set up the same access; the difference is in the numbers — how many connections get written, how many people get more than they need, where a new arrival has to be touched, and what is left behind when someone leaves.
- CI1. The model carries the five teams, three environments, and seven resource types of the
fictional regional measurement network. No real account is run; what is measured is the model
built with
node. - CI2. The need table was written by hand and is derived from the (team, duty) pair, not from the person. In reality, need splits per person; since it is per duty here, the excess permission measured is a lower bound.
- CI3. In this lesson a permission connection is only a (who, what) pair; which action can be taken is not counted. That distinction enters the policy document in the next lesson.
- CI4. The ages of long-lived credentials come from a sequence we generate ourselves; the seed
is written into the code as
20260802. The distribution is fictional. - CI5. The limits are fictional and defined within the lesson: sixty groups, forty roles per account, ten policy documents per identity.
- CI6. Departed people, which identity automations run under, and the daily action counts were written by hand.
A permission matrix in the same form was measured earlier over cluster objects; the object here is not the cluster object but the resource of the rented account, and those numbers do not carry over.
Team, Resource, and Need
// identity.mjs — fictional regional measurement network identity model; not a real account, only a model export const ENV = ['development', 'staging', 'production']; export const TEAM_RESOURCE = { // team -> resource types it touches ingest: ['edge-node-group', 'measurement-store', 'function-package', 'record-stream'], validation: ['machine-cluster', 'function-package', 'measurement-store', 'record-stream'], billing: ['object-store', 'measurement-store', 'record-stream'], field: ['edge-node-group', 'network-config', 'machine-cluster'], dashboard: ['measurement-store', 'record-stream'], }; export const DUTY_ENV = { // duty -> environments it works in developer: ['development', 'staging'], 'on-call': ['staging', 'production'], analyst: ['production'], lead: ENV, }; const ROSTER = { // twenty-three people; written by hand ingest: ['developer', 'developer', 'developer', 'on-call', 'analyst', 'lead'], validation: ['developer', 'developer', 'on-call', 'analyst', 'lead'], billing: ['developer', 'developer', 'analyst', 'lead'], field: ['developer', 'developer', 'on-call', 'on-call', 'lead'], dashboard: ['developer', 'analyst', 'lead'], }; export const PERSON = Object.entries(ROSTER).flatMap(([t, ds]) => ds.map((d, i) => ({ name: `${t}-${i + 1}`, team: t, duty: d }))); export const cross = (types, envs) => // the "what" side of a (who, what) pair new Set(types.flatMap((t) => envs.map((e) => `${t}@${e}`))); export const need = (p) => cross(TEAM_RESOURCE[p.team], DUTY_ENV[p.duty]); export const coarseGroup = (p) => cross(TEAM_RESOURCE[p.team], ENV); export const AUTOMATION = [ // name, identity, personal?, daily actions ['measurement-compaction', 'ingest-1', true, 2450], ['edge-health-scan', 'field-4', true, 720], ['nightly-billing-export', 'billing-1', true, 1840], ['dashboard-refresh', 'dashboard-2', true, 96], ['release-rollout', 'workload-identity-1', false, 310], ['backup-verification', 'workload-identity-2', false, 144], ].map(([name, identity, personal, daily]) => ({ name, identity, personal, daily })); export const DEPARTED = ['ingest-1', 'field-4', 'billing-3', 'dashboard-2']; export const PERSON_DAILY = 40; // actions per person per day, fictional export const LIMIT = { group: 60, role: 40, document: 10 }; // fictional limits, defined in the lesson export function* generator(seed) { // our own generator; the seed is visible let x = seed % 2147483647; for (;;) { x = (x * 48271) % 2147483647; yield x; } }
Need is the product of two factors: which resource types a team touches, and which environments a duty runs in. The coarse group carries the same types but not the environment split — belonging to a team means reaching all its resources across all three environments at once.
Three Identity Types
// run1.mjs — the same need is built with three identity types and the connection count is compared import { PERSON, TEAM_RESOURCE, ENV, need, coarseGroup } from './identity.mjs'; const roleName = (p) => `${p.team}-${p.duty}`; const ROLE = new Map(), GROUP = new Map(); for (const p of PERSON) { if (!ROLE.has(roleName(p))) ROLE.set(roleName(p), need(p)); if (!GROUP.has(p.team)) GROUP.set(p.team, coarseGroup(p)); } const sum = (f) => PERSON.reduce((a, p) => a + f(p), 0); const sumSizes = (m) => [...m.values()].reduce((a, s) => a + s.size, 0); const newcomer = { team: 'ingest', duty: 'developer' }; // a new team member joining const MODEL = [ { name: 'direct user', defs: PERSON.length, idLink: 0, resLink: sum((p) => need(p).size), granted: need, newWrite: need(newcomer).size }, { name: 'group', defs: GROUP.size, idLink: PERSON.length, resLink: sumSizes(GROUP), granted: coarseGroup, newWrite: 1 }, { name: 'role', defs: ROLE.size, idLink: PERSON.length, resLink: sumSizes(ROLE), granted: need, newWrite: 1 }, ]; console.log(`${PERSON.length} people, ${Object.keys(TEAM_RESOURCE).length} teams, ${ENV.length} environments, ` + `${new Set(Object.values(TEAM_RESOURCE).flat()).size} resource types`); console.log(`need derived from duty: ${sum((p) => need(p).size)} (who, what) pairs\n`); console.log('model defs identity link resource link total granted excess new member'); for (const m of MODEL) { const granted = sum((p) => m.granted(p).size); const excess = sum((p) => [...m.granted(p)].filter((x) => !need(p).has(x)).length); console.log(`${m.name.padEnd(19)} ${String(m.defs).padEnd(6)} ${String(m.idLink).padEnd(16)} ` + `${String(m.resLink).padEnd(16)} ${String(m.idLink + m.resLink).padEnd(7)} ` + `${String(granted).padEnd(11)} ${String(excess).padEnd(7)} ${m.newWrite}`); }
23 people, 5 teams, 3 environments, 7 resource types need derived from duty: 157 (who, what) pairs model defs identity link resource link total granted excess new member direct user 23 0 157 157 157 0 8 group 5 23 48 71 231 74 1 role 17 23 115 138 157 0 1
The need drawn from the duties of twenty-three people is a hundred fifty-seven pairs, and that number is independent of the model — what changes is how many connections build those pairs. In the direct write it is one-to-one: a hundred fifty-seven needed, a hundred fifty-seven connections. The group brings that down to seventy-one — eighty-six fewer connections — but granted permission climbs to two hundred thirty-one. Seventy-four permissions sit open that nobody asked for.
This is where the group’s cheapness comes from: the five group definitions never write the environment split at all, and not writing the environment means handing every member all three environments at once. An analyst who only works in production gets exactly three times what they need, because they reach every one of their team’s resource types across all three environments.
The role sits between the two: seventeen definitions, a hundred fifteen resource connections, twenty-three identity connections — a hundred thirty-eight total. Nineteen connections cheaper than the direct write, sixty-seven connections pricier than the group, but its excess permission is zero. Its real payoff shows in the last column: when a new team member arrives, the direct write sets up eight separate connections, the group and the role set up one. The direct write’s cost is not paid once at setup — it is paid again with every arrival.
Arriving, Leaving, and What Remains
// run2.mjs — departed people, permissions left behind and the subject seen in the audit record import { PERSON, DEPARTED, AUTOMATION, PERSON_DAILY, need, coarseGroup, generator } from './identity.mjs'; const lookup = (name) => PERSON.find((p) => p.name === name); const personal = AUTOMATION.filter((o) => o.personal); const linked = new Map(personal.map((o) => [o.identity, o])); console.log('departed duty need group permission automation running on it'); for (const name of DEPARTED) { const p = lookup(name), o = linked.get(name); console.log(`${name.padEnd(13)} ${p.duty.padEnd(12)} ${String(need(p).size).padEnd(8)} ` + `${String(coarseGroup(p).size).padEnd(19)} ${o ? `${o.name} (${o.daily} actions/day)` : '-'}`); } const cannotClose = DEPARTED.filter((a) => linked.has(a)); const stalled = cannotClose.reduce((a, x) => a + linked.get(x).daily, 0); const remaining = (f) => cannotClose.reduce((a, x) => a + f(lookup(x)).size, 0); console.log(`\n${cannotClose.length} identities cannot be closed, ${stalled} actions/day run on top of them`); console.log('model connections to delete permissions alive if left open actions/day stalled if closed'); const row = (name, del, perm) => console.log(`${name.padEnd(19)} ${String(del).padEnd(24)} ${String(perm).padEnd(33)} ${stalled}`); row('direct user', DEPARTED.reduce((a, x) => a + need(lookup(x)).size, 0), remaining(need)); row('group', DEPARTED.length, remaining(coarseGroup)); row('role', DEPARTED.length, remaining(need)); // Long-lived credentials: every identity has one key, personal keys have been copied const identityCount = PERSON.length + AUTOMATION.filter((o) => !o.personal).length; const gen = generator(20260802); const age = Array.from({ length: identityCount }, () => 45 + (gen.next().value % 856)); console.log(`\nlong-lived key: ${identityCount} identities + ${personal.length} copies = ` + `${identityCount + personal.length} storage locations; oldest ${Math.max(...age)} days, ` + `${age.filter((y) => y > 365).length} older than 365 days`); // Audit record: which line is written under whose name const automationTotal = AUTOMATION.reduce((a, o) => a + o.daily, 0); const personalTotal = personal.reduce((a, o) => a + o.daily, 0); const humanTotal = PERSON.length * PERSON_DAILY; const nameCarrying = personalTotal + humanTotal; console.log(`\ndaily audit record ${automationTotal + humanTotal} lines: human ${humanTotal}, automation ${automationTotal}`); console.log(`of the ${nameCarrying} lines written under a person's name, automation produced ${personalTotal} of them ` + `(${((100 * personalTotal) / nameCarrying).toFixed(1)}%)`); console.log(`the names of the ${cannotClose.length} departed people keep appearing in ${30 * stalled} lines over thirty days`);
departed duty need group permission automation running on it ingest-1 developer 8 12 measurement-compaction (2450 actions/day) field-4 on-call 6 9 edge-health-scan (720 actions/day) billing-3 analyst 3 9 - dashboard-2 analyst 2 6 dashboard-refresh (96 actions/day) 3 identities cannot be closed, 3266 actions/day run on top of them model connections to delete permissions alive if left open actions/day stalled if closed direct user 19 16 3266 group 4 27 3266 role 4 16 3266 long-lived key: 25 identities + 4 copies = 29 storage locations; oldest 880 days, 16 older than 365 days daily audit record 6480 lines: human 920, automation 5560 of the 6026 lines written under a person's name, automation produced 5106 of them (84.7%) the names of the 3 departed people keep appearing in 97980 lines over thirty days
Four people left. The direct write should delete nineteen connections, the group and the role, four. The second column gives the unexpected part: three of the four identities cannot be closed, because automation runs on top of them. As long as these three stay open, sixteen permissions in the direct write stay alive, twenty-seven in the group.
The third column gives the flip side. Closing the identities stops three thousand two hundred sixty-six legitimate actions per day, none of which involve the people who left. None of the three models solves the dilemma — the same number stalls in all three: the identity type changes the cleanup cost and the breadth of permission left behind, but not the machine work bound to a person’s identity.
The key line gives the surface’s size. Each of the twenty-five identities has one long-lived key, four of them copied onto automation machines; twenty-nine storage locations in total. Sixteen keys are over a year old, the oldest eight hundred eighty days — a key’s age is the upper bound on how long it stays valid once it leaks.
The last three lines close out the “who can see” column. Eighty-four point seven percent of the six thousand twenty-six audit lines carrying a person’s name were not produced by a human, and the names of the three departed people keep appearing in ninety-seven thousand nine hundred eighty lines over thirty days. The audit record is not giving a wrong answer here; it is answering the question it was asked, and the question is wrong.
The Limit, What Remains, and the Cost of Narrowing
// run3.mjs — the limit, what remains and the cost of narrowing the coarse group import { PERSON, TEAM_RESOURCE, DUTY_ENV, ENV, LIMIT, need, coarseGroup, cross } from './identity.mjs'; const roleName = (p) => `${p.team}-${p.duty}`; const role = new Set(PERSON.map(roleName)); const split = PERSON.reduce((s, p) => { // if every role were split by its own environment for (const e of DUTY_ENV[p.duty]) s.add(`${roleName(p)}@${e}`); return s; }, new Set()); const mostDocs = Math.max(...PERSON.map((p) => TEAM_RESOURCE[p.team].length)); console.log(`fictional limits: ${LIMIT.group} groups, ${LIMIT.role} roles per account; ${LIMIT.document} policy documents per identity`); console.log('definition used remaining'); const limitRow = (name, k, s) => console.log(`${name.padEnd(29)} ${String(k).padEnd(11)} ${s - k}`); limitRow('group (one per team)', new Set(PERSON.map((p) => p.team)).size, LIMIT.group); limitRow('role (team × duty)', role.size, LIMIT.role); limitRow('role (split by environment)', split.size, LIMIT.role); limitRow('document (widest identity)', mostDocs, LIMIT.document); const rolesPerTeam = role.size / new Set(PERSON.map((p) => p.team)).size; console.log(`each team accounts for ${rolesPerTeam} roles; ` + `${Math.floor((LIMIT.role - role.size) / rolesPerTeam)} teams remain before the role limit, ` + `${Math.floor((LIMIT.role - split.size) / (split.size / 5))} teams in the environment-split definition`); // Narrowing the coarse group to its narrowest member: take the environment intersection console.log('\nteam members shared env coarse permission per member narrow permission per member stalled legitimate permission'); let totalDropped = 0, totalKept = 0, totalNeed = 0; for (const t of Object.keys(TEAM_RESOURCE)) { const members = PERSON.filter((p) => p.team === t); const intersection = ENV.filter((e) => members.every((p) => DUTY_ENV[p.duty].includes(e))); const narrow = cross(TEAM_RESOURCE[t], intersection); const needed = members.reduce((a, p) => a + need(p).size, 0); const kept = members.reduce((a, p) => a + [...need(p)].filter((x) => narrow.has(x)).length, 0); totalDropped += needed - kept; totalKept += kept; totalNeed += needed; console.log(`${t.padEnd(11)} ${String(members.length).padEnd(9)} ${String(intersection.length || '-').padEnd(12)} ` + `${String(coarseGroup(members[0]).size).padEnd(30)} ${String(narrow.size).padEnd(30)} ${needed - kept}`); } console.log(`\ntotal: coarse group grants ${PERSON.reduce((a, p) => a + coarseGroup(p).size, 0)} permissions, ` + `narrowed group ${totalKept}; of ${totalNeed} legitimate permissions ${totalDropped} are dropped ` + `(${((100 * totalDropped) / totalNeed).toFixed(1)}%)`);
fictional limits: 60 groups, 40 roles per account; 10 policy documents per identity definition used remaining group (one per team) 5 55 role (team × duty) 17 23 role (split by environment) 35 5 document (widest identity) 4 6 each team accounts for 3.4 roles; 6 teams remain before the role limit, 0 teams in the environment-split definition team members shared env coarse permission per member narrow permission per member stalled legitimate permission ingest 6 - 12 0 48 validation 5 - 12 0 40 billing 4 - 9 0 24 field 5 1 9 3 18 dashboard 3 - 6 0 12 total: coarse group grants 231 permissions, narrowed group 15; of 157 legitimate permissions 142 are dropped (90.4%)
The limit table tells two separate stories. Nothing is tight on the group side: five groups, fifty- five slots free. On the role side, seventeen roles exist with twenty-three slots left; at three point four roles per team, six more teams could still be added. Splitting roles by environment — putting production permission in its own role — pushes the definition count to thirty-five and the slots left down to five: a narrowing that looks reasonable on its own closes the account off from growth.
The lower table counts narrowing’s other cost. Closing the coarse group’s excess permission by touching the group means shrinking it to its narrowest member: the members’ shared environment. Four of the five teams share no environment — a developer works in development and staging, an analyst only in production — so the intersection comes up empty. The coarse group’s two hundred thirty-one permissions drop to fifteen, but a hundred forty-two of what dropped was legitimate need: ninety percent.
The seventy-four excess permissions cannot be cleaned by touching the group definition. The excess is not inside the group — it is in its coarseness, and the only way to close it is to split the group; splitting it along duty lines produces exactly the role model. The group is cheap because it draws no distinction, the role is expensive because it writes the distinction, and the direct write rewrites that same distinction for every single person.
Summary
- The 157 (who, what) pairs drawn from the duties of twenty-three people are built with 157 connections by the direct write, 71 by the coarse group, 138 by the role; a new member costs 8 connections in the direct write, 1 in the group and the role.
- The group’s 86-connection cheapness is paid for with 74 excess permissions: granted permission climbs from 157 to 231, because the group never writes the environment split.
- Three of four departed people cannot have their identity closed because 3,266 actions/day of automation run on top of them; while the identity stays open, 27 permissions stay alive in the group, 16 in the direct write and the role.
- Of the 6,026 audit lines carrying a person’s name, 5,106 were not produced by a human; the long-lived key of 25 identities sits in 29 places, and 16 of them are over a year old.
- Narrowing the coarse group to its narrowest member closes the excess permission but drops 142 of 157 legitimate permissions; the excess is not in the group’s content, it is in its coarseness.
Next Step
This lesson’s connections carry only the (who, what) pair: the ingest team has access to the measurement store. Which access was not written — read, write, or delete the store itself. Until that set is written, the sentence “has access” does not measure anything. The next lesson opens up what lies inside the connection: the effect, action, resource pattern, and condition. It counts how many actions different phrasings of the same intent actually cover, pulls apart the scope a wildcard opens item by item, and shows why an explicit deny always wins.
To keep your progress and take notes, Log in
My notes
Log in to take notes.