273 lines
14 KiB
JavaScript
273 lines
14 KiB
JavaScript
// FED R4 (#1801, federation design 3.7 and the R4 row of section 7): revocation. A loss of a user's
|
|
// grant ends the claim of every run on that user's threads, from one of three triggers: the timer's
|
|
// accessSweep, the human's access.reload, and the next request of any user once the file changed.
|
|
// The access document and its stamp come in as functions, as the runtime passes loadAccess and an
|
|
// lstat key; `edit` changes the document and the stamp together, as a hand edit does. BUILD.md
|
|
// (agents/morgana/work/queue-295) names the mutant each test kills.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Broker } from '../src/broker.mjs';
|
|
import { ACCESS_SWEEP_MS, startBroker } from '../src/runtime.mjs';
|
|
|
|
const shipped = (name) =>
|
|
JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
|
|
const grant = (over = {}) => ({
|
|
id: `g-${over.user ?? 'alice'}`, user: 'alice', business: 'demo', projects: ['*'], instances: ['assistant'], level: 'collaborator', ...over,
|
|
});
|
|
const BUSINESSES = {
|
|
demo: {
|
|
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, plugins: { harness: ['pi'] },
|
|
roles: {
|
|
chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } },
|
|
},
|
|
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } },
|
|
},
|
|
};
|
|
|
|
// Alice and Bob each hold a collaborator grant and own a thread; the human owns thread H. With
|
|
// `runs`, each thread has a claimed run: a-run on A, b-run on B, h-run on H.
|
|
function setup(t, { runs = true, grants = [grant(), grant({ user: 'bob' })] } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-access-revoke-'));
|
|
const store = new Store(root);
|
|
t.after(() => {
|
|
store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
});
|
|
// `reads` counts loader calls; `rev` is the stamp, which a hand edit changes.
|
|
const access = { doc: { accessVersion: 1, revision: 1, users: [], grants }, reads: 0, rev: 1 };
|
|
const broker = () => new Broker({
|
|
store,
|
|
businesses: BUSINESSES,
|
|
access: () => {
|
|
access.reads++;
|
|
return access.doc;
|
|
},
|
|
accessStamp: () => String(access.rev),
|
|
});
|
|
const b = broker();
|
|
const caps = {
|
|
human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
|
tester: b.bindTester({ business: 'demo' }),
|
|
reader: b.bindReader({ business: 'demo' }),
|
|
};
|
|
const call = (who, verb, args = {}) => b.request(caps[who], { verb, args });
|
|
const code = (who, verb, args = {}) => {
|
|
try {
|
|
call(who, verb, args);
|
|
} catch (e) {
|
|
return e.code;
|
|
}
|
|
return null;
|
|
};
|
|
const edit = (fn) => {
|
|
access.doc = { ...access.doc, revision: access.doc.revision + 1, grants: fn(access.doc.grants) };
|
|
access.rev++;
|
|
};
|
|
const record = (name, thread) => ({ business: 'demo', role: 'assistant', run: `${name}-run`, harness: 'pi', thread });
|
|
const launch = (name, thread) => {
|
|
caps[name] = b.bindLaunch(record(name, thread));
|
|
return call(name, 'role.claim');
|
|
};
|
|
// The latest claim row of a run: its op, reason and by, spread off the store's null prototype.
|
|
const claim = (run) => ({ ...store.get('SELECT op, reason, by FROM thread_claims WHERE holder_run=? ORDER BY seq DESC LIMIT 1', run) });
|
|
const P = call('human', 'project.create', { name: 'shared' }).id;
|
|
caps.alice = b.bindUser({ business: 'demo', user: 'alice' });
|
|
caps.bob = b.bindUser({ business: 'demo', user: 'bob' });
|
|
const A = call('alice', 'thread.create', { project: P, name: 'alice work' }).id;
|
|
const B = call('bob', 'thread.create', { project: P, name: 'bob work' }).id;
|
|
const H = call('human', 'thread.create', { project: P, name: 'owner work' }).id;
|
|
if (runs) {
|
|
launch('a', A);
|
|
launch('b', B);
|
|
launch('h', H);
|
|
}
|
|
return { b, store, access, caps, call, code, edit, record, launch, claim, broker, P, A, B, H };
|
|
}
|
|
|
|
const withoutAlice = (gs) => gs.filter((g) => g.user !== 'alice');
|
|
|
|
test('R4: the timer sweep after a hand edit ends only the lost user\'s run; its next call refuses thread-revoked', (t) => {
|
|
const { b, code, edit, claim, A } = setup(t);
|
|
edit(withoutAlice);
|
|
const ended = b.accessSweep();
|
|
assert.equal(ended.length, 1);
|
|
assert.deepEqual({ ...ended[0], session: undefined }, { business: 'demo', thread: A, session: undefined, run: 'a-run' });
|
|
assert.equal(typeof ended[0].session, 'string');
|
|
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
|
|
assert.equal(code('a', 'message.receive'), 'thread-revoked', 'the ended run');
|
|
assert.equal(code('b', 'message.receive'), null, 'another user\'s run stays');
|
|
assert.equal(code('h', 'message.receive'), null, 'the human\'s run stays');
|
|
assert.deepEqual([claim('b-run').op, claim('h-run').op], ['claim', 'claim']);
|
|
// Ended once: a second sweep finds nothing to end.
|
|
assert.deepEqual(b.accessSweep(), []);
|
|
});
|
|
|
|
test('R4: an expiry with no write and no request ends the run at the next timer sweep', (t) => {
|
|
t.mock.timers.enable({ apis: ['Date'], now: Date.parse('2026-10-23T00:00:00Z') });
|
|
const { b, code, claim } = setup(t, { grants: [grant({ expires: '2026-10-23T00:01:00Z' }), grant({ user: 'bob' })] });
|
|
assert.deepEqual(b.accessSweep(), [], 'live');
|
|
t.mock.timers.tick(60_001);
|
|
// Bob's request sees the same stamp, so it doesn't sweep: the timer is what catches an expiry.
|
|
assert.equal(code('bob', 'thread.list'), null);
|
|
assert.equal(claim('a-run').op, 'claim');
|
|
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
|
|
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
|
assert.equal(code('b', 'message.receive'), null);
|
|
});
|
|
|
|
test('R4: a downgrade to viewer is a loss', (t) => {
|
|
const { b, code, edit, claim } = setup(t);
|
|
edit((gs) => gs.map((g) => (g.user === 'alice' ? { ...g, level: 'viewer' } : g)));
|
|
assert.deepEqual(b.accessSweep().map((h) => h.run), ['a-run']);
|
|
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-timer' });
|
|
assert.equal(code('b', 'message.receive'), null);
|
|
});
|
|
|
|
// sage-3139 Q1: no timer runs here, so only the request trigger can end alice's run.
|
|
test('R4: another user\'s request after a hand edit ends the lost user\'s run, and its own run stays', (t) => {
|
|
const { code, edit, claim } = setup(t);
|
|
edit(withoutAlice);
|
|
assert.equal(claim('a-run').op, 'claim', 'nothing ends before a request');
|
|
assert.equal(code('a', 'message.receive'), null, 'an agent request is not a trigger');
|
|
assert.equal(code('bob', 'thread.list'), null);
|
|
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-change' });
|
|
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
|
assert.equal(code('b', 'message.receive'), null);
|
|
assert.equal(code('h', 'message.receive'), null);
|
|
});
|
|
|
|
// sage-3139 Q1, the cost rule: a request only stats the file, and reads it once per change.
|
|
test('R4: a request reads the access file only when its stamp changed, once per change', (t) => {
|
|
const { access, code, edit } = setup(t);
|
|
const reads = access.reads;
|
|
assert.equal(code('bob', 'thread.list'), null);
|
|
assert.equal(code('alice', 'project.list'), null);
|
|
assert.equal(access.reads, reads, 'unchanged: no read');
|
|
edit((gs) => gs);
|
|
assert.equal(code('bob', 'thread.list'), null);
|
|
assert.equal(code('alice', 'thread.list'), null);
|
|
assert.equal(access.reads, reads + 1, 'changed: one read for both requests');
|
|
});
|
|
|
|
test('R4: access.reload ends the lost runs at once for the human; the tester, a reader, an agent and a user are refused', (t) => {
|
|
const { b, code, call, edit, claim, A } = setup(t);
|
|
assert.equal(code('tester', 'access.reload'), 'human-required', 'tester');
|
|
assert.equal(code('reader', 'access.reload'), 'read-only', 'reader');
|
|
assert.equal(code('b', 'access.reload'), 'human-required', 'agent');
|
|
assert.equal(code('bob', 'access.reload'), 'access-refused', 'user');
|
|
assert.deepEqual(call('human', 'access.reload'), { ended: [] }, 'nothing lost');
|
|
assert.equal(code('human', 'access.reload', { force: true }), 'invalid-request');
|
|
edit(withoutAlice);
|
|
const { ended } = call('human', 'access.reload');
|
|
assert.deepEqual(ended.map((h) => [h.thread, h.run]), [[A, 'a-run']]);
|
|
assert.equal(typeof ended[0].session, 'string');
|
|
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'jason' });
|
|
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
|
assert.equal(code('b', 'message.receive'), null);
|
|
assert.deepEqual(b.accessSweep(), []);
|
|
});
|
|
|
|
test('R4: threadsState marks revoked: true on the lost user\'s thread only, a pending message included', (t) => {
|
|
const { b, call, edit, A, B, H } = setup(t, { runs: false });
|
|
call('alice', 'thread.send', { thread: A, body: 'for A' });
|
|
call('bob', 'thread.send', { thread: B, body: 'for B' });
|
|
const row = (id) => b.threadsState('demo').find((x) => x.thread === id);
|
|
assert.equal(row(A).revoked, undefined, 'covered');
|
|
edit(withoutAlice);
|
|
assert.equal(row(A).revoked, true);
|
|
assert.equal(row(A).pending, 1, 'its message is still pending');
|
|
assert.equal(Object.hasOwn(row(B), 'revoked'), false);
|
|
assert.equal(Object.hasOwn(row(H), 'revoked'), false);
|
|
assert.equal(Object.hasOwn(row(A), 'owner'), false, 'the row keeps its old fields');
|
|
});
|
|
|
|
// Dewey's B1 (dewey-0871): the launcher ticks faster than the timer, so a grant that expires with no
|
|
// write must not reach it as revoked: true with the claim still held.
|
|
test('R4: threadsState after an expiry with no write and no sweep ends the claim before it marks the row revoked', (t) => {
|
|
t.mock.timers.enable({ apis: ['Date'], now: Date.parse('2026-10-23T00:00:00Z') });
|
|
const { b, code, claim, store, A, B } = setup(t, { grants: [grant({ expires: '2026-10-23T00:01:00Z' }), grant({ user: 'bob' })] });
|
|
const row = (id) => b.threadsState('demo').find((x) => x.thread === id);
|
|
assert.equal(row(A).revoked, undefined, 'live');
|
|
t.mock.timers.tick(60_001);
|
|
const a = row(A);
|
|
assert.deepEqual([a.revoked, a.holder], [true, null]);
|
|
assert.deepEqual(claim('a-run'), { op: 'revoke', reason: 'access-revoked', by: 'access-expiry' });
|
|
assert.equal(code('a', 'message.receive'), 'thread-revoked');
|
|
assert.deepEqual([row(B).holder, claim('b-run').op], ['b-run', 'claim']);
|
|
// Ended once: the next call writes no second revoke.
|
|
row(A);
|
|
assert.equal(store.get("SELECT count(*) AS n FROM thread_claims WHERE holder_run='a-run' AND op='revoke'").n, 1);
|
|
});
|
|
|
|
test('R4: a new run of a revoked thread refuses thread-revoked at bind and at claim; a rebind is allowed', (t) => {
|
|
const { b, call, edit, record, broker, A, B } = setup(t, { runs: false });
|
|
const late = b.bindLaunch(record('late', A));
|
|
edit(withoutAlice);
|
|
assert.throws(() => b.request(late, { verb: 'role.claim' }), { code: 'thread-revoked' }, 'bound before, claimed after');
|
|
assert.throws(() => b.bindLaunch(record('new', A)), { code: 'thread-revoked' }, 'bound after');
|
|
assert.ok(b.bindLaunch(record('other', B)), 'bob\'s thread binds');
|
|
call('bob', 'thread.list');
|
|
// D1: a later broker over the same store rebinds the run an earlier one bound (recover()), so the
|
|
// host can end it.
|
|
const again = broker();
|
|
assert.ok(again.bindLaunch(record('late', A)));
|
|
assert.equal(again.endLaunch({ business: 'demo', run: 'late-run', reason: 'recovered' }).released, false);
|
|
});
|
|
|
|
// The runtime's timer (design 3.7 trigger 2) on a real 0600 access file read by loadAccess. The mocked
|
|
// setInterval stands in for 30 s; the run is watched through its own calls, since threadsState and a
|
|
// user request would sweep by themselves.
|
|
test('R4 runtime: a hand edit ends the run at the first timer period, intervalMs is checked, and close stops the timer', async (t) => {
|
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
|
const root = mkdtempSync(join(tmpdir(), 'bus-access-timer-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const dataRoot = join(root, 'data');
|
|
mkdirSync(dataRoot, { mode: 0o700 });
|
|
const file = join(root, 'access.json');
|
|
const write = (grants) => writeFileSync(file, JSON.stringify({
|
|
accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'authentik', sub: 'alice-sub' }] }], grants,
|
|
}), { mode: 0o600 });
|
|
write([grant()]);
|
|
const context = { providers: ['authentik'], peers: [], businesses: { demo: { human: 'jason', instances: ['chief', 'assistant'] } }, ssoSubjects: [] };
|
|
for (const intervalMs of [0, -1, 1.5, '30000'])
|
|
await assert.rejects(startBroker({ dataRoot, businesses: BUSINESSES, access: { file, context, intervalMs } }), { code: 'invalid-host-request' }, String(intervalMs));
|
|
const runtime = await startBroker({ dataRoot, businesses: BUSINESSES, access: { file, context } });
|
|
let closed = false;
|
|
t.after(() => closed || runtime.close());
|
|
const b = runtime.broker;
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const alice = b.bindUser({ business: 'demo', user: 'alice' });
|
|
const P = b.request(human, { verb: 'project.create', args: { name: 'shared' } }).id;
|
|
const A = b.request(alice, { verb: 'thread.create', args: { project: P, name: 'alice work' } }).id;
|
|
const run = b.bindLaunch({ business: 'demo', role: 'assistant', run: 'a-run', harness: 'pi', thread: A });
|
|
b.request(run, { verb: 'role.claim' });
|
|
const receive = () => {
|
|
try {
|
|
b.request(run, { verb: 'message.receive' });
|
|
} catch (e) {
|
|
return e.code;
|
|
}
|
|
return null;
|
|
};
|
|
write([]);
|
|
t.mock.timers.tick(ACCESS_SWEEP_MS - 1);
|
|
assert.equal(receive(), null, 'before the first period');
|
|
t.mock.timers.tick(1);
|
|
assert.equal(receive(), 'thread-revoked', 'at the first period');
|
|
// After close the timer is gone: the sweep is never called again.
|
|
let sweeps = 0;
|
|
b.accessSweep = () => void sweeps++;
|
|
t.mock.timers.tick(ACCESS_SWEEP_MS);
|
|
assert.equal(sweeps, 1, 'one more period, one sweep');
|
|
closed = true;
|
|
await runtime.close();
|
|
t.mock.timers.tick(ACCESS_SWEEP_MS * 3);
|
|
assert.equal(sweeps, 1, 'closed');
|
|
});
|