0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CDK Aspects × Pipelines で組織のガードレールを全アカウントに強制展開する

0
Posted at

1. はじめに

AWS を複数のチームで利用する場合、コンプライアンスの準拠は1つのテーマとなります。
Slack で注意喚起をしたり、ドキュメントに記載したとしても、人が増えれば抜け漏れは避けることが困難です。

この記事では 「ルールをコードで強制する」 仕組みを、AWS CDK の 2 つの機能で実現します。

  • CDK Aspects:synth 時にリソースツリーを走査し、ポリシー違反を検出・修正する仕組み
  • CDK Pipelines:Aspects を含む共通ライブラリを、マルチアカウントに自動展開する CI/CD パイプライン

この記事では検証の実施と、実際に組織で運用可能な設計判断 についても触れてみようと思います。

本記事で得られること

  • CDK Aspects の内部動作(ツリー走査・synth フェーズとの関係)の理解
  • S3 暗号化チェック・IAM ワイルドカード検出の実装コードと動作確認
  • Aspects を Construct ライブラリとして切り出し、CDK Pipelines でマルチアカウントに展開する構成
  • 「エラーにすべきか警告にすべきか」「Aspects vs SCP」の設計判断軸
  • Aspects で何ができて何ができないか(遅延評価プロパティの制約)

検証環境

項目 内容
CDK バージョン aws-cdk-lib 2.170.0
言語 TypeScript
アカウント構成 Toolchain / Dev / Staging / Prod の 4 アカウント
Node.js 20.x

2. CDK Aspects とは何か

AWS のコンプライアンスチェックには AWS Config があります。Config は「デプロイ後のリソースを継続的に監視する」サービスですが、前もって非準拠リソースを検知し、コンプライアンスに準拠したリソースのみをプロビジョニングしたい場合もあります。

CDK Aspects は synth 時(デプロイ前)に違反を検出します。
CloudFormation テンプレートが生成される前にチェックするため、違反があればデプロイ自体をブロックできます。

2-1. Aspects の仕組み:コンストラクトツリーとは

CDK でインフラをコード化し、cdk synth を実行すると、内部的に リソースの階層構造(コンストラクトツリー)が作られます。

App(アプリケーション全体)
└── Stack: MyStack
    ├── Bucket: DataBucket        ← S3 バケット
    ├── Function: ProcessorFn     ← Lambda 関数
    │   └── Role: ProcessorFnRole ← Lambda の実行ロール
    └── Queue: InputQueue         ← SQS キュー

Aspects は、この階層構造の中にある全てのリソースに対して、自動的にチェックを実行する仕組みです。

どのように動作するのか

cdk synth を実行すると、Aspects が階層構造を上から順番に巡回し、各リソースに対して visit() メソッドを呼び出します:

1. visit(Bucket: DataBucket)
   → 「このバケットは暗号化されているか?」をチェック

2. visit(Function: ProcessorFn)
   → 「この関数にタグは付いているか?」をチェック

3. visit(Role: ProcessorFnRole)
   → 「このロールにワイルドカードはないか?」をチェック

4. visit(Queue: InputQueue)
   → 「このキューは暗号化されているか?」をチェック

つまり、Aspects を使えば「全リソースに対して同じルールを一括適用」できます。 Stack が10個あっても100個あっても、Aspects のコードは1箇所に書くだけで済みます。

2-2. cdk-nag との関係・違い

「Aspects を使ったガードレール」といえば cdk-nag が有名ですが、用途は異なります。

CDK Aspects(自作) cdk-nag
ルールの主体 自組織のポリシー AWS のベストプラクティス(NIST・PCI等)
カスタマイズ 完全自由 抑制(Suppression)による除外のみ
向いている用途 「このタグは必須」「このポリシーは社内規定」 AWS 標準への準拠チェック

社内固有のルールには自作 Aspects、AWS ベースラインへの準拠には cdk-nag、というように併用するのが理想です。

2-3. Aspects が適用されるタイミング

重要なポイントとして Aspects は Preparation フェーズ(CloudFormation テンプレート生成前)に適用されます。

cdk-ライフサイクル.png

