From a468ae3efd1fb04cc7bbd7d4f59893a8de03f19b Mon Sep 17 00:00:00 2001 From: James Date: Sat, 16 May 2026 23:55:44 +0000 Subject: [PATCH 1/3] feat(cli): add hyperframes lambda policies role/user/validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IAM bootstrap subcommand for the lambda CLI. Closes the "first run hits 'User is not authorized to perform iam:CreateRole'" gap that adopters otherwise have to figure out by hand. hyperframes lambda policies user → prints an inline-policy doc to attach to the IAM user that runs the CLI hyperframes lambda policies role --principal=cloudformation → prints { TrustRelationship, InlinePolicy } for a service role cloudformation can assume hyperframes lambda policies validate ./infra/policy.json → diffs a checked-in policy against the CLI's required action set, expanding s3:* / s3:Get* / * wildcards, exits non-zero on missing actions (wire it into CI to catch drift before deploys fail) The required-actions list is derived from what the SAM template at examples/aws-lambda/template.yaml needs to create plus what renderToLambda/getRenderProgress call against S3 + Step Functions at runtime. Sorted alphabetically per-service so diffs stay readable. Resource is "*" by design — CloudFormation creates new function / state-machine / bucket ARNs on every adopter's first deploy. The generated policy is documented as a starting point; adopters with stricter postures narrow Resource to the deployed ARNs after the first successful run. Tests: 10 unit tests covering the action set, doc shape, trust policy service principal, and validate() against valid / missing / wildcard / single-Statement / Deny-statement inputs. --- docs/packages/cli.mdx | 19 ++ packages/cli/src/commands/lambda.ts | 28 ++ .../cli/src/commands/lambda/policies.test.ts | 180 ++++++++++ packages/cli/src/commands/lambda/policies.ts | 313 ++++++++++++++++++ 4 files changed, 540 insertions(+) create mode 100644 packages/cli/src/commands/lambda/policies.test.ts create mode 100644 packages/cli/src/commands/lambda/policies.ts 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..e51d0cb29 100644 --- a/packages/cli/src/commands/lambda.ts +++ b/packages/cli/src/commands/lambda.ts @@ -41,6 +41,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")} @@ -112,6 +113,13 @@ export default defineCommand({ description: "Poll cadence in ms when --wait is set (default: 5000)", }, + // policies + principal: { + type: "string", + description: + "Trust principal for `policies role` (lambda | cloudformation; default cloudformation)", + }, + // shared json: { type: "boolean", description: "Emit machine-readable JSON" }, }, @@ -257,6 +265,23 @@ 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, + principal: parsePrincipal(args.principal), + json: Boolean(args.json), + }); + return; + } default: console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`); process.exit(1); @@ -306,6 +331,7 @@ const FORMATS = ["mp4", "mov", "png-sequence"] as const; const CODECS = ["h264", "h265"] as const; const QUALITIES = ["draft", "standard", "high"] as const; const CHROME_SOURCES = ["sparticuz", "chrome-headless-shell"] as const; +const PRINCIPALS = ["lambda", "cloudformation"] as const; const parseFormat = (raw: unknown): (typeof FORMATS)[number] => parseEnum(raw, FORMATS, "[lambda render] --format", "mp4")!; @@ -315,3 +341,5 @@ const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined => parseEnum(raw, QUALITIES, "[lambda render] --quality", undefined); const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] => parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!; +const parsePrincipal = (raw: unknown): (typeof PRINCIPALS)[number] => + parseEnum(raw, PRINCIPALS, "[lambda policies role] --principal", "cloudformation")!; 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..aa9734d18 --- /dev/null +++ b/packages/cli/src/commands/lambda/policies.test.ts @@ -0,0 +1,180 @@ +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 scoped to the requested service", () => { + const trust = buildRoleTrustPolicy("cloudformation") as { + Statement: { Action: string; Principal: { Service: string } }[]; + }; + expect(trust.Statement[0]!.Action).toBe("sts:AssumeRole"); + expect(trust.Statement[0]!.Principal.Service).toBe("cloudformation.amazonaws.com"); + const lambdaTrust = buildRoleTrustPolicy("lambda") as { + Statement: { Principal: { Service: string } }[]; + }; + expect(lambdaTrust.Statement[0]!.Principal.Service).toBe("lambda.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([]); + }); +}); + +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..5e4609889 --- /dev/null +++ b/packages/cli/src/commands/lambda/policies.ts @@ -0,0 +1,313 @@ +/** + * `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[]; +} + +/** + * 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", + ], + 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: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", + ], + // The artifact bucket SAM auto-creates via --resolve-s3 needs a few + // permissions on the `aws-sam-cli-*` bucket. Adopters who set + // --s3-bucket explicitly can drop these. + samArtifactBucket: ["s3:CreateBucket", "s3:GetObject", "s3:ListBucket", "s3:PutObject"], +}; + +/** 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 a service-linked IAM role consumes (used by `policies role`). + * Returned as a structurally-correct `Allow sts:AssumeRole` with the named + * service principal attached — the `Principal` field is an IAM-specific + * extension not modelled by our generic `PolicyStatement` type. + */ +export function buildRoleTrustPolicy(principal: "lambda" | "cloudformation"): unknown { + const Service = principal === "lambda" ? "lambda.amazonaws.com" : "cloudformation.amazonaws.com"; + return { + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Principal: { Service }, + Action: "sts:AssumeRole", + }, + ], + }; +} + +export interface PoliciesArgs { + verb: PoliciesVerb; + /** For `validate`: path to an IAM policy JSON file. */ + inputPath?: string; + /** For `role`: which service the role trusts. */ + principal: "lambda" | "cloudformation"; + /** 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(args.principal); + const inline = buildPolicyDocument(); + const wrapped = { + TrustRelationship: trust, + InlinePolicy: inline, + }; + console.log(JSON.stringify(wrapped, null, 2)); + return; + } + case "validate": { + if (!args.inputPath) { + throw new Error( + "[lambda policies validate] usage: hyperframes lambda policies validate ", + ); + } + const result = validatePolicy(args.inputPath); + if (args.json) { + console.log(JSON.stringify(result, null, 2)); + if (result.missing.length > 0) process.exitCode = 1; + return; + } + 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[]; +} + +/** + * 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:*`, `*`) expand to + * match anything in the required list. + */ +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 }> = Array.isArray(parsed.Statement) + ? (parsed.Statement as { Effect?: string; Action?: unknown }[]) + : parsed.Statement + ? [parsed.Statement as { Effect?: string; Action?: unknown }] + : []; + + const grantedPatterns: string[] = []; + for (const stmt of statements) { + if (stmt.Effect !== "Allow") continue; + 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); + } + } + + 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 }; +} + +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; +} From dd3ecdb6fd8303977cdb8423775c039023689c12 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:14:17 +0000 Subject: [PATCH 2/3] refactor(cli): /simplify pass on lambda policies Adds a typed TrustPolicyDocument / TrustPolicyStatement pair so buildRoleTrustPolicy can return a real type instead of unknown. The trust-policy shape has a Principal field that the generic PolicyStatement doesn't model, but it was previously punted via a return unknown rather than a parallel type. Test cleanup: drop the `as {...}` casts that the previous return- unknown signature forced. --- .../cli/src/commands/lambda/policies.test.ts | 8 ++---- packages/cli/src/commands/lambda/policies.ts | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/lambda/policies.test.ts b/packages/cli/src/commands/lambda/policies.test.ts index aa9734d18..1cc9cc150 100644 --- a/packages/cli/src/commands/lambda/policies.test.ts +++ b/packages/cli/src/commands/lambda/policies.test.ts @@ -55,14 +55,10 @@ describe("policies — buildPolicyDocument", () => { describe("policies — buildRoleTrustPolicy", () => { it("returns a sts:AssumeRole statement scoped to the requested service", () => { - const trust = buildRoleTrustPolicy("cloudformation") as { - Statement: { Action: string; Principal: { Service: string } }[]; - }; + const trust = buildRoleTrustPolicy("cloudformation"); expect(trust.Statement[0]!.Action).toBe("sts:AssumeRole"); expect(trust.Statement[0]!.Principal.Service).toBe("cloudformation.amazonaws.com"); - const lambdaTrust = buildRoleTrustPolicy("lambda") as { - Statement: { Principal: { Service: string } }[]; - }; + const lambdaTrust = buildRoleTrustPolicy("lambda"); expect(lambdaTrust.Statement[0]!.Principal.Service).toBe("lambda.amazonaws.com"); }); }); diff --git a/packages/cli/src/commands/lambda/policies.ts b/packages/cli/src/commands/lambda/policies.ts index 5e4609889..9df0b33aa 100644 --- a/packages/cli/src/commands/lambda/policies.ts +++ b/packages/cli/src/commands/lambda/policies.ts @@ -36,6 +36,22 @@ interface PolicyDocument { 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. @@ -166,13 +182,8 @@ export function buildPolicyDocument(): PolicyDocument { }; } -/** - * Trust policy a service-linked IAM role consumes (used by `policies role`). - * Returned as a structurally-correct `Allow sts:AssumeRole` with the named - * service principal attached — the `Principal` field is an IAM-specific - * extension not modelled by our generic `PolicyStatement` type. - */ -export function buildRoleTrustPolicy(principal: "lambda" | "cloudformation"): unknown { +/** Trust policy a service-linked IAM role consumes (used by `policies role`). */ +export function buildRoleTrustPolicy(principal: "lambda" | "cloudformation"): TrustPolicyDocument { const Service = principal === "lambda" ? "lambda.amazonaws.com" : "cloudformation.amazonaws.com"; return { Version: "2012-10-17", From cb48a4186ecec5297208492319db9dc485108ac4 Mon Sep 17 00:00:00 2001 From: James Date: Sun, 17 May 2026 00:47:28 +0000 Subject: [PATCH 3/3] fix(cli): address PR review on lambda policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One blocker + four importants from Vai's review: - REQUIRED_ACTIONS was missing `s3:ListAllMyBuckets` (called by `sam deploy --resolve-s3` on first run to discover/create the `aws-sam-cli-managed-default-*` artifact bucket) and `cloudformation:ValidateTemplate` (CFN template validation during change-set creation). Without these, a first-deploy adopter with the generated policy hits AccessDenied on the very call the PR was meant to unblock. Added both. - `policies role --principal=lambda` was a footgun — it produced a `lambda.amazonaws.com` trust paired with the full deploy superset, i.e. a confusingly-overscoped Lambda execution role no human should attach (the SAM template creates its own scoped execution role automatically). Dropped `lambda` as a principal option; `policies role` now always emits a CloudFormation service-role doc. - `validatePolicy` silently misreported NotAction/NotResource statements (treating them as zero grants), producing false negatives. Detect both shapes and surface them via a new `warnings: string[]` field; NotAction statements are skipped (rather than producing a false negative), NotResource is treated as full action grant + a warning. - Mid-string wildcards (`s3:Get*Object`, `?`) silently failed the matcher. End-anchored wildcards still work; mid-string patterns now warn so users know the validator can't expand them. - Dropped the dead `samArtifactBucket` action group (fully subsumed by `s3Bucket` + `s3Object`). - `validate --json` now wraps errors in a friendly envelope (`{ ok: false, error: "..." }`) so CI consumers have one parse shape regardless of failure mode. - lambda.ts subcommand description and examples updated to include `policies`. Tests: 5 new negative-path tests cover NotAction warning, NotResource warning, mid-string wildcard warning, missing file (ENOENT), malformed JSON (SyntaxError), and absent Statement field. All 21 policies tests pass. --- packages/cli/src/commands/lambda.ts | 23 ++-- .../cli/src/commands/lambda/policies.test.ts | 59 ++++++++- packages/cli/src/commands/lambda/policies.ts | 122 ++++++++++++++---- 3 files changed, 164 insertions(+), 40 deletions(-) diff --git a/packages/cli/src/commands/lambda.ts b/packages/cli/src/commands/lambda.ts index e51d0cb29..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 = ` @@ -59,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 @@ -113,13 +119,6 @@ export default defineCommand({ description: "Poll cadence in ms when --wait is set (default: 5000)", }, - // policies - principal: { - type: "string", - description: - "Trust principal for `policies role` (lambda | cloudformation; default cloudformation)", - }, - // shared json: { type: "boolean", description: "Emit machine-readable JSON" }, }, @@ -277,7 +276,6 @@ export default defineCommand({ await runPolicies({ verb, inputPath: args.extra as string | undefined, - principal: parsePrincipal(args.principal), json: Boolean(args.json), }); return; @@ -331,7 +329,6 @@ const FORMATS = ["mp4", "mov", "png-sequence"] as const; const CODECS = ["h264", "h265"] as const; const QUALITIES = ["draft", "standard", "high"] as const; const CHROME_SOURCES = ["sparticuz", "chrome-headless-shell"] as const; -const PRINCIPALS = ["lambda", "cloudformation"] as const; const parseFormat = (raw: unknown): (typeof FORMATS)[number] => parseEnum(raw, FORMATS, "[lambda render] --format", "mp4")!; @@ -341,5 +338,3 @@ const parseQuality = (raw: unknown): (typeof QUALITIES)[number] | undefined => parseEnum(raw, QUALITIES, "[lambda render] --quality", undefined); const parseChromeSource = (raw: unknown): (typeof CHROME_SOURCES)[number] => parseEnum(raw, CHROME_SOURCES, "[lambda deploy] --chrome-source", "sparticuz")!; -const parsePrincipal = (raw: unknown): (typeof PRINCIPALS)[number] => - parseEnum(raw, PRINCIPALS, "[lambda policies role] --principal", "cloudformation")!; diff --git a/packages/cli/src/commands/lambda/policies.test.ts b/packages/cli/src/commands/lambda/policies.test.ts index 1cc9cc150..af99dd6a9 100644 --- a/packages/cli/src/commands/lambda/policies.test.ts +++ b/packages/cli/src/commands/lambda/policies.test.ts @@ -54,12 +54,10 @@ describe("policies — buildPolicyDocument", () => { }); describe("policies — buildRoleTrustPolicy", () => { - it("returns a sts:AssumeRole statement scoped to the requested service", () => { - const trust = buildRoleTrustPolicy("cloudformation"); + 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"); - const lambdaTrust = buildRoleTrustPolicy("lambda"); - expect(lambdaTrust.Statement[0]!.Principal.Service).toBe("lambda.amazonaws.com"); }); }); @@ -167,6 +165,59 @@ describe("policies — validatePolicy", () => { // 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 { diff --git a/packages/cli/src/commands/lambda/policies.ts b/packages/cli/src/commands/lambda/policies.ts index 9df0b33aa..409873fd0 100644 --- a/packages/cli/src/commands/lambda/policies.ts +++ b/packages/cli/src/commands/lambda/policies.ts @@ -72,6 +72,7 @@ export const REQUIRED_ACTIONS = { "cloudformation:GetTemplateSummary", "cloudformation:ListStacks", "cloudformation:UpdateStack", + "cloudformation:ValidateTemplate", ], cloudwatchAlarms: [ "cloudwatch:DeleteAlarms", @@ -122,6 +123,7 @@ export const REQUIRED_ACTIONS = { "s3:GetBucketTagging", "s3:GetBucketVersioning", "s3:GetLifecycleConfiguration", + "s3:ListAllMyBuckets", "s3:ListBucket", "s3:PutBucketPolicy", "s3:PutBucketTagging", @@ -144,10 +146,6 @@ export const REQUIRED_ACTIONS = { "states:UntagResource", "states:UpdateStateMachine", ], - // The artifact bucket SAM auto-creates via --resolve-s3 needs a few - // permissions on the `aws-sam-cli-*` bucket. Adopters who set - // --s3-bucket explicitly can drop these. - samArtifactBucket: ["s3:CreateBucket", "s3:GetObject", "s3:ListBucket", "s3:PutObject"], }; /** All required actions flattened, deduped, sorted. */ @@ -182,15 +180,20 @@ export function buildPolicyDocument(): PolicyDocument { }; } -/** Trust policy a service-linked IAM role consumes (used by `policies role`). */ -export function buildRoleTrustPolicy(principal: "lambda" | "cloudformation"): TrustPolicyDocument { - const Service = principal === "lambda" ? "lambda.amazonaws.com" : "cloudformation.amazonaws.com"; +/** + * 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 }, + Principal: { Service: "cloudformation.amazonaws.com" }, Action: "sts:AssumeRole", }, ], @@ -201,8 +204,6 @@ export interface PoliciesArgs { verb: PoliciesVerb; /** For `validate`: path to an IAM policy JSON file. */ inputPath?: string; - /** For `role`: which service the role trusts. */ - principal: "lambda" | "cloudformation"; /** Print JSON only. Default true for `role`/`user` (output is JSON by definition); ignored for `validate`. */ json: boolean; } @@ -222,7 +223,7 @@ export async function runPolicies(args: PoliciesArgs): Promise { return; } case "role": { - const trust = buildRoleTrustPolicy(args.principal); + const trust = buildRoleTrustPolicy(); const inline = buildPolicyDocument(); const wrapped = { TrustRelationship: trust, @@ -233,16 +234,37 @@ export async function runPolicies(args: PoliciesArgs): Promise { } case "validate": { if (!args.inputPath) { - throw new Error( - "[lambda policies validate] usage: hyperframes lambda policies validate ", - ); + 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; } - const result = validatePolicy(args.inputPath); if (args.json) { - console.log(JSON.stringify(result, null, 2)); + 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; @@ -265,6 +287,8 @@ export interface ValidateResult { required: string[]; granted: string[]; missing: string[]; + /** Non-fatal warnings about policy shapes we couldn't fully evaluate. */ + warnings: string[]; } /** @@ -272,21 +296,58 @@ export interface ValidateResult { * "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:*`, `*`) expand to - * match anything in the required list. + * 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 }> = Array.isArray(parsed.Statement) - ? (parsed.Statement as { Effect?: string; Action?: 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 }] + ? [ + 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); @@ -295,6 +356,14 @@ export function validatePolicy(policyPath: string): ValidateResult { } } + 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[] = []; @@ -305,7 +374,16 @@ export function validatePolicy(policyPath: string): ValidateResult { missing.push(action); } } - return { required, granted, missing }; + 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 {