Skip to content

Commit 65b5876

Browse files
SwolebrainVictor Moreno
andauthored
feat: add rate limiting to prevent policy conflicts (#550)
feat(policy): Add throttling and resource conflict management Implement intelligent dependency management for policy creation to address CloudFormation limitations: - Add getPolicyPropsFromFile helper to extract policy metadata including principal, action, and resource from Cedar policies - Refactor Policy.fromFile to use new helper function - Enhance PolicyStore.addPoliciesFromPath with dependency management: * Group policies by resource with sequential dependencies * Policies with same resource never created concurrently * Limit the number of parallel resource creation streams to 5 by cfn dependency This prevents CloudFormation throttling (10 TPS default limit) and resource conflicts when creating multiple policies. Co-authored-by: Victor Moreno <morevct@amazon.com>
1 parent 09979d3 commit 65b5876

File tree

5 files changed

+240
-33
lines changed

5 files changed

+240
-33
lines changed

API.md

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/cedar-helpers.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11

2+
import * as fs from 'fs';
3+
import * as path from 'path';
24
import * as cedar from '@cedar-policy/cedar-wasm/nodejs';
5+
import { PolicyProps } from './policy';
36
export const POLICY_DESCRIPTION_ANNOTATION = '@cdkDescription';
47
export const POLICY_ID_ANNOTATION = '@cdkId';
58

@@ -141,4 +144,74 @@ export function buildSchema(
141144
actions,
142145
},
143146
};
144-
}
147+
}
148+
149+
export interface PolicyPropsFromFile {
150+
principal: string;
151+
action: string;
152+
resource: string;
153+
cdkId: string;
154+
policyProps: PolicyProps;
155+
}
156+
157+
/**
158+
* Extracts policy properties from a Cedar policy file
159+
* @param filePath Path to the Cedar policy file
160+
* @param policyStore The policy store to associate policies with
161+
* @param defaultPolicyId Default ID to use if not specified in annotations
162+
* @param defaultDescription Optional default description
163+
* @param enablePolicyValidation Whether to enable policy validation
164+
* @returns Array of policy properties with parsed principal, action, resource
165+
*/
166+
export function getPolicyPropsFromFile(
167+
filePath: string,
168+
policyStore: any,
169+
defaultPolicyId: string,
170+
defaultDescription?: string,
171+
enablePolicyValidation: boolean = true,
172+
): PolicyPropsFromFile[] {
173+
const policyFileContents = fs.readFileSync(filePath).toString();
174+
const relativePath = path.basename(filePath);
175+
const policies = splitPolicies(policyFileContents);
176+
177+
return policies.map(policyContents => {
178+
const parseResult = cedar.policyToJson(policyContents);
179+
if (parseResult.type === 'failure') {
180+
throw new Error(`Failed to parse policy: ${parseResult.errors.map((e: any) => e.message).join('; ')}`);
181+
}
182+
183+
const policyJson = parseResult.json;
184+
const annotations = policyJson.annotations || {};
185+
const cdkId = annotations[POLICY_ID_ANNOTATION.substring(1)] || defaultPolicyId;
186+
const description = annotations[POLICY_DESCRIPTION_ANNOTATION.substring(1)] || defaultDescription || `${relativePath} - Created by CDK`;
187+
188+
const stringifyEntity = (constraint: cedar.PrincipalConstraint | cedar.ActionConstraint | cedar.ResourceConstraint): string => {
189+
if (constraint.op === 'All') {
190+
return 'Unspecified';
191+
}
192+
if (!('entity' in constraint)) {
193+
throw new Error(`Invalid constraint in policy ${policyContents} in file ${filePath}`);
194+
}
195+
const entityUid: cedar.EntityUidJson = constraint.entity;
196+
const typeAndId = '__entity' in entityUid ? entityUid.__entity : entityUid;
197+
return `${typeAndId.type}::"${typeAndId.id}"`;
198+
};
199+
200+
return {
201+
principal: stringifyEntity(policyJson.principal),
202+
action: stringifyEntity(policyJson.action),
203+
resource: stringifyEntity(policyJson.resource),
204+
cdkId,
205+
policyProps: {
206+
definition: {
207+
static: {
208+
statement: policyContents,
209+
description,
210+
enablePolicyValidation,
211+
},
212+
},
213+
policyStore,
214+
},
215+
};
216+
});
217+
}