つまり、Aspects は既に定義されたリソースに対して後から横断的に処理を加える仕組みです。既存の CDK コードを一切変えずにルールを追加できるのが最大の強みです。

参考:AWS 公式ブログ - CDK Aspects を利用してベストプラクティスに従ったインフラストラクチャを構築する

3. ハンズオン①:Aspects の実装

Aspects を使った 3 つのガードレールを実装します。

3-1. プロジェクト構成

guardrail-lib/          ← 共通ライブラリパッケージ
├── src/
│   └── aspects/
│       ├── index.ts
│       ├── s3-encryption.aspect.ts
│       └── iam-wildcard.aspect.ts
├── package.json
└── tsconfig.json

3-2. Aspects で何ができて何ができないか - タグチェックのケーススタディ

本記事では S3 暗号化と IAM ワイルドカードの 2 つの Aspect を実装していますが、当初は「必須タグの強制」についても実施する想定でした。
しかし、必須タグの強制は技術的な制約により実現ができませんでした。
せっかくなので、こちらについても記事で触れ、Aspects の適用範囲を理解しようと思います。

なぜタグチェックは Aspects に向かないのか

CDK では Tags.of(resource).add("CostCenter", "Finance") のようにタグを付与しますが、これは TagManager という内部コンポーネントが遅延評価(lazy evaluation)で処理します。

cdk synth の実行順序:
  1. コンストラクトツリーの構築
  2. prepare フェーズ
  3. ★ Aspects の visit() 実行  ← この時点ではタグが未確定
  4. TagManager がタグを解決
  5. CloudFormation テンプレート生成  ← ここでタグが出力される

Aspects の visit() が呼ばれる時点では、TagManager.hasTags()false を返し、renderTags()undefined を返します。CloudFormation テンプレートには正しくタグが出力されますが、Aspect 実行時には利用できません

実装を試みた結果

// ❌ これは動かない
export class RequiredTagsAspect implements IAspect {
  visit(node: IConstruct): void {
    const cfnResource = node as CfnResource;
    const tags = cfnResource.cfnOptions.tags; // ← undefined
    const tagValue = Tags.of(node).tryGetTag("CostCenter"); // ← 取得できない
  }
}

cdk.out に生成されたテンプレートを見ると、タグは正しく存在します:

{
  "Resources": {
    "MyBucket": {
      "Type": "AWS::S3::Bucket",
      "Properties": {
        "Tags": [{ "Key": "CostCenter", "Value": "Finance" }]
      }
    }
  }
}

しかし、Aspect はこのテンプレート生成に実行されるため、タグにアクセスできません。

Aspects に適したチェック vs 適さないチェック

チェック対象 Aspects 適用可否 理由
S3 バケットの暗号化設定 ✅ 適している bucketEncryption プロパティは即座に確定する
IAM Policy の Action: "*" ✅ 適している policyDocument は visit() 時点で参照可能
Lambda の環境変数の暗号化 ✅ 適している environmentEncryption プロパティが即座に確定
必須タグの検証 ❌ 適さない TagManager の遅延評価により visit() 時は未確定
リソース名の命名規則(動的生成) ❌ 適さない Token 解決前のため実際の名前が分からない
クロススタック参照の循環検知 ❌ 適さない 参照解決は Aspects より後のフェーズで行われる

タグチェックの代替手段

組織でタグ付けを強制するには、以下の代替手段が考えられます。

1. AWS Service Control Policies (SCP)

タグなしリソースの作成を API レベルでブロック:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Deny",
      "Action": ["s3:CreateBucket", "ec2:RunInstances"],
      "Resource": "*",
      "Condition": {
        "StringNotLike": {
          "aws:RequestTag/CostCenter": "*"
        }
      }
    }
  ]
}

2. cdk-nag の既存ルール

cdk-nag には AwsSolutions-S1 などのタグ関連ルールが含まれています。

3. カスタム Pre-deployment バリデーション

synth 後の CloudFormation テンプレートを解析するスクリプト:

// cdk.out/*.template.json を読み込んでタグをチェック
const template = JSON.parse(fs.readFileSync("cdk.out/MyStack.template.json"));
for (const [id, resource] of Object.entries(template.Resources)) {
  if (!resource.Properties?.Tags) {
    throw new Error(`${id} にタグがありません`);
  }
}

