Files
stack/packages/bus/tests/gitea-grant-docker.test.mjs

326 lines
19 KiB
JavaScript

// Row 236 (#1742, #436b r5 section 9, X-G3): the grant path end to end on a scratch gitea/gitea:1.27.1, as
// section 1's facts were found: a throwaway container on 127.0.0.1 over TLS with row 199's scratch CA, its
// data inside the container, removed after. The test never pulls an image; without Docker or the image it
// skips. The admin and seat tokens are scratch tokens this container made; none is printed, and no test
// here reaches git.mosaicstack.dev or any provider.
import test from 'node:test';
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { request } from 'node:https';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { Broker, GRANT_EVIDENCE } from '../src/broker.mjs';
import { Credentials } from '../src/credentials.mjs';
import { SEAT_SCOPES, giteaGrant, giteaKeys } from '../src/gitea-grant.mjs';
import { Store } from '../src/store.mjs';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const IMAGE = 'gitea/gitea:1.27.1';
const USER = 'demo-coder';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const dayFrom = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
const freePort = () =>
new Promise((resolve, reject) => {
const s = createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
// One HTTPS call to the scratch container, trusting only the scratch CA.
function call(url, ca, method, path, { auth = null, body = null } = {}) {
return new Promise((resolve) => {
const data = body ? JSON.stringify(body) : null;
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('end', () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {}
resolve({ status: res.statusCode, json });
});
});
req.on('timeout', () => req.destroy());
req.on('error', () => resolve({ status: 0, json: null }));
req.end(data ?? undefined);
});
}
// C2-21: independent of the executor. F-A and F-B gate production changes; F-C/F-D are observations.
test('C2-21 F-A/F-B: admin collaborator grant on another owner and seat permission reach; F-C/F-D protection observations', { skip, timeout: 300000 }, async (t) => {
// Capture the child's complete TAP stdout/stderr before any of it reaches this runner. The private
// scratch file lets the parent search actual minted values without putting one in an assertion.
if (!process.env.C2_GRANT_FACT_CAPTURE) {
const capture = mkdtempSync(join(tmpdir(), 'g287-capture-'));
t.after(() => rmSync(capture, { recursive: true, force: true }));
const file = join(capture, 'minted');
const childEnv = { ...process.env, C2_GRANT_FACT_CAPTURE: file };
delete childEnv.NODE_TEST_CONTEXT;
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^C2-21 F-A/F-B:', fileURLToPath(import.meta.url)], {
encoding: 'utf8', timeout: 280000, env: childEnv,
});
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
const output = (child.stdout ?? '') + (child.stderr ?? '');
assert.ok(values.every((value) => !output.includes(value)), 'complete TAP stdout/stderr contain no minted token');
t.diagnostic(output);
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
assert.equal(child.status, 0, 'Docker facts child passed');
assert.equal(values.length, 4, 'all four scratch tokens were minted: facts actually ran');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'g287-facts-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
chmodSync(tlsDir, 0o755);
chmodSync(join(tlsDir, 'server.key'), 0o644);
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
const url = `https://127.0.0.1:${port}`, name = 'g287-' + randomBytes(6).toString('hex');
t.after(() => docker('rm', '-f', name));
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
assert.equal(started.status, 0, 'scratch container starts');
const end = Date.now() + 120000;
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
await sleep(500);
}
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
const secrets = [], diagnostics = [];
const say = (line) => { diagnostics.push(line); t.diagnostic(line); };
for (const [user, admin] of [['mosaic-grant', true], ['writer', false], ['creative', false], ['chief', false]]) {
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
assert.equal(r.status, 0, `create scratch ${user}`);
}
const mint = (user, scopes) => {
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'facts', '--scopes', scopes.join(','), '--raw');
assert.equal(r.status, 0, `mint scratch ${user}`);
const value = r.stdout.trim();
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
secrets.push(value);
writeFileSync(process.env.C2_GRANT_FACT_CAPTURE, JSON.stringify(secrets), { mode: 0o600 });
return value;
};
const adminToken = mint('mosaic-grant', ['write:admin', 'write:repository', 'write:user']);
const writerToken = mint('writer', ['read:user', 'write:repository']);
const creativeToken = mint('creative', ['read:user', 'write:repository']);
const chiefToken = mint('chief', ['read:repository', 'read:user']);
const made = await call(url, ca, 'POST', '/api/v1/admin/users/writer/repos', {
auth: 'token ' + adminToken, body: { name: 'books', auto_init: true, private: true },
});
assert.equal(made.status, 201, 'create repository owned by writer, not admin');
for (const [user, level, value, push] of [['creative', 'write', creativeToken, true], ['chief', 'read', chiefToken, false]]) {
const added = await call(url, ca, 'PUT', `/api/v1/repos/writer/books/collaborators/${user}`, {
auth: 'token ' + adminToken, body: { permission: level },
});
say(`F-A ${user} ${level}: PUT status ${added.status}`);
assert.equal(added.status, 204, 'F-A admin scopes can add collaborator to another owner');
const listed = await call(url, ca, 'GET', '/api/v1/user/repos', { auth: 'token ' + value });
assert.equal(listed.status, 200, 'F-B seat lists reachable repos');
const repository = listed.json?.find((r) => r.full_name === 'writer/books');
assert.ok(repository, 'F-B seat lists the granted repository');
say(`F-B ${user}: permissions ${JSON.stringify(repository.permissions)}`);
assert.deepEqual({ admin: repository.permissions?.admin, push: repository.permissions?.push, pull: repository.permissions?.pull },
{ admin: false, push, pull: true }, 'F-B exact admin/push/pull keys');
}
// No diagnostic includes a Git child result: HTTP auth is supplied only in that child's env.
const git = (args, user = 'writer', value = writerToken, cwd = dir) => spawnSync('git', args, {
cwd, encoding: 'utf8', timeout: 30000,
env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0',
GIT_SSL_CAINFO: tls.ca, GIT_CONFIG_COUNT: '3', GIT_CONFIG_KEY_0: 'http.extraHeader',
GIT_CONFIG_VALUE_0: 'Authorization: Basic ' + Buffer.from(`${user}:${value}`).toString('base64'),
GIT_CONFIG_KEY_1: 'user.name', GIT_CONFIG_VALUE_1: 'Scratch writer',
GIT_CONFIG_KEY_2: 'user.email', GIT_CONFIG_VALUE_2: '[email protected]' },
});
const work = join(dir, 'work');
const branch = made.json.default_branch;
const clone = git(['clone', `${url}/writer/books.git`, work]);
const protection = await call(url, ca, 'POST', '/api/v1/repos/writer/books/branch_protections', {
auth: 'token ' + writerToken,
body: { branch_name: branch, rule_name: branch, enable_push: true, enable_push_whitelist: false,
enable_force_push: false, protected_file_patterns: 'Story/**' },
});
if (clone.status !== 0 || protection.status !== 201) {
say(`F-C/F-D not established: clone rc ${clone.status}, protection HTTP ${protection.status}`);
} else {
const commit = (path, text) => {
mkdirSync(join(work, path.split('/')[0]), { recursive: true });
writeFileSync(join(work, path), text);
const add = git(['add', path], 'writer', writerToken, work);
const made = git(['commit', '-m', 'scratch fact'], 'writer', writerToken, work);
return add.status === 0 && made.status === 0;
};
const story = commit('Story/chapter.md', 'scratch story\n');
const seatStory = story ? git(['push', 'origin', `HEAD:${branch}`], 'creative', creativeToken, work).status : null;
const ownerStory = story ? git(['push', 'origin', `HEAD:${branch}`], 'writer', writerToken, work).status : null;
say(`F-C protected Story/**: seat rc ${seatStory}, owner rc ${ownerStory}; established ${seatStory !== null && seatStory !== 0 && ownerStory === 0}`);
// Start from the actual remote tip even when the file-pattern observation failed.
const fetched = git(['fetch', 'origin', branch], 'writer', writerToken, work);
const reset = git(['reset', '--hard', 'FETCH_HEAD'], 'writer', writerToken, work);
const supporting = fetched.status === 0 && reset.status === 0 && commit('Notes/note.md', 'scratch supporting note\n');
const normal = supporting ? git(['push', 'origin', `HEAD:${branch}`], 'creative', creativeToken, work).status : null;
const force = normal === 0 ? git(['push', '--force', 'origin', `HEAD~1:${branch}`], 'creative', creativeToken, work).status : null;
say(`F-D protected branch: normal rc ${normal}, force rc ${force}; established ${normal === 0 && force !== null && force !== 0}`);
}
assert.ok(secrets.every((value) => diagnostics.every((line) => !line.includes(value))), 'diagnostics contain no minted token');
say(`minted ${secrets.length}, hits 0`);
});
test('X-G3: on scratch 1.27.1, a grant makes the user, mints, forks, checks reach, writes the file, and revoke deletes the token', { skip, timeout: 300000 }, async (t) => {
const dir = mkdtempSync(join(tmpdir(), 'g236-docker-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const tlsDir = join(dir, 'tls');
const tls = scratchCa(tlsDir);
// The container's git user reads the scratch key; it is a throwaway key for this run only.
chmodSync(tlsDir, 0o755);
chmodSync(join(tlsDir, 'server.key'), 0o644);
const ca = readFileSync(tls.ca, 'utf8');
const port = await freePort();
const url = `https://127.0.0.1:${port}`;
const name = 'g287-executor-' + randomBytes(6).toString('hex');
t.after(() => docker('rm', '-f', name));
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
'-e', 'GITEA__security__INSTALL_LOCK=true',
'-e', 'GITEA__database__DB_TYPE=sqlite3',
'-e', 'GITEA__service__DISABLE_REGISTRATION=true',
'-e', 'GITEA__server__PROTOCOL=https',
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem',
'-e', 'GITEA__server__KEY_FILE=/certs/server.key',
'-e', `GITEA__server__ROOT_URL=${url}/`,
IMAGE);
assert.equal(started.status, 0, started.stderr);
const end = Date.now() + 120000;
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
if (Date.now() > end) assert.fail('the scratch Gitea did not answer in 120 s: ' + docker('logs', '--tail', '20', name).stdout);
await sleep(500);
}
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
const password = () => randomBytes(18).toString('base64url');
for (const [user, admin] of [['mosaic-grant', true], ['acme', false]]) {
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`, '--password', password(), '--must-change-password=false', ...(admin ? ['--admin'] : []));
assert.equal(r.status, 0, r.stderr);
}
const minted = gitea('admin', 'user', 'generate-access-token', '--username', 'mosaic-grant', '--token-name', 'mosaic-grant-bootstrap',
'--scopes', 'write:admin,write:repository,write:user', '--raw');
assert.equal(minted.status, 0, 'generate-access-token failed');
const adminToken = minted.stdout.trim();
assert.match(adminToken, /^[0-9a-f]{40}$/, 'the admin token has the shape of a Gitea token');
const basic = 'Basic ' + Buffer.from(`mosaic-grant:${adminToken}`).toString('base64');
for (const repo of ['app', 'lib']) {
const r = await call(url, ca, 'POST', '/api/v1/admin/users/acme/repos', { auth: 'token ' + adminToken, body: { name: repo, auto_init: true } });
assert.equal(r.status, 201);
}
const configDir = join(dir, 'cfg');
mkdirSync(configDir, { mode: 0o700 });
mkdirSync(join(dir, 'data'), { mode: 0o700 });
const storeRoot = join(configDir, 'secrets', 'agents');
const store = new Store(join(dir, 'data'));
const credentials = new Credentials({ references: {}, storeRoot, dataRoot: join(dir, 'data') });
t.after(() => {
credentials.close();
store.close();
});
const business = {
id: 'demo',
human: 'jason',
arbiters: { technical: 'pm', delivery: 'pm' },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
coder: { definition: 'coder', ...none, identity: { name: 'Coder', email: '[email protected]', gitea: { url, user: USER } }, publish: ['app', 'lib'] },
},
definitions: {
'chief-of-staff': { roleVersion: 2, credentials: [] },
pm: { roleVersion: 2, credentials: [] },
coder: { roleVersion: 2, credentials: [{ service: 'gitea' }] },
},
projects: { app: { remote: `${url}/acme/app` }, lib: { remote: `${url}/acme/lib` } },
vars: {},
};
const broker = new Broker({
store,
businesses: { demo: business },
secretCheck: (value) => credentials.assertClean(value),
giteaGrant: giteaGrant({ configDir, ca, timeoutMs: 20000 }),
giteaKeys: giteaKeys({ credentials, storeRoot }),
});
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
broker.request(chief, { verb: 'role.claim' });
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
// C1 to C4 on the real server, then the slot is written.
await broker.request(human, { verb: 'grants.admin.set', args: { service: 'gitea', url, account: 'mosaic-grant', rotateBy: dayFrom(60), token: adminToken + '\n' } });
const d = await broker.request(chief, {
verb: 'decision.raise',
args: {
action: 'credential.mint', question: 'Grant coder Gitea?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
grant: { method: 'gitea', agent: 'coder', target: { projects: ['app'] }, rotateBy: dayFrom(30) },
},
});
broker.request(human, { verb: 'decision.resolve', args: { id: d.id, choice: 'yes' } });
const events = () => store.all(`SELECT body FROM events WHERE business='demo' AND ${GRANT_EVIDENCE} ORDER BY seq`).map((e) => JSON.parse(e.body));
const ended = () => events().find((e) => e.grantDecision === d.id && ['grant.applied', 'grant.failed'].includes(e.action));
const until = Date.now() + 120000;
while (!ended() && Date.now() < until) await sleep(100);
await broker.giteaIdle();
const applied = ended();
assert.equal(applied?.action, 'grant.applied', JSON.stringify(applied));
// The user, made with the nonce, and its id recorded.
const nonce = events().find((e) => e.grantDecision === d.id && e.step === 'user').nonce;
const u = await call(url, ca, 'GET', `/api/v1/users/${USER}`, { auth: 'token ' + adminToken });
assert.equal(u.status, 200);
assert.equal(u.json.full_name, `Coder [mosaic ${nonce}]`);
assert.equal(u.json.is_admin, false);
const row = store.get("SELECT id, nonce FROM gitea_users WHERE business='demo' AND agent='coder'");
assert.deepEqual({ ...row }, { id: u.json.id, nonce });
// One token, with the two scopes, at the id the record names.
const list = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
assert.equal(list.status, 200);
assert.equal(list.json.length, 1);
assert.deepEqual([...list.json[0].scopes].sort(), [...SEAT_SCOPES]);
const grant = store.get("SELECT token_id FROM gitea_grants WHERE business='demo' AND agent='coder'");
assert.equal(list.json[0].id, grant.token_id);
// The fork, with its parent.
const fork = await call(url, ca, 'GET', `/api/v1/repos/${USER}/app`, { auth: 'token ' + adminToken });
assert.equal(fork.status, 200);
assert.equal(fork.json.parent?.full_name, 'acme/app');
// The 0600 file, whose token Gitea knows as the recorded id; the value is compared, never printed.
const file = join(storeRoot, 'demo', 'coder', 'gitea');
assert.equal(statSync(file).mode & 0o777, 0o600);
const who = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + readFileSync(file, 'utf8').trim() });
assert.equal(who.status, 200);
assert.equal(who.json.id, grant.token_id);
// Revoke deletes the token at Gitea: gone from the list, the file and the record gone.
const out = await broker.request(human, { verb: 'grants.revoke', args: { service: 'gitea', agent: 'coder', reason: 'X-G3' } });
assert.deepEqual(out.serviceLeft, []);
await broker.giteaIdle();
const after = await call(url, ca, 'GET', `/api/v1/users/${USER}/tokens`, { auth: basic });
assert.equal(after.status, 200);
assert.deepEqual(after.json, []);
assert.equal(existsSync(file), false);
assert.equal(store.get("SELECT count(*) AS n FROM gitea_grants WHERE business='demo'").n, 0);
// No scratch token reached the store.
const text = store.all("SELECT body FROM events WHERE business='demo'").map((r) => r.body).join('\n');
assert.ok(!text.includes(adminToken), 'the admin token reached the store');
});