Files
jason.woltje 930182b54a fix(organization): accounts config directory checks every ancestor (#1873)
assertAccountsOwner refused a symlink or non-directory on the config path,
but checked owner and mode only on the leaf. An ancestor owned by another
user, or writable by others without the sticky bit, let that user rename the
directory away and put their own accounts.json in its place. Since M3 that
file decides root authority on a deployed Console.

One shared helper, config-dir-trust.mjs, now holds A2's rule: every ancestor
belongs to root or this uid and is group or other writable only with the
sticky bit; configDir itself is this uid's and not group or other writable.
The accounts store (load, init, lock, locked write, mosaic accounts) and the
root agent file's writer both call it, each with its own refusal code. A2's
messages are unchanged.

Accounts refusals no longer quote a path: the config directory and accounts
file uid messages name the rule and the uid only (redaction rule, #1916).
A missing config directory now refuses accounts-unsafe instead of a raw
ENOENT.
2026-11-10 22:21:06 -06:00

253 lines
22 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAccounts, loadAccounts, accountsRepairs, assertAccountsOwner, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { templateRender } from '../src/org-service.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { REPO_ROLES } from './helpers.mjs';
import { withRegistry, readRegistryLocked } from '../src/org-registry.mjs';
export const OP = 'u-000000000001', USER = 'u-000000000002';
export const context = { providers: [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }] }] };
export const user = (id = OP, sub = 'owner', instanceRole = 'operator') => ({ id, subjects: [{ provider: 'pocket', sub }], name: 'Person', instanceRole, state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' });
export const member = () => ({ org: 'acme', incarnation: 'a'.repeat(32), user: USER, role: 'member', rights: ['board.read', 'org.view', 'thread.read'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, added: '2026-11-10T00:00:00Z', by: OP, via: 'invite:abc' });
export const document = () => ({ accountsVersion: 1, revision: 1, users: [user(), user(USER, 'member', 'user')], memberships: [], invites: [] });
function setup(t, doc = document()) {
const dir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(dir, 0o700); t.after(() => rmSync(dir, { recursive: true, force: true }));
const file = join(dir, 'accounts.json'); writeFileSync(file, JSON.stringify(doc), { mode: 0o600 });
return { configDir: dir, instance: 'test', ...context, file };
}
const write = (ctx, revision, fn, rest = {}) => withAccounts(ctx, { revision, verb: 'user.signout', ...rest }, fn);
test('loader refuses every malformed document as a whole, including nested unknown keys', t => {
const cases = [d => d.accountsVersion = 2, d => d.revision = 0, d => d.cookie = 'secret', d => d.users[0].id = 'human', d => d.users[0].authGen = 0, d => d.users[0].authGen = 1.5, d => d.users[0].instanceRole = 'owner', d => d.users[0].state = 'disabled', d => d.users[0].subjects[0].provider = 'local-dev', d => d.users[0].subjects[0].provider = 'missing', d => d.users[1].subjects = d.users[0].subjects, d => d.users[0].subjects[0].iss = 'https://evil', d => d.users[0].created = '2026-02-30T00:00:00Z', d => d.memberships.push({}), d => { const m = member(); m.rights.push('org.setup'); d.memberships.push(m); }, d => { const m = member(); m.rights.reverse(); d.memberships.push(m); }, d => { const m = member(); m.scope.instances = ['*', 'coder']; d.memberships.push(m); }, d => { const m = member(); m.user = 'u-000000000003'; d.memberships.push(m); }];
for (const mutate of cases) { const d = document(); mutate(d); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
assert.throws(() => checkAccounts(document(), { providers: [{ id: 'pocket', issuer: 'local-dev' }] }));
const s = setup(t); assert.throws(() => loadAccounts(s.file, s, { uid: process.getuid() + 1 }), /uid/);
for (const mode of [0o620, 0o602]) { chmodSync(s.file, mode); assert.throws(() => loadAccounts(s.file, s), /writable/); }
chmodSync(s.file, 0o600); const target = join(s.configDir, 'real'); writeFileSync(target, JSON.stringify(document()), { mode: 0o600 }); unlinkSync(s.file); symlinkSync(target, s.file); assert.throws(() => loadAccounts(s.file, s), /symbolic/);
});
test('unknown role and right load as repair rows; account may be a business human', () => {
const d = document(); const m = member(); m.role = 'future'; m.rights.push('zz.future'); d.memberships.push(m);
const loaded = checkAccounts(d, { ...context, businesses: { acme: { human: OP } } });
assert.equal(loaded.users[0].id, OP); assert.ok(Object.isFrozen(loaded.users[0]));
assert.deepEqual(accountsRepairs(loaded), [{ kind: 'membership', org: 'acme', user: USER, unknownRole: 'future', unknownRights: ['zz.future'] }]);
});
test('writes refuse stale revision, unknown role/right and reserved rights without a receipt', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 9, () => {}), /revision-conflict/);
for (const change of [m => m.role = 'future', m => m.rights.push('zz.future'), m => m.rights.push('org.setup')]) {
await assert.rejects(write(s, 1, d => { const m = member(); change(m); d.memberships.push(m); }));
}
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(existsSync(join(s.configDir, 'accounts-receipts.jsonl')), false);
});
test('rights/scope revisions and security generations are maintained by the writer', async t => {
const d = document(); d.memberships.push(member()); const s = setup(t, d);
let rev = 1;
for (const change of [u => u.state = 'disabled', u => u.state = 'active', u => u.instanceRole = 'operator', u => u.subjects[0].sub = 'new-sub']) {
const result = await write(s, rev++, next => change(next.users[1])); assert.equal(result.users[1].authGen, rev);
}
let result = await write(s, rev++, next => next.users[1].authGen++); assert.equal(result.users[1].authGen, rev);
result = await write(s, rev++, next => next.memberships[0].rights.push('task.write')); assert.equal(result.memberships[0].rev, 2);
result = await write(s, rev++, next => next.memberships[0].scope.instances = ['coder']); assert.equal(result.memberships[0].rev, 3);
assert.equal(result.users[1].authGen, 6);
const lines = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(lines.length, 7); for (const r of lines) { assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'user.signout'); }
assert.equal(statSync(s.file).mode & 0o777, 0o600);
});
test('concurrent writes serialize and the later stale write changes nothing', async t => {
const s = setup(t); let release, entered; const ready = new Promise(r => entered = r); const wait = new Promise(r => release = r);
const first = write(s, 1, async d => { entered(); await wait; d.users[1].authGen++; }); await ready;
const second = write(s, 1, d => { d.users[1].authGen++; }); release();
const results = await Promise.allSettled([first, second]); assert.equal(results[0].status, 'fulfilled'); assert.equal(results[1].status, 'rejected'); assert.match(results[1].reason.message, /revision-conflict/);
assert.equal(loadAccounts(s.file, s).revision, 2);
});
test('locked helpers require live handles; registry dependency is rechecked before replace', async t => {
const s = setup(t); let held;
await assert.rejects(readRegistryLocked({}), /lock/);
await assert.rejects(withAccountsLocked({}, { revision: 1, verb: 'user.signout' }, () => {}), /lock/);
await withRegistry(s, async registry => {
assert.equal(await readRegistryLocked(registry), registry);
await withAccountsLock(s, async handle => { held = handle; await withAccountsLocked(handle, { revision: 1, verb: 'user.signout', registry }, d => d.users[1].authGen++); });
});
await assert.rejects(withAccountsLocked(held, { revision: 2, verb: 'user.signout' }, () => {}), /lock/);
const before = readFileSync(s.file, 'utf8');
await withRegistry(s, async registry => {
await assert.rejects(write(s, 2, d => { d.users[1].authGen++; writeFileSync(join(s.configDir, 'org-runtime.json'), JSON.stringify({ version: 1, instance: 'test', orgs: { changed: { id: 'changed', root: '/elsewhere', state: 'active', revision: 1, created: 'now' } } }), { mode: 0o600 }); }, { registry }), /registry-changed/);
}, { write: false });
assert.equal(readFileSync(s.file, 'utf8'), before);
});
test('failure before replace leaves old bytes, no receipt line and no temporary file', async t => {
const s = setup(t); const before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { throw new Error('injected failure'); } }), /injected failure/);
assert.equal(readFileSync(s.file, 'utf8'), before); assert.deepEqual(readdirSync(s.configDir).filter(f => f.startsWith('.accounts-')), []); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('receipts never serialize subject secrets, tokens, cookies, names or arbitrary actor text', async t => {
const s = setup(t); const secret = 'subject-secret';
await write(s, 1, d => { d.users[1].subjects[0].sub = secret; d.users[1].name = 'cookie-secret'; }, { actor: OP });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(secret)); assert.ok(!raw.includes('cookie-secret'));
await assert.rejects(write(s, 2, () => {}, { verb: 'token-secret' }));
await assert.rejects(write(s, 2, () => {}, { actor: 'cookie-secret' }));
});
test('malformed membership rows refuse all of the file; reserved rights never load', () => {
const mutations = [m => m.role = 'owner', m => m.rev = 0, m => m.incarnation = 'old', m => m.rights = ['thread.read'], m => m.rights = ['org.view', 'org.view'], m => m.scope = { projects: [], instances: ['*'] }, m => m.scope.projects = ['not-a-uuid'], m => m.scope.expires = 'not-a-time', m => m.extra = true, m => m.by = 'human', m => m.via = '', m => m.added = 'bad'];
for (const mutate of mutations) { const d = document(), m = member(); mutate(m); d.memberships.push(m); assert.throws(() => checkAccounts(d, context), undefined, mutate.toString()); }
for (const right of ['members.manage', 'org.setup', 'org.lifecycle', 'request.write', 'owner']) { const d = document(), m = member(); m.rights = ['org.view', right].sort(); d.memberships.push(m); assert.throws(() => checkAccounts(d, context)); }
});
function invitation() {
const { user: ignored, rev, added, via, ...row } = member();
return { ...row, id: '11111111-2222-3333-4444-555555555555', bind: { email: '[email protected]' }, tokenHash: 'b'.repeat(64), created: '2026-11-10T00:00:00Z', expires: '2026-11-13T00:00:00Z', state: 'open' };
}
test('invite schema refuses raw secrets, domain/bearer bindings, malformed hashes and unknown fields', () => {
const d = document(); d.invites.push(invitation()); assert.equal(checkAccounts(d, context).invites.length, 1);
for (const mutate of [i => i.token = 'raw-token', i => i.cookie = 'raw-cookie', i => i.bind = { domain: 'example.com' }, i => i.bind = {}, i => i.bind = { email: '@example.com' }, i => i.bind.sub = 'extra-binding', i => i.tokenHash = 'raw-token', i => i.state = 'other', i => i.rights = ['org.setup', 'org.view'], i => i.scope.level = 'collaborator']) {
const next = structuredClone(d); mutate(next.invites[0]); assert.throws(() => checkAccounts(next, context));
}
d.invites[0].role = 'future'; d.invites[0].rights.push('zz.future'); assert.equal(accountsRepairs(checkAccounts(d, context))[0].kind, 'invite');
});
test('unsafe receipt destination refuses before publication and leaves its target intact', async t => {
const s = setup(t), target = join(s.configDir, 'target'); writeFileSync(target, 'untouched', { mode: 0o600 }); symlinkSync(target, join(s.configDir, 'accounts-receipts.jsonl'));
const before = readFileSync(s.file, 'utf8'); await assert.rejects(write(s, 1, d => d.users[1].authGen++)); assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(target, 'utf8'), 'untouched');
});
test('account lock allows sequential writes and registry handles cannot cross instance boundaries', async t => {
const s = setup(t), other = setup(t);
await withAccountsLock(s, async handle => {
await withAccountsLocked(handle, { revision: 1, verb: 'user.signout' }, d => d.users[1].authGen++);
await withAccountsLocked(handle, { revision: 2, verb: 'user.signout' }, d => d.users[1].authGen++);
});
await withRegistry(other, async registry => { await assert.rejects(write(s, 3, () => {}, { registry }), /another instance/); });
assert.equal(loadAccounts(s.file, s).revision, 3);
});
test('invite token hashes and subject bindings never reach receipts', async t => {
const s = setup(t); const invite = invitation(); invite.bind = { provider: 'pocket', sub: 'subject-secret' };
await write(s, 1, d => d.invites.push(invite), { verb: 'invite.create' });
const raw = readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'); assert.ok(!raw.includes(invite.tokenHash)); assert.ok(!raw.includes('subject-secret')); assert.equal(JSON.parse(raw).verb, 'invite.create');
});
test('a failed rename preserves published bytes and appends no receipt', async t => {
const s = setup(t), before = readFileSync(s.file, 'utf8');
await assert.rejects(write(s, 1, d => d.users[1].authGen++, { beforeReplace: () => { const temp = readdirSync(s.configDir).find(f => f.startsWith('.accounts-')); unlinkSync(join(s.configDir, temp)); } }), { code: 'ENOENT' });
assert.equal(readFileSync(s.file, 'utf8'), before); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), ''); assert.ok(!existsSync(join(s.configDir, 'accounts.lock')));
});
test('review P2-1: rollback rows survive unrelated disable/signout but cannot be added or edited', async t => {
const d = document(), m = member(), invite = invitation(); m.role = 'future'; m.rights.push('zz.future'); invite.rights.push('zz.future'); d.memberships.push(m); d.invites.push(invite); const s = setup(t, d);
const disabled = await write(s, 1, next => next.users[1].state = 'disabled', { verb: 'user.disable' });
assert.deepEqual(disabled.memberships, d.memberships); assert.deepEqual(disabled.invites, d.invites);
const signedOut = await write(s, 2, next => next.users[0].authGen++); assert.deepEqual(signedOut.memberships, d.memberships); assert.equal(signedOut.users[0].authGen, 2);
await assert.rejects(write(s, 3, next => next.memberships[0].scope.instances = ['coder']), /unknown/);
await assert.rejects(write(s, 3, next => next.invites[0].state = 'revoked'), /unknown/);
const repaired = await write(s, 3, next => { next.memberships[0].role = 'member'; next.memberships[0].rights = ['org.view']; }); assert.equal(repaired.memberships[0].rev, 2); assert.deepEqual(repaired.invites, d.invites);
});
test('review P3-1: control and bidi characters refuse in names, subjects and provenance', () => {
for (const control of ['\x1b', '\x07', '\r', '\n', '\x7f', '\u0085', '\u202e', '\u2066']) for (const field of ['name', 'sub', 'via']) {
const d = document(); d.memberships.push(member()); if (field === 'via') d.memberships[0].via += control; else if (field === 'sub') d.users[1].subjects[0].sub += control; else d.users[1].name += control;
assert.throws(() => checkAccounts(d, context));
}
});
test('review P3-2: invite validity is between one and 168 hours inclusive', () => {
for (const hours of [0.5, 169, 87600]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.throws(() => checkAccounts(d, context)); }
for (const hours of [1, 168]) { const d = document(), i = invitation(); i.expires = new Date(Date.parse(i.created) + hours * 3600000).toISOString(); d.invites.push(i); assert.equal(checkAccounts(d, context).invites.length, 1); }
});
test('review P3-3: receipts record actual changed field names even for a misleading verb', async t => {
const s = setup(t); await write(s, 1, d => d.users[1].instanceRole = 'operator');
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.deepEqual(r.userChanges, [{ id: USER, changed: ['instanceRole', 'authGen'] }]);
});
test('review P3-4: directory durability failure reports committed state and appends no success receipt', async t => {
const s = setup(t); let error;
try { await write(s, 1, d => d.users[1].authGen++, { syncDirectory: async () => { throw new Error('injected directory sync failure'); } }); } catch (e) { error = e; }
assert.match(error?.message ?? '', /injected directory sync failure/); assert.equal(error.committed, true); assert.equal(loadAccounts(s.file, s).revision, 2); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-5: init cannot clobber a file created after its last absence check', async t => {
const s = setup(t); unlinkSync(s.file); const intruder = 'concurrent file';
await assert.rejects(withAccounts(s, { revision: 0, verb: 'init', init: true, beforeReplace: () => writeFileSync(s.file, intruder, { mode: 0o600 }) }, d => d.users.push(user())), { code: 'EEXIST' });
assert.equal(readFileSync(s.file, 'utf8'), intruder); assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8'), '');
});
test('review P3-6: a real business file and accounts file load with the same human id', async t => {
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
});
// org-acct-anchor (#1873, A2 review P2-1): every ancestor of the config directory must belong to root
// or this uid, and be writable by others only with the sticky bit. Otherwise another user could rename
// the directory away and put their own accounts.json in its place. No refusal quotes the path.
const SECRETISH = 'sk-test-NOT-A-SECRET-1234';
function nested(t) {
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
const parent = join(top, SECRETISH), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 }); chmodSync(parent, 0o700);
const accountsFile = join(configDir, 'accounts.json'); writeFileSync(accountsFile, JSON.stringify(document()), { mode: 0o600 });
return { top, parent, configDir, instance: 'test', ...context, file: accountsFile };
}
const unsafe = rule => e => e.code === 'accounts-unsafe' && rule.test(e.message) && !e.message.includes(SECRETISH) && !e.message.includes('/');
const ANCESTOR = /ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit/;
test('org-acct-anchor: a 0777 ancestor refuses load, init and the lock; 1777 passes', async t => {
const s = nested(t), initDir = join(s.parent, 'new'); mkdirSync(initDir, { mode: 0o700 });
const init = { ...s, configDir: initDir, file: join(initDir, 'accounts.json') };
for (const dir of [s.parent, s.top]) {
chmodSync(dir, 0o777);
try {
assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR));
await assert.rejects(withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user())), unsafe(ANCESTOR));
let entered = false;
await assert.rejects(withAccountsLock(s, async () => { entered = true; }), unsafe(ANCESTOR));
assert.equal(entered, false);
for (const c of [s, init]) for (const name of ['accounts.lock', 'accounts-receipts.jsonl']) assert.equal(existsSync(join(c.configDir, name)), false, name);
assert.equal(existsSync(init.file), false);
chmodSync(dir, 0o1777);
assert.equal(loadAccounts(s.file, s).revision, 1);
await withAccountsLock(s, async () => { entered = true; }); assert.equal(entered, true);
await withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user()));
assert.equal(loadAccounts(init.file, init).revision, 1);
rmSync(init.file); rmSync(join(init.configDir, 'accounts-receipts.jsonl'));
} finally { chmodSync(dir, 0o700); }
}
// A group-writable ancestor without the sticky bit refuses too.
chmodSync(s.parent, 0o770);
try { assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR)); } finally { chmodSync(s.parent, 0o700); }
});
test('org-acct-anchor: an ancestor owned by another uid refuses; root-owned ancestors pass', t => {
const s = nested(t), other = process.geteuid() + 1;
// Simulated as the agent-levels tests do: as another uid, this uid's directories are foreign.
assert.throws(() => assertAccountsOwner(s.configDir, { uid: other }), unsafe(new RegExp(`ancestor must belong to root or uid ${other} `)));
assert.throws(() => loadAccounts(s.file, s, { uid: other }), unsafe(ANCESTOR));
// The real ancestors above the scratch root include root-owned ones (at least /), which pass.
assert.equal(lstatSync('/').uid, 0);
assertAccountsOwner(s.configDir);
assert.equal(loadAccounts(s.file, s).revision, 1);
});
test('org-acct-anchor: a symlinked ancestor refuses, and leaf refusals name no path', async t => {
const s = nested(t), link = join(s.top, 'link'); symlinkSync(s.parent, link);
const via = { ...s, configDir: join(link, 'cfg'), file: join(link, 'cfg', 'accounts.json') };
assert.throws(() => loadAccounts(via.file, via), unsafe(/symbolic link or non-directory/));
await assert.rejects(withAccountsLock(via, async () => {}), unsafe(/symbolic link or non-directory/));
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false);
// The leaf checks are unchanged, but no longer quote the path.
const other = process.geteuid() + 1;
for (const mode of [0o720, 0o702]) {
chmodSync(s.configDir, mode);
try { assert.throws(() => loadAccounts(s.file, s), unsafe(/^config directory must not be writable by group or other$/)); } finally { chmodSync(s.configDir, 0o700); }
}
// As another uid the ancestors refuse first; the leaf's uid rule is the same no-path form (source check below).
assert.throws(() => assertAccountsOwner(s.configDir, { uid: process.geteuid() + 1 }), e => !e.message.includes(SECRETISH) && !e.message.includes('/'));
const source = readFileSync(new URL('../src/config-dir-trust.mjs', import.meta.url), 'utf8') + readFileSync(new URL('../src/accounts.mjs', import.meta.url), 'utf8');
assert.match(source, /`config directory must belong to uid \$\{uid\}`/);
assert.match(source, /`accounts file must belong to uid \$\{uid\}`/);
assert.doesNotMatch(source, /must belong to uid \$\{uid\}: \$\{/);
assert.throws(() => loadAccounts(join(s.configDir, 'missing', 'accounts.json'), s), unsafe(/^config directory is missing$/));
assert.throws(() => assertAccountsOwner(`${s.configDir}/../cfg`), unsafe(/absolute normalized/));
});