UNPKG

@mesh-tech/mesh-cli

Version:

CLI for Mesh platform development utilities

355 lines (354 loc) 14.2 kB
import * as fs from "fs"; import * as path from "path"; import { AssumeRoleCommand, AssumeRoleWithWebIdentityCommand, GetCallerIdentityCommand, STSClient, } from "@aws-sdk/client-sts"; import { logInfo, logSuccess, logWarn } from "./log.js"; import { isExpiredAwsTokenMessage, SSO_LOGIN_FIX } from "./auth-preflight.js"; import { ensureLogin } from "../commands/login.js"; const DEFAULT_REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-2"; export function derivePlatformContext(appRoot, stack) { const configFile = path.join(appRoot, `Pulumi.${stack}.yaml`); if (!fs.existsSync(configFile)) return null; const content = fs.readFileSync(configFile, "utf-8"); const jsonMatch = content.match(/mesh:platform:\s*'(\{[^']+\})'/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[1]); if (parsed.name && parsed.env) return `${parsed.name}.${parsed.env}`; } catch { } } const nameMatch = content.match(/mesh:platform:[\s\S]*?name:\s*(\S+)/); const envMatch = content.match(/mesh:platform:[\s\S]*?env:\s*(\S+)/); if (nameMatch && envMatch) { return `${nameMatch[1]}.${envMatch[1]}`; } return null; } function quoteCredProcessToken(token) { return /\s/.test(token) ? `"${token}"` : token; } export function resolveStableMeshBin(argv1, deps = {}) { const exists = deps.exists ?? fs.existsSync; const warn = deps.warn ?? logWarn; let resolved = argv1 ? path.resolve(argv1) : undefined; if (resolved && /\.ts$/.test(resolved)) { resolved = path.join(path.dirname(resolved), path.basename(resolved, ".ts") + ".mjs"); } const transient = resolved !== undefined && (/[\\/]_npx[\\/]/.test(resolved) || /^\/(?:private\/)?(?:tmp|var\/folders)\//.test(resolved)); if (!resolved || !exists(resolved) || transient) { warn(`credential_process cannot reference a stable mesh binary` + (resolved ? ` (${resolved} is transient or missing)` : "") + ` — falling back to \`mesh\` on PATH. Install mesh-cli globally so the profile keeps refreshing.`); return "mesh"; } return resolved; } export function renderCredentialProcessProfile(args) { const lines = [ `[profile ${args.profileName}]`, `credential_process = ${quoteCredProcessToken(args.meshBin)} login ${quoteCredProcessToken(args.context)} --credential-process --role ${quoteCredProcessToken(args.roleArn)}`, ]; if (args.region) lines.push(`region = ${args.region}`); return lines.join("\n") + "\n"; } const MANAGED_START_PREFIX = `# >>> mesh-cli managed profile `; const MANAGED_START = (context) => `${MANAGED_START_PREFIX}${context} >>>`; const MANAGED_END = `# <<< mesh-cli managed <<<`; function orphanedSectionTail(existing, afterStart, boundIdx) { const limit = boundIdx === -1 ? existing.length : boundIdx; let idx = afterStart; if (idx >= limit || existing[idx] !== "\n") return Math.min(idx, limit); idx++; let claimedHeader = false; while (idx < limit) { const nl = existing.indexOf("\n", idx); const lineEnd = nl === -1 || nl > limit ? limit : nl; const line = existing.slice(idx, lineEnd); const ours = claimedHeader ? /^[A-Za-z_][A-Za-z0-9_]*\s*=/.test(line) : /^\[profile [^\]]+\]\s*$/.test(line); if (!ours) return idx; claimedHeader = true; idx = lineEnd >= limit ? limit : lineEnd + 1; } return limit; } export function upsertManagedAwsConfigSection(existing, context, profileBlock) { const body = profileBlock.endsWith("\n") ? profileBlock : profileBlock + "\n"; const section = `${MANAGED_START(context)}\n${body}${MANAGED_END}\n`; const startIdx = existing.indexOf(MANAGED_START(context)); if (startIdx !== -1) { const afterStart = startIdx + MANAGED_START(context).length; const endIdx = existing.indexOf(MANAGED_END, afterStart); const foreignIdx = existing.indexOf(MANAGED_START_PREFIX, afterStart); let tail; if (endIdx !== -1 && (foreignIdx === -1 || endIdx < foreignIdx)) { const tailIdx = endIdx + MANAGED_END.length; tail = existing[tailIdx] === "\n" ? tailIdx + 1 : tailIdx; } else { tail = orphanedSectionTail(existing, afterStart, foreignIdx); } return existing.slice(0, startIdx) + section + existing.slice(tail); } if (existing.length === 0) return section; const sep = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n"; return existing + sep + section; } export function stripBareProfile(existing, profileName) { const header = `[profile ${profileName}]`; const lines = existing.split("\n"); const out = []; let managed = false; let dropping = false; for (const line of lines) { if (line.startsWith(MANAGED_START_PREFIX)) managed = true; if (managed) { out.push(line); if (line === MANAGED_END) managed = false; continue; } if (line.trim() === header) { dropping = true; continue; } if (dropping && line.startsWith("[")) dropping = false; if (!dropping) out.push(line); } return out.join("\n"); } function newStsClient() { return new STSClient({ region: DEFAULT_REGION }); } function toEnv(credentials) { const { AccessKeyId, SecretAccessKey, SessionToken } = credentials; if (!AccessKeyId || !SecretAccessKey || !SessionToken) return null; return { AWS_ACCESS_KEY_ID: AccessKeyId, AWS_SECRET_ACCESS_KEY: SecretAccessKey, AWS_SESSION_TOKEN: SessionToken, }; } function isMaxSessionDurationError(err) { if (!(err instanceof Error)) return false; const message = err.message ?? ""; return (message.includes("MaxSessionDuration") || message.includes("DurationSeconds")); } export async function assumeRole(roleArn, sessionName = "mesh-cli", durationSeconds = 43200) { const sts = newStsClient(); for (const duration of [durationSeconds, 3600]) { try { const resp = await sts.send(new AssumeRoleCommand({ RoleArn: roleArn, RoleSessionName: sessionName, DurationSeconds: duration, })); if (!resp.Credentials) return null; return toEnv(resp.Credentials); } catch (err) { if (!isMaxSessionDurationError(err) || duration === 3600) { const message = err instanceof Error ? err.message : String(err); logWarn(`AssumeRole failed: ${message}`); if (isExpiredAwsTokenMessage(message)) { logWarn(` Your AWS SSO session looks expired or missing — refresh with: ${SSO_LOGIN_FIX}`); } return null; } } } return null; } export async function assumeRoleCredentials(roleArn, idToken, sessionName, durationSeconds = 43200) { const sts = newStsClient(); for (const duration of [durationSeconds, 3600]) { try { const resp = await sts.send(new AssumeRoleWithWebIdentityCommand({ RoleArn: roleArn, RoleSessionName: sessionName, WebIdentityToken: idToken, DurationSeconds: duration, })); if (!resp.Credentials) return null; const env = toEnv(resp.Credentials); if (!env) return null; return { AccessKeyId: env.AWS_ACCESS_KEY_ID, SecretAccessKey: env.AWS_SECRET_ACCESS_KEY, SessionToken: env.AWS_SESSION_TOKEN, Expiration: resp.Credentials.Expiration.toISOString(), }; } catch (err) { if (!isMaxSessionDurationError(err) || duration === 3600) { logWarn(`AssumeRoleWithWebIdentity failed: ${err instanceof Error ? err.message : String(err)}`); return null; } } } return null; } export async function assumeRoleWithWebIdentity(roleArn, idToken, sessionName, durationSeconds = 43200) { const creds = await assumeRoleCredentials(roleArn, idToken, sessionName, durationSeconds); if (!creds) return null; return toEnv(creds); } export function tokenIssuer(token) { const iss = decodeJwtPayload(token)?.iss; return typeof iss === "string" && iss ? iss : undefined; } export function describeAssumeFailure(opts, format = "block") { const account = /^arn:aws:iam::(\d+):/.exec(opts.roleArn)?.[1]; const head = `AssumeRoleWithWebIdentity failed for ${opts.roleArn}` + (account ? ` (account ${account})` : ""); const session = opts.context ? `mesh login context '${opts.context}'` + (opts.issuer ? ` (issuer ${opts.issuer})` : "") : opts.issuer ? `issuer ${opts.issuer}` : undefined; if (format === "inline") { return (head + (session ? ` via ${session}` : "") + " — if the role's account doesn't trust this issuer, this is a context mismatch" + " (wrong `mesh:platform`), not an IAM permission problem"); } const lines = [head]; if (session) lines.push(` Session: ${session}`); lines.push(" If the role's account does not trust this issuer, you are logged into the", " wrong context for this stack — check `mesh:platform` in the app's Pulumi", " config and `mesh login <platform>.<env>` accordingly. (This is a context", " mismatch, not an IAM permission problem.)"); return lines.join("\n"); } export function toCredentialProcessJson(creds) { return JSON.stringify({ Version: 1, AccessKeyId: creds.AccessKeyId, SecretAccessKey: creds.SecretAccessKey, SessionToken: creds.SessionToken, Expiration: creds.Expiration, }); } export async function probeAwsIdentity() { try { const out = await newStsClient().send(new GetCallerIdentityCommand({})); if (!out.Arn || !out.Account) return null; return { arn: out.Arn, accountId: out.Account, source: process.env.AWS_ACCESS_KEY_ID ? "environment" : "profile", }; } catch { return null; } } function decodeJwtPayload(token) { const parts = token.split("."); if (parts.length !== 3 || !parts[1]) return null; try { return JSON.parse(Buffer.from(parts[1], "base64url").toString()); } catch { return null; } } export function selectRoleForCaller(idToken, options) { if (!options.adminRole || !idToken) return options.defaultRole; const adminRoles = options.adminClaimRoles ?? ["mesh.platform:admin"]; const claims = decodeJwtPayload(idToken); if (!claims) return options.defaultRole; const claimedRoles = projectRoleKeys(claims); return adminRoles.some((target) => claimedRoles.includes(target)) ? options.adminRole : options.defaultRole; } export function projectRoleKeys(claims) { const names = new Set(); for (const [key, value] of Object.entries(claims)) { if (!key.startsWith("urn:zitadel:iam:org:project:")) continue; if (!key.endsWith(":roles")) continue; if (!value || typeof value !== "object" || Array.isArray(value)) continue; for (const name of Object.keys(value)) { if (name) names.add(name); } } return [...names]; } export async function resolveAwsCredentials(roleArn, appRoot, stack) { const roleName = roleArn.split("/").pop() ?? roleArn; if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SESSION_TOKEN) { logInfo("Using existing AWS credentials from environment"); return { env: { AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY ?? "", AWS_SESSION_TOKEN: process.env.AWS_SESSION_TOKEN, }, method: "environment", }; } const platformContext = derivePlatformContext(appRoot, stack); if (platformContext) { const meshCreds = await ensureLogin(platformContext, { interactive: false }); if (meshCreds) { const sessionName = (meshCreds.email ?? "mesh-cli") .replace(/[^a-zA-Z0-9=,.@-]/g, "_") .slice(0, 64); logInfo(`Assuming ${roleName} via Zitadel (${meshCreds.email ?? platformContext})`); const creds = await assumeRoleWithWebIdentity(roleArn, meshCreds.idToken, sessionName); if (creds) { logSuccess(`Assumed ${roleName} via Zitadel`); return { env: creds, method: "zitadel" }; } logWarn("Zitadel JWT auth failed — falling back to AWS SSO.\n" + ` (Try: mesh login ${platformContext})`); } } logInfo(`Assuming ${roleName} via AWS SSO`); const creds = await assumeRole(roleArn, "mesh-cli"); if (creds) { logSuccess(`Assumed ${roleName}`); return { env: creds, method: "sso" }; } if (platformContext && process.stdout.isTTY) { const meshCreds = await ensureLogin(platformContext); if (meshCreds) { const sessionName = (meshCreds.email ?? "mesh-cli") .replace(/[^a-zA-Z0-9=,.@-]/g, "_") .slice(0, 64); logInfo(`Assuming ${roleName} via Zitadel (${meshCreds.email ?? platformContext})`); const zCreds = await assumeRoleWithWebIdentity(roleArn, meshCreds.idToken, sessionName); if (zCreds) { logSuccess(`Assumed ${roleName} via Zitadel`); return { env: zCreds, method: "zitadel" }; } } } return null; }