feat(cli): add hyperframes lambda policies role/user/validate (#912)

* feat(cli): add hyperframes lambda policies role/user/validate

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.

* 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.

* fix(cli): address PR review on lambda policies

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.
This commit is contained in:
James Russo
2026-05-17 13:15:01 -04:00
committed by GitHub
parent e90ad2da61
commit 62ddd29b74
4 changed files with 674 additions and 3 deletions
@@ -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;
}
@@ -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<string>();
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<void> {
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 <policy.json>";
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;
}