diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index d716821d7..18193617d 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -930,6 +930,25 @@ hyperframes lambda progress hf-render-abcd1234 Calls `sam delete --no-prompts` and drops the local state file. The render S3 bucket is configured with CloudFormation `Retain` so it survives destruction — empty and delete it via the AWS console / CLI if you want the storage back. +#### `lambda policies role | user | validate` + +Print or validate the minimum IAM policy the CLI needs to deploy / invoke / destroy the stack. + +```bash +# Print an inline-policy doc you can attach to an IAM user that runs the CLI. +hyperframes lambda policies user + +# Print { TrustRelationship, InlinePolicy } for an IAM role (default: cloudformation principal). +hyperframes lambda policies role --principal=cloudformation + +# Validate a checked-in policy still covers the CLI's needs. +hyperframes lambda policies validate ./infra/iam/hyperframes-deploy.json +``` + +`validate` reads the JSON doc and checks the union of its `Effect: Allow` actions against the CLI's required action set, expanding `s3:*` / `s3:Get*` / `*` wildcards. Missing actions print to stderr and the command exits non-zero — wire it into CI to catch drift before the next deploy fails. + +The actions list is deliberately broad (`Resource: "*"`) because CloudFormation creates new function / state-machine / bucket ARNs on every adopter's first deploy. Adopters with stricter security postures should narrow `Resource` to the deployed ARNs after the first successful run. + ### State files `hyperframes lambda` keeps per-stack metadata under `/.hyperframes/lambda-stack-.json` so the verbs don't need to call `describe-stacks` every time. Commit the file to a repo or `.gitignore` it depending on your workflow — it contains the bucket name, state-machine ARN, and region, none of which are secrets but all of which are AWS-account-identifying. diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index a31f4261b..c8c9fbff9 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -28,6 +28,11 @@ export const examples: Example[] = [ "hyperframes lambda sites create ./my-project", ], ["Tear the stack down", "hyperframes lambda destroy"], + ["Print the IAM policy the CLI needs", "hyperframes lambda policies user"], + [ + "Validate a checked-in IAM policy still covers the CLI", + "hyperframes lambda policies validate ./infra/iam/hyperframes.json", + ], ]; const HELP = ` @@ -41,6 +46,7 @@ ${c.bold("SUBCOMMANDS:")} ${c.accent("render")} ${c.dim("Start a distributed render (returns a renderId)")} ${c.accent("progress")} ${c.dim("Print progress + cost for an in-flight or finished render")} ${c.accent("destroy")} ${c.dim("Tear the stack down (S3 bucket is retained)")} + ${c.accent("policies")} ${c.dim("Print or validate the IAM permissions the CLI needs")} ${c.bold("FIRST RUN:")} ${c.accent("hyperframes lambda deploy")} @@ -58,17 +64,18 @@ export default defineCommand({ subcommand: { type: "positional", required: false, - description: "deploy | sites | render | progress | destroy", + description: "deploy | sites | render | progress | destroy | policies", }, target: { type: "positional", required: false, - description: "Subcommand-specific positional (project dir, render id, etc.)", + description: "Subcommand-specific positional (project dir, render id, policies verb, etc.)", }, extra: { type: "positional", required: false, - description: "Extra positional (e.g. `sites create `)", + description: + "Extra positional (e.g. `sites create ` or `policies validate `)", }, // Stack identity @@ -257,6 +264,22 @@ export default defineCommand({ await runDestroy({ stackName, awsProfile: args.profile as string | undefined }); return; } + case "policies": { + const verb = args.target as string | undefined; + if (verb !== "role" && verb !== "user" && verb !== "validate") { + console.error( + `[lambda policies] usage: hyperframes lambda policies [args]`, + ); + process.exit(1); + } + const { runPolicies } = await import("./lambda/policies.js"); + await runPolicies({ + verb, + inputPath: args.extra as string | undefined, + json: Boolean(args.json), + }); + return; + } default: console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`); process.exit(1); diff --git a/packages/cli/src/commands/lambda/policies.test.ts b/packages/cli/src/commands/lambda/policies.test.ts new file mode 100644 index 000000000..af99dd6a9 --- /dev/null +++ b/packages/cli/src/commands/lambda/policies.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + allRequiredActions, + buildPolicyDocument, + buildRoleTrustPolicy, + validatePolicy, +} from "./policies.js"; + +let workdir: string; + +beforeEach(() => { + workdir = mkdtempSync(join(tmpdir(), "hf-lambda-policies-test-")); +}); + +afterEach(() => { + rmSync(workdir, { recursive: true, force: true }); +}); + +describe("policies — required actions", () => { + it("flattens, dedupes, and sorts required actions", () => { + const actions = allRequiredActions(); + // Sorted alphabetically. + expect([...actions].sort()).toEqual(actions); + // No dupes. + expect(new Set(actions).size).toBe(actions.length); + // Covers the obvious touchpoints. + for (const must of [ + "cloudformation:CreateStack", + "lambda:CreateFunction", + "states:StartExecution", + "s3:PutObject", + "iam:CreateRole", + "logs:CreateLogGroup", + "cloudwatch:PutMetricAlarm", + ]) { + expect(actions).toContain(must); + } + }); +}); + +describe("policies — buildPolicyDocument", () => { + it("emits a single Allow statement over all required actions", () => { + const doc = buildPolicyDocument(); + expect(doc.Version).toBe("2012-10-17"); + expect(doc.Statement).toHaveLength(1); + const stmt = doc.Statement[0]!; + expect(stmt.Effect).toBe("Allow"); + expect(stmt.Resource).toBe("*"); + expect(stmt.Action).toEqual(allRequiredActions()); + }); +}); + +describe("policies — buildRoleTrustPolicy", () => { + it("returns a sts:AssumeRole statement for the CloudFormation service principal", () => { + const trust = buildRoleTrustPolicy(); + expect(trust.Statement[0]!.Action).toBe("sts:AssumeRole"); + expect(trust.Statement[0]!.Principal.Service).toBe("cloudformation.amazonaws.com"); + }); +}); + +describe("policies — validatePolicy", () => { + it("returns missing=[] for a policy with the full required set", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: allRequiredActions(), + Resource: "*", + }, + ], + }); + const result = validatePolicy(path); + expect(result.missing).toEqual([]); + expect(result.granted).toEqual(allRequiredActions()); + }); + + it("reports specific missing actions", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:GetObject", "states:StartExecution"], + Resource: "*", + }, + ], + }); + const result = validatePolicy(path); + expect(result.missing).toContain("cloudformation:CreateStack"); + expect(result.missing).toContain("lambda:CreateFunction"); + expect(result.granted).toContain("s3:GetObject"); + expect(result.granted).toContain("states:StartExecution"); + }); + + it("expands service wildcards (s3:*)", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:*"], + Resource: "*", + }, + ], + }); + const result = validatePolicy(path); + // Every s3:* action in the required set is satisfied. + for (const action of result.required.filter((a) => a.startsWith("s3:"))) { + expect(result.granted).toContain(action); + } + // But lambda:* etc. are still missing. + expect(result.missing).toContain("lambda:CreateFunction"); + }); + + it("expands prefix wildcards (s3:Get*)", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:Get*"], + Resource: "*", + }, + ], + }); + const result = validatePolicy(path); + expect(result.granted).toContain("s3:GetObject"); + expect(result.granted).toContain("s3:GetBucketLocation"); + expect(result.missing).toContain("s3:PutObject"); + }); + + it("expands the bare * wildcard", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [{ Effect: "Allow", Action: ["*"], Resource: "*" }], + }); + const result = validatePolicy(path); + expect(result.missing).toEqual([]); + }); + + it("accepts a single Statement object (not just an array)", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: { Effect: "Allow", Action: ["*"], Resource: "*" }, + } as unknown as Parameters[0]); + const result = validatePolicy(path); + expect(result.missing).toEqual([]); + }); + + it("ignores Deny statements", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [ + { Effect: "Allow", Action: ["*"], Resource: "*" }, + { Effect: "Deny", Action: ["s3:DeleteBucket"], Resource: "*" }, + ], + }); + const result = validatePolicy(path); + // The Deny doesn't affect our static "granted" set — that's intentional. + // IAM policy evaluation order is out of scope; we only confirm the + // Allow set covers required actions. + expect(result.missing).toEqual([]); + }); + + it("warns on NotAction shapes instead of producing a false negative", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [{ Effect: "Allow", NotAction: ["iam:DeleteUser"], Resource: "*" }], + } as unknown as Parameters[0]); + const result = validatePolicy(path); + expect(result.warnings.some((w) => /NotAction/.test(w))).toBe(true); + // Without the warning, the validator would silently report everything + // as missing. With it, the validator skips the statement and reports + // honestly. + expect(result.missing.length).toBeGreaterThan(0); + }); + + it("warns on NotResource shapes but still grants the listed actions", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [ + { Effect: "Allow", Action: ["*"], NotResource: ["arn:aws:s3:::secret-bucket/*"] }, + ], + } as unknown as Parameters[0]); + const result = validatePolicy(path); + expect(result.warnings.some((w) => /NotResource/.test(w))).toBe(true); + expect(result.missing).toEqual([]); + }); + + it("warns on mid-string wildcard patterns (`s3:Get*Object`)", () => { + const path = writePolicy({ + Version: "2012-10-17", + Statement: [{ Effect: "Allow", Action: ["s3:Get*Object"], Resource: "*" }], + }); + const result = validatePolicy(path); + expect(result.warnings.some((w) => /mid-string wildcard/.test(w))).toBe(true); + }); + + it("throws ENOENT for a missing file (caller decides UX)", () => { + expect(() => validatePolicy(join(workdir, "does-not-exist.json"))).toThrow(); + }); + + it("throws SyntaxError for malformed JSON (caller decides UX)", () => { + const path = join(workdir, "bad.json"); + writeFileSync(path, "{ not json"); + expect(() => validatePolicy(path)).toThrow(); + }); + + it("treats an absent Statement field as zero grants", () => { + const path = writePolicy({ Version: "2012-10-17" } as unknown as Parameters< + typeof writePolicy + >[0]); + const result = validatePolicy(path); + expect(result.granted).toEqual([]); + expect(result.missing).toEqual(allRequiredActions()); + }); +}); + +function writePolicy(doc: { Version: string; Statement: unknown }): string { + const path = join(workdir, "policy.json"); + writeFileSync(path, JSON.stringify(doc)); + return path; +} diff --git a/packages/cli/src/commands/lambda/policies.ts b/packages/cli/src/commands/lambda/policies.ts new file mode 100644 index 000000000..409873fd0 --- /dev/null +++ b/packages/cli/src/commands/lambda/policies.ts @@ -0,0 +1,402 @@ +/** + * `hyperframes lambda policies role|user|validate` — IAM bootstrap. + * + * Emit the minimum permissions an adopter needs to deploy, invoke, and + * tear down the Lambda render stack. Without this, the typical first + * attempt at `hyperframes lambda deploy` is `User is not authorized to + * perform iam:CreateRole on resource ...` and a 30-minute detour to + * write the policy by hand. + * + * The action lists are derived from what {@link examples/aws-lambda/template.yaml} + * needs to create, plus what `renderToLambda`/`getRenderProgress` + * call against S3 + Step Functions at runtime. The lists are + * deliberately union'd rather than scoped per-verb — the CLI today + * runs every verb against the same credential, so anything narrower + * just makes adopters debug "missing permission" errors per verb. + * + * `validate` reads an existing IAM policy doc and diffs it against the + * required action set, printing what's missing. Useful in CI: emit + * the doc with `policies user`, drift over time, then prove the + * checked-in policy still covers the CLI's needs with `policies validate`. + */ + +import { readFileSync } from "node:fs"; +import { c } from "../../ui/colors.js"; + +export type PoliciesVerb = "role" | "user" | "validate"; + +interface PolicyStatement { + Effect: "Allow"; + Action: string[]; + Resource: string | string[]; +} + +interface PolicyDocument { + Version: "2012-10-17"; + Statement: PolicyStatement[]; +} + +/** + * Trust-policy shape consumed by `policies role`. Has a `Principal` + * field (which generic `PolicyStatement` does not model) — keep it as + * a separate type rather than polluting the action-policy shape. + */ +interface TrustPolicyStatement { + Effect: "Allow"; + Principal: { Service: string }; + Action: "sts:AssumeRole"; +} + +interface TrustPolicyDocument { + Version: "2012-10-17"; + Statement: TrustPolicyStatement[]; +} + +/** + * Actions the CLI needs to deploy/invoke/destroy the stack. Keep this + * sorted alphabetically inside each service so diffs stay readable. + */ +export const REQUIRED_ACTIONS = { + cloudformation: [ + "cloudformation:CreateChangeSet", + "cloudformation:CreateStack", + "cloudformation:DeleteChangeSet", + "cloudformation:DeleteStack", + "cloudformation:DescribeChangeSet", + "cloudformation:DescribeStackEvents", + "cloudformation:DescribeStackResource", + "cloudformation:DescribeStackResources", + "cloudformation:DescribeStacks", + "cloudformation:ExecuteChangeSet", + "cloudformation:GetTemplate", + "cloudformation:GetTemplateSummary", + "cloudformation:ListStacks", + "cloudformation:UpdateStack", + "cloudformation:ValidateTemplate", + ], + cloudwatchAlarms: [ + "cloudwatch:DeleteAlarms", + "cloudwatch:DescribeAlarms", + "cloudwatch:PutMetricAlarm", + ], + iam: [ + "iam:AttachRolePolicy", + "iam:CreateRole", + "iam:DeleteRole", + "iam:DeleteRolePolicy", + "iam:DetachRolePolicy", + "iam:GetRole", + "iam:GetRolePolicy", + "iam:PassRole", + "iam:PutRolePolicy", + "iam:TagRole", + "iam:UntagRole", + ], + lambda: [ + "lambda:AddPermission", + "lambda:CreateFunction", + "lambda:DeleteFunction", + "lambda:GetFunction", + "lambda:GetFunctionConfiguration", + "lambda:InvokeFunction", + "lambda:ListFunctions", + "lambda:PutFunctionConcurrency", + "lambda:RemovePermission", + "lambda:TagResource", + "lambda:UntagResource", + "lambda:UpdateFunctionCode", + "lambda:UpdateFunctionConfiguration", + ], + logs: [ + "logs:CreateLogGroup", + "logs:DeleteLogGroup", + "logs:DescribeLogGroups", + "logs:PutRetentionPolicy", + "logs:TagResource", + ], + s3Bucket: [ + "s3:CreateBucket", + "s3:DeleteBucket", + "s3:DeleteBucketPolicy", + "s3:GetBucketLocation", + "s3:GetBucketPolicy", + "s3:GetBucketTagging", + "s3:GetBucketVersioning", + "s3:GetLifecycleConfiguration", + "s3:ListAllMyBuckets", + "s3:ListBucket", + "s3:PutBucketPolicy", + "s3:PutBucketTagging", + "s3:PutBucketVersioning", + "s3:PutLifecycleConfiguration", + "s3:PutPublicAccessBlock", + ], + s3Object: ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], + states: [ + "states:CreateStateMachine", + "states:DeleteStateMachine", + "states:DescribeExecution", + "states:DescribeStateMachine", + "states:GetExecutionHistory", + "states:ListExecutions", + "states:ListStateMachines", + "states:StartExecution", + "states:StopExecution", + "states:TagResource", + "states:UntagResource", + "states:UpdateStateMachine", + ], +}; + +/** All required actions flattened, deduped, sorted. */ +export function allRequiredActions(): string[] { + const set = new Set(); + for (const group of Object.values(REQUIRED_ACTIONS)) { + for (const action of group) set.add(action); + } + return [...set].sort(); +} + +/** + * Emit a single, broad `Allow *` policy doc. Resource is `*` because the + * CloudFormation stack creates a new function/state-machine/bucket on + * every adopter's account; scoping by name requires the adopter to + * have already deployed, which is exactly what they're trying to do. + * + * Adopters with stricter security postures should narrow the Resource + * scope after the first successful deploy — the SAM template + CDK + * construct both produce predictable ARN patterns. + */ +export function buildPolicyDocument(): PolicyDocument { + return { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: allRequiredActions(), + Resource: "*", + }, + ], + }; +} + +/** + * Trust policy for a CloudFormation service role (used by `policies role`). + * Lambda execution roles are out of scope here: the SAM template creates + * its own scoped execution role, and emitting a `lambda.amazonaws.com` + * trust paired with the full deploy-superset inline policy below would + * be a confusingly-overscoped runtime role no human should attach. + */ +export function buildRoleTrustPolicy(): TrustPolicyDocument { + return { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service: "cloudformation.amazonaws.com" }, + Action: "sts:AssumeRole", + }, + ], + }; +} + +export interface PoliciesArgs { + verb: PoliciesVerb; + /** For `validate`: path to an IAM policy JSON file. */ + inputPath?: string; + /** Print JSON only. Default true for `role`/`user` (output is JSON by definition); ignored for `validate`. */ + json: boolean; +} + +export async function runPolicies(args: PoliciesArgs): Promise { + switch (args.verb) { + case "user": { + const doc = buildPolicyDocument(); + console.log(JSON.stringify(doc, null, 2)); + if (!args.json) { + console.error( + c.dim( + "\n# Attach the above as an inline policy to the IAM user/role that runs `hyperframes lambda *`.\n# Scope `Resource` to your stack's ARNs after the first successful deploy.", + ), + ); + } + return; + } + case "role": { + const trust = buildRoleTrustPolicy(); + const inline = buildPolicyDocument(); + const wrapped = { + TrustRelationship: trust, + InlinePolicy: inline, + }; + console.log(JSON.stringify(wrapped, null, 2)); + return; + } + case "validate": { + if (!args.inputPath) { + const msg = + "[lambda policies validate] usage: hyperframes lambda policies validate "; + if (args.json) { + console.log(JSON.stringify({ ok: false, error: msg }, null, 2)); + process.exitCode = 1; + return; + } + throw new Error(msg); + } + let result: ValidateResult; + try { + result = validatePolicy(args.inputPath); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (args.json) { + console.log(JSON.stringify({ ok: false, error: msg }, null, 2)); + process.exitCode = 1; + return; + } + console.error(c.error(`Failed to validate ${args.inputPath}: ${msg}`)); + process.exitCode = 1; + return; + } + if (args.json) { + console.log(JSON.stringify({ ok: result.missing.length === 0, ...result }, null, 2)); + if (result.missing.length > 0) process.exitCode = 1; + return; + } + for (const warning of result.warnings) { + console.warn(c.dim(`Warning: ${warning}`)); + } + if (result.missing.length === 0) { + console.log(c.success(`Policy covers all ${result.required.length} required actions.`)); + return; + } + console.log(c.error(`Policy is missing ${result.missing.length} required action(s):`)); + for (const action of result.missing) { + console.log(` • ${action}`); + } + console.log(); + console.log( + c.dim("Run `hyperframes lambda policies user` to print the full required policy."), + ); + process.exitCode = 1; + return; + } + } +} + +export interface ValidateResult { + required: string[]; + granted: string[]; + missing: string[]; + /** Non-fatal warnings about policy shapes we couldn't fully evaluate. */ + warnings: string[]; +} + +/** + * Parse an IAM policy doc + flatten its Allow statements into a set of + * "granted" actions. Returns the difference vs {@link allRequiredActions}. + * + * Supports the common shapes: `Action` as a string or array; `Statement` + * as a single object or an array; wildcards (`s3:*`, `s3:Get*`, `*`) + * expand to match anything in the required list. + * + * Limitations surfaced as `warnings`: + * - `NotAction` / `NotResource` shapes — IAM grants the complement of + * the listed actions, but a sound check would need to model the + * full IAM action namespace. We flag the statement instead of + * producing a false negative. + * - Mid-string wildcards (`s3:Get*Object`, `?`) — supported by IAM, + * not by our matcher. We end-anchor only. + */ +export function validatePolicy(policyPath: string): ValidateResult { + const raw = readFileSync(policyPath, "utf-8"); + const parsed = JSON.parse(raw) as { Statement?: unknown }; + const statements: Array<{ + Effect?: string; + Action?: unknown; + NotAction?: unknown; + NotResource?: unknown; + }> = Array.isArray(parsed.Statement) + ? (parsed.Statement as { + Effect?: string; + Action?: unknown; + NotAction?: unknown; + NotResource?: unknown; + }[]) + : parsed.Statement + ? [ + parsed.Statement as { + Effect?: string; + Action?: unknown; + NotAction?: unknown; + NotResource?: unknown; + }, + ] + : []; + + const grantedPatterns: string[] = []; + const warnings: string[] = []; + for (const stmt of statements) { + if (stmt.Effect !== "Allow") continue; + if (stmt.NotAction !== undefined) { + warnings.push( + "Allow statement uses NotAction; the validator only checks positive Action grants, so this statement is being ignored. Convert to an explicit Action list to validate it.", + ); + continue; + } + if (stmt.NotResource !== undefined) { + warnings.push( + "Allow statement uses NotResource; resource-scoping is not modelled by this validator. Treating the statement as fully granted on its Action set.", + ); + } + const actions = stmt.Action; + if (typeof actions === "string") { + grantedPatterns.push(actions); + } else if (Array.isArray(actions)) { + for (const a of actions) if (typeof a === "string") grantedPatterns.push(a); + } + } + + for (const pattern of grantedPatterns) { + if (hasMidStringWildcard(pattern)) { + warnings.push( + `Action pattern ${JSON.stringify(pattern)} contains a mid-string wildcard the validator can't expand; only end-anchored wildcards (\`*\`, \`service:*\`, \`prefix*\`) are honoured.`, + ); + } + } + + const required = allRequiredActions(); + const granted: string[] = []; + const missing: string[] = []; + for (const action of required) { + if (grantedPatterns.some((pattern) => actionMatches(pattern, action))) { + granted.push(action); + } else { + missing.push(action); + } + } + return { required, granted, missing, warnings }; +} + +function hasMidStringWildcard(pattern: string): boolean { + // Wildcards we DO support: bare `*`, `service:*`, `prefix*` (single + // trailing `*`). Anything else (mid-string `*` or `?`) is mid-string. + if (pattern === "*") return false; + if (pattern.endsWith(":*")) return false; + if (pattern.endsWith("*") && !pattern.slice(0, -1).includes("*")) return false; + return pattern.includes("*") || pattern.includes("?"); +} + +function actionMatches(pattern: string, action: string): boolean { + if (pattern === "*") return true; + if (pattern === action) return true; + // `s3:*` matches `s3:GetObject` etc. + if (pattern.endsWith(":*")) { + const service = pattern.slice(0, -2); + return action.startsWith(`${service}:`); + } + // Single trailing `*` wildcard ("s3:Get*"). + if (pattern.endsWith("*")) { + return action.startsWith(pattern.slice(0, -1)); + } + return false; +}