An owner-issued Gitea token for an agent copy is staged inactive in the self-only store, proved live against the copy's Gitea (token scopes by semantic comparison with all and admin refused, the copy's dedicated per-org/copy/service account, every reachable repository, expiry), then committed under the writer's generation, revision, decision, copyId, incarnation and definition-pin recheck. The grant record holds only an opaque grant id and rotate date. Vikunja refuses credential-scope-unverified. Copies still cannot launch (CB3).
228 lines
14 KiB
JavaScript
228 lines
14 KiB
JavaScript
// #1873 CB2 live acceptance: credentials.issue for an agent copy's Gitea service against a scratch
|
|
// gitea/gitea:1.27.1, a throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after.
|
|
// The test never pulls an image; without Docker or the image it skips. Every token here is a scratch
|
|
// token this container made; none is printed, and nothing here reaches git.mosaicstack.dev or any
|
|
// real service.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createHash, 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, BusError } from '../src/broker.mjs';
|
|
import { Store } from '../src/store.mjs';
|
|
import { Credentials } from '../src/credentials.mjs';
|
|
import { copyGrant } from '../src/copy-grant.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';
|
|
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
|
|
|
const IMAGE = 'gitea/gitea:1.27.1';
|
|
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
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);
|
|
});
|
|
}
|
|
|
|
test('CB2-D: on scratch 1.27.1 an owner-issued copy token is proved live: equal and narrower bind; broader, all, admin, foreign, unrelated and opaque refuse', { skip, timeout: 300000 }, async (t) => {
|
|
// Capture the child's complete TAP stdout/stderr before any of it reaches this runner, so the parent
|
|
// can search for the actual minted values without putting one in an assertion.
|
|
if (!process.env.CB2_COPY_CAPTURE) {
|
|
const capture = mkdtempSync(join(tmpdir(), 'cb2cap-'));
|
|
t.after(() => rmSync(capture, { recursive: true, force: true }));
|
|
const file = join(capture, 'minted');
|
|
const childEnv = { ...process.env, CB2_COPY_CAPTURE: file };
|
|
delete childEnv.NODE_TEST_CONTEXT;
|
|
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB2-D:', 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) && !output.includes(sha(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 child passed');
|
|
assert.equal(values.length, 6, 'all six scratch tokens were minted: the acceptance actually ran');
|
|
return;
|
|
}
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'cb2d-'));
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const tlsDir = join(dir, 'tls'), 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'), port = await freePort();
|
|
const url = `https://127.0.0.1:${port}`, name = 'cb2-' + 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 minted = [];
|
|
const createUser = (user, admin = 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', 'cb2-' + randomBytes(4).toString('hex'),
|
|
'--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');
|
|
minted.push(value);
|
|
writeFileSync(process.env.CB2_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
|
|
return value;
|
|
};
|
|
createUser('scratch-admin', true);
|
|
createUser('owner');
|
|
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
|
|
for (const repo of ['stack', 'other']) {
|
|
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
|
|
assert.equal(made.status, 201, `create owner/${repo}`);
|
|
}
|
|
|
|
// The organization: one root agent "rev" whose copy binding approves write:repository and
|
|
// read:user on project stack, whose remote is the scratch owner/stack.
|
|
const root = mkdtempSync(join(tmpdir(), 'cb2o-'));
|
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
const rolesDir = rolesCopy(root);
|
|
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
|
doc.projects.stack.remote = `${url}/owner/stack`;
|
|
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);
|
|
t.after(() => store.close());
|
|
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 });
|
|
// The boot's copies view, as the runtime's loadWithCopies gives the broker.
|
|
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView(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 issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
|
|
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 consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
|
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
|
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
|
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy',
|
|
binding: { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } } });
|
|
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings.rev;
|
|
assert.equal(rec().state, 'pending');
|
|
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
|
|
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
|
|
|
|
// The copy's dedicated account: a read collaborator on owner/stack only.
|
|
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
|
|
createUser(login);
|
|
const collaborate = (repo, permission) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission } });
|
|
assert.equal((await collaborate('stack', 'read')).status, 204, 'bot is a read collaborator on owner/stack');
|
|
|
|
const refused = async (token, code, label) => {
|
|
await assert.rejects(issueToken(token), { code }, label);
|
|
assert.equal(rec().state, 'pending', label);
|
|
assert.equal(rec().services.gitea.grantId, undefined, label);
|
|
assert.ok(!existsSync(slot), `${label}: a refused token leaves no slot`);
|
|
};
|
|
|
|
// Equal: the approved scopes exactly.
|
|
const equal = mint(login, ['write:repository', 'read:user']);
|
|
const self = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + equal });
|
|
assert.equal(self.status, 200);
|
|
assert.equal(self.json.user.login, login, 'Gitea 1.27.1 names the token owner on /api/v1/token');
|
|
const one = await issueToken(equal);
|
|
assert.equal(one.state, 'bound');
|
|
assert.equal(one.account, login);
|
|
assert.deepEqual([rec().state, rec().services.gitea.grantId, rec().services.gitea.grantGeneration], ['bound', `gitea:${self.json.user.id}:${self.json.id}`, 1]);
|
|
assert.equal(statSync(slot).mode & 0o777, 0o600);
|
|
assert.equal(readFileSync(slot, 'utf8'), equal + '\n');
|
|
|
|
// Narrower: read within the approved write.
|
|
const narrow = mint(login, ['read:repository', 'read:user']);
|
|
await issueToken(narrow);
|
|
assert.deepEqual([rec().state, rec().services.gitea.grantGeneration], ['bound', 2]);
|
|
assert.equal(readFileSync(slot, 'utf8'), narrow + '\n');
|
|
|
|
// Broader in one category, and "all": exceeded; a failed re-issue deactivates the binding.
|
|
await refused(mint(login, ['write:repository', 'write:user']), 'credential-scope-exceeded', 'write:user beyond read:user');
|
|
await refused(mint(login, ['all']), 'credential-scope-exceeded', 'all');
|
|
// The administrator's own token, inside the approved scopes: not the copy's account.
|
|
await refused(mint('scratch-admin', ['read:repository', 'read:user']), 'credential-identity-refused', 'admin account');
|
|
// A token Gitea never issued.
|
|
await refused(randomBytes(20).toString('hex'), 'credential-scope-unverified', 'opaque token');
|
|
// An unrelated repository the account can also reach.
|
|
assert.equal((await collaborate('other', 'read')).status, 204);
|
|
await refused(narrow, 'credential-scope-exceeded', 'unrelated repository');
|
|
|
|
// Nothing kept names a token, its digest, the slot path or a request.
|
|
const kept = [file, join(root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8')).join('\n')
|
|
+ ['events', 'decisions'].map((tb) => JSON.stringify(store.all(`SELECT * FROM ${tb}`))).join('\n');
|
|
for (const value of minted) assert.ok(!kept.includes(value) && !kept.includes(sha(value)) && !kept.includes(sha(value + '\n')), 'kept text holds no token');
|
|
for (const bad of [slot, storeRoot, '/api/v1', 'authorization']) assert.ok(!kept.includes(bad), 'kept text holds no path or request');
|
|
t.diagnostic(`minted ${minted.length}`);
|
|
});
|