src/policy-store.ts

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as iam from 'aws-cdk-lib/aws-iam';
55
import { CfnPolicyStore } from 'aws-cdk-lib/aws-verifiedpermissions';
66
import { ArnFormat, IResource, Resource, Stack } from 'aws-cdk-lib/core';
77
import { Construct } from 'constructs';
8-
import { buildSchema, checkParseSchema, cleanUpApiNameForNamespace, validateMultiplePolicies } from './cedar-helpers';
8+
import { buildSchema, checkParseSchema, cleanUpApiNameForNamespace, getPolicyPropsFromFile, validateMultiplePolicies } from './cedar-helpers';
99
import { Policy, PolicyDefinitionProperty } from './policy';
1010
import {
1111
AUTH_ACTIONS,
@@ -14,6 +14,7 @@ import {
1414
} from './private/permissions';
1515

1616
const RELEVANT_HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'];
17+
const SIMULTANEOUS_POLICY_CREATION_STREAMS = 5;
1718

1819
export interface Tag {
1920
readonly key: string;
@@ -446,6 +447,7 @@ export class PolicyStore extends PolicyStoreBase {
446447
* .cedar file as policies to this policy store (searching recursively if needed).
447448
* Parses the policies with cedar-wasm and, if the policy store has a schema,
448449
* performs semantic validation of the policies as well.
450+
* Organizes policies with dependencies to avoid CloudFormation throttling and resource conflicts.
449451
* @param absolutePath a string representing an absolute path to the directory containing your policies
450452
* @param recursive a boolean representing whether or not to search the directory recursively for .cedar files
451453
* @returns An array of created Policy constructs.
@@ -483,14 +485,46 @@ export class PolicyStore extends PolicyStoreBase {
483485
});
484486
}
485487

486-
const policies = policyFileNames.flatMap((cedarFile) =>
487-
// Using base path of the file as default id for policy
488-
Policy.fromFile(this, path.basename(cedarFile), {
489-
path: cedarFile,
490-
policyStore: this,
491-
}),
488+
const allPolicyProps = policyFileNames.flatMap(cedarFile =>
489+
getPolicyPropsFromFile(cedarFile, this, path.basename(cedarFile), undefined, true),
492490
);
493491

492+
// Group policies by resource
493+
const resourceGroups = new Map<string, typeof allPolicyProps>();
494+
for (const props of allPolicyProps) {
495+
const resourceKey = JSON.stringify(props.resource);
496+
if (!resourceGroups.has(resourceKey)) {
497+
resourceGroups.set(resourceKey, []);
498+
}
499+
resourceGroups.get(resourceKey)!.push(props);
500+
}
501+
502+
// Create policies with dependencies
503+
const policies: Policy[] = [];
504+
const firstPolicyPerResourceGroup: Policy[] = [];
505+
506+
// First, handle policies within same resource groups (sequential dependencies)
507+
for (const group of resourceGroups.values()) {
508+
let previousPolicyInGroup: Policy | undefined;
509+
for (const props of group) {
510+
const policy = new Policy(this, props.cdkId, props.policyProps);
511+
if (previousPolicyInGroup) {
512+
policy.node.addDependency(previousPolicyInGroup);
513+
} else {
514+
firstPolicyPerResourceGroup.push(policy);
515+
}
516+
policies.push(policy);
517+
previousPolicyInGroup = policy;
518+
}
519+
}
520+
521+
// Then, organize resource groups in batches to prevent throttling
522+
// First SIMULTANEOUS_POLICY_CREATION_STREAMS resource groups can be created simultaneously
523+
// Each subsequent resource group depends on the resource group SIMULTANEOUS_POLICY_CREATION_STREAMS positions earlier
524+
for (let i = SIMULTANEOUS_POLICY_CREATION_STREAMS; i < firstPolicyPerResourceGroup.length; i++) {
525+
firstPolicyPerResourceGroup[i].node.addDependency(firstPolicyPerResourceGroup[i - SIMULTANEOUS_POLICY_CREATION_STREAMS]);
526+
}
527+
494528
return policies;
495529
}
496530
}

