Lesson 18 / 21
Users and Permissions
Three measures of running rootless: how many of fifteen entries stay open to the privileged identity, how many entries the switch to a rootless identity requires an ownership fix in, and how many paths in the bind mount stay closed after the fix because of ownership coming from the environment.
Contents
The previous lesson built network separation: which name resolves to which address, which port opens outward, and which one, left unopened, stays unreachable, were all counted. All of those measurements counted what the process does. Who runs inside was never asked, and because it was never asked, the default stood: while an image is being built, every file written into it is owned by the privileged identity running the build, and the process starts with the same identity. The regional measurement network’s reading collector service — fictional software that collects, verifies, and hands off meter data to billing — is this lesson’s measurement subject.
Application-level authorization questions are outside this lesson. Where a secret sits, how many places it is repeated in, and what breaks when it is rotated were measured in the Secrets Management lesson. The question here is one boundary lower: which identity can reach the file that secret sits in, and whether that reach comes from the image definition or from the environment.
RT25 — the service’s image consists of fifteen entries (files and directories), and the
service performs twelve operations on them while it runs: read, write, execute. RT26 — every
entry written during the build passes into the ownership of the privileged identity (0:0).
RT27 — in the rootless layout the process identity is 10001:10001. RT28 — the mounted host
directory appears inside with 501:20 ownership; this ownership is read not from the image but
from wherever the mount comes from. RT29 — the privileged identity bypasses discretionary access
control, mode bits are not consulted. RT30 — without user namespace mapping, the numeric
identity inside the container is the same number on the host.
Permission Matrix
The run below really writes files to disk, sets mode bits with chmod, and reads them back with
statSync; the mode column in the table comes from disk. Ownership, on the other hand, is a
model: because changing ownership requires privilege, owner and group numbers are kept in the
table, and the access decision is computed from those numbers over the real mode bits. That access
to a path also requires an execute bit on every ancestor directory is accounted for too.
// tree.mjs — the fictional reading collector service's file tree. // Files and mode bits are REAL: written to disk, set with chmod, read back with statSync. // Ownership is a MODEL: chown requires privilege, so owner/group are kept in the table. import { mkdirSync, writeFileSync, chmodSync, statSync, rmSync } from 'node:fs'; export const PRIVILEGED = { name: 'privileged', uid: 0, gid: 0 }; // RT26 export const ROOTLESS = { name: 'rootless', uid: 10001, gid: 10001 }; // RT27 export const HOST = { uid: 501, gid: 20 }; // RT28 // [path, kind, mode, owner, group] — mounted/ and below appear with host ownership. export const IMAGE = [ ['app', 'd', 0o755, 0, 0], ['app/start.mjs', 'f', 0o755, 0, 0], ['app/collector.mjs', 'f', 0o644, 0, 0], ['app/verifier.mjs', 'f', 0o644, 0, 0], ['config', 'd', 0o755, 0, 0], ['config/service.json', 'f', 0o644, 0, 0], ['config/signing-key', 'f', 0o600, 0, 0], ['state', 'd', 0o755, 0, 0], ['state/queue.data', 'f', 0o644, 0, 0], ['state/lock', 'f', 0o644, 0, 0], ['tmp', 'd', 0o777, 0, 0], ['mounted', 'd', 0o755, HOST.uid, HOST.gid], ['mounted/reading-01.csv', 'f', 0o644, HOST.uid, HOST.gid], ['mounted/reading-02.csv', 'f', 0o640, HOST.uid, HOST.gid], ['mounted/output', 'd', 0o755, HOST.uid, HOST.gid], ]; export function setup(root, nodes) { rmSync(root, { recursive: true, force: true }); mkdirSync(root, { recursive: true }); for (const [path, kind, mode] of nodes) { const full = `${root}/${path}`; if (kind === 'd') mkdirSync(full); else writeFileSync(full, `# ${path}\n`); chmodSync(full, mode); } return nodes.map(([path, kind, , uid, gid]) => ({ path, kind, uid, gid, mode: statSync(`${root}/${path}`).mode & 0o777, })); } // Discretionary access control: one of owner / group / other is selected. // RT29: the privileged identity bypasses the triple; it still looks for an x bit to execute. export function permission(node, identity) { const { mode } = node; if (identity.uid === 0) return { r: true, w: true, x: (mode & 0o111) !== 0 }; const u = identity.uid === node.uid ? mode >> 6 : identity.gid === node.gid ? mode >> 3 : mode; return { r: (u & 4) !== 0, w: (u & 2) !== 0, x: (u & 1) !== 0 }; } // Access to a path also requires the x bit on every ancestor directory. export function access(tree, path, identity) { const p = path.split('/'); for (let i = 1; i < p.length; i += 1) { const ancestor = tree.find((d) => d.path === p.slice(0, i).join('/')); if (ancestor && !permission(ancestor, identity).x) return { r: false, w: false, x: false }; } return permission(tree.find((d) => d.path === path), identity); } export const format = (e) => `${e.r ? 'r' : '-'}${e.w ? 'w' : '-'}${e.x ? 'x' : '-'}`; if (import.meta.url === `file://${process.argv[1]}`) { const tree = setup('./root', IMAGE); const s = (x, n) => String(x).padStart(n); console.log(`${'path'.padEnd(26)}${s('mode', 5)}${s('owner', 8)}${s('privileged', 13)}${s('rootless', 10)}`); for (const d of tree) { console.log(`${(d.kind === 'd' ? `${d.path}/` : d.path).padEnd(26)}${s(d.mode.toString(8), 5)}` + `${s(`${d.uid}:${d.gid}`, 8)}${s(format(access(tree, d.path, PRIVILEGED)), 13)}` + `${s(format(access(tree, d.path, ROOTLESS)), 10)}`); } for (const k of [PRIVILEGED, ROOTLESS]) { const e = tree.map((d) => access(tree, d.path, k)); console.log(`${k.name.padEnd(13)} readable ${s(e.filter((x) => x.r).length, 2)}/${tree.length}` + ` writable ${s(e.filter((x) => x.w).length, 2)}/${tree.length}` + ` executable ${s(e.filter((x) => x.x).length, 2)}/${tree.length}`); } }
path mode owner privileged rootless app/ 755 0:0 rwx r-x app/start.mjs 755 0:0 rwx r-x app/collector.mjs 644 0:0 rw- r-- app/verifier.mjs 644 0:0 rw- r-- config/ 755 0:0 rwx r-x config/service.json 644 0:0 rw- r-- config/signing-key 600 0:0 rw- --- state/ 755 0:0 rwx r-x state/queue.data 644 0:0 rw- r-- state/lock 644 0:0 rw- r-- tmp/ 777 0:0 rwx rwx mounted/ 755 501:20 rwx r-x mounted/reading-01.csv 644 501:20 rw- r-- mounted/reading-02.csv 640 501:20 rw- --- mounted/output/ 755 501:20 rwx r-x privileged readable 15/15 writable 15/15 executable 7/15 rootless readable 13/15 writable 1/15 executable 7/15
The difference between the two columns shows how much decision-making weight the mode bits carry.
For the privileged identity, fifteen of the fifteen entries are readable and fifteen are writable;
both the signing key protected by mode 600 and the measurement file arriving with host ownership
are open. In that column, mode bits carry no decision, they only keep a record. In the
rootless column, the same bits do make the decision: thirteen readable, one writable. The one
writable entry is the tmp/ directory, and the reason is that its mode is 777.
That the execute column comes out at seven for both identities is a separate distinction. The privileged identity bypasses permission checks, but it cannot invent an execute bit that is not there; whether a file is executable comes from its mode, not from privilege. Of all this lesson’s numbers, this is the one column privilege does not change.
The Cost of Switching to Rootless
The matrix alone does not make a decision; without knowing which access is needed, “closed” is not a number. The second run lists the twelve operations the service actually performs, runs them under both identities, and separates the dropped operations: the ones fixable inside the image from the ones coming from the mount.
// matrix.mjs — the cost of switching to rootless and the surface left on the host side. import { setup, permission, access, format, IMAGE, PRIVILEGED, ROOTLESS } from './tree.mjs'; // Operations the service actually performs: [path, op] const REQUIRED = [ ['app/start.mjs', 'x'], ['app/collector.mjs', 'r'], ['app/verifier.mjs', 'r'], ['config/service.json', 'r'], ['config/signing-key', 'r'], ['state', 'w'], ['state/queue.data', 'w'], ['state/lock', 'w'], ['tmp', 'w'], ['mounted/reading-01.csv', 'r'], ['mounted/reading-02.csv', 'r'], ['mounted/output', 'w'], ]; const dropped = (tree, identity) => REQUIRED.filter(([y, i]) => !access(tree, y, identity)[i]); const surface = (tree, identity) => tree.filter((d) => permission(d, identity).w).length; const tree = setup('./root', IMAGE); const s = (x, n) => String(x).padStart(n); console.log(`privileged: dropped ops ${dropped(tree, PRIVILEGED).length}/${REQUIRED.length}` + `, writable entries ${surface(tree, PRIVILEGED)}/${tree.length}`); console.log(`rootless : dropped ops ${dropped(tree, ROOTLESS).length}/${REQUIRED.length}` + `, writable entries ${surface(tree, ROOTLESS)}/${tree.length}`); for (const [y, i] of dropped(tree, ROOTLESS)) { const d = tree.find((x) => x.path === y); console.log(` ${y.padEnd(24)} wants ${i} mode ${d.mode.toString(8)} owner ${d.uid}:${d.gid}` + ` ${d.path.startsWith('mounted/') ? 'bind mount — cannot be fixed in the image' : 'in the image'}`); } // Two fix forms: move ownership, or loosen mode bits. // OUTSIDER: a third identity that benefits from the same bits (another process, or the host when there is no mapping). const OUTSIDER = { uid: 20002, gid: 20002 }; function fix(fixKind) { const t = setup('./root', IMAGE); let touched = 0; for (const [y] of dropped(t, ROOTLESS)) { const d = t.find((x) => x.path === y); if (y.startsWith('mounted/')) continue; if (fixKind === 'ownership') { d.uid = ROOTLESS.uid; d.gid = ROOTLESS.gid; } else d.mode |= d.kind === 'd' ? 0o077 : 0o066; touched += 1; } return { t, touched, remaining: dropped(t, ROOTLESS) }; } console.log(`\n${'fix'.padEnd(16)}${s('touched', 11)}${s('still dropped', 15)}` + `${s('rootless writes', 17)}${s('outsider writes', 17)}${s('outsider reads', 16)}`); let remaining = []; for (const b of ['ownership', 'mode-loosen']) { const r = fix(b); remaining = r.remaining; console.log(`${b.padEnd(16)}${s(r.touched, 11)}${s(r.remaining.length, 15)}` + `${s(`${surface(r.t, ROOTLESS)}/${r.t.length}`, 17)}${s(`${surface(r.t, OUTSIDER)}/${r.t.length}`, 17)}` + `${s(`${r.t.filter((d) => permission(d, OUTSIDER).r).length}/${r.t.length}`, 16)}`); } // Host surface: entries the same numeric identity can touch outside the container (RT30). const HOST_TREE = [ ['data', 'd', 0o755, 0, 0], ['data/reading-archive', 'f', 0o644, 10001, 10001], ['data/service.log', 'f', 0o664, 0, 10001], ['config', 'd', 0o755, 0, 0], ['config/network.json', 'f', 0o644, 0, 0], ['home', 'd', 0o755, 10001, 10001], ['home/key', 'f', 0o600, 10001, 10001], ['home/collector.mjs', 'f', 0o755, 10001, 10001], ]; const hostTree = setup('./host', HOST_TREE); const SHIFT = 100000; // user namespace shift console.log(`\n${'host identity'.padEnd(16)}${s('readable', 12)}${s('writable', 13)}${s('home/key', 12)}`); for (const k of [{ label: 'unmapped 10001', uid: ROOTLESS.uid, gid: ROOTLESS.gid }, { label: `shift +${SHIFT}`, uid: ROOTLESS.uid + SHIFT, gid: ROOTLESS.gid + SHIFT }]) { const e = hostTree.map((d) => access(hostTree, d.path, k)); console.log(`${k.label.padEnd(16)}${s(`${e.filter((x) => x.r).length}/${hostTree.length}`, 12)}` + `${s(`${e.filter((x) => x.w).length}/${hostTree.length}`, 13)}` + `${s(format(access(hostTree, 'home/key', k)), 12)}`); } console.log(`${remaining.length} paths in the bind mount must be fixed on the host side;` + ` with the shift on, the target identity is not ${ROOTLESS.uid} but ${ROOTLESS.uid + SHIFT}`);
privileged: dropped ops 0/12, writable entries 15/15 rootless : dropped ops 6/12, writable entries 1/15 config/signing-key wants r mode 600 owner 0:0 in the image state wants w mode 755 owner 0:0 in the image state/queue.data wants w mode 644 owner 0:0 in the image state/lock wants w mode 644 owner 0:0 in the image mounted/reading-02.csv wants r mode 640 owner 501:20 bind mount — cannot be fixed in the image mounted/output wants w mode 755 owner 501:20 bind mount — cannot be fixed in the image fix touched still dropped rootless writes outsider writes outsider reads ownership 4 2 5/15 1/15 13/15 mode-loosen 4 2 5/15 5/15 14/15 host identity readable writable home/key unmapped 10001 8/8 5/8 rw- shift +100000 7/8 0/8 --- 2 paths in the bind mount must be fixed on the host side; with the shift on, the target identity is not 10001 but 110001
With the privileged identity, zero of the twelve operations drop. This explains in one line why privileged operation persists: nothing breaks, and so nothing is questioned. Switching to the rootless identity drops six of the twelve operations, and the distribution of what drops is the real information — four sit inside the image, two come from the mount.
The four drops inside the image close with a single fix: moving the ownership of those four entries to the rootless identity. This lesson’s cost of isolation is this number — an ownership fix on four entries, that is, four paths that need to be written into the image definition. What actually makes the cost heavier is that these paths cannot be found by reading the code: which file needs it only surfaces once the service runs rootless and drops, and every dropped operation is a separate breakage. After the fix, the writable entry count drops not from ten to five but from fifteen to five: the ten entries the privileged identity could write to close. Among the ten entries that close are the three config files next to the signing key and the application code itself; a compromised process modifying its own code to persist is eliminated by these ten entries closing.
The same four entries can be opened two separate ways, and the table puts them side by side. Both
moving ownership and loosening mode bits touch four entries, both bring the still-dropped count
down to two, and in both the rootless identity can write to five entries. The service comes up the
same way under either fix. The difference is in the table’s last two columns: when ownership is
moved, a third identity can write to only one of the fifteen entries; when mode is loosened, it
can write to five. The read column states the difference more sharply — loosening turns the
signing key’s mode 600 into 666, and the entries an outsider identity can read climb from
thirteen to fourteen. The two fixes silence the same error, write the same number of lines into
the image definition, and leave a different surface; which one was chosen shows up in neither
the error message nor the service’s behavior.
The remaining two operations do not close by writing to the image definition.
mounted/reading-02.csv and mounted/output come from the mount; their ownership is on record
not in the image but wherever the mount comes from. This is the first place isolation is
punctured: the ownership of eleven of the fifteen entries can be pulled into the output, but four
of them come from outside at run time, and the right number for those four can only be settled by
agreement between both sides.
The Sharing of the Numeric Identity
The lower half of the output measures the second hole. The rootless identity 10001 is nothing
but a number, and that number is read from a single kernel table. On the host side, in a fictional
set of eight entries, the same number can write to five entries; home/key is in mode 600,
meaning the host equivalent of the signing key, unreachable from inside, is open rw- to that
identity. Running rootless is not “running unprivileged”; it is running with the authority of a
different number, and what that number means on the host is not written in the image.
User namespace mapping closes this hole: with a shift of one hundred thousand, the internal
10001 becomes 110001 on the host, is the owner or group of no host entry, the writable count
drops from five to zero, and home/key becomes unreadable. Its cost is in the last line. The
target of the ownership fix to be made on the host side for the two mount paths is no longer
10001 but 110001; every new file written from inside also shows up on the host under that
identity. While mapping closes the authority leaking outward, it grows the identity mismatch
between the two ends of the mount. Isolation is still not complete here either — only its cost
gets written to a different entry.
Summary
- The permission matrix carries no decision for the privileged identity: fifteen of fifteen
entries are readable, fifteen are writable; the
600-mode signing key is open too. The one column mode does not change is execute — seven under both identities. - With the rootless identity, six of the twelve operations drop; four are inside the image, two are in the bind mount.
- The cost of switching is an ownership fix on four entries, and these four paths are found not by reading the code but by the service dropping; in exchange, the writable entry count drops from fifteen to five. Opening the same four by loosening mode also runs the service, but it takes an outsider identity’s writable entries from one to five and its readable entries from thirteen to fourteen.
- The ownership of eleven of the fifteen entries can be pulled into the output; four come from the environment at run time and cannot be fixed through the image definition.
- The rootless identity is the same number on the host: in the fictional host set it can write to
five of eight entries and can read the
600-mode host key. - User namespace mapping brings the host writable count from five to zero; its cost is the
identity mismatch at the two ends of the mount deepening, with the fix target shifting to
110001.
Next Step
Everything measured up to this point was on a single container: a file tree, an identity, a permission matrix. But the regional measurement network is not a single process — a verifier reads the queue the reading collector writes, the verified record goes to billing, and a record store sits underneath all of them. Starting these services by hand means setting up each one’s network, volume, and order with separate decisions; which ones are affected when one of them restarts is also buried inside those decisions. The next lesson moves this setup into a declarative compose file and compares the two forms in numbers: how the startup order is derived from the dependency graph in a four-service setup, how many races are born without a readiness check, and how many commands and decisions disappear compared to manual startup.
To keep your progress and take notes, Log in
My notes
Log in to take notes.