mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
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:
@@ -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.
|
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
|
### 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.
|
`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.
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export const examples: Example[] = [
|
|||||||
"hyperframes lambda sites create ./my-project",
|
"hyperframes lambda sites create ./my-project",
|
||||||
],
|
],
|
||||||
["Tear the stack down", "hyperframes lambda destroy"],
|
["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 = `
|
const HELP = `
|
||||||
@@ -41,6 +46,7 @@ ${c.bold("SUBCOMMANDS:")}
|
|||||||
${c.accent("render")} ${c.dim("Start a distributed render (returns a renderId)")}
|
${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("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("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.bold("FIRST RUN:")}
|
||||||
${c.accent("hyperframes lambda deploy")}
|
${c.accent("hyperframes lambda deploy")}
|
||||||
@@ -58,17 +64,18 @@ export default defineCommand({
|
|||||||
subcommand: {
|
subcommand: {
|
||||||
type: "positional",
|
type: "positional",
|
||||||
required: false,
|
required: false,
|
||||||
description: "deploy | sites | render | progress | destroy",
|
description: "deploy | sites | render | progress | destroy | policies",
|
||||||
},
|
},
|
||||||
target: {
|
target: {
|
||||||
type: "positional",
|
type: "positional",
|
||||||
required: false,
|
required: false,
|
||||||
description: "Subcommand-specific positional (project dir, render id, etc.)",
|
description: "Subcommand-specific positional (project dir, render id, policies verb, etc.)",
|
||||||
},
|
},
|
||||||
extra: {
|
extra: {
|
||||||
type: "positional",
|
type: "positional",
|
||||||
required: false,
|
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
|
// Stack identity
|
||||||
@@ -257,6 +264,22 @@ export default defineCommand({
|
|||||||
await runDestroy({ stackName, awsProfile: args.profile as string | undefined });
|
await runDestroy({ stackName, awsProfile: args.profile as string | undefined });
|
||||||
return;
|
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:
|
default:
|
||||||
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
|
console.error(`${c.error("Unknown subcommand:")} ${subcommand}\n${HELP}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user