Open17

Baseline Environment on AWS(BLEA) を読む

dokeitadokeita

参画しているプロジェクトでCDKを使っているが、設定値をyamlに定義して読み込む形をとっている。
yamlからコードベースにすることを検討しており、アーキテクチャを考えるのにBLEAのソースコードと周辺資料を読み込んで参考にできないかを考える。

dokeitadokeita

まずはドキュメントから、日本語のREADMEがありがたい
https://github.com/aws-samples/baseline-environment-on-aws/blob/main/README_ja.md

dokeitadokeita

simple-git-hooks というツールを使うと .git/config を汚さないらしい。なんと
https://github.com/toplenboren/simple-git-hooks

環境変数で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
dokeitadokeita

デプロイの際に必要となる デプロイ先アカウントや通知先メールアドレスなど、各ユースケース固有のパラメータを指定する必要があります。 BLEA では parameter.ts というファイルでパラメータを管理します。書式は TypeScript です。

環境毎に異なる値はparameter.tsに定義

usecases/blea-gov-base-standalone/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)でそれぞれの環境のスタックを作成します。

dokeitadokeita

Pythonだとinterface無いし、↓みたいに実装すれば必須パラメータの定義漏れだったり、デフォルト値の設定ができるかな。

parameter.py
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
dokeitadokeita

ユースケース毎にディレクトリを切って、cdk.jsonも個別に作る

デプロイはこんな感じ

cd path/to/source
npm ci
# デプロイしたいusecaseのディレクトリに移動する
cd usecases/blea-uest-serverless-api-sample
npx aws-cdk deploy --all --profile prof_dev
dokeitadokeita

エントリーポイント 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,
});
dokeitadokeita

環境毎の違い ( devParameter )はここでStackに渡している。Stack内でparameterは呼ばないようにしているのかも。

dokeitadokeita

Stackからは自作のL2, L3コンストラクタを呼び出しているだけなのできれい

usecases/blea-gov-base-standalone/lib/stack/blea-gov-base-standalone-stack.ts
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,
    });
  }
}
dokeitadokeita

aws-cdk-lib.StackProps っていう知らないクラスが出てきた。
調べた感じ、Stack間でリソース共有をする仕組みっぽいけど(クロススタック参照)そういうのやめよう?リソースの参照は同じStack内にしようよ

dokeitadokeita

でもBLEAだとstring型の変数しかないな。パラメータをpropsに入れることで可読性を上げているとか?(確かにコンストラクタは見やすい)

dokeitadokeita

コンストラクタの話し。
環境に依存しない部分はべた書き

usecases/blea-gov-base-standalone/lib/construct/logging.ts
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('*')],
    }),
  );
}
dokeitadokeita

環境依存するものはコンストラクタで渡す

usecases/blea-gov-base-standalone/lib/construct/notification.ts
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から値を取得している。

usecases/blea-gov-base-standalone/lib/stack/blea-gov-base-standalone-stack.ts
    new Notification(this, 'Notification', {
      topicArn: detection.topic.topicArn,
      workspaceId: props.securitySlackWorkspaceId,
      channelId: props.securitySlackChannelId,
    });
usecases/blea-gov-base-standalone/lib/construct/detection.ts
    // 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コンストラクタで実施している。

dokeitadokeita

これ、依存関係を張ってるのがStackじゃなくてコンストラクタなんだよな。
だから、仮にDetection以外で作成したSNSトピックもNotificationコンストラクタで使うようにしたとして、Detectionコンストラクタを削除したとしても追加した方のNotificationには関係ない。

ところで cdk.Stack.of(this).exportValue(topic.topicArn); って必要なのか?
単に this.topic = topic; だけしておけば、Detectionインスタンスを作成しているクラスから
detection.topicとすれば参照できる気がする。

dokeitadokeita

アプリケーションサンプルで API Gateway + Lambdaもあった。
この辺はswagger.yaml から自動生成したいところ。

usecases/blea-guest-serverless-api-sample/lib/construct/api.ts
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));
  }
}