diff --git a/packages/aws-policy/README.md b/packages/aws-policy/README.md index 1c5f01f..236f957 100644 --- a/packages/aws-policy/README.md +++ b/packages/aws-policy/README.md @@ -124,22 +124,21 @@ const stagingPolicy = orgPolicy.fill({ }) ``` -## Scoped Policies +## Construct Scoped Policies -Create policies that can access configuration based on a construct scope. +You can pass a Construct as a parameter to a prepared policy to create scoped policies. -This is especially useful when combined with @cdklib/config to create environment-aware policies. - -It has a similar API to `AwsPreparedPolicy`, but the statement function requires a scope. +This is especially useful when combined with @cdklib/config to automatically fill in configuration values. ```typescript import { AwsPreparedPolicy } from '@cdklib/aws-policy' import { awsConfig } from './config/aws' -// Define a policy that can access configuration from scope -const s3BucketPolicy = AwsPreparedPolicy.newScoped<{ +// Define a policy that includes scope as a parameter +const s3BucketPolicy = AwsPreparedPolicy.new<{ + scope: Construct bucketName: string -}>((scope, { bucketName }) => { +}>(({ scope, bucketName }) => { // Get config values from scope const { accountId } = awsConfig.get(scope) @@ -154,11 +153,32 @@ const s3BucketPolicy = AwsPreparedPolicy.newScoped<{ }) // Provide scope and parameters -const policy = s3BucketPolicy.fill(myApp, { +const policy = s3BucketPolicy.fill({ + scope: myApp, bucketName: 'app-assets', }) ``` +## Combining Policies + +You can combine multiple policies together, for example granting s3 read access and lambda invoke access. + +The policy statements are combined - the library does not attempt to merge policies logically. + +This works for both prepared and normal policies. + +```typescript +// Combine regular policies +const s3ReadPolicy = ... +const lambdaInvokePolicy = ... + +// Creates a new policy with statements from both policies - parameters are combined +AwsPreparedPolicy.combine(lambdaInvokePolicy).fill({ + bucketName: 'my-bucket', + functionName: 'my-function', +}) +``` + ## License MIT diff --git a/packages/aws-policy/package.json b/packages/aws-policy/package.json index 2a9b082..84ea287 100644 --- a/packages/aws-policy/package.json +++ b/packages/aws-policy/package.json @@ -50,8 +50,7 @@ "vitest": "^3.0.7" }, "dependencies": { - "zod": "^3.0.0", - "constructs": "^10.0.0" + "zod": "^3.0.0" }, "repository": { "type": "git", diff --git a/packages/aws-policy/src/aws-policy.test.ts b/packages/aws-policy/src/aws-policy.test.ts new file mode 100644 index 0000000..3331232 --- /dev/null +++ b/packages/aws-policy/src/aws-policy.test.ts @@ -0,0 +1,281 @@ +import { describe, expect, it } from 'vitest' +import { AwsPolicy } from './aws-policy' +import { type AwsPolicyStatementProps } from './statement' + +describe('AwsPolicy', () => { + describe('static factory methods', () => { + it('should create policy with AwsPolicy.from', () => { + const policy = AwsPolicy.from({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }) + + expect(policy.statements.length).toBe(1) + expect(policy.statements[0]!.raw).toEqual({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }) + }) + + it('should create policy with multiple statements', () => { + const policy = AwsPolicy.from( + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }, + { + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }, + ) + + expect(policy.statements.length).toBe(2) + expect(policy.statements[0]!.raw).toEqual({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }) + expect(policy.statements[1]!.raw).toEqual({ + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }) + }) + + it('should create policy from raw JSON array', () => { + const raw = [ + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }, + ] + + const policy = AwsPolicy.fromRaw(raw) + expect(policy.statements.length).toBe(1) + expect(policy.statements[0]!.raw).toEqual(raw[0]) + }) + + it('should create policy from raw JSON object', () => { + const raw = { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + } + + const policy = AwsPolicy.fromRaw(raw) + expect(policy.statements.length).toBe(1) + expect(policy.statements[0]!.raw).toEqual(raw) + }) + }) + + describe('add method', () => { + it('should add statements to an existing policy', () => { + const policy = AwsPolicy.from({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }) + + policy.add({ + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }) + + expect(policy.statements.length).toBe(2) + expect(policy.statements[0]!.raw).toEqual({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }) + expect(policy.statements[1]!.raw).toEqual({ + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }) + }) + + it('should add multiple statements at once', () => { + const policy = AwsPolicy.from({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }) + + policy.add( + { + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }, + { + Effect: 'Allow', + Action: 's3:ListBucket', + Resource: 'arn:aws:s3:::example-bucket', + }, + ) + + expect(policy.statements.length).toBe(3) + }) + + it('should maintain chainability', () => { + const policy = AwsPolicy.from({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }).add({ + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }) + + expect(policy.statements.length).toBe(2) + }) + }) + + describe('toJson method', () => { + it('should convert policy to AWS-compatible JSON string', () => { + const statement: AwsPolicyStatementProps = { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + } + + const policy = AwsPolicy.from(statement!) + const json = policy.toJson() + + const expected = JSON.stringify({ + Version: '2012-10-17', + Statement: [statement], + }) + + expect(json).toEqual(expected) + }) + + it('should handle multiple statements correctly', () => { + const policy = AwsPolicy.from( + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }, + { + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }, + ) + + const json = policy.toJson() + + const expected = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::example-bucket/*', + }, + { + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::example-bucket/sensitive/*', + }, + ], + }) + + expect(json).toEqual(expected) + }) + + it('should preserve array for actions and resources', () => { + const policy = AwsPolicy.from({ + Effect: 'Allow', + Action: ['s3:GetObject', 's3:ListBucket'], + Resource: ['arn:aws:s3:::example-bucket', 'arn:aws:s3:::example-bucket/*'], + }) + + const json = policy.toJson() + + const expected = JSON.stringify({ + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: ['s3:GetObject', 's3:ListBucket'], + Resource: ['arn:aws:s3:::example-bucket', 'arn:aws:s3:::example-bucket/*'], + }, + ], + }) + + expect(json).toEqual(expected) + }) + }) + + describe('combine policies', () => { + it('should combine multiple policies into one', () => { + // Arrange + const readPolicy = AwsPolicy.from({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::my-bucket/*', + }) + + const listPolicy = AwsPolicy.from({ + Effect: 'Allow', + Action: 's3:ListBucket', + Resource: 'arn:aws:s3:::my-bucket', + }) + + const denyPolicy = AwsPolicy.from({ + Effect: 'Deny', + Action: 's3:DeleteObject', + Resource: 'arn:aws:s3:::my-bucket/*', + }) + + // Act + const combinedPolicy = AwsPolicy.combine([readPolicy, listPolicy, denyPolicy]) + const policyJson = JSON.parse(combinedPolicy.toJson()) + + // Assert + expect(policyJson.Statement).toHaveLength(3) + expect(policyJson.Statement[0].Action).toBe('s3:GetObject') + expect(policyJson.Statement[1].Action).toBe('s3:ListBucket') + expect(policyJson.Statement[2].Effect).toBe('Deny') + }) + + it('should return an empty policy when no policies are provided', () => { + // Act + const emptyPolicy = AwsPolicy.combine([]) + const policyJson = JSON.parse(emptyPolicy.toJson()) + + // Assert + expect(policyJson.Statement).toHaveLength(0) + }) + + it('should handle policies with multiple statements', () => { + // Arrange + const multiStatementPolicy = AwsPolicy.from( + { Effect: 'Allow', Action: 's3:GetObject', Resource: 'arn:aws:s3:::bucket-1/*' }, + { Effect: 'Allow', Action: 's3:ListBucket', Resource: 'arn:aws:s3:::bucket-1' }, + ) + + const singleStatementPolicy = AwsPolicy.from({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: 'arn:aws:s3:::bucket-2/*', + }) + + // Act + const combinedPolicy = AwsPolicy.combine([multiStatementPolicy, singleStatementPolicy]) + const policyJson = JSON.parse(combinedPolicy.toJson()) + + // Assert + expect(policyJson.Statement).toHaveLength(3) + }) + }) +}) diff --git a/packages/aws-policy/src/aws-policy.ts b/packages/aws-policy/src/aws-policy.ts new file mode 100644 index 0000000..acbef0c --- /dev/null +++ b/packages/aws-policy/src/aws-policy.ts @@ -0,0 +1,61 @@ +import { type AwsPolicyStatementProps, Statement } from './statement' + +const POLICY_VERSION = '2012-10-17' + +/** Holds one or more AWS statements, and outputs JSON */ +export class AwsPolicy { + static from(...statements: AwsPolicyStatementProps[]) { + return new AwsPolicy(...statements) + } + + /** Creates statements without intellisense, used when loading from files */ + static fromRaw(statements: unknown) { + return Array.isArray(statements) + ? new AwsPolicy(...statements) + : // @ts-expect-error validated in runtime + new AwsPolicy(statements) + } + + public readonly statements: Statement[] + + private constructor(...statements: AwsPolicyStatementProps[]) { + this.statements = statements.map((s) => new Statement(s)) + } + + /** Adds statements to the policy */ + add(...statements: AwsPolicyStatementProps[]) { + this.statements.push(...statements.map((s) => new Statement(s))) + + return this + } + + /** + * Combines multiple AWS policies into a single policy with all their statements. + * + * @param policies An array of AWS policies to combine + * @returns A new AWS policy with all statements from the input policies + * + * @example + * const readPolicy = AwsPolicy.from({ Effect: 'Allow', Action: 's3:GetObject', Resource: 'arn:aws:s3:::my-bucket/*' }); + * const listPolicy = AwsPolicy.from({ Effect: 'Allow', Action: 's3:ListBucket', Resource: 'arn:aws:s3:::my-bucket' }); + * const combinedPolicy = AwsPolicy.combine([readPolicy, listPolicy]); + */ + static combine(policies: AwsPolicy[]): AwsPolicy { + const statements: AwsPolicyStatementProps[] = [] + + // Collect all statements from all policies + for (const policy of policies) { + statements.push(...policy.statements.map((s) => s.raw)) + } + + return AwsPolicy.from(...statements) + } + + /** Convert to an AWS compatible JSON, including Version */ + toJson() { + return JSON.stringify({ + Version: POLICY_VERSION, + Statement: this.statements.map((s) => s.raw), + }) + } +} diff --git a/packages/aws-policy/src/index.ts b/packages/aws-policy/src/index.ts index e294a64..d94cd79 100644 --- a/packages/aws-policy/src/index.ts +++ b/packages/aws-policy/src/index.ts @@ -1,4 +1,3 @@ export { AwsPreparedPolicy } from './prepared-policy' -export { AwsPolicy } from './statement' +export { AwsPolicy } from './aws-policy' export type { AwsPolicyStatementProps } from './statement' -export type { ScopedAwsPreparedPolicy } from './scoped-prepared-policy' diff --git a/packages/aws-policy/src/prepared-policy.test.ts b/packages/aws-policy/src/prepared-policy.test.ts index 15e7df9..f540306 100644 --- a/packages/aws-policy/src/prepared-policy.test.ts +++ b/packages/aws-policy/src/prepared-policy.test.ts @@ -144,4 +144,124 @@ describe('PreparedPolicy', () => { 'arn:aws:iam::123456789012:user/developer', ) }) + + describe('AwsPreparedPolicy.combine', () => { + it('should combine multiple prepared policies with different parameter types', () => { + // Arrange + const s3Policy = AwsPreparedPolicy.new<{ + bucketName: string + }>(({ bucketName }) => ({ + Effect: 'Allow', + Action: ['s3:GetObject', 's3:ListBucket'], + Resource: [`arn:aws:s3:::${bucketName}`, `arn:aws:s3:::${bucketName}/*`], + })) + + const lambdaPolicy = AwsPreparedPolicy.new<{ + functionName: string + region: string + }>(({ functionName, region }) => ({ + Effect: 'Allow', + Action: ['lambda:InvokeFunction'], + Resource: [`arn:aws:lambda:${region}:*:function:${functionName}`], + })) + + // Act + const combinedPolicy = AwsPreparedPolicy.combine(s3Policy, lambdaPolicy) + + // Assert - TypeScript should enforce all parameters are required + const policy = combinedPolicy.fill({ + bucketName: 'data-bucket', + functionName: 'processor', + region: 'us-west-2', + }) + + const policyJson = JSON.parse(policy.toJson()) + + expect(policyJson.Statement).toHaveLength(2) + expect(policyJson.Statement[0].Resource).toContain('arn:aws:s3:::data-bucket') + expect(policyJson.Statement[1].Resource[0]).toBe( + 'arn:aws:lambda:us-west-2:*:function:processor', + ) + }) + + it('should handle policies that return multiple statements', () => { + // Arrange + const s3Policy = AwsPreparedPolicy.new<{ + bucketName: string + }>(({ bucketName }) => [ + { + Effect: 'Allow', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucketName}/*`, + }, + { + Effect: 'Allow', + Action: 's3:ListBucket', + Resource: `arn:aws:s3:::${bucketName}`, + }, + ]) + + const dynamoPolicy = AwsPreparedPolicy.new<{ + tableName: string + }>(({ tableName }) => ({ + Effect: 'Allow', + Action: ['dynamodb:GetItem', 'dynamodb:PutItem'], + Resource: `arn:aws:dynamodb:*:*:table/${tableName}`, + })) + + // Act + const combinedPolicy = AwsPreparedPolicy.combine(s3Policy, dynamoPolicy) + const policy = combinedPolicy.fill({ + bucketName: 'assets', + tableName: 'users', + }) + + const policyJson = JSON.parse(policy.toJson()) + + // Assert + expect(policyJson.Statement).toHaveLength(3) + expect(policyJson.Statement[0].Resource).toBe('arn:aws:s3:::assets/*') + expect(policyJson.Statement[1].Resource).toBe('arn:aws:s3:::assets') + expect(policyJson.Statement[2].Resource).toBe('arn:aws:dynamodb:*:*:table/users') + }) + + it('should work with partial filling', () => { + // Arrange + const s3Policy = AwsPreparedPolicy.new<{ + bucketName: string + }>(({ bucketName }) => ({ + Effect: 'Allow', + Action: 's3:GetObject', + Resource: `arn:aws:s3:::${bucketName}/*`, + })) + + const snsPolicy = AwsPreparedPolicy.new<{ + topicName: string + }>(({ topicName }) => ({ + Effect: 'Allow', + Action: 'sns:Publish', + Resource: `arn:aws:sns:*:*:${topicName}`, + })) + + // Act + const combinedPolicy = AwsPreparedPolicy.combine(s3Policy, snsPolicy) + + // Partially fill + const partialPolicy = combinedPolicy.fillPartial({ + bucketName: 'logs', + }) + + // Fill remaining params + const fullPolicy = partialPolicy.fill({ + topicName: 'alerts', + }) + + const policyJson = JSON.parse(fullPolicy.toJson()) + + // Assert + expect(policyJson.Statement).toHaveLength(2) + expect(policyJson.Statement[0].Resource).toBe('arn:aws:s3:::logs/*') + expect(policyJson.Statement[1].Resource).toBe('arn:aws:sns:*:*:alerts') + }) + }) }) diff --git a/packages/aws-policy/src/prepared-policy.ts b/packages/aws-policy/src/prepared-policy.ts index fbfff2e..89b2068 100644 --- a/packages/aws-policy/src/prepared-policy.ts +++ b/packages/aws-policy/src/prepared-policy.ts @@ -1,16 +1,14 @@ -import { type Construct } from 'constructs' -import type { Except } from 'type-fest' -import { type AwsPolicyStatementProps, AwsPolicy } from './statement' -import { ScopedAwsPreparedPolicy } from './scoped-prepared-policy' +import type { Except, UnionToIntersection } from 'type-fest' +import { AwsPolicy } from './aws-policy' +import { type AwsPolicyStatementProps } from './statement' /** * Represents a policy statement with parameters that can be filled in later. + * * This allows for creating reusable policy templates that can be filled with * specific values when needed. - * - * @template T The type of parameters this policy requires */ -export class AwsPreparedPolicy> { +export class AwsPreparedPolicy> { /** * Creates a new prepared policy with the given statement function. * Note: Use the static `new` method instead of this constructor. @@ -26,7 +24,6 @@ export class AwsPreparedPolicy> { /** * Creates a new prepared policy with explicitly typed parameters. * - * @template T The type of parameters this policy requires * @param statementFn Function that generates policy statements from parameters * @returns A new AwsPreparedPolicy instance * @@ -40,43 +37,12 @@ export class AwsPreparedPolicy> { * Resource: [`arn:aws:s3:::${bucketName}/*`] * })); */ - static new>( + static new>( statementFn: (params: T) => AwsPolicyStatementProps | AwsPolicyStatementProps[], ): AwsPreparedPolicy { return new AwsPreparedPolicy(statementFn) } - /** - * Creates a new prepared policy that has access to a construct scope. - * This allows the policy to access configuration values from the scope. - * - * @template T The type of parameters this policy requires - * @param statementFn Function that generates policy statements from scope and parameters - * @returns A new ScopedAwsPreparedPolicy instance - * - * @example - * const s3ReadPolicy = AwsPreparedPolicy.newScoped<{ - * bucketName: string; - * }>((scope, { bucketName }) => { - * // Get config values from scope - * const { accountId } = awsConfig.get(scope); - * - * return { - * Effect: 'Allow', - * Action: ['s3:GetObject'], - * Resource: [`arn:aws:s3:::${bucketName}/*`] - * }; - * }); - */ - static newScoped>( - statementFn: ( - scope: Construct, - params: T, - ) => AwsPolicyStatementProps | AwsPolicyStatementProps[], - ): ScopedAwsPreparedPolicy { - return ScopedAwsPreparedPolicy.new(statementFn) - } - /** * Fills all required parameters and creates a concrete AWS policy. * @@ -99,7 +65,6 @@ export class AwsPreparedPolicy> { * Partially fills some parameters and returns a new AwsPreparedPolicy * that only requires the remaining parameters. * - * @template K Keys of parameters being filled * @param params Subset of parameters to fill now * @returns A new AwsPreparedPolicy requiring only the remaining parameters * @@ -119,4 +84,46 @@ export class AwsPreparedPolicy> { return this.statementFn(combinedParams) }) } + + /** + * Combines multiple prepared policies into a single prepared policy. + * The resulting policy will accept the union of all parameter types. + * + * @param policies An array of prepared policies to combine + * @returns A new prepared policy that requires all parameters from the input policies + * + * @example + * ```typescript + * const s3Policy = AwsPreparedPolicy.new<{ bucketName: string }>( ... ); + * const lambdaPolicy = AwsPreparedPolicy.new<{ functionName: string }>( ... ); + * const combinedPolicy = AwsPreparedPolicy.combine([s3Policy, lambdaPolicy]); + * + * // Combined policy requires both parameters + * const policy = combinedPolicy.fill({ + * bucketName: 'my-bucket', + * functionName: 'my-function' + * }); + * ``` + */ + static combine[]>( + ...policies: { + [K in keyof T]: AwsPreparedPolicy + } + ): AwsPreparedPolicy> { + return new AwsPreparedPolicy>((params) => { + const statements: AwsPolicyStatementProps[] = [] + + // Apply parameters to each policy and collect statements + for (const policy of policies) { + const policyResult = policy.statementFn(params) + if (Array.isArray(policyResult)) { + statements.push(...policyResult) + } else { + statements.push(policyResult) + } + } + + return statements + }) + } } diff --git a/packages/aws-policy/src/scoped-prepared-policy.test.ts b/packages/aws-policy/src/scoped-prepared-policy.test.ts deleted file mode 100644 index e3cb901..0000000 --- a/packages/aws-policy/src/scoped-prepared-policy.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { AwsPreparedPolicy } from './prepared-policy' -import { type Construct, RootConstruct } from 'constructs' - -// Mock config system to simulate what users would do with real config -const mockAwsConfig = { - get: vi.fn(), -} - -// Mock Construct class -class MockConstruct extends RootConstruct { - constructor(id: string) { - super(id) - } -} - -describe('ScopedAwsPreparedPolicy', () => { - let mockScope: Construct - - beforeEach(() => { - mockScope = new MockConstruct('test-scope') - - // Reset the mock - vi.resetAllMocks() - - // Set up default config values - mockAwsConfig.get.mockReturnValue({ - accountId: '123456789012', - region: 'us-east-1', - environment: 'dev', - }) - }) - - it('should create a policy with scope-based values', () => { - // Arrange - const s3ReadPolicy = AwsPreparedPolicy.newScoped<{ - bucketName: string - }>((scope, { bucketName }) => { - // Get config from scope - const { accountId } = mockAwsConfig.get(scope) - - return { - Effect: 'Allow', - Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${bucketName}/*`], - Principal: { - AWS: `arn:aws:iam::${accountId}:root`, - }, - } - }) - - // Act - const policy = s3ReadPolicy.fill(mockScope, { bucketName: 'test-bucket' }) - const policyJson = JSON.parse(policy.toJson()) - - // Assert - expect(mockAwsConfig.get).toHaveBeenCalledWith(mockScope) - expect(policyJson.Statement[0].Resource[0]).toBe('arn:aws:s3:::test-bucket/*') - expect(policyJson.Statement[0].Principal.AWS).toBe('arn:aws:iam::123456789012:root') - }) - - it('should create multi-statement policies with scope', () => { - // Arrange - const complexPolicy = AwsPreparedPolicy.newScoped<{ - bucketName: string - lambdaName: string - }>((scope, { bucketName, lambdaName }) => { - // Get config from scope - const { accountId, region } = mockAwsConfig.get(scope) - - return [ - { - Effect: 'Allow', - Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${bucketName}/*`], - }, - { - Effect: 'Allow', - Action: ['lambda:InvokeFunction'], - Resource: [`arn:aws:lambda:${region}:${accountId}:function:${lambdaName}`], - }, - ] - }) - - // Act - const policy = complexPolicy.fill(mockScope, { - bucketName: 'data-bucket', - lambdaName: 'process-function', - }) - const policyJson = JSON.parse(policy.toJson()) - - // Assert - expect(mockAwsConfig.get).toHaveBeenCalledWith(mockScope) - expect(policyJson.Statement).toHaveLength(2) - expect(policyJson.Statement[0].Resource[0]).toBe('arn:aws:s3:::data-bucket/*') - expect(policyJson.Statement[1].Resource[0]).toBe( - 'arn:aws:lambda:us-east-1:123456789012:function:process-function', - ) - }) - - it('should support partial parameter filling with scope', () => { - // Arrange - const envPolicy = AwsPreparedPolicy.newScoped<{ - serviceName: string - bucketName: string - }>((scope, { serviceName, bucketName }) => { - // Get config from scope - const { accountId, environment } = mockAwsConfig.get(scope) - - return { - Effect: 'Allow', - Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${bucketName}/${environment}/${serviceName}/*`], - Principal: { - AWS: `arn:aws:iam::${accountId}:role/${serviceName}-role`, - }, - } - }) - - // Act - fill in serviceName first - const servicePolicy = envPolicy.fillPartial({ - serviceName: 'api', - }) - - // Then fill in bucketName and provide scope - const fullPolicy = servicePolicy.fill(mockScope, { - bucketName: 'company-assets', - }) - - const policyJson = JSON.parse(fullPolicy.toJson()) - - // Assert - expect(mockAwsConfig.get).toHaveBeenCalledWith(mockScope) - expect(policyJson.Statement[0].Resource[0]).toBe('arn:aws:s3:::company-assets/dev/api/*') - expect(policyJson.Statement[0].Principal.AWS).toBe( - 'arn:aws:iam::123456789012:role/api-role', - ) - }) - - it('should handle multi-stage parameter filling with scope', () => { - // Arrange - const deploymentPolicy = AwsPreparedPolicy.newScoped<{ - bucketName: string - serviceName: string - team: string - }>((scope, { bucketName, serviceName, team }) => { - // Get config from scope - const { accountId, environment } = mockAwsConfig.get(scope) - - return { - Effect: 'Allow', - Action: ['s3:GetObject', 's3:PutObject'], - Resource: [`arn:aws:s3:::${bucketName}/${environment}/${team}/${serviceName}/*`], - Principal: { - AWS: `arn:aws:iam::${accountId}:role/${environment}-${serviceName}`, - }, - } - }) - - // Act - multi-stage filling - const stage1 = deploymentPolicy.fillPartial({ - bucketName: 'deployments', - }) - - const stage2 = stage1.fillPartial({ - team: 'backend', - }) - - const finalPolicy = stage2.fill(mockScope, { - serviceName: 'auth-service', - }) - - const policyJson = JSON.parse(finalPolicy.toJson()) - - // Assert - expect(mockAwsConfig.get).toHaveBeenCalledWith(mockScope) - expect(policyJson.Statement[0].Resource[0]).toBe( - 'arn:aws:s3:::deployments/dev/backend/auth-service/*', - ) - expect(policyJson.Statement[0].Principal.AWS).toBe( - 'arn:aws:iam::123456789012:role/dev-auth-service', - ) - }) - - it('should handle different config values for different scopes', () => { - // Arrange - const prodScope = new MockConstruct('prod-scope') - - // Different config for prod scope - mockAwsConfig.get.mockImplementation((scope) => { - if (scope === prodScope) { - return { - accountId: '987654321098', - region: 'us-west-2', - environment: 'prod', - } - } - return { - accountId: '123456789012', - region: 'us-east-1', - environment: 'dev', - } - }) - - const s3Policy = AwsPreparedPolicy.newScoped<{ - bucketName: string - }>((scope, { bucketName }) => { - const { accountId, environment } = mockAwsConfig.get(scope) - return { - Effect: 'Allow', - Action: ['s3:GetObject'], - Resource: [`arn:aws:s3:::${bucketName}-${environment}/*`], - Principal: { - AWS: `arn:aws:iam::${accountId}:root`, - }, - } - }) - - // Act - create policies for different environments - const devPolicy = s3Policy.fill(mockScope, { bucketName: 'assets' }) - const prodPolicy = s3Policy.fill(prodScope, { bucketName: 'assets' }) - - const devPolicyJson = JSON.parse(devPolicy.toJson()) - const prodPolicyJson = JSON.parse(prodPolicy.toJson()) - - // Assert - expect(mockAwsConfig.get).toHaveBeenCalledWith(mockScope) - expect(mockAwsConfig.get).toHaveBeenCalledWith(prodScope) - - expect(devPolicyJson.Statement[0].Resource[0]).toBe('arn:aws:s3:::assets-dev/*') - expect(devPolicyJson.Statement[0].Principal.AWS).toBe('arn:aws:iam::123456789012:root') - - expect(prodPolicyJson.Statement[0].Resource[0]).toBe('arn:aws:s3:::assets-prod/*') - expect(prodPolicyJson.Statement[0].Principal.AWS).toBe('arn:aws:iam::987654321098:root') - }) -}) diff --git a/packages/aws-policy/src/scoped-prepared-policy.ts b/packages/aws-policy/src/scoped-prepared-policy.ts deleted file mode 100644 index f57b4ef..0000000 --- a/packages/aws-policy/src/scoped-prepared-policy.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { type Construct } from 'constructs' -import type { Except } from 'type-fest' -import { AwsPolicy, type AwsPolicyStatementProps } from './statement' - -/** - * Represents a policy statement with parameters that can be filled in later, - * with access to a construct scope for retrieving configuration values. - * - * This allows for creating reusable policy templates that can access configuration - * based on the construct scope, like @cdklib/config. - */ -export class ScopedAwsPreparedPolicy> { - private constructor( - private readonly statementFn: ( - scope: Construct, - params: T, - ) => AwsPolicyStatementProps | AwsPolicyStatementProps[], - ) {} - - /** - * Creates a new scoped prepared policy with the given statement function. - * - * @param statementFn Function that generates policy statements from parameters and scope - */ - static new>( - statementFn: ( - scope: Construct, - params: T, - ) => AwsPolicyStatementProps | AwsPolicyStatementProps[], - ): ScopedAwsPreparedPolicy { - return new ScopedAwsPreparedPolicy(statementFn) - } - - /** - * Fills all required parameters with the provided scope and creates a concrete AWS policy. - * The scope can be used within the policy template to access configuration values. - * - * @param scope The construct scope, typically used to access configuration - * @param params The parameter values to fill the policy with - * @returns A fully instantiated AWS policy - * - * @example - * const policy = s3ReadPolicy.fill(this, { - * bucketName: 'my-bucket' - * }); - */ - fill(scope: Construct, params: T): AwsPolicy { - const result = this.statementFn(scope, params) - const statements = Array.isArray(result) ? result : [result] - return AwsPolicy.from(...statements) - } - - /** - * Partially fills some parameters and returns a new ScopedAwsPreparedPolicy - * that only requires the remaining parameters and scope. - * - * @template K Keys of parameters being filled - * @param params Subset of parameters to fill now - * @returns A new ScopedAwsPreparedPolicy requiring only the remaining parameters - * - * @example - * const partialPolicy = s3ReadPolicy.fillPartial({ - * bucketName: 'my-bucket' - * }); - * - * // Later, provide the scope - * const fullPolicy = partialPolicy.fill(this, {}); - */ - fillPartial(params: Pick): ScopedAwsPreparedPolicy> { - return new ScopedAwsPreparedPolicy>((scope, remainingParams) => { - const combinedParams = { ...params, ...remainingParams } as unknown as T - return this.statementFn(scope, combinedParams) - }) - } -} diff --git a/packages/aws-policy/src/statement.test.ts b/packages/aws-policy/src/statement.test.ts index b8c4a4a..37dfcbf 100644 --- a/packages/aws-policy/src/statement.test.ts +++ b/packages/aws-policy/src/statement.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect } from 'vitest' -import { AwsPolicy, type AwsPolicyStatementProps, Statement } from './statement' +import { describe, expect, it } from 'vitest' import { ZodError } from 'zod' +import { type AwsPolicyStatementProps, Statement } from './statement' describe('Statement', () => { describe('instantiation and validation', () => { @@ -116,218 +116,3 @@ describe('Statement', () => { }) }) }) - -describe('AwsPolicy', () => { - describe('static factory methods', () => { - it('should create policy with AwsPolicy.from', () => { - const policy = AwsPolicy.from({ - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }) - - expect(policy.statements.length).toBe(1) - expect(policy.statements[0]!.raw).toEqual({ - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }) - }) - - it('should create policy with multiple statements', () => { - const policy = AwsPolicy.from( - { - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }, - { - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }, - ) - - expect(policy.statements.length).toBe(2) - expect(policy.statements[0]!.raw).toEqual({ - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }) - expect(policy.statements[1]!.raw).toEqual({ - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }) - }) - - it('should create policy from raw JSON array', () => { - const raw = [ - { - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }, - ] - - const policy = AwsPolicy.fromRaw(raw) - expect(policy.statements.length).toBe(1) - expect(policy.statements[0]!.raw).toEqual(raw[0]) - }) - - it('should create policy from raw JSON object', () => { - const raw = { - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - } - - const policy = AwsPolicy.fromRaw(raw) - expect(policy.statements.length).toBe(1) - expect(policy.statements[0]!.raw).toEqual(raw) - }) - }) - - describe('add method', () => { - it('should add statements to an existing policy', () => { - const policy = AwsPolicy.from({ - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }) - - policy.add({ - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }) - - expect(policy.statements.length).toBe(2) - expect(policy.statements[0]!.raw).toEqual({ - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }) - expect(policy.statements[1]!.raw).toEqual({ - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }) - }) - - it('should add multiple statements at once', () => { - const policy = AwsPolicy.from({ - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }) - - policy.add( - { - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }, - { - Effect: 'Allow', - Action: 's3:ListBucket', - Resource: 'arn:aws:s3:::example-bucket', - }, - ) - - expect(policy.statements.length).toBe(3) - }) - - it('should maintain chainability', () => { - const policy = AwsPolicy.from({ - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }).add({ - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }) - - expect(policy.statements.length).toBe(2) - }) - }) - - describe('toJson method', () => { - it('should convert policy to AWS-compatible JSON string', () => { - const statement: AwsPolicyStatementProps = { - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - } - - const policy = AwsPolicy.from(statement!) - const json = policy.toJson() - - const expected = JSON.stringify({ - Version: '2012-10-17', - Statement: [statement], - }) - - expect(json).toEqual(expected) - }) - - it('should handle multiple statements correctly', () => { - const policy = AwsPolicy.from( - { - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }, - { - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }, - ) - - const json = policy.toJson() - - const expected = JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Action: 's3:GetObject', - Resource: 'arn:aws:s3:::example-bucket/*', - }, - { - Effect: 'Deny', - Action: 's3:DeleteObject', - Resource: 'arn:aws:s3:::example-bucket/sensitive/*', - }, - ], - }) - - expect(json).toEqual(expected) - }) - - it('should preserve array for actions and resources', () => { - const policy = AwsPolicy.from({ - Effect: 'Allow', - Action: ['s3:GetObject', 's3:ListBucket'], - Resource: ['arn:aws:s3:::example-bucket', 'arn:aws:s3:::example-bucket/*'], - }) - - const json = policy.toJson() - - const expected = JSON.stringify({ - Version: '2012-10-17', - Statement: [ - { - Effect: 'Allow', - Action: ['s3:GetObject', 's3:ListBucket'], - Resource: ['arn:aws:s3:::example-bucket', 'arn:aws:s3:::example-bucket/*'], - }, - ], - }) - - expect(json).toEqual(expected) - }) - }) -}) diff --git a/packages/aws-policy/src/statement.ts b/packages/aws-policy/src/statement.ts index ca78be5..bd3f72d 100644 --- a/packages/aws-policy/src/statement.ts +++ b/packages/aws-policy/src/statement.ts @@ -1,8 +1,6 @@ import z from 'zod' import { conditionOperators } from './condition-operators' -const POLICY_VERSION = '2012-10-17' - /** One or more strings */ const stringsSchema = z.union([z.string(), z.array(z.string()).min(1)]) @@ -26,39 +24,3 @@ export class Statement { policyStatementSchema.parse(raw) } } - -/** Holds one or more AWS statements, and outputs JSON */ -export class AwsPolicy { - static from(...statements: AwsPolicyStatementProps[]) { - return new AwsPolicy(...statements) - } - - /** Creates statements without intellisense, used when loading from files */ - static fromRaw(statements: unknown) { - return Array.isArray(statements) - ? new AwsPolicy(...statements) - : // @ts-expect-error validated in runtime - new AwsPolicy(statements) - } - - public readonly statements: Statement[] - - private constructor(...statements: AwsPolicyStatementProps[]) { - this.statements = statements.map((s) => new Statement(s)) - } - - /** Adds statements to the policy */ - add(...statements: AwsPolicyStatementProps[]) { - this.statements.push(...statements.map((s) => new Statement(s))) - - return this - } - - /** Convert to an AWS compatible JSON, including Version */ - toJson() { - return JSON.stringify({ - Version: POLICY_VERSION, - Statement: this.statements.map((s) => s.raw), - }) - } -} diff --git a/packages/aws-policy/tsup.config.ts b/packages/aws-policy/tsup.config.ts index e96d84f..f2a54c5 100644 --- a/packages/aws-policy/tsup.config.ts +++ b/packages/aws-policy/tsup.config.ts @@ -8,5 +8,5 @@ export default defineConfig({ sourcemap: true, clean: true, treeshake: true, - external: ['zod', 'constructs'], + external: ['zod'], }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 35ca915..ce35c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,6 @@ importers: packages/aws-policy: dependencies: - constructs: - specifier: ^10.0.0 - version: 10.4.2 zod: specifier: ^3.0.0 version: 3.24.2