tasks: row 199 request calls use useFor keyed from the session; the host's start probe keeps use; a D5 test pins the use( call sites (#1697)

This commit is contained in:
jason.woltje committed 2026-10-20 14:59:45 -05:00
1 parent 39d9bd6e64
commit a18fc6cf8c
4 files changed
+45 -13

No files matched your search

+28 -1
View File
@@ -2,7 +2,7 @@
// <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only. // <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only.
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
import { Credentials } from '../src/credentials.mjs'; import { Credentials } from '../src/credentials.mjs';
@@ -153,3 +153,30 @@ test('T5: delete refuses an agent dir holding anything but tokens', (t) => {
assert.throws(() => c.remove('acme/coder'), /credential-file/); assert.throws(() => c.remove('acme/coder'), /credential-file/);
assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true); assert.equal(existsSync(join(s.storeRoot, 'acme', 'coder', 'notes')), true);
}); });
// Design D5: the name-keyed use() is the host's own, for @sync and the tracker's start check of each
// role's token. Every other credential use goes through useFor, keyed from the broker's session. A
// new use( call site outside this list fails here.
test('D5: every credentials use( call site is on the allowlist', () => {
const packages = new URL('../../', import.meta.url);
const allowed = new Map([
['bus/src/credentials.mjs', 1], // useFor's own call
['tasks/src/adapter.mjs', 1], // the host's use(instance) for @sync and probe
]);
const found = new Map();
const walk = (dir, rel) => {
for (const name of readdirSync(dir)) {
if (name === 'node_modules' || name === 'tests') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path, `${rel}${name}/`);
else if (name.endsWith('.mjs') || name.endsWith('.js')) {
const code = readFileSync(path, 'utf8').split('\n').filter((line) => !/^\s*\/\//.test(line)).join('\n');
const n = (code.match(/(?:credentials|this)\.use\(/g) ?? []).length;
if (n) found.set(`${rel}${name}`, n);
}
}
};
for (const pkg of readdirSync(packages)) if (statSync(new URL(`${pkg}/src`, packages), { throwIfNoEntry: false })?.isDirectory())
walk(new URL(`${pkg}/src`, packages).pathname, `${pkg}/src/`);
assert.deepEqual(Object.fromEntries([...found].sort()), Object.fromEntries([...allowed].sort()));
});
+6 -2
View File
@@ -80,7 +80,11 @@ export function tasksAdapter({
tree: null, tree: null,
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]), bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
sync: use('@sync'), sync: use('@sync'),
as: (role, method, path, opts) => use(role)(method, path, opts), // Row 199 (#1697, design D5): a request's call keys from the broker's session for its cap,
// never from a role name. probe is the host's own start check of each role's token.
as: (session, method, path, opts) =>
credentials.useFor(session, 'vikunja', (token) => call(token, method, path, opts)),
probe: (role, method, path, opts) => use(role)(method, path, opts),
state: 'starting', state: 'starting',
refused: null, refused: null,
queue: Promise.resolve(), queue: Promise.resolve(),
@@ -96,7 +100,7 @@ export function tasksAdapter({
timers: [], timers: [],
}; };
ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts); ctx.syncCall = (_token, method, path, opts) => ctx.sync(method, path, opts);
ctx.asCall = (role) => (_token, method, path, opts) => ctx.as(role, method, path, opts); ctx.asCall = (role) => (_token, method, path, opts) => ctx.probe(role, method, path, opts);
ctx.handle = verbs(ctx); ctx.handle = verbs(ctx);
contexts.set(name, ctx); contexts.set(name, ctx);
} }
+2 -2
View File
@@ -41,7 +41,7 @@ export async function startup(ctx) {
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad'); if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
expect(probe, 401, 'tracker-unexpected'); expect(probe, 401, 'tracker-unexpected');
for (const [role, r] of Object.entries(ctx.roles)) { for (const [role, r] of Object.entries(ctx.roles)) {
const control = await ctx.as(role, 'GET', `/tasks/${MISSING}`); const control = await ctx.probe(role, 'GET', `/tasks/${MISSING}`);
if (control.status !== 404 || control.json?.code !== 4002) { if (control.status !== 404 || control.json?.code !== 4002) {
expect(control, 404, 'tracker-unexpected'); expect(control, 404, 'tracker-unexpected');
refuse('tracker-unexpected'); refuse('tracker-unexpected');
@@ -49,7 +49,7 @@ export async function startup(ctx) {
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]]; const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]); if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
for (const [method, path, body] of probes) { for (const [method, path, body] of probes) {
const p = await ctx.as(role, method, path, { body }); const p = await ctx.probe(role, method, path, { body });
if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad'); if (p.status === 404 || p.status === 403 || outcome(p) === 'ok') refuse('scope-too-broad');
if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected'); if (p.status !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
} }
+9 -8
View File
@@ -122,8 +122,9 @@ export function verbs(ctx) {
const relatedTo = (task, kind, other) => const relatedTo = (task, kind, other) =>
Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other); Array.isArray(task.related_tasks?.[kind]) && task.related_tasks[kind].some((t) => t?.id === other);
// One write with the acting role's token. An uncertain answer is settled by a re-read. // One write with the acting role's token. An uncertain answer is settled by a re-read.
async function step(role, method, path, body, at, done) { // `me` is the broker's session for the caller's cap (design D5).
const r = await ctx.as(role, method, path, { body }); async function step(me, method, path, body, at, done) {
const r = await ctx.as(me, method, path, { body });
const o = outcome(r); const o = outcome(r);
if (o === 'ok') return r; if (o === 'ok') return r;
if (o === 'uncertain' && at !== null && done) { if (o === 'uncertain' && at !== null && done) {
@@ -144,7 +145,7 @@ export function verbs(ctx) {
if (!mine) fail('tracker-role'); if (!mine) fail('tracker-role');
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer'); if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
const plan = prepare(verb, args, role, mine); const plan = prepare(verb, args, role, mine);
if (verb === 'task.create') return create(cap, role, plan); if (verb === 'task.create') return create(cap, me, plan);
const at = target(args.task_ref); const at = target(args.task_ref);
const before = await read(at); const before = await read(at);
if (args.expect !== undefined && args.expect !== before.digest) { if (args.expect !== undefined && args.expect !== before.digest) {
@@ -165,7 +166,7 @@ export function verbs(ctx) {
let landed = 0; let landed = 0;
try { try {
for (const [method, path, body, done] of work.steps) { for (const [method, path, body, done] of work.steps) {
await step(role, method, path, body, at, done); await step(me, method, path, body, at, done);
landed++; landed++;
} }
} catch (e) { } catch (e) {
@@ -385,12 +386,12 @@ export function verbs(ctx) {
fail('unknown-verb'); fail('unknown-verb');
} }
} }
async function create(cap, role, plan) { async function create(cap, me, plan) {
if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found'); if (!ctx.broker.taskView(ctx.business, 'input', plan.request)) fail('request-not-found');
await located(plan.others); await located(plan.others);
ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null }); ctx.broker.authorize(cap, 'task.create', { decision: plan.decision, target: null });
// No re-read can find a create whose answer was lost, so an uncertain create is reported as such. // No re-read can find a create whose answer was lost, so an uncertain create is reported as such.
const r = await step(role, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null); const r = await step(me, 'POST', `/projects/${plan.at.project}/tasks`, plan.body, null, null);
if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected'); if (r.status !== 201 && r.status !== 200) fail('tracker-unexpected');
checkTask(r.json); checkTask(r.json);
const id = r.json.id; const id = r.json.id;
@@ -399,9 +400,9 @@ export function verbs(ctx) {
let failure = null; let failure = null;
try { try {
for (const l of plan.labels) for (const l of plan.labels)
await step(role, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l)); await step(me, 'POST', `/tasks/${id}/labels`, { label_id: l }, at, (a) => a.fields.labels.includes(l));
for (const x of plan.relations) for (const x of plan.relations)
await step(role, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) => await step(me, 'POST', `/tasks/${id}/relations`, { other_task_id: x.other, relation_kind: x.kind }, at, (a) =>
relatedTo(a.task, x.kind, x.other), relatedTo(a.task, x.kind, x.other),
); );
} catch (e) { } catch (e) {