Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close