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
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",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These permissions for global tables very likely need to stay. The admin user should be allowed to make replicas.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These permissions changes were the result of:

  1. updating to the full current list of DDB permissions
  2. removing permissions not supported by DDB resource policy

Here are the administer-resource permissions from the full set of DDB perms that the DDB resource policy API rejected:

(administer-resource) One or more parameter values were invalid: Invalid policy document: The following action names are invalid: "dynamodb:UpdateGlobalTable", "dynamodb:UpdateGlobalTableSettings", "dynamodb:CreateTable", "dynamodb:UpdateGlobalTableVersion", "dynamodb:CreateGlobalTable", "dynamodb:RestoreTableFromBackup", "dynamodb:RestoreTableFromAwsBackup", "dynamodb:CreateTableReplica", "dynamodb:PurchaseReservedCapacityOfferings", "dynamodb:ImportTable"

(I have similar output for read-config, read-data, and write-data if you are interested)

I can see why you logically you want an admin of a table to create a replica. However, I think that probably has to be granted from the Identity side. That aligns more with how Create[Resource] is generally not supported in resource policy. And in this specific case, I would think the principal CFn uses to create the table resources would be the one that needs permissions.

wdyt?

"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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This works for our needs, but I was wondering if you still wanted to wrap this in a grantAccessViaResourcePolicy method to be consistent with s3.

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' +

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good check to have.

' 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(

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Set has a native intersection method.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, I don't see an intersection method available on TypeScript 5.4.5 & Node 20.14.0 (what I dev on).

Looks like an upgrade might help here. However, it's tough when CDK supports all the way back to Node 14.15.0 and I know we have users on 16.x

[...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 {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you hit a different naming restriction here that forced the casing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. DDB doesn't allow spaces nor hyphens.

// 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
Loading