@mesh-tech/mesh-cli
Version:
CLI for Mesh platform development utilities
355 lines (354 loc) • 14.2 kB
JavaScript
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;
}