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:
1 parent
39d9bd6e64
commit
a18fc6cf8c
4 files changed
+45
-13
No files matched your search
@@ -2,7 +2,7 @@
|
||||
// <configDir>/secrets/agents/<business>/<agent>/<service>. Fake tokens in a scratch root only.
|
||||
import test from 'node:test';
|
||||
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 { join } from 'node:path';
|
||||
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.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()));
|
||||
});
|
||||
@@ -80,7 +80,11 @@ export function tasksAdapter({
|
||||
tree: null,
|
||||
bots: new Set([businesses[name].tracker.sync.botId, ...Object.values(s.roles).map((r) => r.botId)]),
|
||||
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',
|
||||
refused: null,
|
||||
queue: Promise.resolve(),
|
||||
@@ -96,7 +100,7 @@ export function tasksAdapter({
|
||||
timers: [],
|
||||
};
|
||||
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);
|
||||
contexts.set(name, ctx);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export async function startup(ctx) {
|
||||
if (probe.status === 404 || probe.status === 403 || outcome(probe) === 'ok') refuse('scope-too-broad');
|
||||
expect(probe, 401, 'tracker-unexpected');
|
||||
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) {
|
||||
expect(control, 404, 'tracker-unexpected');
|
||||
refuse('tracker-unexpected');
|
||||
@@ -49,7 +49,7 @@ export async function startup(ctx) {
|
||||
const probes = [['DELETE', `/tasks/${MISSING}`, undefined]];
|
||||
if (r.definition !== 'pm') probes.push(['POST', `/tasks/${MISSING}/labels`, { label_id: 1 }]);
|
||||
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 !== 401) refuse(p.status === 0 || p.status >= 500 ? 'tracker-unavailable' : 'tracker-unexpected');
|
||||
}
|
||||
|
||||
@@ -122,8 +122,9 @@ export function verbs(ctx) {
|
||||
const relatedTo = (task, kind, 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.
|
||||
async function step(role, method, path, body, at, done) {
|
||||
const r = await ctx.as(role, method, path, { body });
|
||||
// `me` is the broker's session for the caller's cap (design D5).
|
||||
async function step(me, method, path, body, at, done) {
|
||||
const r = await ctx.as(me, method, path, { body });
|
||||
const o = outcome(r);
|
||||
if (o === 'ok') return r;
|
||||
if (o === 'uncertain' && at !== null && done) {
|
||||
@@ -144,7 +145,7 @@ export function verbs(ctx) {
|
||||
if (!mine) fail('tracker-role');
|
||||
if (PM_VERBS.has(verb) && mine.definition !== 'pm') fail('field-writer');
|
||||
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 before = await read(at);
|
||||
if (args.expect !== undefined && args.expect !== before.digest) {
|
||||
@@ -165,7 +166,7 @@ export function verbs(ctx) {
|
||||
let landed = 0;
|
||||
try {
|
||||
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++;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -385,12 +386,12 @@ export function verbs(ctx) {
|
||||
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');
|
||||
await located(plan.others);
|
||||
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.
|
||||
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');
|
||||
checkTask(r.json);
|
||||
const id = r.json.id;
|
||||
@@ -399,9 +400,9 @@ export function verbs(ctx) {
|
||||
let failure = null;
|
||||
try {
|
||||
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)
|
||||
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),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
Reference in new issue
Block a user