diff --git a/README.md b/README.md index 1b134ec..c6a6e83 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Supported services: * S3 * KMS +* DynamoDB This library [simplifies IAM as described in Effective IAM for AWS](https://www.effectiveiam.com/simplify-aws-iam) and is fully-supported by k9 Security. We're happy to answer questions or help you integrate it via a [GitHub issue](https://github.com/k9securityio/k9-cdk/issues) or email to [support@k9security.io](mailto:support@k9security.io?subject=k9-cdk). @@ -34,7 +35,8 @@ const administerResourceArns = [ ]; const readConfigArns = administerResourceArns.concat([ - "arn:aws:iam::123456789012:role/k9-auditor" + "arn:aws:iam::123456789012:role/k9-auditor", + "arn:aws:iam::123456789012:role/aws-service-role/access-analyzer.amazonaws.com/AWSServiceRoleForAccessAnalyzer" ]); const app = new cdk.App(); @@ -93,20 +95,40 @@ new kms.Key(stack, 'KMSKey', { }); ``` -The example stack demonstrates full use of the k9 S3 and KMS policy generators. Generated policies: +Protecting a DynamoDB table follows the same path as KMS, generating a policy then providing it to the DynamoDB table construct via props: + +```typescript +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; + +const ddbResourcePolicyProps: k9.dynamodb.K9DynamoDBResourcePolicyProps = { + k9DesiredAccess: k9BucketPolicyProps.k9DesiredAccess +}; + + +const ddbResourcePolicy = k9.dynamodb.makeResourcePolicy(ddbResourcePolicyProps); + +const table = new dynamodb.TableV2(stack, 'app-table-with-k9-policy', { + partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, + resourcePolicy: ddbResourcePolicy, +}); +``` + +The example stack demonstrates full use of the k9 S3, KMS, and DynamoDB policy generators. Generated policies: S3 Bucket Policy: + * [Templatized Bucket Policy](examples/generated.bucket-policy.json) * [BucketPolicy resource in CFn template](examples/K9Example.template.json) KMS Key Policy: + * [Templatized Key Policy](examples/generated.key-policy.json) * [KeyPolicy attribute of Key resource in CFn template](examples/K9Example.template.json) ## Specialized Use Cases k9-cdk can be configured to support specialized use cases, including: -* [Public Bucket](docs/use-case-public-bucket.md) - Publicaly readable objects, least privilege for all other actions +* [Public Bucket](docs/use-case-public-bucket.md) - Publicly readable objects, least privilege for all other actions ## Local Development and Testing diff --git a/bin/k9-cdk.ts b/bin/k9-cdk.ts index 4315738..c1d0690 100644 --- a/bin/k9-cdk.ts +++ b/bin/k9-cdk.ts @@ -6,6 +6,7 @@ import {RemovalPolicy, Tags} from "aws-cdk-lib"; // import * as cforigins from "aws-cdk-lib/aws-cloudfront-origins"; import * as kms from "aws-cdk-lib/aws-kms"; import * as s3 from "aws-cdk-lib/aws-s3"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; import {BlockPublicAccess, BucketEncryption} from "aws-cdk-lib/aws-s3"; import * as k9 from "../lib"; @@ -182,6 +183,44 @@ const cloudfrontOACBucketPolicyProps: k9.s3.K9BucketPolicyProps = { k9.s3.grantAccessViaResourcePolicy(stack, "CloudFrontOACBucket", cloudfrontOACBucketPolicyProps); +// Demonstrate generating and applying a DynamoDB resource policy +const ddbResourcePolicyProps: k9.dynamodb.K9DynamoDBResourcePolicyProps = { + k9DesiredAccess: new Array( + { + accessCapabilities: k9.k9policy.AccessCapability.ADMINISTER_RESOURCE, + allowPrincipalArns: administerResourceArns, + }, + { + accessCapabilities: k9.k9policy.AccessCapability.READ_CONFIG, + allowPrincipalArns: readConfigArns.concat([ + "arn:aws:iam::139710491120:role/aws-service-role/access-analyzer.amazonaws.com/AWSServiceRoleForAccessAnalyzer" + ]), + }, + { + accessCapabilities: k9.k9policy.AccessCapability.READ_DATA, + allowPrincipalArns: readWriteDataArns, + }, + { + accessCapabilities: k9.k9policy.AccessCapability.WRITE_DATA, + allowPrincipalArns: readWriteDataArns, + }, + { + accessCapabilities: k9.k9policy.AccessCapability.DELETE_DATA, + allowPrincipalArns: readWriteDataArns, + }, + ) +}; + + +const ddbResourcePolicy = k9.dynamodb.makeResourcePolicy(ddbResourcePolicyProps); + +const table = new dynamodb.TableV2(stack, 'k9-cdk-v2-int-test', { + partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, + removalPolicy: cdk.RemovalPolicy.DESTROY, + resourcePolicy: ddbResourcePolicy +}); + + for (let construct of [bucket, websiteBucket, autoDeleteBucket, @@ -189,6 +228,7 @@ for (let construct of [bucket, // cloudfrontDistribution, cloudfrontOACBucket, cloudfrontOACKey, + table, ]) { Tags.of(construct).add('k9security:analysis', 'include'); } diff --git a/resources/capability_summary.json b/resources/capability_summary.json index e2b03f4..511c2e9 100644 --- a/resources/capability_summary.json +++ b/resources/capability_summary.json @@ -70,70 +70,49 @@ "DynamoDB": { "administer-resource": [ "dynamodb:CreateBackup", - "dynamodb:CreateGlobalTable", - "dynamodb:CreateTable", - "dynamodb:CreateTableReplica", + "dynamodb:DeleteResourcePolicy", "dynamodb:DeleteTableReplica", + "dynamodb:DisableKinesisStreamingDestination", + "dynamodb:EnableKinesisStreamingDestination", "dynamodb:ExportTableToPointInTime", - "dynamodb:PurchaseReservedCapacityOfferings", - "dynamodb:RestoreTableFromBackup", + "dynamodb:PutResourcePolicy", "dynamodb:RestoreTableToPointInTime", "dynamodb:TagResource", "dynamodb:UntagResource", "dynamodb:UpdateContinuousBackups", "dynamodb:UpdateContributorInsights", - "dynamodb:UpdateGlobalTable", - "dynamodb:UpdateGlobalTableSettings", + "dynamodb:UpdateKinesisStreamingDestination", "dynamodb:UpdateTable", "dynamodb:UpdateTableReplicaAutoScaling", "dynamodb:UpdateTimeToLive" ], "delete-data": [ - "dynamodb:DeleteBackup", "dynamodb:DeleteItem", "dynamodb:DeleteTable", "dynamodb:DeleteTableReplica", "dynamodb:PartiQLDelete" ], "read-config": [ - "dynamodb:DescribeBackup", "dynamodb:DescribeContinuousBackups", "dynamodb:DescribeContributorInsights", "dynamodb:DescribeExport", - "dynamodb:DescribeGlobalTable", - "dynamodb:DescribeGlobalTableSettings", - "dynamodb:DescribeLimits", - "dynamodb:DescribeReservedCapacity", - "dynamodb:DescribeReservedCapacityOfferings", - "dynamodb:DescribeStream", + "dynamodb:DescribeKinesisStreamingDestination", "dynamodb:DescribeTable", "dynamodb:DescribeTableReplicaAutoScaling", "dynamodb:DescribeTimeToLive", - "dynamodb:ListBackups", - "dynamodb:ListContributorInsights", - "dynamodb:ListExports", - "dynamodb:ListGlobalTables", - "dynamodb:ListStreams", - "dynamodb:ListTables", - "dynamodb:ListTagsOfResource", - "dynamodbstreams:DescribeStream", - "dynamodbstreams:ListStreams" + "dynamodb:GetResourcePolicy", + "dynamodb:ListTagsOfResource" ], "read-data": [ "dynamodb:BatchGetItem", "dynamodb:ConditionCheckItem", "dynamodb:GetItem", - "dynamodb:GetRecords", - "dynamodb:GetShardIterator", "dynamodb:PartiQLSelect", "dynamodb:Query", - "dynamodb:Scan", - "dynamodbstreams:GetRecords", - "dynamodbstreams:GetShardIterator" + "dynamodb:Scan" ], "write-data": [ "dynamodb:BatchWriteItem", - "dynamodb:CreateTableReplica", "dynamodb:PartiQLInsert", "dynamodb:PartiQLUpdate", "dynamodb:PutItem", diff --git a/src/dynamodb.ts b/src/dynamodb.ts new file mode 100644 index 0000000..bab9e7d --- /dev/null +++ b/src/dynamodb.ts @@ -0,0 +1,92 @@ +import { AccountRootPrincipal, Effect, PolicyDocument, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { + AccessCapability, + canPrincipalsManageResources, + getAccessCapabilityFromValue, + IAccessSpec, + K9PolicyFactory, +} from './k9policy'; + + +export interface K9DynamoDBResourcePolicyProps { + readonly k9DesiredAccess: Array; +} + +let SUPPORTED_CAPABILITIES = new Array( + AccessCapability.ADMINISTER_RESOURCE, + AccessCapability.READ_CONFIG, + AccessCapability.READ_DATA, + AccessCapability.WRITE_DATA, + AccessCapability.DELETE_DATA, +); + +export const SID_DENY_EVERYONE_ELSE = 'DenyEveryoneElse'; + +/** + * Generate a DynamoDB resource policy from the provided props that can be attached to DynamoDB + * resources, particularly tables & indices. + * + * @param props specifying desired access + * @return a PolicyDocument that can be attached to DynamoDB resources + */ +export function makeResourcePolicy(props: K9DynamoDBResourcePolicyProps): PolicyDocument { + const policyFactory = new K9PolicyFactory(); + const policy = new iam.PolicyDocument(); + + const resourceArns = ['*']; + + let accessSpecsByCapabilityRecs = policyFactory.mergeDesiredAccessSpecsByCapability(SUPPORTED_CAPABILITIES, props.k9DesiredAccess); + let accessSpecsByCapability: Map = new Map(); + + for (let [capabilityStr, accessSpec] of Object.entries(accessSpecsByCapabilityRecs)) { + accessSpecsByCapability.set(getAccessCapabilityFromValue(capabilityStr), accessSpec); + } + + if (!canPrincipalsManageResources(accessSpecsByCapability)) { + throw Error('At least one principal must be able to administer and read-config for DynamoDB resources' + + ' so data data remains accessible; found:\n' + + `administer-resource: '${accessSpecsByCapability.get(AccessCapability.ADMINISTER_RESOURCE)?.allowPrincipalArns}'\n` + + `read-config: '${accessSpecsByCapability.get(AccessCapability.READ_CONFIG)?.allowPrincipalArns}'`, + ); + } + + const allowStatements = policyFactory.makeAllowStatements('DynamoDB', + SUPPORTED_CAPABILITIES, + Array.from(accessSpecsByCapability.values()), + resourceArns, + true); + policy.addStatements(...allowStatements); + + const denyEveryoneElseStatement = new PolicyStatement({ + sid: SID_DENY_EVERYONE_ELSE, + effect: Effect.DENY, + principals: policyFactory.makeDenyEveryoneElsePrincipals(), + actions: ['dynamodb:*'], + resources: resourceArns, + }); + denyEveryoneElseStatement.addCondition('Bool', { + 'aws:PrincipalIsAWSService': ['false'], + }); + const denyEveryoneElseTest = policyFactory.wasLikeUsed(props.k9DesiredAccess) ? + 'ArnNotLike' : + 'ArnNotEquals'; + const allAllowedPrincipalArns = policyFactory.getAllowedPrincipalArns(props.k9DesiredAccess); + const accountRootPrincipal = new AccountRootPrincipal(); + denyEveryoneElseStatement.addCondition(denyEveryoneElseTest, { + 'aws:PrincipalArn': [ + // Place Root Principal arn in stable, prominent position; + // will render as an object Fn::Join'ing Partition & AccountId + accountRootPrincipal.arn, + ...allAllowedPrincipalArns, + ], + }); + + policy.addStatements( + denyEveryoneElseStatement, + ); + + policy.validateForResourcePolicy(); + + return policy; +} diff --git a/src/index.ts b/src/index.ts index 263e9ee..dd1de73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ export * as k9policy from './k9policy'; +export * as dynamodb from './dynamodb'; export * as kms from './kms'; export * as s3 from './s3'; diff --git a/src/k9policy.ts b/src/k9policy.ts index 07f31d1..761a8fd 100644 --- a/src/k9policy.ts +++ b/src/k9policy.ts @@ -61,6 +61,49 @@ export interface IAWSServiceAccessGenerator { makeConditionsToExceptFromDenyEveryoneElse(): Conditions; } +/** + * Check whether the provided access specs ensure that at least one principal can both read and administer configuration. + * @param accessSpecsByCapability is a map of access specs keyed by access capability + * + * @return true when at least one principal that can administer and read configuration exists + */ +export function canPrincipalsManageResources(accessSpecsByCapability: Map) { + let adminSpec = accessSpecsByCapability.get(AccessCapability.ADMINISTER_RESOURCE); + let readConfigSpec = accessSpecsByCapability.get(AccessCapability.READ_CONFIG); + + if ((adminSpec?.allowPrincipalArns && adminSpec.allowPrincipalArns.length > 0) + && (readConfigSpec?.allowPrincipalArns && readConfigSpec.allowPrincipalArns.length > 0)) { + const adminPrincipals = new Set(adminSpec.allowPrincipalArns); + const readConfigPrincipals = new Set(readConfigSpec.allowPrincipalArns); + const intersection = new Set( + [...adminPrincipals].filter(x => readConfigPrincipals.has(x))); + return intersection.size > 0; + } + return false; +} + + +/** + * Converts a string to PascalCase, which is useful for e.g. policy types that don't + * do not support spaces or hyphens in statement ids. + * + * @param input + */ +export function toPascalCase(input: string): string { + // Remove placeholders like ${something} and trim whitespace + const cleanedInput = input.replace(/\$\{.*?\}/g, '').trim(); + + // Split the input into words based on spaces, hyphens, underscores, or other delimiters + const words = cleanedInput.split(/[\s_\-]+/); + + // Convert each word to PascalCase + return words + .map( + word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), // Capitalize the first letter, lower the rest + ) + .join(''); +} + export class K9PolicyFactory { /** @@ -86,6 +129,7 @@ export class K9PolicyFactory { _SUPPORTED_SERVICES = new Set([ 'S3', 'KMS', + 'DynamoDB', ]); /** @internal */ @@ -178,7 +222,8 @@ export class K9PolicyFactory { makeAllowStatements(serviceName: string, supportedCapabilities: Array, desiredAccess: Array, - resourceArns: Array): Array { + resourceArns: Array, + usePascalCase: boolean = false): Array { let policyStatements = new Array(); let accessSpecsByCapabilityRecs = this.mergeDesiredAccessSpecsByCapability(supportedCapabilities, desiredAccess); let accessSpecsByCapability: Map = new Map(); @@ -201,7 +246,12 @@ export class K9PolicyFactory { let arnConditionTest = accessSpec.test || 'ArnEquals'; - let statement = this.makeAllowStatement(`Allow Restricted ${supportedCapability}`, + let sid = `Allow Restricted ${supportedCapability}`; + if (usePascalCase) { + sid = toPascalCase(sid); + } + + let statement = this.makeAllowStatement(sid, this.getActions(serviceName, supportedCapability), accessSpec.allowPrincipalArns, arnConditionTest, @@ -273,4 +323,3 @@ export class K9PolicyFactory { } } - diff --git a/src/kms.ts b/src/kms.ts index e31cd3a..af7b617 100644 --- a/src/kms.ts +++ b/src/kms.ts @@ -9,6 +9,7 @@ import { } from 'aws-cdk-lib/aws-iam'; import { AccessCapability, + canPrincipalsManageResources, getAccessCapabilityFromValue, IAccessSpec, IAWSServiceAccessGenerator, @@ -90,21 +91,6 @@ export class CloudFrontOACReadAccessGenerator implements IAWSServiceAccessGenera } -function canPrincipalsManageKey(accessSpecsByCapability: Map) { - let adminSpec = accessSpecsByCapability.get(AccessCapability.ADMINISTER_RESOURCE); - let readConfigSpec = accessSpecsByCapability.get(AccessCapability.READ_CONFIG); - - if ((adminSpec?.allowPrincipalArns && adminSpec.allowPrincipalArns.length > 0) - && (readConfigSpec?.allowPrincipalArns && readConfigSpec.allowPrincipalArns.length > 0)) { - const adminPrincipals = new Set(adminSpec.allowPrincipalArns); - const readConfigPrincipals = new Set(readConfigSpec.allowPrincipalArns); - const intersection = new Set( - [...adminPrincipals].filter(x => readConfigPrincipals.has(x))); - return intersection.size > 0; - } - return false; -} - export function makeKeyPolicy(props: K9KeyPolicyProps): PolicyDocument { const policyFactory = new K9PolicyFactory(); const policy = new iam.PolicyDocument(); @@ -118,7 +104,7 @@ export function makeKeyPolicy(props: K9KeyPolicyProps): PolicyDocument { accessSpecsByCapability.set(getAccessCapabilityFromValue(capabilityStr), accessSpec); } - if (!canPrincipalsManageKey(accessSpecsByCapability)) { + if (!canPrincipalsManageResources(accessSpecsByCapability)) { throw Error('At least one principal must be able to administer and read-config for keys' + ' so encrypted data remains accessible; found:\n' + `administer-resource: '${accessSpecsByCapability.get(AccessCapability.ADMINISTER_RESOURCE)?.allowPrincipalArns}'\n` + diff --git a/test/__snapshots__/k9.test.ts.snap b/test/__snapshots__/k9.test.ts.snap index e122bd9..e241a17 100644 --- a/test/__snapshots__/k9.test.ts.snap +++ b/test/__snapshots__/k9.test.ts.snap @@ -1,5 +1,213 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`DynamoDBResourcePolicy Typical usage 1`] = ` +Object { + "Resources": Object { + "testtabletypicalusageD052F216": Object { + "DeletionPolicy": "Delete", + "Properties": Object { + "AttributeDefinitions": Array [ + Object { + "AttributeName": "pk", + "AttributeType": "S", + }, + ], + "BillingMode": "PAY_PER_REQUEST", + "KeySchema": Array [ + Object { + "AttributeName": "pk", + "KeyType": "HASH", + }, + ], + "Replicas": Array [ + Object { + "Region": "us-east-1", + "ResourcePolicy": Object { + "PolicyDocument": Object { + "Statement": Array [ + Object { + "Action": Array [ + "dynamodb:CreateBackup", + "dynamodb:DeleteResourcePolicy", + "dynamodb:DeleteTableReplica", + "dynamodb:DisableKinesisStreamingDestination", + "dynamodb:EnableKinesisStreamingDestination", + "dynamodb:ExportTableToPointInTime", + "dynamodb:PutResourcePolicy", + "dynamodb:RestoreTableToPointInTime", + "dynamodb:TagResource", + "dynamodb:UntagResource", + "dynamodb:UpdateContinuousBackups", + "dynamodb:UpdateContributorInsights", + "dynamodb:UpdateKinesisStreamingDestination", + "dynamodb:UpdateTable", + "dynamodb:UpdateTableReplicaAutoScaling", + "dynamodb:UpdateTimeToLive", + ], + "Condition": Object { + "ArnEquals": Object { + "aws:PrincipalArn": Array [ + "arn:aws:iam::139710491120:user/ci", + ], + }, + }, + "Effect": "Allow", + "Principal": Object { + "AWS": "*", + }, + "Resource": "*", + "Sid": "AllowRestrictedAdministerResource", + }, + Object { + "Action": Array [ + "dynamodb:DescribeContinuousBackups", + "dynamodb:DescribeContributorInsights", + "dynamodb:DescribeExport", + "dynamodb:DescribeKinesisStreamingDestination", + "dynamodb:DescribeTable", + "dynamodb:DescribeTableReplicaAutoScaling", + "dynamodb:DescribeTimeToLive", + "dynamodb:GetResourcePolicy", + "dynamodb:ListTagsOfResource", + ], + "Condition": Object { + "ArnEquals": Object { + "aws:PrincipalArn": Array [ + "arn:aws:iam::139710491120:user/ci", + ], + }, + }, + "Effect": "Allow", + "Principal": Object { + "AWS": "*", + }, + "Resource": "*", + "Sid": "AllowRestrictedReadConfig", + }, + Object { + "Action": Array [ + "dynamodb:BatchGetItem", + "dynamodb:ConditionCheckItem", + "dynamodb:GetItem", + "dynamodb:PartiQLSelect", + "dynamodb:Query", + "dynamodb:Scan", + ], + "Condition": Object { + "ArnEquals": Object { + "aws:PrincipalArn": Array [ + "arn:aws:iam::123456789012:role/app-backend", + "arn:aws:iam::123456789012:role/customer-service", + ], + }, + }, + "Effect": "Allow", + "Principal": Object { + "AWS": "*", + }, + "Resource": "*", + "Sid": "AllowRestrictedReadData", + }, + Object { + "Action": Array [ + "dynamodb:BatchWriteItem", + "dynamodb:PartiQLInsert", + "dynamodb:PartiQLUpdate", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + ], + "Condition": Object { + "ArnEquals": Object { + "aws:PrincipalArn": Array [ + "arn:aws:iam::123456789012:role/app-backend", + ], + }, + }, + "Effect": "Allow", + "Principal": Object { + "AWS": "*", + }, + "Resource": "*", + "Sid": "AllowRestrictedWriteData", + }, + Object { + "Action": Array [ + "dynamodb:DeleteItem", + "dynamodb:DeleteTable", + "dynamodb:DeleteTableReplica", + "dynamodb:PartiQLDelete", + ], + "Condition": Object { + "ArnEquals": Object { + "aws:PrincipalArn": Array [ + "arn:aws:iam::139710491120:user/super-admin", + ], + }, + }, + "Effect": "Allow", + "Principal": Object { + "AWS": "*", + }, + "Resource": "*", + "Sid": "AllowRestrictedDeleteData", + }, + Object { + "Action": "dynamodb:*", + "Condition": Object { + "ArnNotEquals": Object { + "aws:PrincipalArn": Array [ + Object { + "Fn::Join": Array [ + "", + Array [ + "arn:", + Object { + "Ref": "AWS::Partition", + }, + ":iam::", + Object { + "Ref": "AWS::AccountId", + }, + ":root", + ], + ], + }, + "arn:aws:iam::139710491120:user/ci", + "arn:aws:iam::123456789012:role/app-backend", + "arn:aws:iam::123456789012:role/customer-service", + "arn:aws:iam::139710491120:user/super-admin", + ], + }, + "Bool": Object { + "aws:PrincipalIsAWSService": Array [ + "false", + ], + }, + }, + "Effect": "Deny", + "Principal": Object { + "AWS": Array [ + "*", + "*", + ], + }, + "Resource": "*", + "Sid": "DenyEveryoneElse", + }, + ], + "Version": "2012-10-17", + }, + }, + }, + ], + }, + "Type": "AWS::DynamoDB::GlobalTable", + "UpdateReplacePolicy": "Delete", + }, + }, +} +`; + exports[`K9BucketPolicy - IAccessSpec with set of capabilities 1`] = ` Object { "Resources": Object { diff --git a/test/k9.test.ts b/test/k9.test.ts index 76713bb..72a6b91 100644 --- a/test/k9.test.ts +++ b/test/k9.test.ts @@ -1,4 +1,5 @@ import { expect as expectCDK, haveResource, SynthUtils } from '@aws-cdk/assert'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import { AddToResourcePolicyResult } from 'aws-cdk-lib/aws-iam'; import * as kms from 'aws-cdk-lib/aws-kms'; import * as s3 from 'aws-cdk-lib/aws-s3'; @@ -16,6 +17,7 @@ import { SID_DENY_UNEXPECTED_ENCRYPTION_METHOD, CloudFrontOACReadAccessGenerator, } from '../lib/s3'; +import { K9DynamoDBResourcePolicyProps } from '../src/dynamodb'; // @ts-ignore // Test the primary public interface to k9 cdk @@ -534,6 +536,84 @@ describe('K9KeyPolicy', () => { }); +describe('DynamoDBResourcePolicy', () => { + const desiredAccess = new Array( + { + accessCapabilities: [ + AccessCapability.ADMINISTER_RESOURCE, + AccessCapability.READ_CONFIG, + ], + allowPrincipalArns: administerResourceArns, + }, + { + accessCapabilities: AccessCapability.WRITE_DATA, + allowPrincipalArns: writeDataArns, + }, + { + accessCapabilities: AccessCapability.READ_DATA, + allowPrincipalArns: readDataArns, + }, + { + accessCapabilities: AccessCapability.DELETE_DATA, + allowPrincipalArns: deleteDataArns, + }, + ); + + test('Typical usage', () => { + const stack = new cdk.Stack(app, 'K9DDBResourcePolicyTestTypicalUsage', { env: { region: 'us-east-1' } }); + + const ddbResourcePolicyProps: K9DynamoDBResourcePolicyProps = { + k9DesiredAccess: desiredAccess, + }; + + let resourcePolicy = k9.dynamodb.makeResourcePolicy(ddbResourcePolicyProps); + console.log('resourcePolicy: ' + stringifyPolicy(resourcePolicy)); + + expect(resourcePolicy).toBeDefined(); + + let policyStr = stringifyPolicy(resourcePolicy); + let policyObj = JSON.parse(policyStr); + let actualPolicyStatements = policyObj.Statement; + + expect(actualPolicyStatements).toBeDefined(); + + const expectStmtIds = [ + SID_DENY_EVERYONE_ELSE, + 'AllowRestrictedAdministerResource', + 'AllowRestrictedReadConfig', + 'AllowRestrictedReadData', + 'AllowRestrictedWriteData', + 'AllowRestrictedDeleteData', + ]; + expect(actualPolicyStatements).toHaveLength(expectStmtIds.length); + + const policyStatementMap: { [key: string]: any } = {}; + for (let stmt of actualPolicyStatements) { + if (stmt.Sid) { + policyStatementMap[stmt.Sid] = stmt; + } + } + + for (let expectStmtId of expectStmtIds) { + expect(policyStatementMap[expectStmtId]).toBeTruthy(); + } + + const table = new dynamodb.TableV2(stack, 'test-table-typical-usage', { + partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, + removalPolicy: cdk.RemovalPolicy.DESTROY, + resourcePolicy: resourcePolicy, + }); + + console.log('table: ' + table); + console.log('table.resourcePolicy: ' + stringifyPolicy(table.resourcePolicy)); + + expectCDK(stack).to(haveResource('AWS::DynamoDB::GlobalTable')); + expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); + }); + +}); + + function assertContainsStatementWithId(expectStmtId:string, statements:any) { let foundStmt = false; console.log(`looking for statement id: ${expectStmtId}`); diff --git a/test/k9policy.test.ts b/test/k9policy.test.ts index 86272c6..a099c9d 100644 --- a/test/k9policy.test.ts +++ b/test/k9policy.test.ts @@ -1,6 +1,13 @@ import { AnyPrincipal, PolicyStatement } from 'aws-cdk-lib/aws-iam'; import { stringifyStatement } from './helpers'; -import { AccessCapability, getAccessCapabilityFromValue, IAccessSpec, K9PolicyFactory } from '../lib/k9policy'; +import { + AccessCapability, + canPrincipalsManageResources, + getAccessCapabilityFromValue, + IAccessSpec, + K9PolicyFactory, + toPascalCase, +} from '../lib/k9policy'; // @ts-ignore const S3_SUPPORTED_CAPABILITIES = new Array( @@ -26,6 +33,86 @@ test('getAccessCapabilityFromValue throws error for undefined capabilities', () }).toThrow('Could not get AccessCapability from value: unknown-capability'); }); +describe('canPrincipalsManageResources', () => { + test('returns false when no principals have both administer-resource and read-config', () => { + const unmanageableCapabilityCombos = [ + [], + [AccessCapability.ADMINISTER_RESOURCE], + [AccessCapability.READ_CONFIG], + [AccessCapability.ADMINISTER_RESOURCE, AccessCapability.WRITE_DATA], + ]; + + for (let unmanageableAccessCapabilities of unmanageableCapabilityCombos) { + let accessSpecsByCapability = new Map(); + + for (let capability of unmanageableAccessCapabilities) { + let unmanageableAccessSpec = { + accessCapabilities: capability, + allowPrincipalArns: [ + 'arn:aws:iam::123456789012:role/not-an-admin', + ], + }; + accessSpecsByCapability.set(capability, unmanageableAccessSpec); + } + + expect(canPrincipalsManageResources(accessSpecsByCapability)).toBeFalsy(); + } + }); + + test('returns false when no there are principals with either administer-resource and read-config but not both', () => { + let accessSpecsByCapability = new Map(); + + accessSpecsByCapability.set(AccessCapability.ADMINISTER_RESOURCE, { + accessCapabilities: AccessCapability.ADMINISTER_RESOURCE, + allowPrincipalArns: [ + 'arn:aws:iam::123456789012:role/not-an-admin-1', + ], + }); + accessSpecsByCapability.set(AccessCapability.READ_CONFIG, { + accessCapabilities: AccessCapability.READ_CONFIG, + allowPrincipalArns: [ + 'arn:aws:iam::123456789012:role/not-an-admin-2', + ], + }); + + expect(canPrincipalsManageResources(accessSpecsByCapability)).toBeFalsy(); + }); + + test('returns true when a principal has both administer-resource and read-config', () => { + const manageableCapabilityCombos = [ + [AccessCapability.ADMINISTER_RESOURCE, AccessCapability.READ_CONFIG], + [AccessCapability.ADMINISTER_RESOURCE, AccessCapability.READ_CONFIG, AccessCapability.WRITE_DATA], + ]; + + for (let manageableAccessCapabilities of manageableCapabilityCombos) { + let accessSpecsByCapability = new Map(); + + for (let capability of manageableAccessCapabilities) { + let unmanageableAccessSpec = { + accessCapabilities: capability, + allowPrincipalArns: [ + 'arn:aws:iam::123456789012:role/admin', + ], + }; + accessSpecsByCapability.set(capability, unmanageableAccessSpec); + } + + expect(canPrincipalsManageResources(accessSpecsByCapability)).toBeTruthy(); + } + }); +}); + + +test('toPascalCase converts classic Allow Restricted X SID', () => { + expect(toPascalCase('Allow Restricted administer-resource')) + .toEqual('AllowRestrictedAdministerResource'); +}); + +test('toPascalCase trims leading and trailing spaces', () => { + expect(toPascalCase(' Allow Restricted With Leading and Trailing Spaces')) + .toEqual('AllowRestrictedWithLeadingAndTrailingSpaces'); +}); + test('K9PolicyFactory#wasLikeUsed', () => { let k9PolicyFactory = new K9PolicyFactory(); expect(k9PolicyFactory.wasLikeUsed([])).toBeFalsy();