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.
104 lines
10 KiB
JavaScript
104 lines
10 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { accountsCommand } from '../src/accounts.mjs';
|
|
import { main } from '../src/cli.mjs';
|
|
import { withRegistry, reserveOrg } from '../../organization/src/org-registry.mjs';
|
|
const providers = [{ id: 'pocket', issuer: 'https://id.example', subjects: [{ sub: 'owner' }, { sub: 'other-owner' }] }, { id: 'second', issuer: 'https://second.example', subjects: [{ sub: 'owner' }] }];
|
|
function setup(t) {
|
|
const configDir = mkdtempSync(join(tmpdir(), 'a-')); chmodSync(configDir, 0o700); t.after(() => rmSync(configDir, { recursive: true, force: true }));
|
|
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } }; let output = '';
|
|
const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
|
return { configDir, deps, io, run: args => accountsCommand(args, io, deps), read: () => JSON.parse(readFileSync(join(configDir, 'accounts.json'))), output: () => output };
|
|
}
|
|
test('init makes exactly one operator from all configured subjects, then refuses rerun', async t => {
|
|
const s = setup(t); await s.run(['init']); const d = s.read(); assert.equal(d.users.length, 1); assert.equal(d.users[0].instanceRole, 'operator'); assert.equal(d.users[0].subjects.length, 3); assert.match(d.users[0].id, /^u-[0-9a-f]{12}$/); assert.equal(d.revision, 1);
|
|
await assert.rejects(s.run(['init']), /already exists/); await s.run(['list', '--json']); assert.match(s.output(), /authGen/);
|
|
const r = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(r.uid, process.getuid()); assert.equal(r.verb, 'init'); assert.ok(!JSON.stringify(r).includes('other-owner'));
|
|
});
|
|
test('CLI add, disable, enable, signout and remove; last operator guard and stale revisions', async t => {
|
|
const s = setup(t); await s.run(['init']); const op = s.read().users[0].id;
|
|
await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat:sub', '--revision', '1']); const id = s.read().users[1].id;
|
|
for (const [verb, revision, generation] of [['disable', 2, 2], ['enable', 3, 3], ['signout', 4, 4]]) { await s.run(['user', verb, id, '--revision', String(revision)]); assert.equal(s.read().users[1].authGen, generation); }
|
|
await assert.rejects(s.run(['user', 'disable', op, '--revision', '5']), /active operator/);
|
|
await assert.rejects(s.run(['user', 'signout', id, '--revision', '1']), /revision-conflict/);
|
|
await s.run(['user', 'remove', id, '--revision', '5']); assert.equal(s.read().users.length, 1); assert.equal(s.read().revision, 6);
|
|
assert.equal(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8').trim().split('\n').length, 6);
|
|
});
|
|
test('host uid refusal happens before any lock, even on list or remove', async t => {
|
|
const s = setup(t); for (const args of [['list'], ['init'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
|
await assert.rejects(accountsCommand(args, s.io, { ...s.deps, uid: process.getuid() + 1 }), /uid/);
|
|
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false); assert.equal(existsSync(join(s.configDir, 'org-runtime.lock')), false);
|
|
}
|
|
});
|
|
test('remove holds registry before accounts, refuses ownership of active or archived orgs, including a preceding create', async t => {
|
|
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
|
const root = join(s.configDir, 'acme'); mkdirSync(root, { mode: 0o700 });
|
|
let release, entered; const ready = new Promise(r => entered = r), hold = new Promise(r => release = r);
|
|
const create = withRegistry(s.deps, async registry => { entered(); await hold; registry.orgs.acme = { id: 'acme', root, state: 'archived', revision: 1, created: '2026-11-10T00:00:00Z' }; writeFileSync(join(root, 'owner.json'), JSON.stringify({ provider: 'pocket', iss: 'https://id.example', sub: 'pat' }), { mode: 0o600 }); });
|
|
await ready; const removing = s.run(['user', 'remove', id, '--revision', '2']); release(); await create; await assert.rejects(removing, /user-owns-org/); assert.equal(s.read().revision, 2);
|
|
});
|
|
test('remove drops memberships atomically, and malformed ownership metadata refuses', async t => {
|
|
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const d = s.read(), id = d.users[1].id;
|
|
d.memberships.push({ org: 'acme', incarnation: 'a'.repeat(32), user: id, role: 'member', rights: ['org.view'], scope: { projects: ['*'], instances: ['*'] }, rev: 1, by: d.users[0].id, added: '2026-11-10T00:00:00Z', via: 'invite:abc' }); writeFileSync(join(s.configDir, 'accounts.json'), JSON.stringify(d));
|
|
await s.run(['user', 'remove', id, '--revision', '2']); assert.deepEqual(s.read().memberships, []);
|
|
const root = join(s.configDir, 'broken'); mkdirSync(root, { mode: 0o700 });
|
|
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{}', { mode: 0o600 }); });
|
|
await assert.rejects(s.run(['user', 'remove', s.read().users[0].id, '--revision', '3']), /owner/);
|
|
});
|
|
test('mosaic main wires accounts without a bus; tester, agent and unexpected args refuse', async t => {
|
|
const s = setup(t); await main(['accounts', 'init'], s.io, { system: s.deps.system, accountsConfigDir: s.configDir }); assert.equal(s.read().users.length, 1);
|
|
for (const args of [['list', '--tester'], ['list', '--subject', 'pocket:x'], ['user', 'add', 'chosen-id', '--name', 'Pat', '--subject', 'pocket:x', '--revision', '1'], ['list', '--json', '--json']]) await assert.rejects(s.run(args));
|
|
await assert.rejects(accountsCommand(['list'], { ...s.io, env: { MOSAIC_RUN_ID: 'run' } }, s.deps), /human shell/);
|
|
});
|
|
|
|
|
|
test('review P2-2: real reservation wins before owner.json exists, and removal refuses', async t => {
|
|
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
|
const dataRoot = join(s.configDir, 'data'); mkdirSync(dataRoot, { mode: 0o700 }); const ctx = { configDir: s.configDir, instance: 'test', dataRoot };
|
|
const org = await reserveOrg(ctx, 'acme'); assert.equal(existsSync(join(org.root, 'owner.json')), false);
|
|
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), /org-create-in-progress/); assert.equal(s.read().users.length, 2); assert.equal(s.read().revision, 2);
|
|
});
|
|
|
|
|
|
test('host boundary and receipt use effective uid, not real uid (current design clarification)', async t => {
|
|
const s = setup(t), realUid = process.getuid, effectiveUid = process.geteuid();
|
|
process.getuid = () => effectiveUid + 1;
|
|
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
|
|
finally { process.getuid = realUid; }
|
|
});
|
|
|
|
// The uid identifies this process, not the human who invoked it. The instance
|
|
// uid can rewrite its receipt; this is neither human attribution nor tamper evidence.
|
|
test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => {
|
|
const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs');
|
|
const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root);
|
|
const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
|
|
writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config));
|
|
const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname;
|
|
const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000});
|
|
writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/accounts-unsafe: (a config directory ancestor|config directory) must belong to (root or )?uid \d+/);
|
|
for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false);
|
|
// Different real uid, same effective uid: the effective identity is recorded.
|
|
writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr);
|
|
const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid());
|
|
});
|
|
|
|
// org-acct-anchor (#1873): `mosaic accounts init` refuses under an ancestor another user could
|
|
// rename, before any lock or write, and the refusal names the rule, never the path.
|
|
test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any lock; 1777 passes', async t => {
|
|
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
|
|
const parent = join(top, 'sk-test-NOT-A-SECRET-1234'), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
let output = ''; const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
|
|
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } };
|
|
chmodSync(parent, 0o777);
|
|
for (const args of [['init'], ['list'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
|
|
await assert.rejects(accountsCommand(args, io, deps), e => e.exitCode === 3 && /^refused: accounts-unsafe: a config directory ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit$/.test(e.message) && !e.message.includes('sk-test') && !e.message.includes('/'));
|
|
for (const name of ['accounts.json', 'accounts.lock', 'accounts-receipts.jsonl', 'org-runtime.lock']) assert.equal(existsSync(join(configDir, name)), false, name);
|
|
}
|
|
chmodSync(parent, 0o1777);
|
|
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
|
|
});
|