fix(bus): sweep ungranted copy slots at start, clean a failed stage (#1873)
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.
This commit was merged in pull request #1944.
This commit is contained in:
1 parent
d5aa963893
commit
6d0e9f89be
5 files changed
+229
-26
No files matched your search
@@ -17,13 +17,15 @@
|
||||
// receipt land.
|
||||
// Any refusal after the stage removes the slot it wrote and leaves the record pending. A lost reply
|
||||
// or a crash leaves the record pending too; nothing resolves a pending record, and recovery is a
|
||||
// new verified issue. Nothing here logs, and no result, receipt or refusal holds the token, the
|
||||
// new verified issue. A token a crash left staged goes at the next start (sweepCopySlots). Nothing here logs, and no result, receipt or refusal holds the token, the
|
||||
// slot's path, a request or anything derived from the token.
|
||||
import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { Credentials, assertNotHeld, day } from './credentials.mjs';
|
||||
import { Credentials, assertNotHeld, day, privateDir } from './credentials.mjs';
|
||||
import { send, versionAtLeast, REPO_PAGE, REPO_PAGES } from './gitea-grant.mjs';
|
||||
import { scanOrganizations } from './definition-scan.mjs';
|
||||
import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES } from '../../organization/src/vocabulary.mjs';
|
||||
import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
|
||||
import { copyBotLogin, scopesWithin } from '../../organization/src/copy-bindings.mjs';
|
||||
|
||||
const deny = (code, reason) => {
|
||||
@@ -101,8 +103,9 @@ export async function verifyGiteaToken({ transport = send, url, ca, timeoutMs, t
|
||||
if (user.json.id !== info.json.user.id || lower(user.json.login) !== lower(login) || user.json.is_admin !== false)
|
||||
deny('credential-identity-refused', login);
|
||||
// Every repository the account can reach: owned, collaborator and team access. One unrelated
|
||||
// repository, an admin permission, or push where the binding approved read refuses.
|
||||
const write = levels.repository === 'write';
|
||||
// repository, an admin permission, or push where the binding approved read refuses. Push is the
|
||||
// account's right, so it is held to what the binding approved, not to the token's own level.
|
||||
const write = tokenLevels(approved)?.repository === 'write';
|
||||
const seen = new Map();
|
||||
let total = null;
|
||||
for (let page = 1; ; page++) {
|
||||
@@ -194,3 +197,37 @@ export function copyGrant({ credentials, writer, copies = null, organizations =
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Security review P2: at start, before Credentials loads and before any issue can run, each staged
|
||||
// file of a copy's service whose binding holds no grant for that service goes, and so does any
|
||||
// interrupted write's temp file. An issue killed after its stage leaves the record pending and the
|
||||
// token on disk; nothing resolves a pending record and no reference loads a copy slot, so the file
|
||||
// only keeps a secret nothing will use. `documents` is [business, validated organization document]
|
||||
// for each loaded business (null when unreadable: nothing of it is touched). Only names the document
|
||||
// binds as copies are looked at, never a name its own roles hold or a slot a loaded reference names.
|
||||
// A store dir that isn't private refuses credential-file, as Credentials would. Returns
|
||||
// [{ instance, service }], never a path.
|
||||
export function sweepCopySlots(storeRoot, documents, references = {}) {
|
||||
const removed = [];
|
||||
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
|
||||
for (const [business, doc] of documents) {
|
||||
if (!object(doc) || !object(doc.copyBindings) || !ID_PATTERN.test(business)) continue;
|
||||
const dir = join(storeRoot, business);
|
||||
if (!privateDir(dir, { missing: true })) continue;
|
||||
for (const [copy, record] of Object.entries(doc.copyBindings)) {
|
||||
if (!ID_PATTERN.test(copy) || Object.hasOwn(doc.roles ?? {}, copy)) continue;
|
||||
const copyDir = join(dir, copy);
|
||||
if (!privateDir(copyDir, { missing: true })) continue;
|
||||
const instance = business + '/' + copy;
|
||||
for (const name of readdirSync(copyDir)) {
|
||||
const service = name.endsWith('.tmp') ? name.slice(0, -4) : name;
|
||||
if (!SERVICES.includes(service) || references[instance]?.[service] !== undefined) continue;
|
||||
if (name === service && typeof record?.services?.[service]?.grantId === 'string') continue;
|
||||
if (lstatSync(join(copyDir, name)).isDirectory()) continue;
|
||||
unlinkSync(join(copyDir, name));
|
||||
removed.push({ instance, service });
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -490,7 +490,7 @@ export class Credentials {
|
||||
}
|
||||
// The store slot write issue and stageCopy share: tmp (O_EXCL, O_NOFOLLOW, 0600), fsync, rename
|
||||
// over the slot, fsync the dir. Returns the identity of the file it wrote.
|
||||
#writeSlot(instance, service, text) {
|
||||
#writeSlot(instance, service, text, { undo = false } = {}) {
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||||
@@ -505,22 +505,43 @@ export class Credentials {
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
// A write that fails leaves no token behind: the temp file this call made goes, and with `undo`
|
||||
// (stageCopy) so does the slot it renamed into place.
|
||||
let fd, identity;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, text);
|
||||
fsyncSync(fd);
|
||||
const st = fstatSync(fd);
|
||||
identity = { dev: st.dev, ino: st.ino };
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
const st = fstatSync(fd);
|
||||
identity = { dev: st.dev, ino: st.ino };
|
||||
writeSync(fd, text);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
} catch (e) {
|
||||
if (identity) {
|
||||
try {
|
||||
const st = lstatSync(tmp);
|
||||
if (st.isFile() && st.dev === identity.dev && st.ino === identity.ino) unlinkSync(tmp);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
} catch (e) {
|
||||
if (undo) {
|
||||
try {
|
||||
this.unlinkCopy(instance, service, identity);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
@@ -534,7 +555,7 @@ export class Credentials {
|
||||
stageCopy(instance, service, token) {
|
||||
if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) deny('credential-reference');
|
||||
const value = Credentials.#format(service, token);
|
||||
return this.#writeSlot(instance, service, value.token + '\n');
|
||||
return this.#writeSlot(instance, service, value.token + '\n', { undo: true });
|
||||
}
|
||||
// The staged token, read back with the store's checks plus one link only and the exact identity the
|
||||
// stage wrote: a symlink, a hard link, a replaced or a stale file refuses credential-file.
|
||||
|
||||
@@ -46,7 +46,7 @@ import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { copyGrant } from './copy-grant.mjs';
|
||||
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -404,6 +404,20 @@ export async function startBroker({
|
||||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
||||
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
||||
// #1873 CB2 (security review P2): a copy slot an issue staged before a crash, whose binding holds
|
||||
// no grant for it, goes before Credentials loads and before any issue can run.
|
||||
if (storeRoot) {
|
||||
const reader = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot });
|
||||
const documents = Object.keys(businesses).map((id) => {
|
||||
try {
|
||||
return [id, reader.read(id).document];
|
||||
} catch {
|
||||
return [id, null];
|
||||
}
|
||||
});
|
||||
for (const entry of sweepCopySlots(storeRoot, documents, references))
|
||||
process.stderr.write(`copies: ${JSON.stringify({ event: 'copy-slot-swept', ...entry })}\n`);
|
||||
}
|
||||
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
|
||||
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
|
||||
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 } from '../src/copy-grant.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';
|
||||
@@ -146,6 +146,60 @@ test('CB2 read is narrower than write within a category; categories are independ
|
||||
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'] } } });
|
||||
@@ -263,9 +317,13 @@ test('CB2 a foreign account, an administrator or another copy\'s token is identi
|
||||
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();
|
||||
await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' });
|
||||
// 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);
|
||||
@@ -373,13 +431,16 @@ test('CB2 crash points leave the record pending and the slot removed; a new veri
|
||||
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: the record is pending and the staged slot stays. Nothing resolves
|
||||
// it; only a fresh verified issue binds.
|
||||
// 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.
|
||||
@@ -387,6 +448,49 @@ test('CB2 crash points leave the record pending and the slot removed; a new veri
|
||||
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();
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
// scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { definitionPin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { readTemplate } from '../../organization/src/definitions.mjs';
|
||||
|
||||
const TOKEN = 'fake_issued_token_0123456789';
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
@@ -18,16 +20,18 @@ const OTHER = 'fake_other_token_5555555555';
|
||||
const DATE = '2099-01-01';
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
|
||||
async function setup(t, { storeRoot = true } = {}) {
|
||||
async function setup(t, { storeRoot = true, before = () => {}, edit = () => {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
edit(doc, rolesDir);
|
||||
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
before(store);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
@@ -187,3 +191,26 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
|
||||
test('CB2 security review P2: the start sweep removes a copy slot a crashed issue left staged', async (t) => {
|
||||
// A copy binding left pending by an issue killed after its stage, its token and a temp file on disk.
|
||||
const pending = (doc, rolesDir) => {
|
||||
doc.copyBindings = { rev: {
|
||||
copyId: 'c'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
|
||||
definition: definitionPin('reviewer', { definitions: {}, rolesDir, readTemplate }), generation: 2, state: 'pending',
|
||||
services: { gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] } } },
|
||||
decision: '11111111-1111-4111-8111-111111111111' } };
|
||||
};
|
||||
const staged = (store) => {
|
||||
mkdirSync(join(store('rev', 'gitea'), '..'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(store('rev', 'gitea'), '..', '..'), 0o700);
|
||||
for (const name of ['gitea', 'gitea.tmp']) writeFileSync(store('rev', name), TOKEN + '\n', { mode: 0o600 });
|
||||
mkdirSync(join(store('coder', 'gitea'), '..'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(store('coder', 'gitea'), TOKEN + '\n', { mode: 0o600 });
|
||||
};
|
||||
const c = await setup(t, { edit: pending, before: staged });
|
||||
assert.equal(c.file().copyBindings.rev.state, 'pending');
|
||||
assert.deepEqual(readdirSync(join(c.store('rev', 'gitea'), '..')), []);
|
||||
// A role's own store slot is the store's, not the sweep's.
|
||||
assert.equal(existsSync(c.store('coder', 'gitea')), true);
|
||||
});
|
||||
Reference in new issue
Block a user