Security review P2: a hard crash after the stage left the staged token on disk. sweepCopySlots runs at start, before Credentials loads, and removes each copy service slot whose binding holds no grant, plus any staged temp file. It never touches a role's slots, a slot a loaded reference names, or an organization whose document cannot be read. A stage write that fails removes its temp file, and its slot when the rename has already happened. Build review P3s: push is held to the binding's approved level, not the token's. Tests now cover the boot-copy pre-checks, the format check running before the stage, and stageCopy's reference refusal.
559 lines
39 KiB
JavaScript
559 lines
39 KiB
JavaScript
// #1873 CB2 (design section 5 steps 4 to 6, section 8 row CB2): credentials.issue for an agent copy's
|
|
// bound Gitea service, verified live against a fake Gitea before any grant metadata lands. The
|
|
// organization writer's half is packages/organization/tests/copy-grant-writer.test.mjs; the real
|
|
// server acceptance is copy-grant-docker.test.mjs.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash } from 'node:crypto';
|
|
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { Broker, BusError } from '../src/broker.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
import { REFUSALS } from '../src/refusals.mjs';
|
|
import { copyGrant, tokenLevels, verifyGiteaToken, boundRepositories, MAX_ROTATE_DAYS, sweepCopySlots } from '../src/copy-grant.mjs';
|
|
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
|
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
|
import { organizationWriter } from '../../organization/src/organization.mjs';
|
|
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
|
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
|
import { materializeCopies, copiesView } from '../../organization/src/agent-copies.mjs';
|
|
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
|
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
|
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
|
|
|
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
|
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
|
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
|
const present = (p) => { try { lstatSync(p); return true; } catch { return false; } };
|
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
|
|
async function fixture(t, { services = READ, edit = (d) => d } = {}) {
|
|
const root = mkdtempSync(join(tmpdir(), 'cb2-')), rolesDir = rolesCopy(root);
|
|
const api = await fakeGiteaApi(t, root);
|
|
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
|
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
|
base.projects.stack.remote = `${api.url}/owner/stack`;
|
|
const doc = edit(base);
|
|
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
|
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
|
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
|
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
|
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
|
|
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
|
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null });
|
|
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView((opts.copies ?? snapshot).copies) }; };
|
|
const storeRoot = join(root, 'secrets', 'agents');
|
|
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
|
|
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
|
const hooks = {};
|
|
const opts = { timeoutMs: 3000 };
|
|
const issue = (...a) => copyGrant({ credentials, writer, copies: opts.copies ?? snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: opts.timeoutMs, hooks })(...a);
|
|
const direct = (args, ctx) => issue('demo', args, ctx);
|
|
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
|
|
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a) });
|
|
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
|
b.request(chief, { verb: 'role.claim' });
|
|
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
|
const bind = (name = 'rev', svc = services, extra = {}) => call(owner, 'hire.start.console', { op: 'change', level: 'root', name, reason: 'Bind the copy', binding: { services: svc }, ...extra });
|
|
await bind();
|
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
|
const rec = (name = 'rev') => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.[name];
|
|
const repo = api.addRepo('owner', 'stack');
|
|
// The copy's dedicated account, a read collaborator on owner/stack, and one token from it.
|
|
const bot = (name = 'rev', { perm = 'read', admin = false } = {}) => {
|
|
const r = rec(name);
|
|
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
|
|
const u = api.users.get(login) ?? api.addUser(login, { is_admin: admin }).user;
|
|
repo.collaborators.push(login); repo.permissions[login] = perm;
|
|
return u;
|
|
};
|
|
const token = (u, scopes = ['read:repository', 'read:user'], o) => api.addToken(u.login, `t${api.issued.length}`, scopes, o);
|
|
const slot = (name = 'rev') => join(storeRoot, 'demo', name, 'gitea');
|
|
const issueAs = (cap, args) => call(cap, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), ...args });
|
|
return { root, file, api, b, store, writer, credentials, hooks, opts, direct, owner, human, chief, call, bind, rec, bot, token, slot, repo, storeRoot, issueAs, loaded, snapshot };
|
|
}
|
|
|
|
// Everything the bus or the organization keeps after an issue: the document, the receipts, the store.
|
|
function kept(f) {
|
|
const files = [f.file, join(f.root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8'));
|
|
const rows = ['events', 'decisions'].map((tb) => JSON.stringify(f.store.all(`SELECT * FROM ${tb}`)));
|
|
return files.join('\n') + rows.join('\n');
|
|
}
|
|
|
|
test('CB2 an owner-issued copy token is verified live, staged and bound; nothing kept names the token or its path', async (t) => {
|
|
const f = await fixture(t);
|
|
assert.equal(f.rec().state, 'pending');
|
|
const u = f.bot();
|
|
const tok = f.token(u);
|
|
const date = ahead(30);
|
|
const result = await f.issueAs(f.human, { token: tok.sha1, date });
|
|
assert.deepEqual(result, { agent: 'rev', service: 'gitea', verb: 'issue', date, state: 'bound', account: u.login, generation: result.generation });
|
|
const r = f.rec();
|
|
assert.equal(r.state, 'bound');
|
|
assert.deepEqual(r.services.gitea, { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, rotateBy: date, grantId: `gitea:${u.id}:${tok.id}`, grantGeneration: 1 });
|
|
assert.equal(statSync(f.slot()).mode & 0o777, 0o600);
|
|
assert.equal(readFileSync(f.slot(), 'utf8'), tok.sha1 + '\n');
|
|
// Only GETs went to Gitea, every one as the copy's account.
|
|
assert.ok(f.api.requests.length > 0);
|
|
for (const req of f.api.requests) {
|
|
assert.equal(req.method, 'GET');
|
|
if (req.path !== '/api/v1/version') assert.equal(req.as, u.login);
|
|
}
|
|
const text = kept(f) + JSON.stringify(result);
|
|
for (const bad of [tok.sha1, sha(tok.sha1), sha(tok.sha1 + '\n'), f.slot(), f.storeRoot, '/api/v1', 'authorization']) assert.ok(!text.includes(bad), 'kept text holds a secret, path or request');
|
|
// Copies still cannot launch in CB2: the snapshot does not resolve a gitea-only copy (CB3 owns launch).
|
|
});
|
|
|
|
test('CB2 narrower and equal scopes are accepted; any broader token is exceeded and leaves the record inactive', async (t) => {
|
|
const f = await fixture(t, { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } });
|
|
const u = f.bot('rev', { perm: 'write' });
|
|
for (const scopes of [['write:repository', 'write:user'], ['all'], ['write:admin', 'read:user'], ['read:repository', 'read:user', 'read:issue'], ['read:user', 'read:misc']]) {
|
|
const tok = f.token(u, scopes);
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' }, scopes.join());
|
|
assert.equal(f.rec().state, 'pending', scopes.join());
|
|
assert.equal(f.rec().services.gitea.grantId, undefined);
|
|
assert.ok(!existsSync(f.slot()), 'a refused token leaves no slot');
|
|
}
|
|
// Equal.
|
|
await f.issueAs(f.human, { token: f.token(u, ['write:repository', 'read:user']).sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
// Narrower (read within the approved write) re-issues; the grant generation counts up.
|
|
f.repo.permissions[u.login] = 'read';
|
|
const narrow = f.token(u, ['read:repository', 'read:user']);
|
|
await f.issueAs(f.human, { token: narrow.sha1 });
|
|
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration, f.rec().services.gitea.grantId], ['bound', 2, `gitea:${u.id}:${narrow.id}`]);
|
|
// A failed re-issue after bound deactivates the binding and removes its slot.
|
|
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['all']).sha1 }), { code: 'credential-scope-exceeded' });
|
|
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false]);
|
|
});
|
|
|
|
test('CB2 read is narrower than write within a category; categories are independent; push needs write', async (t) => {
|
|
assert.deepEqual(tokenLevels(['read:repository', 'write:repository', 'read:user']), { repository: 'write', user: 'read' });
|
|
assert.deepEqual(tokenLevels(['write:issue', 'read:issue']), { issue: 'write' });
|
|
for (const bad of [['all'], ['write:admin'], ['read:admin'], ['read:sudo'], ['repository'], [], null, [7]]) assert.equal(tokenLevels(bad), null, JSON.stringify(bad));
|
|
const f = await fixture(t);
|
|
// The binding approved read:repository; the account can push, so the token reaches more than read.
|
|
const u = f.bot('rev', { perm: 'write' });
|
|
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-scope-exceeded' });
|
|
// write:issue is another category: read:repository never approves it.
|
|
f.repo.permissions[u.login] = 'read';
|
|
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository', 'read:user', 'write:issue']).sha1 }), { code: 'credential-scope-exceeded' });
|
|
await f.issueAs(f.human, { token: f.token(u).sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
});
|
|
|
|
test('CB2 push is held to the binding, not the token: a narrower token binds for a push account on a write binding', async () => {
|
|
const push = { '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: true, pull: true } }], total: 1 } };
|
|
await assert.rejects(V(scripted(push).transport), { code: 'credential-scope-exceeded' });
|
|
const write = { approved: ['write:repository', 'read:user'] };
|
|
assert.deepEqual(await V(scripted(push).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
|
|
const writeToken = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['write:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
|
|
assert.deepEqual(await V(scripted({ ...push, ...writeToken }).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
|
|
await assert.rejects(V(scripted({ ...push, ...writeToken }).transport), { code: 'credential-scope-exceeded' });
|
|
});
|
|
|
|
test('CB2 the boot copy must still match the record: a missing, refused or changed copy refuses before anything is staged or sent', async (t) => {
|
|
const f = await fixture(t);
|
|
const tok = f.token(f.bot());
|
|
const rev = f.snapshot.copies.rev;
|
|
const cases = [
|
|
[{ ...f.snapshot, copies: {} }, 'copy-unavailable'],
|
|
[{ ...f.snapshot, org: 'other' }, 'copy-unavailable'],
|
|
[{ ...f.snapshot, copies: { rev: { ...rev, state: 'refused' } } }, 'copy-unavailable'],
|
|
[{ ...f.snapshot, copies: { rev: { ...rev, level: 'project', project: 'stack' } } }, 'credential-binding-changed'],
|
|
[{ ...f.snapshot, copies: { rev: { ...rev, name: 'other' } } }, 'credential-binding-changed'],
|
|
[{ ...f.snapshot, copies: { rev: { ...rev, definition: 'coder' } } }, 'credential-binding-changed'],
|
|
];
|
|
const before = kept(f), sent = f.api.requests.length;
|
|
for (const [copies, code] of cases) {
|
|
f.opts.copies = copies;
|
|
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code }, JSON.stringify(copies.copies?.rev ?? copies.org));
|
|
}
|
|
f.opts.copies = null;
|
|
// No record for the name, or a name the organization's own roles hold.
|
|
await assert.rejects(f.direct({ agent: 'nobody', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'credential-unbound' });
|
|
f.opts.copies = { ...f.snapshot, copies: { ...f.snapshot.copies, chief: { ...rev, name: 'chief' } } };
|
|
const { document } = f.writer.read('demo');
|
|
const shadow = { ...f.writer, read: () => ({ ...f.writer.read('demo'), document: { ...document, copyBindings: { ...document.copyBindings, chief: document.copyBindings.rev } } }) };
|
|
await assert.rejects(copyGrant({ credentials: f.credentials, writer: shadow, copies: f.opts.copies, organizations: () => ['demo'] })('demo', { agent: 'chief', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'copy-unavailable' });
|
|
f.opts.copies = null;
|
|
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo'))], [true, sent, false]);
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
});
|
|
|
|
test('CB2 stageCopy refuses a name a loaded reference holds and an unknown service, leaving the file alone', async (t) => {
|
|
const root = mkdtempSync(join(tmpdir(), 'cb2-ref-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const storeRoot = join(root, 'agents');
|
|
mkdirSync(join(storeRoot, 'demo', 'rev'), { recursive: true, mode: 0o700 }); chmodSync(storeRoot, 0o700); chmodSync(join(storeRoot, 'demo'), 0o700);
|
|
const file = join(storeRoot, 'demo', 'rev', 'gitea');
|
|
writeFileSync(file, 'r'.repeat(40) + '\n', { mode: 0o600 });
|
|
const credentials = new Credentials({ references: { 'demo/rev': { gitea: { service: 'gitea', store: true, file, rotateBy: ahead(30) } } }, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
|
assert.throws(() => credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)), { code: 'credential-reference' });
|
|
assert.equal(readFileSync(file, 'utf8'), 'r'.repeat(40) + '\n');
|
|
assert.throws(() => credentials.stageCopy('demo/other', 'model', 'a'.repeat(40)), { code: 'credential-reference' });
|
|
assert.equal(present(join(storeRoot, 'demo', 'other')), false);
|
|
});
|
|
|
|
test('CB2 a Vikunja binding refuses credential-scope-unverified before anything is staged or sent', async (t) => {
|
|
const vik = { tasks: ['read_one'] };
|
|
const f = await fixture(t, { services: { gitea: READ.gitea, vikunja: { scopes: vik, projects: ['stack'] } } });
|
|
const before = readFileSync(f.file, 'utf8');
|
|
await assert.rejects(f.issueAs(f.human, { service: 'vikunja', token: 'v'.repeat(40) }), { code: 'credential-scope-unverified' });
|
|
assert.equal(readFileSync(f.file, 'utf8'), before);
|
|
assert.equal(f.api.requests.length, 0);
|
|
assert.ok(!existsSync(join(f.storeRoot, 'demo', 'rev')));
|
|
// With a Gitea grant the record still waits on Vikunja: pending, never bound.
|
|
await f.issueAs(f.human, { token: f.token(f.bot()).sha1 });
|
|
assert.equal(f.rec().state, 'pending');
|
|
assert.ok(f.rec().services.gitea.grantId);
|
|
assert.ok(REFUSALS['credential-scope-unverified'].nextStep.includes('Vikunja'));
|
|
});
|
|
|
|
test('CB2 unrelated repositories, admin rights or a missing bound repository refuse', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
const tok = f.token(u);
|
|
const other = f.api.addRepo('someone', 'private', { collaborators: [u.login] });
|
|
other.permissions[u.login] = 'read';
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
|
|
other.collaborators.length = 0;
|
|
const own = f.api.addRepo(u.login, 'scratch');
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
|
|
f.api.repos.splice(f.api.repos.indexOf(own), 1);
|
|
f.repo.collaborators.length = 0;
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
|
assert.equal(f.rec().state, 'pending');
|
|
f.repo.collaborators.push(u.login);
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
});
|
|
|
|
// A scripted Gitea for the verifier's own branches: paging, organizations and malformed answers.
|
|
function scripted(over = {}) {
|
|
const base = {
|
|
'/api/v1/version': { status: 200, json: { version: '1.27.1' } },
|
|
'/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:repository', 'read:user'], user: { id: 5, login: 'bot' } } },
|
|
'/api/v1/user': { status: 200, json: { id: 5, login: 'bot', is_admin: false } },
|
|
'/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: false, pull: true } }], total: 1 },
|
|
'/api/v1/user/orgs': { status: 200, json: [], total: 0 },
|
|
...over,
|
|
};
|
|
const seen = [];
|
|
const transport = async ({ path, method }) => {
|
|
seen.push(`${method} ${path}`);
|
|
const key = path.split('?')[0];
|
|
const a = base[key];
|
|
return typeof a === 'function' ? a(path) : { total: null, ...a };
|
|
};
|
|
return { transport, seen };
|
|
}
|
|
const V = (transport, extra = {}) => verifyGiteaToken({ transport, url: 'https://127.0.0.1:1', token: 'x'.repeat(40), approved: ['read:repository', 'read:user', 'read:organization'], repos: new Set(['owner/stack']), login: 'bot', rotateBy: ahead(30), ...extra });
|
|
|
|
test('CB2 verifier: paging must agree, organizations are refused with an organization scope', async () => {
|
|
assert.deepEqual(await V(scripted().transport), { userId: 5, tokenId: 9, login: 'bot' });
|
|
const pages = (p1, p2) => (path) => (path.includes('page=1') ? p1 : p2);
|
|
const repo = (n) => ({ full_name: `owner/${n}`, permissions: { admin: false, push: false, pull: true } });
|
|
const two = new Set(['owner/stack', 'owner/b']);
|
|
const many = Array.from({ length: 50 }, (_, i) => repo(`r${i}`));
|
|
const fifty = new Set(many.map((r) => r.full_name.toLowerCase()));
|
|
// A total that changes between pages, a page that comes back short, and no total at all.
|
|
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 51 }, { status: 200, json: [repo('x')], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
|
|
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 52 }, { status: 200, json: [], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
|
|
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack')], total: null } }).transport), { code: 'credential-scope-unverified' });
|
|
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport), { code: 'credential-scope-exceeded' });
|
|
assert.ok(await V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport, { repos: two }));
|
|
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { push: false, pull: true } }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
|
|
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack' }], total: 1 } }).transport), { code: 'credential-scope-unverified' });
|
|
// Organizations: only asked when the token has an organization level.
|
|
const plain = scripted({ '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } });
|
|
assert.ok(await V(plain.transport));
|
|
assert.ok(!plain.seen.some((s) => s.includes('/user/orgs')));
|
|
const orgTok = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:organization', 'read:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
|
|
assert.ok(await V(scripted(orgTok).transport));
|
|
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
|
|
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 500, json: null } }).transport), { code: 'credential-scope-unverified' });
|
|
// The bound projects' remotes: one https Gitea origin, agreeing remotes.
|
|
const doc = (projects) => ({ projects });
|
|
assert.deepEqual([...boundRepositories(doc({ a: { remote: 'https://127.0.0.1:9/Owner/Stack' } }), ['a']).repos], ['owner/stack']);
|
|
for (const [d, ps] of [[doc({ a: { remote: 'http://127.0.0.1:9/o/r' } }), ['a']], [doc({ a: {} }), ['a']], [doc({}), ['a']], [doc({ a: { remote: 'https://127.0.0.1:9/o/r' }, b: { remote: 'https://127.0.0.2:9/o/r' } }), ['a', 'b']], [doc({}), []],
|
|
[doc({ a: { remote: 'https://127.0.0.1:9/o/r', git: { mode: 'remote', remote: 'https://127.0.0.1:9/o/s' } } }), ['a']]])
|
|
assert.throws(() => boundRepositories(d, ps), { code: 'credential-scope-unverified' });
|
|
});
|
|
|
|
test('CB2 a foreign account, an administrator or another copy\'s token is identity-refused', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
const foreign = f.api.addUser('someone-else').user;
|
|
f.repo.collaborators.push('someone-else');
|
|
for (const tok of [f.token(foreign), { sha1: f.api.adminToken }]) {
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-identity-refused' });
|
|
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
|
}
|
|
// The copy's own login, made an administrator.
|
|
u.is_admin = true;
|
|
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
|
|
u.is_admin = false;
|
|
// /user names another account than /token did.
|
|
const undo = f.api.rule((e) => e.path === '/api/v1/user', { status: 200, json: { id: u.id + 1000, login: u.login, is_admin: false } });
|
|
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
|
|
undo();
|
|
// Another copy's account and token: checker.stack is bound in the same organization.
|
|
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: READ } });
|
|
const checker = f.bot('checker.stack');
|
|
const theirs = f.token(checker);
|
|
await assert.rejects(f.issueAs(f.human, { token: theirs.sha1 }), { code: 'credential-identity-refused' });
|
|
await f.issueAs(f.human, { agent: 'checker.stack', token: theirs.sha1 });
|
|
assert.equal(f.rec('checker.stack').state, 'bound');
|
|
assert.equal(f.rec().state, 'pending');
|
|
assert.notEqual(checker.login, u.login);
|
|
});
|
|
|
|
test('CB2 an opaque or unverifiable token refuses unverified; a malformed one refuses credential-format', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
// A malformed token refuses before anything is staged or sent (build review 2): the bound record is
|
|
// unchanged, nothing reached Gitea and no slot exists.
|
|
const before = kept(f), sent = f.api.requests.length;
|
|
await assert.rejects(f.issueAs(f.human, { token: 'short' }), { code: 'credential-format' });
|
|
await assert.rejects(f.issueAs(f.human, { token: 'has space ' + 'x'.repeat(30) }), { code: 'credential-format' });
|
|
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo', 'rev'))], [true, sent, false]);
|
|
await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' });
|
|
const tok = f.token(u);
|
|
for (const answer of [{ status: 200, json: { id: tok.id, scopes: tok.scopes } }, { status: 200, json: { id: tok.id, scopes: tok.scopes, user: { login: u.login } } }, { status: 500, json: null }, { drop: true }]) {
|
|
const undo = f.api.rule((e) => e.path === '/api/v1/token', answer);
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' }, JSON.stringify(answer));
|
|
undo();
|
|
}
|
|
f.opts.timeoutMs = 300;
|
|
const hang = f.api.rule((e) => e.path.startsWith('/api/v1/user/repos'), { hang: true });
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
|
hang();
|
|
f.opts.timeoutMs = 3000;
|
|
f.api.state.version = '1.26.0';
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
|
f.api.state.version = '1.27.1';
|
|
// The token lacks read:user, which the binding approved: its repositories cannot be listed.
|
|
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository']).sha1 }), { code: 'credential-scope-unverified' });
|
|
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
});
|
|
|
|
test('CB2 a token any definition already holds refuses before anything is staged or sent', async (t) => {
|
|
const f = await fixture(t);
|
|
const tok = f.token(f.bot());
|
|
const before = readFileSync(f.file, 'utf8');
|
|
// The boot copy snapshot's definitions are live text for the bus's lifetime.
|
|
f.opts.copies = { ...f.snapshot, definitions: { ...f.snapshot.definitions, leaky: { note: `see ${tok.sha1}` } } };
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'definition-holds-credential' });
|
|
assert.deepEqual([readFileSync(f.file, 'utf8') === before, existsSync(f.slot()), f.api.requests.length], [true, false, 0]);
|
|
f.opts.copies = null;
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
});
|
|
|
|
test('CB2 a binding without read:user is unverifiable before anything is staged', async (t) => {
|
|
const f = await fixture(t, { services: { gitea: { scopes: ['read:repository'], projects: ['stack'] } } });
|
|
const before = readFileSync(f.file, 'utf8');
|
|
await assert.rejects(f.issueAs(f.human, { token: f.token(f.bot()).sha1 }), { code: 'credential-scope-unverified' });
|
|
assert.equal(readFileSync(f.file, 'utf8'), before);
|
|
assert.equal(f.api.requests.length, 0);
|
|
});
|
|
|
|
test('CB2 dates: the rotation date is after today, at most a year ahead, and not after the token expires', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
const tok = f.token(u);
|
|
const before = readFileSync(f.file, 'utf8');
|
|
for (const date of [ahead(0), ahead(-1), ahead(MAX_ROTATE_DAYS + 1), '2099-02-30', '2099-1-01', 'tomorrow'])
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1, date }), { code: 'credential-date' }, date);
|
|
assert.equal(readFileSync(f.file, 'utf8'), before);
|
|
assert.equal(f.api.requests.length, 0);
|
|
const soon = f.token(u, undefined, { expires: `${ahead(10)}T00:00:00Z` });
|
|
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(10) }), { code: 'credential-date' });
|
|
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(30) }), { code: 'credential-date' });
|
|
const odd = f.token(u, undefined, { expires: 'not-a-date' });
|
|
await assert.rejects(f.issueAs(f.human, { token: odd.sha1 }), { code: 'credential-scope-unverified' });
|
|
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
|
await f.issueAs(f.human, { token: soon.sha1, date: ahead(9) });
|
|
assert.equal(f.rec().services.gitea.rotateBy, ahead(9));
|
|
await f.issueAs(f.human, { token: f.token(u, undefined, { expires: `${ahead(400)}T00:00:00Z` }).sha1, date: ahead(MAX_ROTATE_DAYS) });
|
|
assert.equal(f.rec().state, 'bound');
|
|
});
|
|
|
|
test('CB2 the slot is read back by identity: symlinks, hard links, stale and replaced files refuse credential-file', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
const tok = f.token(u);
|
|
const other = f.token(u);
|
|
const elsewhere = join(f.root, 'elsewhere');
|
|
const cases = {
|
|
symlink: ['afterStage', () => { writeFileSync(elsewhere, tok.sha1 + '\n', { mode: 0o600 }); unlinkSync(f.slot()); symlinkSync(elsewhere, f.slot()); }],
|
|
hardlink: ['afterStage', () => { rmSync(elsewhere, { force: true }); linkSync(f.slot(), elsewhere); }],
|
|
stale: ['afterStage', () => { renameSync(f.slot(), elsewhere); writeFileSync(f.slot(), tok.sha1 + '\n', { mode: 0o600 }); }],
|
|
mode: ['afterStage', () => chmodSync(f.slot(), 0o644)],
|
|
// A writer replaces a file by renaming a new one over it, as the store does.
|
|
replacedAfterRemote: ['afterRemote', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
|
|
replacedBeforeCommit: ['beforeCommit', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
|
|
// Unlinked and written again: the inode number may be reused, the content still differs.
|
|
reusedInode: ['afterStage', () => { unlinkSync(f.slot()); writeFileSync(f.slot(), other.sha1 + '\n', { mode: 0o600 }); }],
|
|
rewrittenInPlace: ['beforeCommit', () => writeFileSync(f.slot(), other.sha1 + '\n')],
|
|
};
|
|
for (const [name, [hook, act]] of Object.entries(cases)) {
|
|
rmSync(elsewhere, { force: true });
|
|
f.hooks[hook] = act;
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-file' }, name);
|
|
delete f.hooks[hook];
|
|
assert.equal(f.rec().state, 'pending', name);
|
|
assert.equal(f.rec().services.gitea.grantId, undefined, name);
|
|
// The bus removes only the file it wrote; a file someone else put there stays.
|
|
if (['symlink', 'stale', 'replacedAfterRemote', 'replacedBeforeCommit'].includes(name)) assert.ok(present(f.slot()), name);
|
|
if (['hardlink', 'mode', 'rewrittenInPlace'].includes(name)) assert.ok(!present(f.slot()), name);
|
|
rmSync(f.slot(), { force: true });
|
|
}
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
});
|
|
|
|
test('CB2 crash points leave the record pending and the slot removed; a new verified issue recovers', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
const tok = f.token(u);
|
|
for (const hook of ['afterStage', 'afterRemote', 'beforeCommit']) {
|
|
f.hooks[hook] = () => { throw new Error('crash'); };
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'outcome-unknown' }, hook);
|
|
delete f.hooks[hook];
|
|
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false], hook);
|
|
}
|
|
// A hard crash after the stage (security review P2): the record is pending and the staged token is
|
|
// on disk. The next start's sweep removes it before anything loads; the record stays pending and
|
|
// only a fresh verified issue binds.
|
|
const r = f.rec();
|
|
const { revision } = f.writer.read('demo');
|
|
f.writer.stageCopyGrant('demo', { instance: 'rev', service: 'gitea', copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }, { revision, actor: 'jason', owner: true });
|
|
f.credentials.stageCopy('demo/rev', 'gitea', tok.sha1);
|
|
assert.equal(f.rec().state, 'pending');
|
|
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', f.writer.read('demo').document]], {}), [{ instance: 'demo/rev', service: 'gitea' }]);
|
|
assert.deepEqual([f.rec().state, present(f.slot())], ['pending', false]);
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 1]);
|
|
// A lost reply: the commit landed but the caller never heard. Issuing again re-verifies.
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 2]);
|
|
});
|
|
|
|
test('CB2 the start sweep removes only ungranted copy slots and staged temp files', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
const tok = f.token(u);
|
|
await f.issueAs(f.human, { token: tok.sha1 });
|
|
assert.equal(f.rec().state, 'bound');
|
|
const doc = () => [['demo', f.writer.read('demo').document]];
|
|
const copyDir = join(f.storeRoot, 'demo', 'rev');
|
|
// A granted slot stays; an interrupted write's temp file goes; a name that is no service stays.
|
|
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
|
|
writeFileSync(join(copyDir, 'notes'), 'x', { mode: 0o600 });
|
|
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'gitea' }]);
|
|
assert.deepEqual([present(f.slot()), present(join(copyDir, 'gitea.tmp')), present(join(copyDir, 'notes'))], [true, false, true]);
|
|
// An ungranted service of a bound copy goes; a slot a loaded reference names stays.
|
|
writeFileSync(join(copyDir, 'vikunja'), 'x', { mode: 0o600 });
|
|
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), { 'demo/rev': { vikunja: { service: 'vikunja' } } }), []);
|
|
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'vikunja' }]);
|
|
// A role's own slots and an unreadable organization are never touched.
|
|
const roleDir = join(f.storeRoot, 'demo', 'chief');
|
|
mkdirSync(roleDir, { mode: 0o700 }); writeFileSync(join(roleDir, 'gitea'), 'x', { mode: 0o600 });
|
|
const edited = { ...f.writer.read('demo').document };
|
|
edited.copyBindings = { ...edited.copyBindings, chief: { ...edited.copyBindings.rev, services: {} } };
|
|
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', edited]], {}), []);
|
|
assert.equal(present(join(roleDir, 'gitea')), true);
|
|
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
|
|
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', null]], {}), []);
|
|
assert.equal(present(join(copyDir, 'gitea.tmp')), true);
|
|
// No store, a missing org dir: nothing. A store dir that isn't private refuses as Credentials would.
|
|
assert.deepEqual(sweepCopySlots(null, doc(), {}), []);
|
|
assert.deepEqual(sweepCopySlots(f.storeRoot, [['other', f.writer.read('demo').document]], {}), []);
|
|
chmodSync(copyDir, 0o755);
|
|
assert.throws(() => sweepCopySlots(f.storeRoot, doc(), {}), /credential-file/);
|
|
chmodSync(copyDir, 0o700);
|
|
});
|
|
|
|
test('CB2 a stage write that fails leaves no staged token behind', async (t) => {
|
|
const f = await fixture(t);
|
|
// The slot path is a non-empty directory: the rename fails after the temp file holds the token.
|
|
mkdirSync(join(f.slot(), 'x'), { recursive: true, mode: 0o700 });
|
|
assert.throws(() => f.credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)));
|
|
assert.deepEqual(readdirSync(join(f.storeRoot, 'demo', 'rev')), ['gitea']);
|
|
});
|
|
|
|
test('CB2 a rebind, a failed owner recheck or a concurrent issue during the remote work refuses the commit', async (t) => {
|
|
const f = await fixture(t);
|
|
const u = f.bot();
|
|
const tok = f.token(u);
|
|
// The owner approves a new binding while Gitea is being asked.
|
|
f.hooks.afterRemote = () => f.bind('rev', { gitea: { scopes: ['read:user', 'read:repository'], projects: ['stack'] } });
|
|
// The document moved under the commit: its revision check refuses before the binding fence.
|
|
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'revision-conflict' });
|
|
delete f.hooks.afterRemote;
|
|
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
|
// The caller's owner recheck fails after the remote work (the broker's #copyOwner), and again
|
|
// when it fails only under the writer's locks.
|
|
for (const at of [1, 2]) {
|
|
let n = 0;
|
|
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true, recheck: () => { if (++n >= at) throw new BusError('human-required'); } }), { code: 'human-required' });
|
|
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
|
}
|
|
// Not the owner, or the tester's name, never reaches the writer.
|
|
for (const ctx of [{ actor: 'jason', owner: false }, { actor: 'tester', owner: true }])
|
|
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, ctx), { code: 'human-required' });
|
|
// Two issues: the first is held after its remote work while the second binds.
|
|
const second = f.token(u);
|
|
let release, first = true;
|
|
const held = new Promise((r) => { release = r; });
|
|
f.hooks.afterRemote = () => { if (first) { first = false; return held; } };
|
|
const a = f.issueAs(f.human, { token: tok.sha1 });
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
while (f.hooks.afterRemote && first) await new Promise((r) => setTimeout(r, 10));
|
|
await f.issueAs(f.human, { token: second.sha1 });
|
|
release();
|
|
await assert.rejects(a, (e) => ['revision-conflict', 'credential-binding-changed', 'credential-file'].includes(e.code));
|
|
delete f.hooks.afterRemote;
|
|
assert.equal(f.rec().state, 'bound');
|
|
assert.equal(f.rec().services.gitea.grantId, `gitea:${u.id}:${second.id}`);
|
|
assert.equal(readFileSync(f.slot(), 'utf8'), second.sha1 + '\n', 'the second issue\'s slot stays');
|
|
});
|
|
|
|
test('CB2 only the organization owner issues: the chief, the tester, a reader and an org.hire member are refused before staging', async (t) => {
|
|
const f = await fixture(t);
|
|
const tok = f.token(f.bot());
|
|
const before = readFileSync(f.file, 'utf8');
|
|
const member = (owner) => f.b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights: ['org.hire', 'decide'], owner, instanceOwner: true });
|
|
const got = [];
|
|
for (const cap of [f.chief, f.b.bindTester({ business: 'demo' }), f.b.bindReader({ business: 'demo' }), member(false), member(true), f.owner]) {
|
|
await assert.rejects(f.issueAs(cap, { token: tok.sha1 }), (e) => { got.push(e.code); return !String(e.message).includes(tok.sha1); });
|
|
}
|
|
// The Console caps, the owner's included, never reach credentials.issue: the token comes from the
|
|
// owner's CLI on standard input.
|
|
assert.deepEqual(got, ['human-required', 'tester-refused', 'human-required', 'human-required', 'human-required', 'human-required']);
|
|
assert.equal(readFileSync(f.file, 'utf8'), before);
|
|
assert.equal(f.api.requests.length, 0);
|
|
assert.ok(!existsSync(join(f.storeRoot, 'demo')));
|
|
// Only a name the boot's copies view holds goes to the copy verifier; any other unknown name
|
|
// answers credential-unknown as before, and nothing is staged or sent.
|
|
await assert.rejects(f.issueAs(f.human, { agent: 'nobody', token: tok.sha1 }), { code: 'credential-unknown' });
|
|
assert.deepEqual([readFileSync(f.file, 'utf8') === before, f.api.requests.length, existsSync(join(f.storeRoot, 'demo'))], [true, 0, false]);
|
|
});
|
|
|
|
test('CB2 refusal table: the new codes have rails and next steps that name no path', () => {
|
|
for (const code of ['credential-scope-unverified', 'credential-identity-refused', 'credential-date', 'credential-format', 'credential-file', 'copy-unavailable']) {
|
|
const g = REFUSALS[code];
|
|
assert.ok(g && g.rail.length && g.nextStep.length, code);
|
|
assert.doesNotMatch(g.nextStep, /\/|~|\.json\b|secrets/, code);
|
|
}
|
|
});
|