本記事で実装する 2 つの Aspect

以降では、Aspects に適した以下の 2 つのガードレールを実装します:

  • S3 暗号化チェック(bucketEncryption プロパティの検証)
  • IAM ワイルドカード検出(policyDocument の Action フィールドの検証)

これらは visit() 実行時点でプロパティが確定しているため、正確に動作します。

3-3. S3 暗号化チェック Aspect

// src/aspects/s3-encryption.aspect.ts
import { IAspect, Annotations } from "aws-cdk-lib";
import { CfnBucket } from "aws-cdk-lib/aws-s3";
import { IConstruct } from "constructs";

export class S3EncryptionAspect implements IAspect {
  constructor(private readonly strict: boolean = true) {}

  visit(node: IConstruct): void {
    // CfnBucket かどうかを cfnResourceType で判定
    // instanceof は module 境界で失敗することがあるため
    const cfnNode = node as any;
    if (cfnNode.cfnResourceType !== "AWS::S3::Bucket") return;

    const bucket = node as CfnBucket;
    const encryption = bucket.bucketEncryption;

    // SSE-S3(AES256)または SSE-KMS のいずれかが必須
    const isEncrypted = this.checkEncryption(encryption);

    if (!isEncrypted) {
      const message =
        `[S3Encryption] S3 バケット "${node.node.id}" に暗号化設定がありません。` +
        `SSE-S3 (AES256) または SSE-KMS を設定してください。`;

      if (this.strict) {
        Annotations.of(node).addError(message);
      } else {
        Annotations.of(node).addWarning(message);
      }
    }
  }

  private checkEncryption(encryption: any): boolean {
    if (!encryption) return false;

    const config = encryption.serverSideEncryptionConfiguration;
    if (!config || !Array.isArray(config)) return false;

    return config.some((rule: any) => {
      const defaultEncryption = rule.serverSideEncryptionByDefault;
      if (!defaultEncryption) return false;

      const algorithm = defaultEncryption.sseAlgorithm;
      return algorithm === "AES256" || algorithm === "aws:kms";
    });
  }
}

ポイント:

  • cfnResourceType による型判定で instanceof の問題を回避
  • strict パラメータで Error/Warning を切り替え可能
  • SSE-S3 と SSE-KMS の両方をサポート

3-4. IAM ワイルドカード検出 Aspect

// src/aspects/iam-wildcard.aspect.ts
import { IAspect, Annotations } from "aws-cdk-lib";
import { CfnPolicy, CfnRole } from "aws-cdk-lib/aws-iam";
import { IConstruct } from "constructs";

export class IamWildcardAspect implements IAspect {
  constructor(
    private readonly allowedPaths: string[] = [],
    private readonly strict: boolean = true,
  ) {}

  visit(node: IConstruct): void {
    const cfnNode = node as any;
    const resourceType = cfnNode.cfnResourceType;

    if (
      resourceType === "AWS::IAM::Policy" ||
      resourceType === "AWS::IAM::Role"
    ) {
      // 許可リストに該当する場合はスキップ
      if (this.allowedPaths.some((p) => node.node.path.includes(p))) return;

      const documents = this.getPolicyDocuments(node as CfnPolicy | CfnRole);
      for (const document of documents) {
        this.checkDocument(node, document);
      }
    }
  }

  private getPolicyDocuments(node: CfnPolicy | CfnRole): any[] {
    const documents: any[] = [];
    const cfnNode = node as any;
    const resourceType = cfnNode.cfnResourceType;

    if (resourceType === "AWS::IAM::Policy") {
      const policyDocument = cfnNode.policyDocument;
      if (policyDocument) documents.push(policyDocument);
    } else if (resourceType === "AWS::IAM::Role") {
      const policies = cfnNode.policies;
      if (policies && Array.isArray(policies)) {
        for (const policy of policies) {
          if (
            typeof policy === "object" &&
            policy !== null &&
            "policyDocument" in policy
          ) {
            const policyDoc = (policy as any).policyDocument;
            if (policyDoc) documents.push(policyDoc);
          }
        }
      }
    }

    return documents;
  }

