railiance-platform/scripts/mas_evidence_iam_session.mjs
codex fbc4830aa0
All checks were successful
CCR intake gate / gate (push) Successful in 2m14s
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Mint a separate MAS workload key in the attended gate instead of copying the supplier key.
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
2026-11-02 23:41:20 +01:00

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();
}