Skip to content

Commit

Permalink
Add KmsKeyId to LogGroup (#27)
Browse files Browse the repository at this point in the history
  • Loading branch information
benbridts committed Jul 15, 2020
1 parent 4dd22d7 commit 8b9229f
Show file tree
Hide file tree
Showing 15 changed files with 637 additions and 16 deletions.
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Files local to the user
/overrides.json

# Compiled class file
*.class

Expand Down Expand Up @@ -29,6 +32,9 @@ hs_err_pid*
# Build
target/

# Test
.hypothesis/

# Generated Files
.classpath
.factorypath
Expand Down
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ repos:
- --no-sort-keys
- id: check-merge-conflict
- id: check-yaml
args:
# Needed, or !Sub (and other CloudFormation specific tags will not be recognized)
- --unsafe
1 change: 1 addition & 0 deletions aws-logs-loggroup/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ out/

# auto-generated files
target/
docs/README.md

# our logs
rpdk.log
49 changes: 45 additions & 4 deletions aws-logs-loggroup/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# AWS::Logs::LogGroup

Congratulations on starting development! Next steps:
## Developing

1. Write the JSON schema describing your resource, `aws-logs-loggroup.json`
2. The RPDK will automatically generate the correct resource model from the
- The JSON schema describing this resource is `aws-logs-loggroup.json`
- The RPDK will automatically generate the correct resource model from the
schema whenever the project is built via Maven. You can also do this manually
with the following command: `cfn-cli generate`
3. Implement your resource handlers
- Resource handlers live in `src/main/java/software/amazon/logs/loggroup`


Please don't modify files under `target/generated-sources/rpdk`, as they will be
Expand All @@ -15,3 +15,44 @@ automatically overwritten.
The code use [Lombok](https://projectlombok.org/), and [you may have to install
IDE integrations](https://projectlombok.org/) to enable auto-complete for
Lombok-annotated classes.

## Running Contract Tests

You can execute the following commands to run the tests.
You will need to have docker installed and running.

```bash
# Create a CloudFormation stack with development dependencies (a KMS CMK)
# NOTE: this has a monthly cost of 1 USD for the CMK
aws cloudformation deploy \
--stack-name aws-logs-loggroup-dev-resources \
--template-file dev-resources.yaml
# Write the stack output to overrides.json
aws cloudformation describe-stacks \
--stack-name aws-logs-loggroup-dev-resources \
--query "Stacks[0].Outputs[?OutputKey=='OverridesJson'].OutputValue" \
--output text > overrides.json
# Package the code with Maven
mvn package
# Start the code as a lambda function in the background
# You can also run it in a separate terminal (without the & to run it in the foreground)
sam local start-lambda &
# Test the resource handlers by running them with credentials in your account
cfn test
# Stop the lambda function in the background
kill $(jobs -lp | tail -n1)
# Destroy the CLoudFormation stack
# NOTE: destroying and recreating will increase the monthly cost
aws cloudformation delete-stack \
--stack-name aws-logs-loggroup-dev-resources
# Wait for the stack to be completly deleted
aws cloudformation wait stack-delete-complete \
--stack-name aws-logs-loggroup-dev-resources
```

Currently the following tests are broken, see issue [\#25](https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-logs/issues/25)

- contract_create_read_success
- contract_create_list_success
- contract_update_read_success
- contract_update_list_success
13 changes: 11 additions & 2 deletions aws-logs-loggroup/aws-logs-loggroup.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,18 @@
"sourceUrl": "https://github.com/aws-cloudformation/aws-cloudformation-resource-providers-logs.git",
"properties": {
"LogGroupName": {
"description": "The name of the log group. If you don't specify a name, AWS CloudFormation generates a unique ID for the log group. ",
"description": "The name of the log group. If you don't specify a name, AWS CloudFormation generates a unique ID for the log group.",
"type": "string",
"minLength": 1,
"maxLength": 512,
"pattern": "^[.\\-_/#A-Za-z0-9]{1,512}\\Z"
},
"KmsKeyId": {
"description": "The Amazon Resource Name (ARN) of the CMK to use when encrypting log data.",
"type": "string",
"maxLength": 256,
"pattern": "^arn:[a-z0-9-]+:kms:[a-z0-9-]+:\\d{12}:(key|alias)/.+\\Z"
},
"RetentionInDays": {
"description": "The number of days to retain the log events in the specified log group. Possible values are: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, and 3653.",
"type": "integer",
Expand Down Expand Up @@ -42,7 +48,8 @@
"create": {
"permissions": [
"logs:DescribeLogGroups",
"logs:CreateLogGroup"
"logs:CreateLogGroup",
"logs:PutRetentionPolicy"
]
},
"read": {
Expand All @@ -53,6 +60,8 @@
"update": {
"permissions": [
"logs:DescribeLogGroups",
"logs:AssociateKmsKey",
"logs:DisassociateKmsKey",
"logs:PutRetentionPolicy",
"logs:DeleteRetentionPolicy"
]
Expand Down
39 changes: 39 additions & 0 deletions aws-logs-loggroup/dev-resources.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
AWSTemplateFormatVersion: "2010-09-09"
Description: Development dependencies for AWS::Logs::LogGroup
Resources:
Cmk:
Type: AWS::KMS::Key
Properties:
Description: KMS CMK for use with the AWS::Logs::LogGroup resource provider
KeyUsage: ENCRYPT_DECRYPT
KeyPolicy:
Version: 2012-10-17
Id: cmk-policy
Statement:
- Sid: Enable IAM User Permissions
Effect: Allow
Principal:
AWS: !Sub "arn:${AWS::Partition}:iam::${AWS::AccountId}:root"
Action: kms:*
Resource: "*"
- Sid: CloudWatch Logs Permissions
Effect: Allow
Principal:
Service: !Sub "logs.${AWS::Region}.${AWS::URLSuffix}"
Action:
- "kms:Encrypt*"
- "kms:Decrypt*"
- "kms:ReEncrypt*"
- "kms:GenerateDataKey*"
- "kms:Describe*"
Resource: "*"
# Alias is only added to show the StackName in the KMS Console
CmkAlias:
Type: "AWS::KMS::Alias"
Properties:
AliasName: !Sub "arn:${AWS::Partition}:kms:${AWS::Region}:${AWS::AccountId}:alias/${AWS::StackName}"
TargetKeyId: !Ref Cmk
Outputs:
OverridesJson:
Description: Use this as overrides.json
Value: !Sub "{\"CREATE\": {\"/KmsKeyId\": \"${Cmk.Arn}\"}}"
2 changes: 2 additions & 0 deletions aws-logs-loggroup/resource-role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ Resources:
Statement:
- Effect: Allow
Action:
- "logs:AssociateKmsKey"
- "logs:CreateLogGroup"
- "logs:DeleteLogGroup"
- "logs:DeleteRetentionPolicy"
- "logs:DescribeLogGroups"
- "logs:DisassociateKmsKey"
- "logs:PutRetentionPolicy"
Resource: "*"
Outputs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.DescribeLogGroupsResponse;
import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.AssociateKmsKeyRequest;

import java.util.Collection;
import java.util.List;
Expand Down Expand Up @@ -39,6 +41,7 @@ static DeleteLogGroupRequest translateToDeleteRequest(final ResourceModel model)
static CreateLogGroupRequest translateToCreateRequest(final ResourceModel model) {
return CreateLogGroupRequest.builder()
.logGroupName(model.getLogGroupName())
.kmsKeyId(model.getKmsKeyId())
.build();
}

Expand All @@ -51,8 +54,21 @@ static PutRetentionPolicyRequest translateToPutRetentionPolicyRequest(final Reso

static DeleteRetentionPolicyRequest translateToDeleteRetentionPolicyRequest(final ResourceModel model) {
return DeleteRetentionPolicyRequest.builder()
.logGroupName(model.getLogGroupName())
.build();
.logGroupName(model.getLogGroupName())
.build();
}

static DisassociateKmsKeyRequest translateToDisassociateKmsKeyRequest(final ResourceModel model) {
return DisassociateKmsKeyRequest.builder()
.logGroupName(model.getLogGroupName())
.build();
}

static AssociateKmsKeyRequest translateToAssociateKmsKeyRequest(final ResourceModel model) {
return AssociateKmsKeyRequest.builder()
.logGroupName(model.getLogGroupName())
.kmsKeyId(model.getKmsKeyId())
.build();
}

static ResourceModel translateForRead(final DescribeLogGroupsResponse response) {
Expand All @@ -62,19 +78,25 @@ static ResourceModel translateForRead(final DescribeLogGroupsResponse response)
.findAny()
.orElse(null);
final String logGroupArn = streamOfOrEmpty(response.logGroups())
.map(software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup::arn)
.filter(Objects::nonNull)
.findAny()
.orElse(null);
.map(software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup::arn)
.filter(Objects::nonNull)
.findAny()
.orElse(null);
final Integer retentionInDays = streamOfOrEmpty(response.logGroups())
.map(software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup::retentionInDays)
.filter(Objects::nonNull)
.findAny()
.orElse(null);
final String kmsKeyId = streamOfOrEmpty(response.logGroups())
.map(software.amazon.awssdk.services.cloudwatchlogs.model.LogGroup::kmsKeyId)
.filter(Objects::nonNull)
.findAny()
.orElse(null);
return ResourceModel.builder()
.arn(logGroupArn)
.logGroupName(logGroupName)
.retentionInDays(retentionInDays)
.kmsKeyId(kmsKeyId)
.build();
}

Expand All @@ -84,6 +106,7 @@ static List<ResourceModel> translateForList(final DescribeLogGroupsResponse resp
.arn(logGroup.arn())
.logGroupName(logGroup.logGroupName())
.retentionInDays(logGroup.retentionInDays())
.kmsKeyId(logGroup.kmsKeyId())
.build())
.collect(Collectors.toList());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package software.amazon.logs.loggroup;

import software.amazon.cloudformation.exceptions.CfnInternalFailureException;
import software.amazon.cloudformation.exceptions.CfnResourceConflictException;
import software.amazon.cloudformation.exceptions.CfnServiceInternalErrorException;
import software.amazon.cloudformation.proxy.AmazonWebServicesClientProxy;
import software.amazon.cloudformation.proxy.Logger;
import software.amazon.cloudformation.proxy.ProgressEvent;
import software.amazon.cloudformation.proxy.ResourceHandlerRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.DeleteRetentionPolicyRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.PutRetentionPolicyRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.DisassociateKmsKeyRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.AssociateKmsKeyRequest;
import software.amazon.awssdk.services.cloudwatchlogs.model.InvalidParameterException;
import software.amazon.awssdk.services.cloudwatchlogs.model.OperationAbortedException;
import software.amazon.awssdk.services.cloudwatchlogs.model.ResourceNotFoundException;
import software.amazon.awssdk.services.cloudwatchlogs.model.ServiceUnavailableException;

import java.util.Objects;

Expand All @@ -19,15 +27,26 @@ public ProgressEvent<ResourceModel, CallbackContext> handleRequest(
final CallbackContext callbackContext,
final Logger logger) {

// RetentionPolicyInDays is the only attribute that is not createOnly
// Everything except RetentionPolicyInDays and KmsKeyId is createOnly
final ResourceModel model = request.getDesiredResourceState();

if (model.getRetentionInDays() == null) {
final ResourceModel previousModel = request.getPreviousResourceState();
final boolean retentionChanged = ! retentionUnchanged(previousModel, model);
final boolean kmsKeyChanged = ! kmsKeyUnchanged(previousModel, model);
if (retentionChanged && model.getRetentionInDays() == null) {
deleteRetentionPolicy(proxy, request, logger);
} else {
} else if (retentionChanged){
putRetentionPolicy(proxy, request, logger);
}

// It can take up to five minutes for the (dis)associate operation to take effect
// It's unclear from the documentation if that state can be checked via the API.
// https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/encrypt-log-data-kms.html
if (kmsKeyChanged && model.getKmsKeyId() == null) {
disassociateKmsKey(proxy, request, logger);
} else if (kmsKeyChanged) {
associateKmsKey(proxy, request, logger);
}

return ProgressEvent.defaultSuccessHandler(model);
}

Expand Down Expand Up @@ -69,8 +88,77 @@ private void putRetentionPolicy(final AmazonWebServicesClientProxy proxy,
logger.log(retentionPolicyMessage);
}

private void disassociateKmsKey(final AmazonWebServicesClientProxy proxy,
final ResourceHandlerRequest<ResourceModel> request,
final Logger logger) {
final ResourceModel model = request.getDesiredResourceState();
final DisassociateKmsKeyRequest disassociateKmsKeyRequest =
Translator.translateToDisassociateKmsKeyRequest(model);
try {
proxy.injectCredentialsAndInvokeV2(disassociateKmsKeyRequest,
ClientBuilder.getClient()::disassociateKmsKey);
} catch (final ResourceNotFoundException e) {
// The specified resource does not exist.
throwNotFoundException(model);
} catch (final InvalidParameterException e) {
// A parameter is specified incorrectly. We should be passing valid parameters.
throw new CfnInternalFailureException(e);
} catch (final OperationAbortedException e){
// Multiple requests to update the same resource were in conflict.
throw new CfnResourceConflictException(ResourceModel.TYPE_NAME,
Objects.toString(model.getPrimaryIdentifier()), "OperationAborted", e);
} catch (final ServiceUnavailableException e) {
// The service cannot complete the request.
throw new CfnServiceInternalErrorException(e);
}

final String kmsKeyMessage =
String.format("%s [%s] successfully disassociated kms key.",
ResourceModel.TYPE_NAME, model.getLogGroupName());
logger.log(kmsKeyMessage);
}

private void associateKmsKey(final AmazonWebServicesClientProxy proxy,
final ResourceHandlerRequest<ResourceModel> request,
final Logger logger) {
final ResourceModel model = request.getDesiredResourceState();
final AssociateKmsKeyRequest associateKmsKeyRequest =
Translator.translateToAssociateKmsKeyRequest(model);
try {
proxy.injectCredentialsAndInvokeV2(associateKmsKeyRequest,
ClientBuilder.getClient()::associateKmsKey);
} catch (final ResourceNotFoundException e) {
// The specified resource does not exist.
throwNotFoundException(model);
} catch (final InvalidParameterException e) {
// A parameter is specified incorrectly. We should be passing valid parameters.
throw new CfnInternalFailureException(e);
} catch (final OperationAbortedException e){
// Multiple requests to update the same resource were in conflict.
throw new CfnResourceConflictException(ResourceModel.TYPE_NAME,
Objects.toString(model.getPrimaryIdentifier()), "OperationAborted", e);
} catch (final ServiceUnavailableException e) {
// The service cannot complete the request.
throw new CfnServiceInternalErrorException(e);
}

final String kmsKeyMessage =
String.format("%s [%s] successfully associated kms key: [%s].",
ResourceModel.TYPE_NAME, model.getLogGroupName(), model.getKmsKeyId());
logger.log(kmsKeyMessage);
}

private void throwNotFoundException(final ResourceModel model) {
throw new software.amazon.cloudformation.exceptions.ResourceNotFoundException(ResourceModel.TYPE_NAME,
Objects.toString(model.getPrimaryIdentifier()));
}


private static boolean retentionUnchanged(final ResourceModel previousModel, final ResourceModel model) {
return (previousModel != null && model.getRetentionInDays().equals(previousModel.getRetentionInDays()));
}

private static boolean kmsKeyUnchanged(final ResourceModel previousModel, final ResourceModel model) {
return (previousModel != null && model.getKmsKeyId().equals(previousModel.getKmsKeyId()));
}
}
Loading

0 comments on commit 8b9229f

Please sign in to comment.