  private checkDocument(node: IConstruct, doc: any): void {
    if (!doc) return;

    // CDK の PolicyDocument は内部的に statements プロパティ(小文字)を持つ
    const statements = doc.statements || doc.Statement;

    if (!statements || !Array.isArray(statements)) return;

    for (const statement of statements) {
      // Deny ステートメントはワイルドカードを使っても問題ない
      if (statement.effect === "Deny" || statement.Effect === "Deny") continue;

      const actionsProp = statement.actions || statement.Action;
      const actions: string[] = this.normalizeToArray(actionsProp);

      if (actions.includes("*")) {
        const message =
          `[IamWildcard] "${node.node.path}" に Action: "*" が含まれています。` +
          `最小権限の原則に従い、必要なアクションのみを指定してください。`;

        if (this.strict) {
          Annotations.of(node).addError(message);
        } else {
          Annotations.of(node).addWarning(message);
        }
      }
    }
  }

  private normalizeToArray(value: any): string[] {
    if (Array.isArray(value)) return value;
    if (typeof value === "string") return [value];
    return [];
  }
}

ポイント:

  • cfnResourceType を使って IAM Policy/Role を判定
  • PolicyDocument の内部構造が statements (小文字) であることに注意
  • CDK が自動生成するカスタムリソースのロールは allowedPaths で除外

3-5. Aspect のエクスポートと一括適用

3-3、3-4 で実装した2つの Aspect を、実際に使いやすくするための仕組みを作ります。

個別に Aspects.of(scope).add(new S3EncryptionAspect()) のように追加していくこともできますが、組織全体で使うには以下の課題があります:

  • 各チームが個別に Aspect を追加すると、バージョンや設定がバラバラになる
  • 本番環境と開発環境で厳格さ(Error/Warning)を変えたい
  • 新しい Aspect を追加したときに、全チームに展開する必要がある

そこで、組織標準のガードレールセットとして一括適用する関数を用意します。

// src/aspects/index.ts
export { S3EncryptionAspect } from "./s3-encryption.aspect";
export { IamWildcardAspect } from "./iam-wildcard.aspect";

// 組織標準のガードレールセット
export function applyOrganizationGuardrails(
  scope: IConstruct,
  env: string,
): void {
  // 本番環境では strict モード(Error)、開発環境では Warning
  const strict = env === "prod";

  // S3 暗号化チェック
  Aspects.of(scope).add(new S3EncryptionAspect(strict));

  // IAM ワイルドカード検出
  Aspects.of(scope).add(
    new IamWildcardAspect(
      [
        "Custom::", // CDK カスタムリソースの自動生成ロールは除外
        "/AWS679f53fac002430cb0da5b7982bd2287", // CDK のカスタムリソースプロバイダー
      ],
      strict,
    ),
  );
}

実装のポイント:

  • strict パラメータで環境別に Error/Warning を切り替え
  • Aspects.of(scope).add() で App、Stage、Stack のいずれにも適用可能
  • CDK が自動生成するカスタムリソースのロールは例外リストで除外

使い方:

// App レベルで適用すると、配下のすべての Stack に適用される
import { applyOrganizationGuardrails } from "guardrail-lib";

const app = new App();
const stack = new MyStack(app, "MyStack");

applyOrganizationGuardrails(app, "dev"); // 開発環境では Warning

実行結果(違反がある場合・dev モード):

$ cdk synth

[Warning at /MyStack/UnencryptedBucket] [S3Encryption] S3 バケット "UnencryptedBucket" に暗号化設定がありません。
[Warning at /MyStack/WildcardRole/DefaultPolicy/Resource] [IamWildcard] "MyStack/WildcardRole/DefaultPolicy/Resource" に Action: "*" が含まれています。

※ Warning のため synth は成功し、デプロイ可能(ただし警告あり)

実行結果(違反がある場合・prod モード):

$ cdk synth

[Error at /MyStack/UnencryptedBucket] [S3Encryption] S3 バケット "UnencryptedBucket" に暗号化設定がありません。
[Error at /MyStack/WildcardRole/DefaultPolicy/Resource] [IamWildcard] "MyStack/WildcardRole/DefaultPolicy/Resource" に Action: "*" が含まれています。

