mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-07 10:06:21 +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:
@@ -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