@bongos/core 1.25.20 → 1.25.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +213 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +34 -8
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +18 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +693 -125
- package/docs/api-reference.md +13 -4
- package/docs/copy-inventory.md +432 -427
- package/docs/copy-registry.json +571 -526
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +350 -344
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +6 -3
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +290 -32
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +32 -0
- package/scripts/bongos/genesis.js +49 -3
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +19 -3
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- package/src/bongos/folder-upload.js +243 -0
- package/src/bongos/routes/github.js +109 -16
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/src/sandbox/gate.js +164 -0
- package/src/sandbox/index.js +144 -0
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/project_facts.mjs +158 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +16 -8
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
// tests/provisioning_project_grants.mjs
|
|
2
|
+
//
|
|
3
|
+
// The owner-made grant (task 1004161, ADR 0407): a hosted project's provisioning routes admit
|
|
4
|
+
// its owner, an archon where they already did, and — on exactly seven routes — a person the
|
|
5
|
+
// OWNER granted 'operate' access. A sign-in grants nothing.
|
|
6
|
+
//
|
|
7
|
+
// Covers:
|
|
8
|
+
// • THE ATTACK: a GitHub user who only ran the device flow (a hub session and a witnessed
|
|
9
|
+
// `member` membership row, no grant) is refused on EVERY /provisioning/instances/:id route
|
|
10
|
+
// the module declares, found by reading the route files, so a route added later is covered
|
|
11
|
+
// too; nothing is written for them.
|
|
12
|
+
// • a grantee is admitted on the seven routes and refused (404) on every other one; their
|
|
13
|
+
// restart is recorded as api:grantee:<id>; their project read carries no owner bill and no
|
|
14
|
+
// GitHub steps
|
|
15
|
+
// • taking access back binds on the very next request
|
|
16
|
+
// • giving access is the owner's alone: an archon or the grantee cannot; the owner cannot
|
|
17
|
+
// grant themselves; an unknown login is named
|
|
18
|
+
// • a grant read that throws DENIES (500), never admits
|
|
19
|
+
// • grants.js: the data rules hold whoever calls (owner refusal, the limit, one live grant)
|
|
20
|
+
// • the structure: exactly the seven routes opt in, only grants.js writes the table, and the
|
|
21
|
+
// gate never reads a membership row
|
|
22
|
+
//
|
|
23
|
+
// DB-free: the real routers and the real error envelope over stubbed data access (the
|
|
24
|
+
// provisioning_project_owner_gate.mjs pattern). Every query that reaches the pool is recorded,
|
|
25
|
+
// and a membership query answers `member` — so a gate that ever consulted membership to admit
|
|
26
|
+
// would let the attacker in and turn this file red.
|
|
27
|
+
//
|
|
28
|
+
// Run: node --test tests/provisioning_project_grants.mjs
|
|
29
|
+
|
|
30
|
+
import assert from 'node:assert/strict';
|
|
31
|
+
import { test, before, after } from 'node:test';
|
|
32
|
+
import fs from 'node:fs';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { createRequire } from 'node:module';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const require = createRequire(import.meta.url);
|
|
38
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
39
|
+
|
|
40
|
+
const api = require(path.join(ROOT, 'src/module-api.js'));
|
|
41
|
+
const provisioning = require(path.join(ROOT, 'modules/provisioning/provisioning.js'));
|
|
42
|
+
const grants = require(path.join(ROOT, 'modules/provisioning/grants.js'));
|
|
43
|
+
const envManifest = require(path.join(ROOT, 'modules/provisioning/env-manifest.js'));
|
|
44
|
+
const githubSetup = require(path.join(ROOT, 'modules/provisioning/github-setup.js'));
|
|
45
|
+
const catalogBridge = require(path.join(ROOT, 'modules/provisioning/catalog-bridge.js'));
|
|
46
|
+
|
|
47
|
+
const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
|
|
48
|
+
const ATTACKER = { id: 66, github_login: 'device-flow-only', rank: 'xenos' };
|
|
49
|
+
const GRANTEE = { id: 44, github_login: 'helper', rank: 'xenos' };
|
|
50
|
+
const ARCHON = { id: 999, github_login: 'fleet-support', rank: 'archon' };
|
|
51
|
+
const ROW = {
|
|
52
|
+
id: 18, owner_builder_id: 7, slug: 'mercury', status: 'active', hosting_shape: 'cloud-host',
|
|
53
|
+
domain: 'mercury.example.com', target_ref: 'owner/mercury', settings: {}, detail: {}, tier: null,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// ── the world the routes see ────────────────────────────────────────────────────────
|
|
57
|
+
let builder = OWNER;
|
|
58
|
+
const writes = []; // every write any stubbed data function or the pool saw
|
|
59
|
+
const sql = []; // every query that reached the pool
|
|
60
|
+
const live = new Map(); // `${instanceId}:${builderId}` → grant
|
|
61
|
+
let grantReadThrows = false;
|
|
62
|
+
const grantRow = (b) => ({ id: 3, instance_id: 18, grantee_builder_id: b.id, level: 'operate',
|
|
63
|
+
granted_by_builder_id: OWNER.id, granted_at: 't', revoked_at: null, revoked_by_builder_id: null });
|
|
64
|
+
|
|
65
|
+
api.requireBuilder = (req, _res, next) => { req.builder = { ...builder }; next(); };
|
|
66
|
+
// Nobody in this file holds a system permission: the fleet-wide routes refuse everyone.
|
|
67
|
+
api.requirePermission = () => (_req, res) => res.fail('permission_forbidden', 403);
|
|
68
|
+
api.branding = () => ({ domains: { publicOrigin: 'https://cb.test', instanceBase: 'cb.test' }, identity: { productName: 'Cloud Bongos' } });
|
|
69
|
+
|
|
70
|
+
// A pool that records everything. A membership read answers `member` with the hub's witness —
|
|
71
|
+
// exactly what a device-flow sign-in leaves behind.
|
|
72
|
+
api.pool.query = async (text, params) => {
|
|
73
|
+
const q = String(text).replace(/\s+/g, ' ').trim();
|
|
74
|
+
sql.push(q);
|
|
75
|
+
if (/^(INSERT|UPDATE|DELETE)/i.test(q)) writes.push(q);
|
|
76
|
+
if (/platform_identity_project_memberships/.test(q)) {
|
|
77
|
+
return { rows: [{ client_id: 'mercury', github_id: '6600', membership_kind: 'member', signin_verified_at: 'now' }], rowCount: 1 };
|
|
78
|
+
}
|
|
79
|
+
return { rows: [], rowCount: 0 };
|
|
80
|
+
};
|
|
81
|
+
api.pool.connect = async () => ({ query: api.pool.query, release() {} });
|
|
82
|
+
|
|
83
|
+
grants.liveGrant = async (_db, instanceId, builderId) => {
|
|
84
|
+
if (grantReadThrows) throw new Error('relation "provisioning_project_grants" does not exist');
|
|
85
|
+
return live.get(`${instanceId}:${builderId}`) || null;
|
|
86
|
+
};
|
|
87
|
+
grants.listLiveGrants = async (_db, instanceId) => [...live.values()].filter((g) => g.instance_id === instanceId)
|
|
88
|
+
.map((g) => ({ ...g, github_login: GRANTEE.github_login, display_name: null, avatar_url: null }));
|
|
89
|
+
grants.findGranteeByLogin = async (_db, login) => (
|
|
90
|
+
[GRANTEE, OWNER, ATTACKER].find((b) => b.github_login === login) || null);
|
|
91
|
+
grants.grantProjectAccess = async (_db, g) => {
|
|
92
|
+
writes.push(`grant ${g.granteeBuilderId}`);
|
|
93
|
+
if (String(g.granteeBuilderId) === String(ROW.owner_builder_id)) return { refused: 'grantee_is_owner' };
|
|
94
|
+
const row = { ...grantRow({ id: g.granteeBuilderId }), granted_by_builder_id: g.grantedByBuilderId };
|
|
95
|
+
live.set(`${g.instanceId}:${g.granteeBuilderId}`, row);
|
|
96
|
+
return { grant: row, created: true };
|
|
97
|
+
};
|
|
98
|
+
grants.getGrant = async (_db, instanceId, grantId) => (
|
|
99
|
+
[...live.values()].find((g) => g.instance_id === instanceId && g.id === grantId) || null);
|
|
100
|
+
grants.revokeProjectAccess = async (_db, { instanceId, grantId }) => {
|
|
101
|
+
writes.push(`revoke ${grantId}`);
|
|
102
|
+
for (const [k, g] of live) if (g.instance_id === instanceId && g.id === grantId) { live.delete(k); return { ...g, revoked_at: 'now' }; }
|
|
103
|
+
return null;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const events = [];
|
|
107
|
+
const enqueues = [];
|
|
108
|
+
provisioning.getInstanceById = async (_db, id) => (String(id) === String(ROW.id) ? { ...ROW } : null);
|
|
109
|
+
provisioning.recordEvent = async (_db, e) => { events.push(e); writes.push(`event ${e.event}`); };
|
|
110
|
+
for (const fn of ['enqueueRestart', 'enqueueTeardown', 'enqueueIntent', 'enqueueRepoPrivate', 'enqueueRenderStandup',
|
|
111
|
+
'updateInstanceSettings', 'updateInstanceDetail', 'updateInstanceDomain', 'createOAuthManifest']) {
|
|
112
|
+
provisioning[fn] = async (_db, id, actor) => { enqueues.push({ fn, id, actor }); writes.push(fn); return { created: true, intent: { action: fn, state: 'pending' } }; };
|
|
113
|
+
}
|
|
114
|
+
provisioning.getOpenIntent = async () => null;
|
|
115
|
+
provisioning.getLatestOAuthManifestForInstance = async () => null;
|
|
116
|
+
githubSetup.githubSetupRead = async () => ({ steps: [], install_url: 'https://github.com/apps/x/installations/new' });
|
|
117
|
+
githubSetup.installNudge = () => ({ install_url: 'https://github.com/apps/x/installations/new', repo: 'owner/mercury' });
|
|
118
|
+
catalogBridge.healInstanceCatalog = async () => {};
|
|
119
|
+
envManifest.listEnvVars = async () => [];
|
|
120
|
+
envManifest.declareEnvVar = async (_db, d) => { writes.push('declare'); return { row: { id: 5, name: d.name }, created: true }; };
|
|
121
|
+
envManifest.updateEnvVar = async () => { writes.push('update-var'); return { id: 5, name: 'PORT' }; };
|
|
122
|
+
envManifest.removeEnvVar = async () => { writes.push('remove-var'); return true; };
|
|
123
|
+
|
|
124
|
+
// ── every route the module declares on a project id ─────────────────────────────────
|
|
125
|
+
const MODULE = path.join(ROOT, 'modules/provisioning');
|
|
126
|
+
const routeFiles = JSON.parse(fs.readFileSync(path.join(MODULE, 'module.json'), 'utf8')).contributes.routes;
|
|
127
|
+
const read = (f) => fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n');
|
|
128
|
+
const DECLARED = [];
|
|
129
|
+
for (const name of routeFiles) {
|
|
130
|
+
const src = read(path.join(MODULE, 'routes', `${name}.js`));
|
|
131
|
+
const re = /router\.(get|post|patch|put|delete)\(\s*'(\/provisioning\/instances\/:id[^']*)'/g;
|
|
132
|
+
let m;
|
|
133
|
+
while ((m = re.exec(src))) DECLARED.push({ method: m[1], route: m[2], file: name });
|
|
134
|
+
}
|
|
135
|
+
const concrete = (r) => r.replace(':id', '18').replace(':varId', '5').replace(':grantId', '3');
|
|
136
|
+
|
|
137
|
+
// The seven routes a grantee may use (ADR 0407 §3) — and nothing else.
|
|
138
|
+
const GRANTEE_ROUTES = new Set([
|
|
139
|
+
'get /provisioning/instances/:id',
|
|
140
|
+
'get /provisioning/instances/:id/settings',
|
|
141
|
+
'post /provisioning/instances/:id/restart',
|
|
142
|
+
'get /provisioning/instances/:id/env-manifest',
|
|
143
|
+
'post /provisioning/instances/:id/env-manifest',
|
|
144
|
+
'patch /provisioning/instances/:id/env-manifest/:varId',
|
|
145
|
+
'delete /provisioning/instances/:id/env-manifest/:varId',
|
|
146
|
+
]);
|
|
147
|
+
const BODIES = {
|
|
148
|
+
'post /provisioning/instances/:id/env-manifest': { name: 'PORT' },
|
|
149
|
+
'patch /provisioning/instances/:id/env-manifest/:varId': { description: 'the port' },
|
|
150
|
+
'post /provisioning/instances/:id/grants': { github_login: 'helper' },
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const express = require('express');
|
|
154
|
+
const { attachFail } = require(path.join(ROOT, 'src/bongos/middleware/error-envelope.js'));
|
|
155
|
+
let server, base;
|
|
156
|
+
before(async () => {
|
|
157
|
+
const app = express();
|
|
158
|
+
app.use(express.json());
|
|
159
|
+
app.use(attachFail);
|
|
160
|
+
for (const name of routeFiles) app.use('/api/bongos', require(path.join(MODULE, 'routes', `${name}.js`))());
|
|
161
|
+
server = app.listen(0);
|
|
162
|
+
await new Promise((r) => server.once('listening', r));
|
|
163
|
+
base = `http://127.0.0.1:${server.address().port}/api/bongos`;
|
|
164
|
+
});
|
|
165
|
+
after(() => { server.closeAllConnections(); server.close(); });
|
|
166
|
+
|
|
167
|
+
async function call(method, route, body) {
|
|
168
|
+
const key = `${method} ${route}`;
|
|
169
|
+
const payload = body === undefined ? BODIES[key] || {} : body;
|
|
170
|
+
const res = await fetch(`${base}${concrete(route)}`, {
|
|
171
|
+
method: method.toUpperCase(),
|
|
172
|
+
headers: { 'content-type': 'application/json', connection: 'close' },
|
|
173
|
+
body: method === 'get' ? undefined : JSON.stringify(payload),
|
|
174
|
+
});
|
|
175
|
+
const raw = await res.text();
|
|
176
|
+
let json = null;
|
|
177
|
+
try { json = JSON.parse(raw); } catch { /* an HTML page, e.g. a redirect target */ }
|
|
178
|
+
return { status: res.status, body: json, raw };
|
|
179
|
+
}
|
|
180
|
+
function as(who) {
|
|
181
|
+
builder = who;
|
|
182
|
+
writes.length = 0; sql.length = 0; events.length = 0; enqueues.length = 0;
|
|
183
|
+
grantReadThrows = false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── THE ATTACK ──────────────────────────────────────────────────────────────────────
|
|
187
|
+
test('the module declares the routes this file walks (a sanity floor, not a fixed count)', () => {
|
|
188
|
+
assert.ok(DECLARED.length >= 30, `found ${DECLARED.length}: ${DECLARED.map((d) => `${d.method} ${d.route}`).join(' · ')}`);
|
|
189
|
+
for (const k of GRANTEE_ROUTES) assert.ok(DECLARED.some((d) => `${d.method} ${d.route}` === k), `${k} is declared`);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a GitHub user who only signed in (device flow, witnessed member row, no grant) is refused on EVERY project route, and nothing is written', async () => {
|
|
193
|
+
for (const d of DECLARED) {
|
|
194
|
+
as(ATTACKER);
|
|
195
|
+
const r = await call(d.method, d.route);
|
|
196
|
+
assert.ok(r.status >= 400 && r.status < 500, `${d.method} ${d.route} → ${r.status} ${r.raw.slice(0, 200)}`);
|
|
197
|
+
assert.deepEqual(writes, [], `${d.method} ${d.route} wrote ${JSON.stringify(writes)}`);
|
|
198
|
+
assert.deepEqual(enqueues, [], `${d.method} ${d.route} queued work`);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('the seven grantee routes and the grant routes answer the attacker with the stranger’s 404 — the id is not confirmed', async () => {
|
|
203
|
+
for (const d of DECLARED.filter((x) => GRANTEE_ROUTES.has(`${x.method} ${x.route}`) || x.file === 'grants')) {
|
|
204
|
+
as(ATTACKER);
|
|
205
|
+
const r = await call(d.method, d.route);
|
|
206
|
+
assert.equal(r.status, 404, `${d.method} ${d.route}: ${r.raw}`);
|
|
207
|
+
assert.equal(r.body.error.code, 'instance_not_found', `${d.method} ${d.route}`);
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('the attacker cannot give themselves access', async () => {
|
|
212
|
+
as(ATTACKER);
|
|
213
|
+
const r = await call('post', '/provisioning/instances/:id/grants', { github_login: 'device-flow-only' });
|
|
214
|
+
assert.equal(r.status, 404, r.raw);
|
|
215
|
+
assert.equal(live.size, 0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── a person the owner granted ──────────────────────────────────────────────────────
|
|
219
|
+
test('the owner gives the helper access: 201, a live grant, and an audit row naming the owner', async () => {
|
|
220
|
+
as(OWNER);
|
|
221
|
+
const r = await call('post', '/provisioning/instances/:id/grants', { github_login: '@helper' });
|
|
222
|
+
assert.equal(r.status, 201, r.raw);
|
|
223
|
+
assert.equal(r.body.grant.builder.github_login, 'helper', 'the reply names the person found');
|
|
224
|
+
assert.ok(live.get('18:44'));
|
|
225
|
+
assert.equal(events[0].event, 'grant-made');
|
|
226
|
+
assert.equal(events[0].actor, 'api:self');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the grantee is admitted on exactly the seven routes, and refused (404) on every other', async () => {
|
|
230
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
231
|
+
for (const d of DECLARED) {
|
|
232
|
+
as(GRANTEE);
|
|
233
|
+
const key = `${d.method} ${d.route}`;
|
|
234
|
+
const r = await call(d.method, d.route);
|
|
235
|
+
if (GRANTEE_ROUTES.has(key)) {
|
|
236
|
+
assert.ok(r.status >= 200 && r.status < 300, `${key} should admit the grantee: ${r.status} ${r.raw.slice(0, 200)}`);
|
|
237
|
+
} else if (key === 'delete /provisioning/instances/:id/grants/:grantId') {
|
|
238
|
+
continue; // taking their OWN grant back is allowed — its own test below
|
|
239
|
+
} else {
|
|
240
|
+
assert.ok(r.status >= 400 && r.status < 500, `${key} must refuse the grantee: ${r.status} ${r.raw.slice(0, 200)}`);
|
|
241
|
+
assert.deepEqual(enqueues, [], `${key} queued work for the grantee`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('a grantee’s restart is recorded as theirs, never as the owner’s', async () => {
|
|
247
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
248
|
+
as(GRANTEE);
|
|
249
|
+
const r = await call('post', '/provisioning/instances/:id/restart');
|
|
250
|
+
assert.equal(r.status, 200, r.raw);
|
|
251
|
+
assert.deepEqual(enqueues, [{ fn: 'enqueueRestart', id: 18, actor: 'api:grantee:44' }]);
|
|
252
|
+
assert.equal(events[0].actor, 'api:grantee:44');
|
|
253
|
+
assert.match(events[0].detail, /restart requested by grantee helper/);
|
|
254
|
+
assert.doesNotMatch(events[0].detail, /by owner/);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test('a grantee’s change to the variable list is audited as theirs', async () => {
|
|
258
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
259
|
+
as(GRANTEE);
|
|
260
|
+
const r = await call('post', '/provisioning/instances/:id/env-manifest', { name: 'PORT' });
|
|
261
|
+
assert.equal(r.status, 201, r.raw);
|
|
262
|
+
assert.equal(events[0].event, 'env-manifest');
|
|
263
|
+
assert.equal(events[0].actor, 'api:grantee:44');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('the grantee’s project read carries no owner bill and no GitHub steps; the owner’s carries both', async () => {
|
|
267
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
268
|
+
as(GRANTEE);
|
|
269
|
+
const g = await call('get', '/provisioning/instances/:id');
|
|
270
|
+
assert.equal(g.status, 200, g.raw);
|
|
271
|
+
assert.equal('owner_monthly_usd' in g.body.instance, false);
|
|
272
|
+
assert.equal('github_install' in g.body.instance, false);
|
|
273
|
+
assert.equal(g.body.github_app, null);
|
|
274
|
+
assert.equal(g.body.github_setup, null);
|
|
275
|
+
as(OWNER);
|
|
276
|
+
const o = await call('get', '/provisioning/instances/:id');
|
|
277
|
+
assert.ok('owner_monthly_usd' in o.body.instance);
|
|
278
|
+
assert.ok(o.body.instance.github_install);
|
|
279
|
+
assert.ok(o.body.github_setup);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── taking access back ──────────────────────────────────────────────────────────────
|
|
283
|
+
test('the owner takes access back and the grantee is refused on the very next request', async () => {
|
|
284
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
285
|
+
as(GRANTEE);
|
|
286
|
+
assert.equal((await call('post', '/provisioning/instances/:id/restart')).status, 200);
|
|
287
|
+
as(OWNER);
|
|
288
|
+
const r = await call('delete', '/provisioning/instances/:id/grants/:grantId');
|
|
289
|
+
assert.equal(r.status, 200, r.raw);
|
|
290
|
+
assert.equal(events[0].event, 'grant-revoked');
|
|
291
|
+
assert.equal(events[0].actor, 'api:self');
|
|
292
|
+
as(GRANTEE);
|
|
293
|
+
const after = await call('post', '/provisioning/instances/:id/restart');
|
|
294
|
+
assert.equal(after.status, 404, after.raw);
|
|
295
|
+
assert.deepEqual(enqueues, []);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('a grantee may take their own access back; an archon may take anyone’s; a stranger may not', async () => {
|
|
299
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
300
|
+
as(ATTACKER);
|
|
301
|
+
assert.equal((await call('delete', '/provisioning/instances/:id/grants/:grantId')).status, 404);
|
|
302
|
+
assert.ok(live.get('18:44'), 'a stranger removed nothing');
|
|
303
|
+
as(GRANTEE);
|
|
304
|
+
const own = await call('delete', '/provisioning/instances/:id/grants/:grantId');
|
|
305
|
+
assert.equal(own.status, 200, own.raw);
|
|
306
|
+
assert.equal(events[0].actor, 'api:grantee:44');
|
|
307
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
308
|
+
as(ARCHON);
|
|
309
|
+
const a = await call('delete', '/provisioning/instances/:id/grants/:grantId');
|
|
310
|
+
assert.equal(a.status, 200, a.raw);
|
|
311
|
+
assert.equal(events[0].actor, 'api:archon:999');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// ── who may give access ─────────────────────────────────────────────────────────────
|
|
315
|
+
test('an archon cannot give access on someone else’s project, and a grantee cannot pass it on', async () => {
|
|
316
|
+
live.clear();
|
|
317
|
+
as(ARCHON);
|
|
318
|
+
const a = await call('post', '/provisioning/instances/:id/grants', { github_login: 'helper' });
|
|
319
|
+
assert.equal(a.status, 404, a.raw);
|
|
320
|
+
assert.equal(live.size, 0);
|
|
321
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
322
|
+
as(GRANTEE);
|
|
323
|
+
try {
|
|
324
|
+
const g = await call('post', '/provisioning/instances/:id/grants', { github_login: 'device-flow-only' });
|
|
325
|
+
// A grantee already knows the project, so the refusal says why (task 1004162, ADR 0407 §6).
|
|
326
|
+
assert.equal(g.status, 403, g.raw);
|
|
327
|
+
assert.equal(g.body.error.code, 'owner_only');
|
|
328
|
+
assert.equal(live.has('18:66'), false);
|
|
329
|
+
} finally {
|
|
330
|
+
live.clear();
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('the owner cannot grant themselves, and an unknown login is named, not guessed', async () => {
|
|
335
|
+
as(OWNER);
|
|
336
|
+
const self = await call('post', '/provisioning/instances/:id/grants', { github_login: 'owner' });
|
|
337
|
+
assert.equal(self.status, 400, self.raw);
|
|
338
|
+
assert.equal(self.body.error.code, 'grantee_is_owner');
|
|
339
|
+
const nobody = await call('post', '/provisioning/instances/:id/grants', { github_login: 'nobody-here' });
|
|
340
|
+
assert.equal(nobody.status, 404, nobody.raw);
|
|
341
|
+
assert.equal(nobody.body.error.code, 'grantee_not_found');
|
|
342
|
+
const bad = await call('post', '/provisioning/instances/:id/grants', { github_login: 'not a login' });
|
|
343
|
+
assert.equal(bad.status, 400, bad.raw);
|
|
344
|
+
assert.equal(live.size, 0);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// ── failure shapes ──────────────────────────────────────────────────────────────────
|
|
348
|
+
test('a grant read that throws DENIES with the opaque 500 — the route body never runs', async () => {
|
|
349
|
+
live.set('18:44', grantRow(GRANTEE));
|
|
350
|
+
as(GRANTEE);
|
|
351
|
+
grantReadThrows = true;
|
|
352
|
+
const r = await call('post', '/provisioning/instances/:id/restart');
|
|
353
|
+
assert.equal(r.status, 500, r.raw);
|
|
354
|
+
assert.deepEqual(r.body.error, { code: 'instance_read_failed', message: 'internal error' });
|
|
355
|
+
assert.deepEqual(enqueues, []);
|
|
356
|
+
live.clear();
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test('the owner never needs the grant read, so a broken grants table cannot lock the owner out', async () => {
|
|
360
|
+
as(OWNER);
|
|
361
|
+
grantReadThrows = true;
|
|
362
|
+
const r = await call('post', '/provisioning/instances/:id/restart');
|
|
363
|
+
assert.equal(r.status, 200, r.raw);
|
|
364
|
+
assert.equal(enqueues[0].actor, 'api:self');
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test('an unknown grantee level is a programming error at mount time, never a silent widening', () => {
|
|
368
|
+
const { requireProjectOwner } = require(path.join(MODULE, 'route-helpers.js'));
|
|
369
|
+
assert.throws(() => requireProjectOwner(async () => null, { grantee: 'admin' }), /unknown grantee level/);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// ── grants.js: the data rules hold whoever calls ────────────────────────────────────
|
|
373
|
+
function scriptedDb({ owner = 7, live: liveRows = [], count = 0 }) {
|
|
374
|
+
const seen = [];
|
|
375
|
+
const q = async (text, params) => {
|
|
376
|
+
const s = String(text).replace(/\s+/g, ' ').trim();
|
|
377
|
+
seen.push(s);
|
|
378
|
+
if (/FOR UPDATE/.test(s)) return { rows: [{ id: params[0], owner_builder_id: owner }] };
|
|
379
|
+
if (/revoked_at IS NULL LIMIT 1/.test(s)) return { rows: liveRows };
|
|
380
|
+
if (/count\(\*\)/.test(s)) return { rows: [{ n: count }] };
|
|
381
|
+
if (/^INSERT INTO provisioning_project_grants/.test(s)) return { rows: [{ id: 9, instance_id: params[0], grantee_builder_id: params[1], level: params[2] }] };
|
|
382
|
+
return { rows: [] };
|
|
383
|
+
};
|
|
384
|
+
return { seen, connect: async () => ({ query: q, release() {} }) };
|
|
385
|
+
}
|
|
386
|
+
const realGrants = (() => {
|
|
387
|
+
const p = require.resolve(path.join(MODULE, 'grants.js'));
|
|
388
|
+
const saved = require.cache[p];
|
|
389
|
+
delete require.cache[p];
|
|
390
|
+
const fresh = require(p);
|
|
391
|
+
require.cache[p] = saved;
|
|
392
|
+
return fresh;
|
|
393
|
+
})();
|
|
394
|
+
|
|
395
|
+
test('grants.js refuses the owner, an unknown level and a full project, and returns an existing live grant', async () => {
|
|
396
|
+
let db = scriptedDb({});
|
|
397
|
+
assert.deepEqual(await realGrants.grantProjectAccess(db, { instanceId: 18, granteeBuilderId: 7, grantedByBuilderId: 7 }), { refused: 'grantee_is_owner' });
|
|
398
|
+
assert.ok(!db.seen.some((s) => /^INSERT/.test(s)));
|
|
399
|
+
assert.deepEqual(await realGrants.grantProjectAccess(scriptedDb({}), { instanceId: 18, granteeBuilderId: 44, level: 'admin', grantedByBuilderId: 7 }), { refused: 'bad_level' });
|
|
400
|
+
db = scriptedDb({ count: realGrants.MAX_LIVE_GRANTS });
|
|
401
|
+
assert.deepEqual(await realGrants.grantProjectAccess(db, { instanceId: 18, granteeBuilderId: 44, grantedByBuilderId: 7 }), { refused: 'grant_limit' });
|
|
402
|
+
assert.ok(!db.seen.some((s) => /^INSERT/.test(s)));
|
|
403
|
+
db = scriptedDb({ live: [{ id: 3, level: 'operate' }] });
|
|
404
|
+
const again = await realGrants.grantProjectAccess(db, { instanceId: 18, granteeBuilderId: 44, grantedByBuilderId: 7 });
|
|
405
|
+
assert.equal(again.created, false);
|
|
406
|
+
assert.ok(!db.seen.some((s) => /^INSERT/.test(s)), 'one live grant per person');
|
|
407
|
+
db = scriptedDb({});
|
|
408
|
+
const made = await realGrants.grantProjectAccess(db, { instanceId: 18, granteeBuilderId: 44, grantedByBuilderId: 7 });
|
|
409
|
+
assert.equal(made.created, true);
|
|
410
|
+
assert.ok(db.seen.indexOf(db.seen.find((s) => /FOR UPDATE/.test(s))) < db.seen.indexOf(db.seen.find((s) => /^INSERT/.test(s))), 'the count runs under the project lock');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('a revoke is a stamp scoped to the project, and the live read only ever sees unrevoked rows', async () => {
|
|
414
|
+
const seen = [];
|
|
415
|
+
const db = { query: async (t, p) => { seen.push({ s: String(t).replace(/\s+/g, ' '), p }); return { rows: [] }; } };
|
|
416
|
+
assert.equal(await realGrants.revokeProjectAccess(db, { instanceId: 18, grantId: 3, revokedByBuilderId: 7 }), null);
|
|
417
|
+
assert.match(seen[0].s, /UPDATE provisioning_project_grants SET revoked_at = now\(\)/);
|
|
418
|
+
assert.match(seen[0].s, /WHERE id = \$1 AND instance_id = \$2 AND revoked_at IS NULL/);
|
|
419
|
+
assert.doesNotMatch(seen[0].s, /DELETE/);
|
|
420
|
+
await realGrants.liveGrant(db, 18, 44);
|
|
421
|
+
assert.match(seen[1].s, /instance_id = \$1 AND g\.grantee_builder_id = \$2 AND g\.revoked_at IS NULL/);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── the structure ───────────────────────────────────────────────────────────────────
|
|
425
|
+
test('exactly the seven routes opt in to grantee access', () => {
|
|
426
|
+
const optedIn = [];
|
|
427
|
+
for (const name of routeFiles) {
|
|
428
|
+
for (const line of read(path.join(MODULE, 'routes', `${name}.js`)).split('\n')) {
|
|
429
|
+
const m = /router\.(get|post|patch|put|delete)\('(\/provisioning\/instances\/:id[^']*)'/.exec(line);
|
|
430
|
+
if (m && /requireProjectOwner\(loadInstance, GRANTEE\)/.test(line)) optedIn.push(`${m[1]} ${m[2]}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
assert.deepEqual(optedIn.sort(), [...GRANTEE_ROUTES].sort());
|
|
434
|
+
for (const name of routeFiles) {
|
|
435
|
+
const src = read(path.join(MODULE, 'routes', `${name}.js`));
|
|
436
|
+
assert.doesNotMatch(src, /grantee:\s*'(?!operate')/, `${name}.js names an unknown level`);
|
|
437
|
+
const defs = src.match(/const GRANTEE = \{[^}]*\}/g) || [];
|
|
438
|
+
for (const d of defs) assert.equal(d.replace(/\s+/g, ' '), "const GRANTEE = { grantee: 'operate' }", `${name}.js`);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test('only grants.js writes the grants table, and the gate never reads a membership row', () => {
|
|
443
|
+
const files = [];
|
|
444
|
+
const walk = (dir) => {
|
|
445
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
446
|
+
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
447
|
+
const p = path.join(dir, e.name);
|
|
448
|
+
if (e.isDirectory()) walk(p); else if (e.name.endsWith('.js')) files.push(p);
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
for (const d of ['modules', 'src', 'scripts']) walk(path.join(ROOT, d));
|
|
452
|
+
const writers = files.filter((f) => /(INSERT INTO|UPDATE|DELETE FROM)\s+provisioning_project_grants/.test(read(f)))
|
|
453
|
+
.map((f) => path.relative(ROOT, f).replace(/\\/g, '/'));
|
|
454
|
+
assert.deepEqual(writers, ['modules/provisioning/grants.js']);
|
|
455
|
+
// code only — the comments are allowed to explain why membership is never read
|
|
456
|
+
const gate = read(path.join(MODULE, 'route-helpers.js')).replace(/\/\/.*$/gm, '');
|
|
457
|
+
assert.doesNotMatch(gate, /membership|platform-identity|platform_identity|resolveOptional/i);
|
|
458
|
+
});
|
|
@@ -251,7 +251,9 @@ test('every /provisioning/instances/:id route in the three files mounts the gate
|
|
|
251
251
|
const m = /router\.(get|post|patch|put|delete)\('(\/provisioning\/instances\/:id[^']*)'/.exec(line);
|
|
252
252
|
if (!m || m[2].endsWith('/force-teardown')) continue;
|
|
253
253
|
declared.push(`${m[1]} ${m[2]}`);
|
|
254
|
-
|
|
254
|
+
// The grantee opt-in (task 1004161, ADR 0407 §3) is the only argument a route may add;
|
|
255
|
+
// which routes carry it is pinned in tests/provisioning_project_grants.mjs.
|
|
256
|
+
assert.match(line, /requireBuilder, (?:\w+, )?requireProjectOwner\(loadInstance(?:, GRANTEE)?\), async \(req, res\) =>/, `${m[1]} ${m[2]}`);
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
assert.equal(declared.length, 14, `the 14 own-scoped routes, found: ${declared.join(' · ')}`);
|