Found errors

※ Error のため synth 失敗、デプロイ不可(exit code 1)

4. ハンズオン②:CDK Pipelines でマルチアカウントに展開

Aspects を含む共通ライブラリを、全アカウントに自動展開する仕組みを作ります。

4-1. アカウント構成の設計

cdk.png

ポイント: guardrail-lib(Aspects のパッケージ)は Toolchain アカウントの CodeArtifact に配布し、各チームの CDK プロジェクトはそこから参照します。Pipeline はライブラリの更新を検知して全アカウントに自動展開します。

4-2. Pipeline Stack の実装

// pipeline-stack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import {
  CodePipeline,
  CodePipelineSource,
  ShellStep,
  ManualApprovalStep,
} from "aws-cdk-lib/pipelines";
import { Construct } from "constructs";
import { GuardrailStage } from "./guardrail-stage";

export class GuardrailPipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const pipeline = new CodePipeline(this, "Pipeline", {
      pipelineName: "guardrail-deployment",
      synth: new ShellStep("Synth", {
        input: CodePipelineSource.codeCommit(
          Repository.fromRepositoryName(this, "Repo", "guardrail-infra"),
          "main",
        ),
        commands: ["npm ci", "npm run build", "npx cdk synth"],
      }),
    });

    // Wave 1: Dev(自動デプロイ・承認不要)
    const devWave = pipeline.addWave("Wave1-Dev");
    devWave.addStage(
      new GuardrailStage(this, "Dev", {
        env: { account: "111111111111", region: "ap-northeast-1" },
        environment: "dev",
      }),
    );

    // Wave 2: Staging(Dev のデプロイ成功後に自動実行)
    const stagingWave = pipeline.addWave("Wave2-Staging");
    stagingWave.addStage(
      new GuardrailStage(this, "Staging", {
        env: { account: "222222222222", region: "ap-northeast-1" },
        environment: "staging",
      }),
      {
        pre: [
          new ShellStep("IntegrationTest", {
            commands: [
              // Dev アカウントで統合テストを実行してから Staging へ
              "npm run test:integration",
            ],
          }),
        ],
      },
    );

    // Wave 3: Prod(手動承認ゲート付き)
    const prodWave = pipeline.addWave("Wave3-Prod");
    prodWave.addStage(
      new GuardrailStage(this, "Prod", {
        env: { account: "333333333333", region: "ap-northeast-1" },
        environment: "prod",
      }),
      {
        pre: [new ManualApprovalStep("ProdApproval")], // Slack 通知と組み合わせ
      },
    );
  }
}

4-3. GuardrailStage の実装

各環境にデプロイされる Stage です。ここで Aspects を applyOrganizationGuardrails() で適用します。

