326 lines
19 KiB
JavaScript
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');
|
|
});
|