Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions packages/aws-policy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
3 changes: 1 addition & 2 deletions packages/aws-policy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@
"vitest": "^3.0.7"
},
"dependencies": {
"zod": "^3.0.0",
"constructs": "^10.0.0"
"zod": "^3.0.0"
},
"repository": {
"type": "git",
Expand Down
281 changes: 281 additions & 0 deletions packages/aws-policy/src/aws-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
Loading
Loading