// guardrail-stage.ts
import { Stage, StageProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { applyOrganizationGuardrails } from "guardrail-lib";

interface GuardrailStageProps extends StageProps {
  environment: "dev" | "staging" | "prod";
}

export class GuardrailStage extends Stage {
  constructor(scope: Construct, id: string, props: GuardrailStageProps) {
    super(scope, id, props);

    // 各チームのアプリケーション Stack を import して適用
    // 実際の運用では Organizations API で動的にスタック一覧を取得することも可能
    new TeamAStack(this, "TeamA", { env: props.env });
    new TeamBStack(this, "TeamB", { env: props.env });

    // Stage スコープで Aspects を適用 → 配下の全 Stack に横断適用される
    applyOrganizationGuardrails(this, props.environment);
  }
}

実際の Pipeline 実行結果(正常ケース):

cdk-pipeline_正常実行.png

図:すべてのガードレールに準拠したコードをデプロイした場合、Dev → Staging → Prod の順に正常にデプロイが進行する

4-4. 違反が出た場合に Pipeline が止まる様子

意図的に違反を含むコードを Push した場合の挙動を確認します。

違反コード例(S3バケットの暗号化なし):

// team-a-stack.ts(違反)
new s3.Bucket(this, "DataBucket", {
  // encryption: s3.BucketEncryption.S3_MANAGED, // ← コメントアウトして暗号化を無効化
  removalPolicy: cdk.RemovalPolicy.DESTROY,
});

このコードを Push すると、以下のように Pipeline が停止します。

Pipeline 実行ログ(違反がある場合):

cdk-pipeline_エラー.png

図:Aspects がポリシー違反を検出すると、Build ステージで synth が失敗し、後続のデプロイは実行されない

CloudWatch Logs の詳細エラー:

cdk-pipeline_エラー詳細.png

図:CodeBuild で Aspects のエラーメッセージを確認できる。どのリソースのどのプロパティが違反しているかが明確に表示される

5. 設計判断のまとめ

5-1. エラーにすべきか、警告にすべきか

実運用では「既存リソースであっても大量のエラーが出て誰も直せない」「エラーがでていることが当たり前」という状況が起きがちです。
組織によって設計は変わると思いますが、以下の基準で使い分けるのがいいかなと個人的には考えています。

ルール 推奨 理由
セキュリティ必須要件(S3 暗号化必須) Error 妥協不可。即時修正が必要
IAM ワイルドカード Error 最小権限の原則は全環境で徹底すべき
タグ付けルール SCP で実装 Aspects では遅延評価の制約あり。SCP での強制が適切
ベストプラクティス推奨(例:Multi-AZ 設定) Warning 強制ではなく、改善を促す

参考:
AWS CDK 公式ドキュメント - Aspects and the AWS CDK
AWS Well-Architected Framework - SEC03-BP02 最小権限アクセスを付与する

5-2. Aspects vs SCP(Service Control Policies)の使い分け

「同じことを SCP でもできるのでは?」という疑問が必ず出ます。

CDK Aspects SCP
適用タイミング デプロイ前(synth 時)にブロック デプロイ時(API コール時)にブロック
エラーの分かりやすさ ◎ コードの行レベルで違反箇所が分かる △ API エラーのみ、原因特定が難しい
対象 CDK で管理するリソースのみ アカウント全体(CDK 外の操作も含む)
修正のしやすさ ◎ synth → fix → synth のループで即確認 △ デプロイしてみないと分からない

推奨: Aspects と SCP は両方使うのが理想です。Aspects は「開発者がデプロイ前に気づける仕組み」、SCP は「万が一抜けた場合の最後の防衛線」という位置づけで、層を重ねることでより堅牢になります。

5-3. 導入時のチームへの説明コスト

新しくガードレールを導入するとき、チームの反応は「なぜ急に制約が増えたのか」です。以下のアプローチが実務で有効でした。

  1. まず Warning で一覧を可視化する:いきなりビルドを止めず、「こういう違反がある」を可視化することで納得感を得る
  2. 猶予期間を設ける:Warning で通知→ 4 週間後に Error へ昇格、のように段階を踏む
  3. cdk-nag と同じ addMetadata で既知の例外を宣言できる仕組みを作る:「このリソースだけ意図的に除外する」を文書化できると反発が減る

6. まとめ

本記事では CDK Aspects と CDK Pipelines を組み合わせて、組織のガードレールをマルチアカウントに強制展開する仕組みを実装しました。

ポイントのおさらい

  1. Aspects は synth の最後に動く:既存の CDK コードを変更せず、横断的にルールを適用できる
  2. Aspects で何ができて何ができないか:即座に確定するプロパティ(暗号化設定、Policy構造)には適しているが、遅延評価プロパティ(タグ、Token解決が必要な値)にはアクセスできない
  3. cdk-nag と自作 Aspects は役割が違う:AWS 標準への準拠は cdk-nag、組織固有ルールは自作 Aspects で補完する
  4. Wave × ManualApprovalStep で段階的展開:Dev で自動検証 → Staging で統合テスト → Prod で手動承認、のフローでリスクを最小化できる
  5. Aspects は dev でも warning、prod では error:一律 Error にすると既存資産が大量エラーになる。環境に応じた厳格さの調整が実用上は重要
  6. Aspects と SCP は補完関係:Aspects でデプロイ前に止め、SCP で最後の防衛線を張る二層防御が理想。タグ強制のような遅延評価が絡むルールは SCP が適切
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?