The lane design forbids copying the founder-held supplier key onto the workload path. The gate now mints a second key on the MAS application, proves it, writes it CAS=0 over HTTP, and deletes it if a later step fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 4541@bnt-lap001 Assistant-Session: 2099b3aa-d7d9-49a9-ab4e-fa8b7d0e39e8
131 lines
5.3 KiB
JavaScript
131 lines
5.3 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* One attended Console login.
|
|
*
|
|
* Default: the helper compares and corrects the MAS IAM rule.
|
|
* --mint-workload: start the attended OpenBao platform-admin login with the
|
|
* publisher gate as its child. The console session is read and sent only
|
|
* after the gate reports ready, so the OIDC login cannot age it out.
|
|
*/
|
|
import {spawn} from 'node:child_process';
|
|
import {existsSync, readFileSync, rmSync} from 'node:fs';
|
|
import {homedir} from 'node:os';
|
|
import {dirname, join, resolve} from 'node:path';
|
|
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
import {parseArgs} from 'node:util';
|
|
import {createInterface} from 'node:readline/promises';
|
|
|
|
const {values} = parseArgs({options: {'playwright-module': {type: 'string'}, 'mint-workload': {type: 'boolean'}}});
|
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const grant = join(root, 'scripts', 'mas_evidence_iam_grant.py');
|
|
const gate = join(root, 'scripts', 'mas_evidence_publisher_gate.py');
|
|
const ready = join(homedir(), '.local/state/railiance/receipts/mas-evidence-mint-ready.json');
|
|
const READY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
let browser;
|
|
let child;
|
|
|
|
async function sessionAuth(page) {
|
|
if (new URL(page.url()).origin !== 'https://console.scaleway.com') {
|
|
throw new Error('console_page_required');
|
|
}
|
|
return page.evaluate(() => {
|
|
const names = Object.keys(localStorage).filter((name) => name.startsWith('_scw_session_console_scaleway_com_'));
|
|
if (names.length !== 1) throw new Error('unambiguous_session_required');
|
|
const session = JSON.parse(localStorage.getItem(names[0]));
|
|
if (new Date(session.jwt.expiresAt).getTime() < Date.now() + 30000) {
|
|
throw new Error('fresh_console_session_required');
|
|
}
|
|
return {session_token: session.token, user_agent: session.jwt.userAgent};
|
|
});
|
|
}
|
|
|
|
function runGrant(auth) {
|
|
return new Promise((resolveResult, reject) => {
|
|
child = spawn('python3', [grant, '--execute'], {stdio: ['pipe', 'pipe', 'pipe']});
|
|
let output = '';
|
|
child.stdout.on('data', (data) => {
|
|
if (output.length < 65536) output += data.toString();
|
|
});
|
|
child.stderr.on('data', () => {});
|
|
child.on('error', () => reject(new Error('grant_child_failed')));
|
|
child.on('close', (code) => {
|
|
child = null;
|
|
resolveResult({exit_code: code, output});
|
|
});
|
|
child.stdin.end(JSON.stringify(auth));
|
|
});
|
|
}
|
|
|
|
function runMint(page) {
|
|
rmSync(ready, {force: true});
|
|
return new Promise((resolveResult, reject) => {
|
|
const env = {...process.env};
|
|
env.BAO_ADDR = env.BAO_ADDR || 'http://127.0.0.1:18200';
|
|
env.VAULT_ADDR = env.VAULT_ADDR || env.BAO_ADDR;
|
|
child = spawn('warden', [
|
|
'access', 'openbao-platform-admin-login', '--exec', '--', 'python3', gate, '--mint-workload',
|
|
], {stdio: ['pipe', 'inherit', 'inherit'], env});
|
|
let sent = false;
|
|
const started = Date.now();
|
|
const timer = setInterval(async () => {
|
|
if (sent) return;
|
|
if (Date.now() - started > READY_TIMEOUT_MS) {
|
|
sent = true;
|
|
clearInterval(timer);
|
|
child?.stdin.end('');
|
|
return;
|
|
}
|
|
if (!existsSync(ready)) return;
|
|
sent = true;
|
|
clearInterval(timer);
|
|
try {
|
|
child?.stdin.end(JSON.stringify(await sessionAuth(page)));
|
|
} catch {
|
|
child?.stdin.end('');
|
|
}
|
|
}, 1000);
|
|
child.on('error', () => {
|
|
clearInterval(timer);
|
|
reject(new Error('warden_child_failed'));
|
|
});
|
|
child.on('close', (code) => {
|
|
clearInterval(timer);
|
|
child = null;
|
|
resolveResult({exit_code: code, output: `warden exit ${code}; receipts under ~/.local/state/railiance/receipts\n`});
|
|
});
|
|
});
|
|
}
|
|
|
|
try {
|
|
const modulePath = resolve(values['playwright-module'] || '');
|
|
const pkg = JSON.parse(readFileSync(join(dirname(modulePath), 'package.json'), 'utf8'));
|
|
if (pkg.name !== 'playwright' || pkg.version !== '1.62.1') throw new Error('locked_playwright_required');
|
|
const imported = await import(pathToFileURL(modulePath).href);
|
|
const playwright = imported.chromium ? imported : imported.default;
|
|
if (!playwright?.chromium?.launch) throw new Error('playwright_export_unusable');
|
|
const {chromium} = playwright;
|
|
browser = await chromium.launch({headless: false});
|
|
const context = await browser.newContext();
|
|
const page = await context.newPage();
|
|
await page.goto('https://console.scaleway.com/', {waitUntil: 'domcontentloaded', timeout: 60000});
|
|
const reader = createInterface({input: process.stdin, output: process.stdout});
|
|
console.log('Console opened. Sign in, then press Enter. The session token stays in this process.');
|
|
await reader.question('');
|
|
reader.close();
|
|
const outcome = values['mint-workload'] ? await runMint(page) : await runGrant(await sessionAuth(page));
|
|
process.stdout.write(outcome.output.endsWith('\n') ? outcome.output : `${outcome.output}\n`);
|
|
process.exitCode = outcome.exit_code === 0 ? 0 : 1;
|
|
try {
|
|
await page.goto('https://account.scaleway.com/logout', {waitUntil: 'domcontentloaded', timeout: 30000});
|
|
} catch {}
|
|
await context.clearCookies();
|
|
} catch (error) {
|
|
const code = typeof error?.message === 'string' && /^[a-z0-9_]+$/.test(error.message)
|
|
? error.message
|
|
: (error?.name || 'Error');
|
|
console.error(`IAM grant session failed: ${code}; no credential values printed.`);
|
|
process.exitCode = 1;
|
|
} finally {
|
|
if (child) child.kill('SIGTERM');
|
|
if (browser) await browser.close();
|
|
}
|