Baseline Environment on AWS(BLEA) を読む
参画しているプロジェクトでCDKを使っているが、設定値をyamlに定義して読み込む形をとっている。
yamlからコードベースにすることを検討しており、アーキテクチャを考えるのにBLEAのソースコードと周辺資料を読み込んで参考にできないかを考える。
まずはドキュメントから、日本語のREADMEがありがたい
おぉ、 pre-commit hookで Linter, Formatter, git-secrets のチェックをしているのか。
simple-git-hooks
というツールを使うと .git/config
を汚さないらしい。なんと
環境変数でpre commit hook をスキップできるのか、、、ええやん
# Set the environment variable
export SKIP_SIMPLE_GIT_HOOKS=1
# Subsequent Git commands will skip the hooks
git add .
git commit -m "commit message" # pre-commit hooks are bypassed
git push origin main # pre-push hooks are bypassed
デプロイの際に必要となる デプロイ先アカウントや通知先メールアドレスなど、各ユースケース固有のパラメータを指定する必要があります。 BLEA では parameter.ts というファイルでパラメータを管理します。書式は TypeScript です。
環境毎に異なる値はparameter.tsに定義
import { Environment } from 'aws-cdk-lib';
export interface AppParameter {
env?: Environment;
envName: string;
securityNotifyEmail: string;
securitySlackWorkspaceId: string; // required if deploy via CLI
securitySlackChannelId: string; // required if deploy via CLI
}
// Example
export const devParameter: AppParameter = {
envName: 'Development',
securityNotifyEmail: 'notify-security@example.com',
securitySlackWorkspaceId: 'T8XXXXXXX',
securitySlackChannelId: 'C00XXXXXXXX',
// env: { account: '123456789012', region: 'ap-northeast-1' },
};
このサンプルは devParameter というパラメータセットを定義する例です。同様の設定を検証、本番アカウントにもデプロイできるようにするには、stagingParameterやprodParameterといったパラメータセットを定義し、App (ここでは bin/blea-gov-base-standalone.ts)でそれぞれの環境のスタックを作成します。
Pythonだとinterface無いし、↓みたいに実装すれば必須パラメータの定義漏れだったり、デフォルト値の設定ができるかな。
from abc import ABC
from aws_cdk import (
Environment, )
class AppParameter(ABC):
def __init__(self,
env: Environment,
env_name: str,
security_notify_email: str,
security_slack_workspace_id: str = None,
security_slack_channel_id: str = None) -> None:
self.env = env
self.env_name = env_name
self.security_notify_email = security_notify_email
if bool(security_slack_workspace_id) != bool(
security_slack_channel_id):
raise ValueError(
"Both security_slack_workspace_id and security_slack_channel_id must be provided or both must be None."
)
self.security_slack_workspace_id = security_slack_workspace_id
self.security_slack_channel_id = security_slack_channel_id
ユースケース毎にディレクトリを切って、cdk.jsonも個別に作る
デプロイはこんな感じ
cd path/to/source
npm ci
# デプロイしたいusecaseのディレクトリに移動する
cd usecases/blea-uest-serverless-api-sample
npx aws-cdk deploy --all --profile prof_dev
エントリーポイント blea-gov-base-standalone.ts
- パラメータチェックはここでやる。他の処理をする前にやるのが合理的
- Stackのインスタンスを作成。これはまぁそう。
- いくつかのサービスを使っているとはずだがStackは1つ。サービス単位ではなく、要件(ここではセキュリティベースラインとなる一連のサービス群)で作る。
import * as cdk from 'aws-cdk-lib';
import { BLEAGovBaseStandaloneStack } from '../lib/stack/blea-gov-base-standalone-stack';
// Import parameters for each enviroment
import { devParameter } from '../parameter';
const app = new cdk.App();
if (!devParameter.securitySlackWorkspaceId || !devParameter.securitySlackChannelId) {
throw new Error('securitySlackWorkspaceId and securitySlackChannelId are required');
}
// Create stack for "Dev" environment.
// If you have multiple environments, instantiate stacks with its parameters.
new BLEAGovBaseStandaloneStack(app, 'Dev-BLEAGovBaseStandalone', {
description: 'BLEA Governance Base for standalone account (uksb-1tupboc58) (tag:blea-gov-base-standalone)',
env: {
account: devParameter.env?.account || process.env.CDK_DEFAULT_ACCOUNT,
region: devParameter.env?.region || process.env.CDK_DEFAULT_REGION,
},
tags: {
Repository: 'aws-samples/baseline-environment-on-aws',
Environment: devParameter.envName,
},
securityNotifyEmail: devParameter.securityNotifyEmail,
securitySlackWorkspaceId: devParameter.securitySlackWorkspaceId,
securitySlackChannelId: devParameter.securitySlackChannelId,
});
環境毎の違い ( devParameter )はここでStackに渡している。Stack内でparameterは呼ばないようにしているのかも。
Stackからは自作のL2, L3コンストラクタを呼び出しているだけなのできれい
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { Detection } from '../construct/detection';
import { Iam } from '../construct/iam';
import { Logging } from '../construct/logging';
import { Notification } from '../construct/notification';
export interface BLEAGovBaseStandaloneProps extends StackProps {
securityNotifyEmail: string;
securitySlackWorkspaceId: string;
securitySlackChannelId: string;
}
export class BLEAGovBaseStandaloneStack extends Stack {
constructor(scope: Construct, id: string, props: BLEAGovBaseStandaloneProps) {
super(scope, id, props);
new Iam(this, 'Iam');
const logging = new Logging(this, 'Logging');
const detection = new Detection(this, 'Detection', {
cloudTrailLogGroupName: logging.trailLogGroup.logGroupName,
notifyEmail: props.securityNotifyEmail,
});
// You must create a configuration recorder before you can create or update a Config rule.
detection.node.addDependency(logging);
new Notification(this, 'Notification', {
topicArn: detection.topic.topicArn,
workspaceId: props.securitySlackWorkspaceId,
channelId: props.securitySlackChannelId,
});
}
}
コンストラクタの話し。
環境に依存しない部分はべた書き
import * as cdk from 'aws-cdk-lib';
import {
aws_cloudtrail as trail,
aws_config as config,
aws_iam as iam,
aws_kms as kms,
aws_logs as cwl,
aws_s3 as s3,
} from 'aws-cdk-lib';
import { Construct } from 'constructs';
export class Logging extends Construct {
public readonly trailLogGroup: cwl.ILogGroup;
constructor(scope: Construct, id: string) {
super(scope, id);
// === AWS CloudTrail ===
// Server Access Log Bucket for CloudTrail
const cloudTrailAccessLogBucket = new s3.Bucket(this, 'CloudTrailAccessLogBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
versioned: true,
encryption: s3.BucketEncryption.S3_MANAGED,
removalPolicy: cdk.RemovalPolicy.RETAIN,
enforceSSL: true,
lifecycleRules: [
{
enabled: true,
expiration: cdk.Duration.days(2555),
transitions: [
{
transitionAfter: cdk.Duration.days(90),
storageClass: s3.StorageClass.GLACIER,
},
],
},
],
});
addBaseBucketPolicy(cloudTrailAccessLogBucket);
// Bucket for CloudTrail
const cloudTrailBucket = new s3.Bucket(this, 'CloudTrailBucket', {
accessControl: s3.BucketAccessControl.PRIVATE,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
versioned: true,
serverAccessLogsBucket: cloudTrailAccessLogBucket,
serverAccessLogsPrefix: 'cloudtraillogs',
removalPolicy: cdk.RemovalPolicy.RETAIN,
enforceSSL: true,
});
cloudTrailBucket.node.addDependency();
addBaseBucketPolicy(cloudTrailBucket);
// CMK for CloudTrail
const cloudTrailKey = new kms.Key(this, 'CloudTrailKey', {
enableKeyRotation: true,
description: 'BLEA Governance Base: CMK for CloudTrail',
alias: cdk.Names.uniqueResourceName(this, {}),
});
cloudTrailKey.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['kms:GenerateDataKey*'],
principals: [new iam.ServicePrincipal('cloudtrail.amazonaws.com')],
resources: ['*'],
conditions: {
StringLike: {
'kms:EncryptionContext:aws:cloudtrail:arn': [`arn:aws:cloudtrail:*:${cdk.Stack.of(this).account}:trail/*`],
},
},
}),
);
cloudTrailKey.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['kms:DescribeKey'],
principals: [new iam.ServicePrincipal('cloudtrail.amazonaws.com')],
resources: ['*'],
}),
);
cloudTrailKey.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['kms:Decrypt', 'kms:ReEncryptFrom'],
principals: [new iam.AnyPrincipal()],
resources: ['*'],
conditions: {
StringEquals: { 'kms:CallerAccount': `${cdk.Stack.of(this).account}` },
StringLike: {
'kms:EncryptionContext:aws:cloudtrail:arn': [`arn:aws:cloudtrail:*:${cdk.Stack.of(this).account}:trail/*`],
},
},
}),
);
cloudTrailKey.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['kms:Encrypt*', 'kms:Decrypt*', 'kms:ReEncrypt*', 'kms:GenerateDataKey*', 'kms:Describe*'],
principals: [new iam.ServicePrincipal('logs.amazonaws.com')],
resources: ['*'],
conditions: {
ArnEquals: {
'kms:EncryptionContext:aws:logs:arn': `arn:aws:logs:${cdk.Stack.of(this).region}:${
cdk.Stack.of(this).account
}:log-group:*`,
},
},
}),
);
// CloudWatch Logs Group for CloudTrail
const cloudTrailLogGroup = new cwl.LogGroup(this, 'CloudTrailLogGroup', {
retention: cwl.RetentionDays.THREE_MONTHS,
encryptionKey: cloudTrailKey,
});
this.trailLogGroup = cloudTrailLogGroup;
// CloudTrail
new trail.Trail(this, 'CloudTrail', {
bucket: cloudTrailBucket,
enableFileValidation: true,
includeGlobalServiceEvents: true,
cloudWatchLogGroup: cloudTrailLogGroup,
encryptionKey: cloudTrailKey,
sendToCloudWatchLogs: true,
});
// === AWS Config ===
const configRole = new iam.Role(this, 'ConfigRole', {
assumedBy: new iam.ServicePrincipal('config.amazonaws.com'),
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWS_ConfigRole')],
});
new config.CfnConfigurationRecorder(this, 'ConfigRecorder', {
roleArn: configRole.roleArn,
recordingGroup: {
allSupported: true,
includeGlobalResourceTypes: true,
},
});
const configBucket = new s3.Bucket(this, 'ConfigBucket', {
accessControl: s3.BucketAccessControl.PRIVATE,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
versioned: true,
removalPolicy: cdk.RemovalPolicy.RETAIN,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
});
// Attaches the AWSConfigBucketPermissionsCheck policy statement.
configBucket.addToResourcePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [configRole],
resources: [configBucket.bucketArn],
actions: ['s3:GetBucketAcl'],
}),
);
// Attaches the AWSConfigBucketDelivery policy statement.
configBucket.addToResourcePolicy(
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
principals: [configRole],
resources: [configBucket.arnForObjects(`AWSLogs/${cdk.Stack.of(this).account}/Config/*`)],
actions: ['s3:PutObject'],
conditions: {
StringEquals: {
's3:x-amz-acl': 'bucket-owner-full-control',
},
},
}),
);
new config.CfnDeliveryChannel(this, 'ConfigDeliveryChannel', {
s3BucketName: configBucket.bucketName,
});
}
}
// Add base BucketPolicy for CloudTrail
function addBaseBucketPolicy(bucket: s3.Bucket): void {
bucket.addToResourcePolicy(
new iam.PolicyStatement({
sid: 'Restrict Delete* Actions',
effect: iam.Effect.DENY,
actions: ['s3:Delete*'],
principals: [new iam.AnyPrincipal()],
resources: [bucket.arnForObjects('*')],
}),
);
}
環境依存するものはコンストラクタで渡す
import { Names } from 'aws-cdk-lib';
import { CfnSlackChannelConfiguration } from 'aws-cdk-lib/aws-chatbot';
import { ManagedPolicy, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
export interface NotificationProps {
topicArn: string;
channelId: string;
workspaceId: string;
}
export class Notification extends Construct {
constructor(scope: Construct, id: string, props: NotificationProps) {
super(scope, id);
// AWS Chatbot configuration for sending message
const chatbotRole = new Role(this, 'ChatbotRole', {
assumedBy: new ServicePrincipal('chatbot.amazonaws.com'),
managedPolicies: [
ManagedPolicy.fromAwsManagedPolicyName('ReadOnlyAccess'),
ManagedPolicy.fromAwsManagedPolicyName('CloudWatchReadOnlyAccess'),
],
});
// !!! Create SlackChannel and add aws chatbot app to the room
new CfnSlackChannelConfiguration(this, 'ChatbotChannel', {
configurationName: Names.uniqueResourceName(this, {}),
slackChannelId: props.channelId,
iamRoleArn: chatbotRole.roleArn,
slackWorkspaceId: props.workspaceId,
snsTopicArns: [props.topicArn],
});
}
}
topicArn
。は Detection
コンストラクタで作った topicから値を取得している。
new Notification(this, 'Notification', {
topicArn: detection.topic.topicArn,
workspaceId: props.securitySlackWorkspaceId,
channelId: props.securitySlackChannelId,
});
// SNS Topic for Security Alarm
const topic = new sns.Topic(this, 'AlarmTopic');
new sns.Subscription(this, 'SecurityAlarmEmail', {
endpoint: props.notifyEmail,
protocol: sns.SubscriptionProtocol.EMAIL,
topic: topic,
});
cdk.Stack.of(this).exportValue(topic.topicArn);
this.topic = topic;
監視対象、監視対象に対する通知ルール、通知先(SNS Topic)は Detectionコンストラクタ
SNS TopicでどこにサブスクライブするかはNotificationコンストラクタで実施している。
これ、依存関係を張ってるのがStackじゃなくてコンストラクタなんだよな。
だから、仮にDetection以外で作成したSNSトピックもNotificationコンストラクタで使うようにしたとして、Detectionコンストラクタを削除したとしても追加した方のNotificationには関係ない。
ところで cdk.Stack.of(this).exportValue(topic.topicArn);
って必要なのか?
単に this.topic = topic;
だけしておけば、Detectionインスタンスを作成しているクラスから
detection.topicとすれば参照できる気がする。
アプリケーションサンプルで API Gateway + Lambdaもあった。
この辺はswagger.yaml から自動生成したいところ。
import * as cdk from 'aws-cdk-lib';
import {
aws_apigateway as apigateway,
aws_cloudwatch as cw,
aws_cloudwatch_actions as cw_actions,
aws_logs as cw_logs,
aws_sns as sns,
} from 'aws-cdk-lib';
import { ITable } from 'aws-cdk-lib/aws-dynamodb';
import { IKey } from 'aws-cdk-lib/aws-kms';
import { Construct } from 'constructs';
import { LambdaNodejs } from './lambda-nodejs';
import { LambdaPython } from './lambda-python';
export interface ApiProps {
alarmTopic: sns.ITopic;
appKey: IKey;
table: ITable;
}
export class Api extends Construct {
constructor(scope: Construct, id: string, props: ApiProps) {
super(scope, id);
// Sample log group for API Gateway
const apiGatewayLogGroup = new cw_logs.LogGroup(this, 'ApiGatewayLogGroup', {
retention: cw_logs.RetentionDays.ONE_MONTH,
});
// REST API
//
// Note: Enable Metrics, Logging(info level), Tracing(X-Ray),
// See: https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-monitor.html
//
const restApi = new apigateway.RestApi(this, 'RestApi', {
deployOptions: {
accessLogDestination: new apigateway.LogGroupLogDestination(apiGatewayLogGroup),
accessLogFormat: apigateway.AccessLogFormat.jsonWithStandardFields(),
loggingLevel: apigateway.MethodLoggingLevel.INFO,
tracingEnabled: true, // Enable X-ray tracing
metricsEnabled: true, // Enable Metrics for this method
// cachingEnabled: true, // Please unncomment if you want to use cache
},
// CORS Prefilight Options sample
// See: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigateway-readme.html#cross-origin-resource-sharing-cors
// defaultCorsPreflightOptions: {
// allowOrigins: apigateway.Cors.ALL_ORIGINS,
// allowMethods: apigateway.Cors.ALL_METHODS,
// allowHeaders: apigateway.Cors.DEFAULT_HEADERS,
// },
});
// Sample metrics as for API Gateway
// Alarms for API Gateway
// See: https://docs.aws.amazon.com/apigateway/latest/developerguide/monitoring-cloudwatch.html
//
restApi
.metricCount({
period: cdk.Duration.minutes(1),
statistic: cw.Stats.AVERAGE,
})
.createAlarm(this, 'APIGatewayInvocationCount', {
evaluationPeriods: 3,
datapointsToAlarm: 3,
threshold: 70,
comparisonOperator: cw.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
actionsEnabled: true,
})
.addAlarmAction(new cw_actions.SnsAction(props.alarmTopic));
// Defining Lambda-backed APIs
// See: https://docs.aws.amazon.com/cdk/api/latest/docs/aws-apigateway-readme.html#aws-lambda-backed-apis
// Nodejs
const nodejsFunc = new LambdaNodejs(this, 'LambdaNodejs', {
alarmTopic: props.alarmTopic,
appKey: props.appKey,
table: props.table,
});
const nodejs = restApi.root.addResource('nodejs');
const nodejsList = nodejs.addResource('list');
nodejsList.addMethod('GET', new apigateway.LambdaIntegration(nodejsFunc.listItemsFunction));
const nodejsItem = nodejs.addResource('item');
nodejsItem.addMethod('POST', new apigateway.LambdaIntegration(nodejsFunc.putItemFunction));
const nodejsTitle = nodejsItem.addResource('{title}');
nodejsTitle.addMethod('GET', new apigateway.LambdaIntegration(nodejsFunc.getItemFunction));
// Python
const pythonFunc = new LambdaPython(this, 'LambdaPython', {
alarmTopic: props.alarmTopic,
appKey: props.appKey,
table: props.table,
});
const python = restApi.root.addResource('python');
const pythonList = python.addResource('list');
pythonList.addMethod('GET', new apigateway.LambdaIntegration(pythonFunc.listItemsFunction));
const pythonItem = python.addResource('item');
pythonItem.addMethod('POST', new apigateway.LambdaIntegration(pythonFunc.putItemFunction));
const pythonTitle = pythonItem.addResource('{title}');
pythonTitle.addMethod('GET', new apigateway.LambdaIntegration(pythonFunc.getItemFunction));
}
}
testはusecase/bin の単位で SnapShotテスト