Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cwd>/.hyperframes/lambda-stack-<name>.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.
Expand Down
29 changes: 26 additions & 3 deletions packages/cli/src/commands/lambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand All @@ -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")}
Expand All @@ -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 <projectDir>`)",
description:
"Extra positional (e.g. `sites create <projectDir>` or `policies validate <policy.json>`)",
},

// Stack identity
Expand Down Expand Up @@ -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 <role|user|validate> [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);
Expand Down
227 changes: 227 additions & 0 deletions packages/cli/src/commands/lambda/policies.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof writePolicy>[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<typeof writePolicy>[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<typeof writePolicy>[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;
}
Loading
Loading