---
title: 'Users, Groups, and Roles'
source: 'https://academia.sh/en/courses/cloud-governance/users-groups-and-roles'
course: 'Cloud Governance, Security and Cost'
language: en
updated: '2026-09-13T05:07:35+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

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

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

```js
// 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

```js
// 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

```js
// 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

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