src/policy.ts

Lines changed: 14 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
import * as fs from 'fs';
2-
import * as path from 'path';
31
import { CfnPolicy } from 'aws-cdk-lib/aws-verifiedpermissions';
42
import { IResource, Resource } from 'aws-cdk-lib/core';
53
import { Construct } from 'constructs';
6-
import { checkParsePolicy, getPolicyDescription, getPolicyId, splitPolicies } from './cedar-helpers';
4+
import { checkParsePolicy, getPolicyDescription, getPolicyPropsFromFile } from './cedar-helpers';
75
import { IPolicyStore } from './policy-store';
86
import { IPolicyTemplate } from './policy-template';
97

@@ -204,36 +202,29 @@ export class Policy extends PolicyBase {
204202
* `PolicyStore.addPoliciesFromPath()`
205203
*
206204
* @param scope The parent creating construct (usually `this`).
207-
* @param props A `StaticPolicyFromFileProps` object.
208205
* @param defaultPolicyId The Policy construct default id. This may be directly passed to the method or defined inside the file.
209206
* When you have multiple policies per file it's strongly suggested to define the id directly
210207
* inside the file in order to avoid multiple policy constructs with the same id. In case of id passed
211208
* directly to the method and also defined in file, the latter will take priority.
209+
* @param props A `StaticPolicyFromFileProps` object.
212210
*/
213211
public static fromFile(
214212
scope: Construct,
215213
defaultPolicyId: string,
216214
props: StaticPolicyFromFileProps,
217215
): Policy[] {
218-
const policyFileContents = fs.readFileSync(props.path).toString();
219-
let relativePath = path.basename(props.path);
220-
let enablePolicyValidation = (props.enablePolicyValidation == undefined) ? true : props.enablePolicyValidation;
221-
const policies = splitPolicies(policyFileContents);
222-
const policyDefinitions = policies.map(policyContents => {
223-
let policyId = getPolicyId(policyContents) || defaultPolicyId;
224-
let policyDescription = getPolicyDescription(policyContents) || props.description || `${relativePath}${POLICY_DESC_SUFFIX_FROM_FILE}`;
225-
return new Policy(scope, policyId, {
226-
definition: {
227-
static: {
228-
statement: policyContents,
229-
description: policyDescription,
230-
enablePolicyValidation: enablePolicyValidation,
231-
},
232-
},
233-
policyStore: props.policyStore,
234-
});
235-
});
236-
return policyDefinitions;
216+
const enablePolicyValidation = props.enablePolicyValidation ?? true;
217+
const policyPropsArray = getPolicyPropsFromFile(
218+
props.path,
219+
props.policyStore,
220+
defaultPolicyId,
221+
props.description,
222+
enablePolicyValidation,
223+
);
224+
225+
return policyPropsArray.map(({ cdkId, policyProps }) =>
226+
new Policy(scope, cdkId, policyProps),
227+
);
237228
}
238229

239230
readonly policyId: string;

test/policy-dependencies.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import * as fs from 'fs';
2+
import * as path from 'path';
3+
import { Stack } from 'aws-cdk-lib';
4+
import { PolicyStore, ValidationSettingsMode } from '../src/policy-store';
5+
6+
describe('Policy Dependencies', () => {
7+
let testDir: string;
8+
9+
beforeEach(() => {
10+
testDir = fs.mkdtempSync('/tmp/test-policies-');
11+
});
12+
13+
afterEach(() => {
14+
if (fs.existsSync(testDir)) {
15+
fs.rmSync(testDir, { recursive: true });
16+
}
17+
});
18+
19+
test('policies with same resource have sequential dependencies', () => {
20+
// Create policies with same resource
21+
fs.writeFileSync(
22+
path.join(testDir, 'policy1.cedar'),
23+
'permit(principal, action, resource == Photo::"vacation.jpg");',
24+
);
25+
fs.writeFileSync(
26+
path.join(testDir, 'policy2.cedar'),
27+
'permit(principal, action, resource == Photo::"vacation.jpg");',
28+
);
29+
30+
const stack = new Stack();
31+
const policyStore = new PolicyStore(stack, 'PolicyStore', {
32+
validationSettings: { mode: ValidationSettingsMode.OFF },
33+
});
34+
35+
const policies = policyStore.addPoliciesFromPath(testDir);
36+
37+
expect(policies.length).toBe(2);
38+
// Second policy should depend on first
39+
expect(policies[1].node.dependencies.length).toBeGreaterThan(0);
40+
});
41+
42+
test('policies are organized in resource group batches', () => {
43+
// Create 12 policies with different resources (12 resource groups)
44+
for (let i = 0; i < 12; i++) {
45+
fs.writeFileSync(
46+
path.join(testDir, `policy${i}.cedar`),
47+
`permit(principal, action, resource == Photo::"photo${i}.jpg");`,
48+
);
49+
}
50+
51+
const stack = new Stack();
52+
const policyStore = new PolicyStore(stack, 'PolicyStore', {
53+
validationSettings: { mode: ValidationSettingsMode.OFF },
54+
});
55+
56+
const policies = policyStore.addPoliciesFromPath(testDir);
57+
58+
expect(policies.length).toBe(12);
59+
60+
// First 5 resource groups should have no dependencies (can be created simultaneously)
61+
for (let i = 0; i < 5; i++) {
62+
const deps = policies[i].node.dependencies.filter(
63+
d => d.node.id !== 'PolicyStore',
64+
);
65+
expect(deps.length).toBe(0);
66+
}
67+
68+
// Each resource group after the first 5 depends on the resource group 5 positions earlier
69+
// Resource group 5 depends on resource group 0
70+
expect(policies[5].node.dependencies.some(d => d === policies[0])).toBe(true);
71+
72+
// Resource group 6 depends on resource group 1
73+
expect(policies[6].node.dependencies.some(d => d === policies[1])).toBe(true);
74+
75+
// Resource group 10 depends on resource group 5
76+
expect(policies[10].node.dependencies.some(d => d === policies[5])).toBe(true);
77+
78+
// Total number of dependencies should be numPolicies - SIMULTANEOUS_POLICY_CREATION_STREAMS
79+
const SIMULTANEOUS_POLICY_CREATION_STREAMS = 5;
80+
const totalDeps = policies.reduce((count, policy) => {
81+
return count + policy.node.dependencies.filter(d => d.node.id !== 'PolicyStore').length;
82+
}, 0);
83+
expect(totalDeps).toBe(policies.length - SIMULTANEOUS_POLICY_CREATION_STREAMS);
84+
});
85+
86+
test('policies with annotations use correct cdkId and description', () => {
87+
fs.writeFileSync(
88+
path.join(testDir, 'annotated1.cedar'),
89+
'@cdkId("CustomId1")\n@cdkDescription("Custom description 1")\npermit(principal, action, resource);',
90+
);
91+
fs.writeFileSync(
92+
path.join(testDir, 'annotated2.cedar'),
93+
'@cdkId("CustomId2")\n@cdkDescription("Custom description 2")\npermit(principal, action, resource);',
94+
);
95+
96+
const stack = new Stack();
97+
const policyStore = new PolicyStore(stack, 'PolicyStore', {
98+
validationSettings: { mode: ValidationSettingsMode.OFF },
99+
});
100+
101+
const policies = policyStore.addPoliciesFromPath(testDir);
102+
103+
expect(policies.length).toBe(2);
104+
expect(policies[0].node.id).toBe('CustomId1');
105+
expect(policies[0].definition.static?.description).toBe('Custom description 1');
106+
expect(policies[1].node.id).toBe('CustomId2');
107+
expect(policies[1].definition.static?.description).toBe('Custom description 2');
108+
});
109+
});

0 commit comments

Comments
 (0)