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.
|
// <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()));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user