Skip to content

Commit

Permalink
Merge pull request #33 from k9securityio/feat-generate-ddb-resource-p…
Browse files Browse the repository at this point in the history
…olicies

feat: Generate DynamoDB resource policies
  • Loading branch information
skuenzli authored Dec 12, 2024
2 parents 4f8bd6b + 716d347 commit e356b7f
Show file tree
Hide file tree
Showing 10 changed files with 597 additions and 53 deletions.
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [[email protected]](mailto:[email protected]?subject=k9-cdk).

Expand All @@ -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();
Expand Down Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions bin/k9-cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -182,13 +183,52 @@ 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<k9.k9policy.IAccessSpec>(
{
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,
key,
// cloudfrontDistribution,
cloudfrontOACBucket,
cloudfrontOACKey,
table,
]) {
Tags.of(construct).add('k9security:analysis', 'include');
}
39 changes: 9 additions & 30 deletions resources/capability_summary.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
92 changes: 92 additions & 0 deletions src/dynamodb.ts
Original file line number Diff line number Diff line change
@@ -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<IAccessSpec>;
}

let SUPPORTED_CAPABILITIES = new Array<AccessCapability>(
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<AccessCapability, IAccessSpec> = 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;
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * as k9policy from './k9policy';
export * as dynamodb from './dynamodb';
export * as kms from './kms';
export * as s3 from './s3';
55 changes: 52 additions & 3 deletions src/k9policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AccessCapability, IAccessSpec>) {
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<string>(adminSpec.allowPrincipalArns);
const readConfigPrincipals = new Set<string>(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 {

/**
Expand All @@ -86,6 +129,7 @@ export class K9PolicyFactory {
_SUPPORTED_SERVICES = new Set<string>([
'S3',
'KMS',
'DynamoDB',
]);

/** @internal */
Expand Down Expand Up @@ -178,7 +222,8 @@ export class K9PolicyFactory {
makeAllowStatements(serviceName: string,
supportedCapabilities: Array<AccessCapability>,
desiredAccess: Array<IAccessSpec>,
resourceArns: Array<string>): Array<PolicyStatement> {
resourceArns: Array<string>,
usePascalCase: boolean = false): Array<PolicyStatement> {
let policyStatements = new Array<PolicyStatement>();
let accessSpecsByCapabilityRecs = this.mergeDesiredAccessSpecsByCapability(supportedCapabilities, desiredAccess);
let accessSpecsByCapability: Map<AccessCapability, IAccessSpec> = new Map();
Expand All @@ -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,
Expand Down Expand Up @@ -273,4 +323,3 @@ export class K9PolicyFactory {
}

}

Loading

0 comments on commit e356b7f

Please sign in to comment.