diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..53fb50c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +name: Publish + +on: + push: + branches: + - main + +permissions: + id-token: write # Required for OIDC + contents: read + +jobs: + publish: + environment: prd + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-node@v6 + with: + node-version: 22 + registry-url: "https://registry.npmjs.org" + + - name: Update npm + run: npm install -g npm@latest + + - name: Install dependencies + run: npm ci + + - name: Run template tests + run: npm run test + + - name: Build module + run: npm run build + + - name: Build module + run: npm publish --access public diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml deleted file mode 100644 index 8cca68f..0000000 --- a/.github/workflows/version-check.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Version Check - -on: - pull_request: - branches: - - main - -jobs: - version-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - - name: Check version bump - id: check-version - run: | - git fetch origin main - git fetch --tags origin main - if ! git diff origin/main package.json | grep '"version":'; then - echo "ERROR: package.json version must be bumped" - exit 1 - fi - VERSION=$(jq -r '.version' package.json) - echo "version=$VERSION" >> $GITHUB_OUTPUT - - - name: Check for git tag on commit - id: check-tag - run: | - # Get tags pointing at HEAD - TAG=$(git tag --points-at HEAD | head -n1) - if [ -z "$TAG" ]; then - echo "ERROR: Commit must be tagged with a version before merging to main" - exit 1 - fi - echo "Found tag(s): $TAG" - echo "tag=$TAG" >> $GITHUB_OUTPUT - - - name: Check that tag matches version - run: | - if [ "$TAG" != "v$VERSION" ]; then - echo "ERROR: Git tag ($TAG) does not match package.json version (v$VERSION)" - exit 1 - fi - echo "Git tag matches package.json version ✅" - env: - VERSION: ${{ steps.check-version.outputs.version }} - TAG: ${{ steps.check-tag.outputs.tag }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98f47bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out + +**/.venv + +dist/ + +*.tgz diff --git a/.husky/pre-push b/.husky/pre-push old mode 100644 new mode 100755 diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..c1d6d45 --- /dev/null +++ b/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..ecc5196 --- /dev/null +++ b/README.md @@ -0,0 +1,196 @@ +# xa-cdk + +![npm](https://img.shields.io/npm/v/xa-cdk) +![MIT](https://img.shields.io/badge/license-MIT-green) + +## Description + +This library contains resources that can be configured to be accessible across AWS accounts. +By default, CDK can't update the resource policies of resources in another account, so when +setting up cross-account access between resources, some manual configuration is required. + +BUT NO MORE. +No more shall developers be forced to click through dashboard menus (or type CLI commands, +tedious in its own right) to simply access their S3 buckets (et al) from another account! +FREEDOM!! + +## Installation + +Run: +`npm install @doceight/xa-cdk` + +## Usage + +Given accessed AWS account with ID: `000000000000` +and accessor AWS account with ID: `111111111111`, +you can automate IAM policy management across AWS accounts/CDK stacks using the following +pattern: + +1. In `000000000000`: Deploy the resource(s) to be accessed + +```typescript +const key = new xa.CrossAccountKmsKey(this, "xaKey", { + xaAwsIds: ["111111111111"], // AWS account IDs that need access +}); + +new xa.CrossAccountS3Bucket(this, "XaBucket", { + bucketName: "xa-bucket", + xaAwsIds: ["111111111111"], // AWS account IDs that need access + encryptionKey: key.key, +}); +``` + +2. In `111111111111`: Register the resources that need access + +```typescript +// Import the resources to be accessed +const xaBucket = s3.Bucket.fromBucketName( + this, + "XaBucket", + "xa-bucket", +); +const xaKey = kms.Key.fromKeyArn( + this, + "XaKey", + "arn:aws:kms:ap-northeast-1:000000000000:key/00000000-0000-4000-000-000000000000", +); + +// Make the distribution itself +this.distribution = new cloudfront.Distribution(this, "Distribution", { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(xaBucket), + }, +}); + +// Register the Cloudfront distribution with the S3 Bucket and KMS key here +xa.CrossAccountS3BucketManager.allowCloudfront({ + scope: this, + bucketName: xaBucket.bucketName, + distributionId: this.distribution.distributionId, +}); +xa.CrossAccountKmsKeyManager.allowCloudfront({ + scope: this, + keyId: xaKey.keyId, + distributionId: this.distribution.distributionId, +}); +``` + +3. In `111111111111`: After all accessors have been registered, create a cross-account resource manager for each resource in the other account + +```typescript +... + +// Create resource managers to update policies on the resources in the other account +new xa.CrossAccountS3BucketManager(this, "XaBucketMgmt", { + xaBucketName: "xa-bucket", + xaAwsId: "000000000000", // AWS account ID of the S3 Bucket +}); +new xa.CrossAccountKmsKeyManager(this, "XaKeyMgmt", { + xaKeyId: "00000000-0000-4000-000-000000000000", + xaAwsId: "000000000000", // AWS account ID of the KMS Key +}); + +``` + +**Note:** Attempting to register new accessors after this step will result in an error. + +## API Reference + +(v1.0.0) + +- Managed S3 Bucket: +```typescript +CrossAccountS3Bucket(scope: Construct, id: string, { + xaAwsIds: string[], + ...s3.BucketProps +}) +``` +- Managed KMS key: +```typescript +CrossAccountKmsKey(scope: Construct, id: string, { + xaAwsIds: string[], + ...kms.KeyProps +}) +``` +- S3 Bucket Manager: +```typescript +CrossAccountS3BucketManager(scope: Construct, id: string, { + xaBucketName: string, + xaAwsId: string, + managerTimeout?: number = 30, + callerTimeout?: number = 30 +}) +``` + - Register Cloudfront distribution: + ```typescript + static allowCloudfront({ + scope: Construct, + distributionId: string, + bucketName: string, + actions?: string[] = ["s3:GetObject"] + }) + ``` +- KMS Key Manager: +```typescript +CrossAccountKmsKeyManager(scope: Construct, id: string, { + xaKeyId: string, + xaAwsId: string, + managerTimeout?: number = 30, + callerTimeout?: number = 30 +}) +``` + - Register Cloudfront distribution: + ```typescript + static allowCloudfront({ + scope: Construct, + distributionId: string, + keyId: string, + actions?: string[] = [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:DescribeKey" + ] + }) + ``` + +## Diagram + +```mermaid + +architecture-beta + group accessor(logos:aws-cloud)[Accessor] + service cf(logos:aws-cloudfront)[CloudFront] in accessor + service s3-mgmt(logos:aws-lambda)[S3 Updater] in accessor + service kms-mgmt(logos:aws-lambda)[KMS Updater] in accessor + junction to-mgmt in accessor + + group accessed(logos:aws-cloud)[Accessed] + group s3(logos:aws-s3)[S3] in accessed + service bucket(logos:aws-s3)[Bucket] in s3 + service bucket-role(logos:aws-iam)[Bucket Updater Role] in s3 + group kms(logos:aws-kms)[KMS] in accessed + service key(logos:aws-kms)[Key] in kms + service key-role(logos:aws-iam)[Key Updater Role] in kms + + cf:R -[on update]- L:to-mgmt + + to-mgmt:T -- B:s3-mgmt + to-mgmt:B -- T:kms-mgmt + + s3-mgmt:R -[assume role]- L:bucket-role + bucket-role:R -[assume role]-> L:bucket + + kms-mgmt:R -[update policy]- L:key-role + key-role:R -[update policy]-> L:key + + key:T -[encryption]- B:bucket + +``` + +(One of these days GitHub will support logos:aws-icons in its inline Mermaid diagrams, thus +I'm using them now.) + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..f1f3558 --- /dev/null +++ b/index.ts @@ -0,0 +1,10 @@ +export { CrossAccountS3Bucket, CrossAccountS3BucketManager } from "./lib/s3"; +export { CrossAccountKmsKey, CrossAccountKmsKeyManager } from "./lib/kms"; +export type { + CrossAccountS3BucketProps, + CrossAccountS3BucketManagerProps, +} from "./lib/s3"; +export type { + CrossAccountKmsKeyProps, + CrossAccountKmsKeyManagerProps, +} from "./lib/kms"; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..08263b8 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/lambda-code/kms/main.py b/lambda-code/kms/main.py new file mode 100644 index 0000000..bd570fe --- /dev/null +++ b/lambda-code/kms/main.py @@ -0,0 +1,112 @@ +import json +import logging +import os + +import boto3 +from botocore.exceptions import ClientError + +# Testing +LOCAL = os.environ.get("LOCAL", "false") +local_endpoint_url = "http://localhost:4566" + +# STS client (points to LocalStack if LOCAL environment variable set to true) +sts = ( + boto3.client( + "sts", + endpoint_url=local_endpoint_url, + region_name="ap-northeast-1", + aws_access_key_id="test", + aws_secret_access_key="test", + ) + if LOCAL == "true" + else boto3.client("sts") +) + +XA_MGMT_ROLE_ARN = os.environ["XA_MGMT_ROLE_ARN"] +KEY_ID = os.environ["RESOURCE_ID"] +ACCESSOR_ACCOUNT_ID = os.environ["ACCESSOR_ACCOUNT_ID"] +ACCESSOR_STACK_NAME = os.environ["ACCESSOR_STACK_NAME"] + +logger = logging.getLogger() +logger.setLevel("INFO") + + +def kms_client_with_role(operation): + logger.info(f"assuming role: {XA_MGMT_ROLE_ARN}") + assume_role_response = sts.assume_role( + RoleArn=XA_MGMT_ROLE_ARN, + RoleSessionName=f"{KEY_ID}-policy-{operation}", + ) + credentials = assume_role_response["Credentials"] + + session = boto3.Session( + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + ) + + return ( + session.client("kms", endpoint_url=local_endpoint_url) + if LOCAL == "true" + else session.client("kms") + ) + + +def get_key_policy(kms): + try: + get_policy_response = kms.get_key_policy(KeyId=KEY_ID, PolicyName="default") + return json.loads(get_policy_response["Policy"]) + except ClientError as e: + logger.error(f"Failed to get key policy: {e}") + raise + + +def put_key_policy(kms, policy): + try: + kms.put_key_policy( + KeyId=KEY_ID, PolicyName="default", Policy=json.dumps(policy) + ) + logger.info("Successfully updated key policy") + except ClientError as e: + logger.error(f"Failed to update key policy: {e}") + raise + + +def handler(event, context): + operation = event["operation"] + logger.info(f"operation: {operation}") + + kms = kms_client_with_role(operation) + + cloudfront_distribution_ids = sorted(list(event["cloudfrontAccessors"].keys())) + logger.info(f"cloudfrontAccessors: {'\n- '.join(cloudfront_distribution_ids)}") + + # First, remove the statements set by this stack + key_policy = get_key_policy(kms) + sid_prefix = f"{ACCESSOR_ACCOUNT_ID} {ACCESSOR_STACK_NAME} " + key_policy["Statement"] = [ + statement + for statement in key_policy["Statement"] + if not statement.get("Sid", "").startswith(sid_prefix) + ] + + # Re-add the statements if this is not a delete operation + if operation != "delete": + for id in cloudfront_distribution_ids: + actions = event["cloudfrontAccessors"][id] + statement = { + "Sid": f"{sid_prefix}: cf-{id}", + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": actions, + "Resource": "*", + "Condition": { + "StringEquals": { + "AWS:SourceArn": f"arn:aws:cloudfront::{ACCESSOR_ACCOUNT_ID}:distribution/{id}" + } + }, + } + key_policy["Statement"].append(statement) + + # Update the key policy + put_key_policy(kms, key_policy) diff --git a/lambda-code/s3/main.py b/lambda-code/s3/main.py new file mode 100644 index 0000000..eb5ccaa --- /dev/null +++ b/lambda-code/s3/main.py @@ -0,0 +1,127 @@ +import json +import logging +import os + +import boto3 +from botocore.exceptions import ClientError + +# Testing +LOCAL = os.environ.get("LOCAL", "false") +local_endpoint_url = "http://localhost:4566" + +# STS client (points to LocalStack if LOCAL environment variable set to true) +sts = ( + boto3.client( + "sts", + endpoint_url=local_endpoint_url, + region_name="ap-northeast-1", + aws_access_key_id="test", + aws_secret_access_key="test", + ) + if LOCAL == "true" + else boto3.client("sts") +) + +XA_MGMT_ROLE_ARN = os.environ["XA_MGMT_ROLE_ARN"] +BUCKET_NAME = os.environ["RESOURCE_ID"] +ACCESSOR_ACCOUNT_ID = os.environ["ACCESSOR_ACCOUNT_ID"] +ACCESSOR_STACK_NAME = os.environ["ACCESSOR_STACK_NAME"] + +logger = logging.getLogger() +logger.setLevel("INFO") + + +def s3_client_with_role(operation): + logger.info(f"assuming role: {XA_MGMT_ROLE_ARN}") + assume_role_response = sts.assume_role( + RoleArn=XA_MGMT_ROLE_ARN, + RoleSessionName=f"{BUCKET_NAME}-policy-{operation}", + ) + credentials = assume_role_response["Credentials"] + + session = boto3.Session( + aws_access_key_id=credentials["AccessKeyId"], + aws_secret_access_key=credentials["SecretAccessKey"], + aws_session_token=credentials["SessionToken"], + ) + + return ( + session.client("s3", endpoint_url=local_endpoint_url) + if LOCAL == "true" + else session.client("s3") + ) + + +def get_bucket_policy(s3): + try: + get_policy_response = s3.get_bucket_policy(Bucket=BUCKET_NAME) + return json.loads(get_policy_response["Policy"]) + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code") + if error_code == "NoSuchBucketPolicy": + logger.info(f"No bucket policy found for {BUCKET_NAME}, starting fresh.") + return {"Version": "2012-10-17", "Statement": []} + else: + logger.error(f"Failed to get bucket policy: {e}") + raise + + +def put_bucket_policy(s3, policy): + try: + s3.put_bucket_policy(Bucket=BUCKET_NAME, Policy=json.dumps(policy)) + logger.info("Successfully updated bucket policy") + except ClientError as e: + logger.error(f"Failed to update bucket policy: {e}") + raise + + +def delete_bucket_policy(s3): + try: + s3.delete_bucket_policy(Bucket=BUCKET_NAME) + logger.info("Successfully cleared empty bucket policy") + except ClientError as e: + logger.error(f"Failed to clear bucket policy: {e}") + raise + + +def handler(event, context): + operation = event["operation"] + logger.info(f"operation: {operation}") + + s3 = s3_client_with_role(operation) + + cloudfront_distribution_ids = sorted(list(event["cloudfrontAccessors"].keys())) + logger.info(f"cloudfrontAccessors: {'\n- '.join(cloudfront_distribution_ids)}") + + # First, remove the statements set by this stack + bucket_policy = get_bucket_policy(s3) + sid_prefix = f"{ACCESSOR_ACCOUNT_ID} {ACCESSOR_STACK_NAME} " + bucket_policy["Statement"] = [ + statement + for statement in bucket_policy["Statement"] + if not statement.get("Sid", "").startswith(sid_prefix) + ] + + # Re-add the statements if this is not a delete operation + if operation != "delete": + for id in cloudfront_distribution_ids: + actions = event["cloudfrontAccessors"][id] + statement = { + "Sid": f"{sid_prefix}: cf-{id}", + "Effect": "Allow", + "Principal": {"Service": "cloudfront.amazonaws.com"}, + "Action": actions, + "Resource": f"arn:aws:s3:::{BUCKET_NAME}/*", + "Condition": { + "StringEquals": { + "AWS:SourceArn": f"arn:aws:cloudfront::{ACCESSOR_ACCOUNT_ID}:distribution/{id}" + } + }, + } + bucket_policy["Statement"].append(statement) + + # Update the bucket policy (delete if no statements are left) + if len(bucket_policy["Statement"]) > 0: + put_bucket_policy(s3, bucket_policy) + else: + delete_bucket_policy(s3) diff --git a/lib/cross-account/index.ts b/lib/cross-account/index.ts new file mode 100644 index 0000000..e70b5da --- /dev/null +++ b/lib/cross-account/index.ts @@ -0,0 +1,5 @@ +export { CrossAccountConstruct } from "./xa-construct"; +export type { CrossAccountConstructProps } from "./xa-construct"; + +export { CrossAccountManager } from "./xa-manager"; +export type { CrossAccountManagerProps } from "./xa-manager"; diff --git a/lib/cross-account/xa-construct.ts b/lib/cross-account/xa-construct.ts new file mode 100644 index 0000000..c2e1333 --- /dev/null +++ b/lib/cross-account/xa-construct.ts @@ -0,0 +1,111 @@ +import { Construct } from "constructs"; +import { CfnOutput } from "aws-cdk-lib"; +import { + AccountPrincipal, + AccountRootPrincipal, + ArnPrincipal, + Effect, + PolicyDocument, + PolicyStatement, + Role, +} from "aws-cdk-lib/aws-iam"; + +/** + * Properties for CrossAccountConstruct. + */ +export interface CrossAccountConstructProps { + /** + * List of AWS account IDs allowed to assume the management role. + * Each string should be a 12-digit numeric AWS account ID. + */ + xaAwsIds: string[]; +} + +/** + * Abstract base class for cross-account resource management. + * + * @remarks + * Provides a standard way to create an IAM management role that can be + * assumed by specified accounts to manage resource policies (e.g., S3 bucket policies, KMS key policies). + * + * Subclasses should call `createManagementRole()` in their constructor or initialization + * to set up the IAM role and grant cross-account assume-role permissions. + */ +export abstract class CrossAccountConstruct extends Construct { + /** The IAM role used for cross-account policy management */ + private mgmtRole: Role; + + /** Getter for the cross-account management role */ + public get role(): Role { + return this.mgmtRole; + } + + private readonly xaAwsIds: string[]; + + /** + * Creates the IAM role used to manage policies on the target resource. + * + * @param resourceIdentifier - A human-readable identifier for the resource (used in the role name and description). + * @param policyTarget - The resource target in the policy for this role (usually an ARN). + * @param policyActions - List of actions the role can perform on the resource (e.g., ["s3:GetBucketPolicy", "s3:PutBucketPolicy"]). + */ + protected createManagementRole( + resourceIdentifier: string, + policyTarget: string, + policyActions: string[], + ) { + this.mgmtRole = new Role(this, "XaMgmtRole", { + assumedBy: new AccountRootPrincipal(), // placeholder + description: `IAM role to enable cross-account management of policy for ${resourceIdentifier}`, + roleName: `${resourceIdentifier}-xa-mgmt`, + inlinePolicies: { + UpdateResourcePolicy: new PolicyDocument({ + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + actions: policyActions, + resources: [policyTarget], + }), + ], + }), + }, + }); + const accessorRoleName = `${resourceIdentifier}-xa-mgmt-ex`; + + this.role.assumeRolePolicy?.addStatements( + new PolicyStatement({ + effect: Effect.ALLOW, + principals: this.xaAwsIds.map((id) => new AccountPrincipal(id)), + actions: ["sts:AssumeRole"], + conditions: { + StringLike: { + "aws:PrincipalArn": this.xaAwsIds.map( + (id) => `arn:aws:iam::${id}:role/${accessorRoleName}`, + ), + }, + }, + }), + ); + + new CfnOutput(this, "XaMgmtRoleArn", { + value: this.role.roleArn, + description: + "ARN of the IAM role used for cross-account resource policy management", + }); + } + + /** + * Constructs a CrossAccountConstruct. + * + * @param scope - The parent Construct scope. + * @param id - Logical ID of the construct. + * @param props - Properties including the cross-account AWS IDs. + */ + constructor(scope: Construct, id: string, props: CrossAccountConstructProps) { + super(scope, id); + + const { xaAwsIds } = props; + + this.xaAwsIds = xaAwsIds; + } +} diff --git a/lib/cross-account/xa-manager-registry.ts b/lib/cross-account/xa-manager-registry.ts new file mode 100644 index 0000000..dacde3f --- /dev/null +++ b/lib/cross-account/xa-manager-registry.ts @@ -0,0 +1,103 @@ +// Sveltekit-style state manager + +import { Stack } from "aws-cdk-lib"; + +interface Access { + accessorIdentifier: string; + targetIdentifier: string; + permissions: string[]; +} +interface ConsumptionStatus { + targetIdentifier: string; + consumed: boolean; +} + +// A map of Function (manager Construct constuctor) -> cloudfrontAccessors array (set to false if consumed) +type Registry = Map; +const ensureRegistry = ( + registry: WeakMap>, + stack: Stack, + manager: Function, +) => { + if (!registry.has(stack)) registry.set(stack, new Map()); + const stackRegistry = registry.get(stack)!; + if (!stackRegistry.has(manager)) stackRegistry.set(manager, []); + return stackRegistry.get(manager)!; +}; + +interface RegistryManagementBaseProps { + stack: Stack; + manager: Function; + targetIdentifier: string; +} + +// A map of Stack instance -> Function (manager Construct constuctor) -> cloudfrontAccessors array +const cloudfrontRegistries = new WeakMap>(); + +// A map of Stack instance -> Function (manager Construct constuctor) -> ConsumptionStatus array +const consumptionRegistries = new WeakMap>(); + +interface IsConsumedProps extends RegistryManagementBaseProps {} + +// Checks if the given resource manager for the given stack has been consumed +const isConsumed = (props: IsConsumedProps) => + consumptionRegistries + .get(props.stack) + ?.get(props.manager) + ?.find((r) => r.targetIdentifier == props.targetIdentifier)?.consumed ?? + false; + +export interface ConsumeCloudfrontAccessorsProps + extends RegistryManagementBaseProps {} + +// Consume the cloudfrontAccessors for the provided manager construct of the given stack +export const consumeCloudfrontAccessors = ( + props: ConsumeCloudfrontAccessorsProps, +) => { + const { stack, manager, targetIdentifier } = props; + if (isConsumed({ stack, manager, targetIdentifier })) + throw new Error( + `Manager for ${targetIdentifier} has already been consumed.`, + ); + ensureRegistry(consumptionRegistries, stack, manager).push({ + targetIdentifier, + consumed: true, + }); + return ensureRegistry(cloudfrontRegistries, stack, manager).filter( + (r) => r.targetIdentifier == targetIdentifier, + ); +}; + +export interface RegisterCloudfrontAccessorProps + extends RegistryManagementBaseProps { + distributionId: string; + actions: string[]; +} + +// Manager functions that call this should specify a default list of actions for ergonomics +export const registerCloudfrontAccessor = ( + props: RegisterCloudfrontAccessorProps, +) => { + const { stack, manager, targetIdentifier, distributionId, actions } = props; + if (isConsumed({ stack, manager, targetIdentifier })) + throw new Error( + `Cannot register resources for ${targetIdentifier} manager after creation ` + + `(registering ${distributionId}).`, + ); + if ( + ensureRegistry(cloudfrontRegistries, stack, manager).find( + (r) => + r.targetIdentifier == targetIdentifier && + r.accessorIdentifier == distributionId, + ) + ) + throw new Error( + `Distribution ${distributionId} has already been registered for ` + + `${targetIdentifier} manager.`, + ); + ensureRegistry(cloudfrontRegistries, stack, manager).push({ + accessorIdentifier: distributionId, + targetIdentifier, + permissions: actions, + }); +}; diff --git a/lib/cross-account/xa-manager.ts b/lib/cross-account/xa-manager.ts new file mode 100644 index 0000000..a77f1d4 --- /dev/null +++ b/lib/cross-account/xa-manager.ts @@ -0,0 +1,171 @@ +import { createHash } from "crypto"; +import { Duration, Stack } from "aws-cdk-lib"; +import { Construct } from "constructs"; +import * as iam from "aws-cdk-lib/aws-iam"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { + AwsCustomResource, + AwsCustomResourcePolicy, + PhysicalResourceId, +} from "aws-cdk-lib/custom-resources"; + +import { + ConsumeCloudfrontAccessorsProps, + RegisterCloudfrontAccessorProps, +} from "./xa-manager-registry"; +import * as registry from "./xa-manager-registry"; + +export interface AllowCloudfrontBaseProps { + scope: Construct; + distributionId: string; + actions?: string[]; +} + +export interface CrossAccountManagerProps { + resourceIdentifier: string; + xaAwsId: string; + managerTimeout?: number; + callerTimeout?: number; + subclassDir: string; +} + +const hashAccessors = (accessors: { [accessor: string]: string[] }) => { + const snapshot = JSON.stringify( + Object.fromEntries( + Object.entries(accessors) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([k, v]) => [k, v.sort()]), + ), + ); + return createHash("sha256").update(snapshot).digest("hex").slice(0, 16); +}; + +export abstract class CrossAccountManager extends Construct { + /** + * The Lambda Function that will be called to assume a role cross-account and + * manage the resource + */ + private mgrFunction: lambda.Function; + public get function(): lambda.Function { + return this.mgrFunction; + } + + /** + * Given a stack, subclass constructor, and a resource identifier, returns + * the mapping of accessor resource IDs to the permissions they need. + */ + private static consumeCloudfrontAccessors( + props: ConsumeCloudfrontAccessorsProps, + ) { + const accessInfo = registry.consumeCloudfrontAccessors(props); + const accessors: { [accessor: string]: string[] } = {}; + for (const access of accessInfo) { + accessors[access.accessorIdentifier] = access.permissions; + } + return accessors; + } + + constructor(scope: Construct, id: string, props: CrossAccountManagerProps) { + super(scope, id); + + const { + resourceIdentifier, + xaAwsId, + managerTimeout = 30, // default timeout of 3 seconds is awful short/fragile + callerTimeout = 30, // default timeout of 3 seconds is awful short/fragile + subclassDir, + } = props; + + const xaMgmtRoleArn = `arn:aws:iam::${xaAwsId}:role/${resourceIdentifier}-xa-mgmt`; + const assumeXaMgmtRolePolicy = new iam.PolicyDocument({ + statements: [ + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + actions: ["sts:AssumeRole"], + resources: [xaMgmtRoleArn], + }), + ], + }); + + // Manager Lambda execution role + const role = new iam.Role(this, "XaMgmtLambdaRole", { + assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"), + description: `Execution role for ${resourceIdentifier} manager Lambda function.`, + inlinePolicies: { + assumeXaMgmtRolePolicy, + }, + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName( + "service-role/AWSLambdaBasicExecutionRole", + ), + ], + roleName: `${resourceIdentifier}-xa-mgmt-ex`, + }); + + // Manager Lambda + this.mgrFunction = new lambda.Function(this, "XaMgmtLambda", { + code: lambda.Code.fromAsset(subclassDir), + handler: "main.handler", + runtime: lambda.Runtime.PYTHON_3_13, + timeout: Duration.seconds(managerTimeout), + environment: { + XA_MGMT_ROLE_ARN: xaMgmtRoleArn, + RESOURCE_ID: resourceIdentifier, + ACCESSOR_ACCOUNT_ID: Stack.of(this).account, + ACCESSOR_STACK_NAME: Stack.of(this).stackName, + }, + role, + }); + + // Get the Cloudfront IDs and their permissions for this subclass from the + // Registries + const stack = Stack.of(this); + const manager = this.constructor; + const cloudfrontAccessors = CrossAccountManager.consumeCloudfrontAccessors({ + stack, + manager, + targetIdentifier: resourceIdentifier, + }); + const cloudfrontAccessorsHash = hashAccessors(cloudfrontAccessors); + + // Util factory to get an AwsSdkCall for the AwsCustomResource + const callFor = (operation: string) => { + return { + physicalResourceId: PhysicalResourceId.of(cloudfrontAccessorsHash), + service: "Lambda", + action: "Invoke", + parameters: { + FunctionName: this.function.functionName, + InvocationType: "Event", + Payload: JSON.stringify({ + operation, + cloudfrontAccessors, + }), + }, + }; + }; + + const caller = new AwsCustomResource(this, "XaMgmtLambdaCaller", { + onCreate: callFor("create"), + onUpdate: callFor("update"), + onDelete: callFor("delete"), + policy: AwsCustomResourcePolicy.fromSdkCalls({ + resources: [this.function.functionArn], + }), + timeout: Duration.seconds(callerTimeout), + }); + this.function.grantInvoke(caller.grantPrincipal); + } + + /** + * Grant access to the given cross-account resource to the specified Cloudfront + * Distribution ID + * Should be implemented by the child class, passing the subclass's constructor and + * specifying a default list of actions + */ + protected static registerCloudfrontAccessor( + props: RegisterCloudfrontAccessorProps, + ) { + registry.registerCloudfrontAccessor(props); + } +} diff --git a/lib/kms/index.ts b/lib/kms/index.ts new file mode 100644 index 0000000..3223bba --- /dev/null +++ b/lib/kms/index.ts @@ -0,0 +1,5 @@ +export { CrossAccountKmsKey } from "./key"; +export type { CrossAccountKmsKeyProps } from "./key"; + +export { CrossAccountKmsKeyManager } from "./manager"; +export type { CrossAccountKmsKeyManagerProps } from "./manager"; diff --git a/lib/kms/key.ts b/lib/kms/key.ts new file mode 100644 index 0000000..e6e1f2b --- /dev/null +++ b/lib/kms/key.ts @@ -0,0 +1,43 @@ +import { Construct } from "constructs"; +import { CfnOutput } from "aws-cdk-lib"; +import { Key, KeyProps } from "aws-cdk-lib/aws-kms"; +import { + CrossAccountConstruct, + CrossAccountConstructProps, +} from "../cross-account"; + +export interface CrossAccountKmsKeyProps + extends KeyProps, + CrossAccountConstructProps {} + +/** + * CrossAccountKmsKey creates a KMS key with a corresponding IAM role + * that can be assumed by specific cross-account roles to update the resource policy. + * + * @remarks + * - `xaAwsIds` should be the list of AWS account IDs that are allowed to assume + * the management role. + * - The IAM role created is scoped to `kms:GetKeyPolicy` and `kms:PutKeyPolicy` + * on this specific key only. + */ +export class CrossAccountKmsKey extends CrossAccountConstruct { + /** The managed KMS key */ + public readonly key: Key; + + constructor(scope: Construct, id: string, props: CrossAccountKmsKeyProps) { + const { xaAwsIds, ...keyProps } = props; + super(scope, id, { xaAwsIds }); + + this.key = new Key(this, "XaKey", keyProps); + + this.createManagementRole(this.key.keyId, this.key.keyArn, [ + "kms:GetKeyPolicy", + "kms:PutKeyPolicy", + ]); + + new CfnOutput(this, "XaKeyArn", { + value: this.key.keyArn, + description: "ARN of the cross-account managed KMS key", + }); + } +} diff --git a/lib/kms/manager.ts b/lib/kms/manager.ts new file mode 100644 index 0000000..7496d16 --- /dev/null +++ b/lib/kms/manager.ts @@ -0,0 +1,90 @@ +import path from "path"; + +import { Stack } from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { CrossAccountManager } from "../cross-account"; +import { AllowCloudfrontBaseProps } from "../cross-account/xa-manager"; + +export interface AllowCloudfrontProps extends AllowCloudfrontBaseProps { + keyId: string; +} + +export interface CrossAccountKmsKeyManagerProps { + xaKeyId: string; + xaAwsId: string; + managerTimeout?: number; + callerTimeout?: number; +} + +/** + * CrossAccountKmsKeyManager creates a Lambda function and calls it at deployment + * time using an AwsCustomResource (Lambda:InvokeFunction). + * + * @remarks + * - `xaKeyId` should be the ID of the KMS key in the other account whose + * policy we want to manage. + * - `xaAwsId` should be the ID of the AWS account the aforementioned key + * belongs to. + * - `managerTimeout` is optional and specifies the number of seconds for the manager + * Lambda Function's timeout (defaults to 30). + * - `callerTimeout` is optional and specifies the number of seconds for the + * AwsCustomResource's timeout (defaults to 30). + * - To use this construct, first register the resources that need to access a given + * KMS key using one of the following static registry functions: + * - allowCloudfront(keyId, cloudfrontId) + */ +export class CrossAccountKmsKeyManager extends CrossAccountManager { + constructor( + scope: Construct, + id: string, + props: CrossAccountKmsKeyManagerProps, + ) { + const { + xaKeyId, + xaAwsId, + managerTimeout, + callerTimeout, // default timeout of 3 seconds is awful short/fragile + } = props; + super(scope, id, { + resourceIdentifier: xaKeyId, + xaAwsId, + managerTimeout, + callerTimeout, + subclassDir: path.join(__dirname, "../../lambda-code/kms"), + }); + } + + /** + * Grant access to the given cross-account KMS key to the specified Cloudfront + * Distribution ID + * Optionally specify a list of actions + * (default: [ + * "kms:Decrypt", + * "kms:Encrypt", + * "kms:GenerateDataKey*", + * "kms:DescribeKey" + * ]) + */ + public static allowCloudfront(props: AllowCloudfrontProps) { + const { + scope, + distributionId, + keyId, + actions = [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:DescribeKey", + ], + } = props; + const stack = Stack.of(scope); + const manager = this; + super.registerCloudfrontAccessor({ + stack, + manager, + targetIdentifier: keyId, + distributionId, + actions, + }); + } +} diff --git a/lib/s3/bucket.ts b/lib/s3/bucket.ts new file mode 100644 index 0000000..580e4eb --- /dev/null +++ b/lib/s3/bucket.ts @@ -0,0 +1,52 @@ +import { Construct } from "constructs"; +import { CfnOutput } from "aws-cdk-lib"; +import { Bucket, BucketProps } from "aws-cdk-lib/aws-s3"; +import { + CrossAccountConstruct, + CrossAccountConstructProps, +} from "../cross-account"; + +export interface CrossAccountS3BucketProps + extends BucketProps, + CrossAccountConstructProps {} + +/** + * CrossAccountS3Bucket creates an S3 bucket with a corresponding IAM role + * that can be assumed by specific cross-account roles to update the bucket policy. + * + * @remarks + * - `xaAwsIds` should be the list of AWS account IDs that are allowed to assume + * the management role. + * - The IAM role created is scoped to `s3:GetBucketPolicy` and `s3:PutBucketPolicy` + * on the bucket only. + */ +export class CrossAccountS3Bucket extends CrossAccountConstruct { + /** The managed S3 bucket */ + public readonly bucket: Bucket; + + constructor(scope: Construct, id: string, props: CrossAccountS3BucketProps) { + const { xaAwsIds, ...bucketProps } = props; + super(scope, id, { xaAwsIds }); + + // The updater Lambda's execution role will have 11 characters appended to it + // so let's keep the bucket name to 52 characters or less + if ((bucketProps.bucketName?.length ?? 0) > 52) { + throw new Error( + `Bucket name "${bucketProps.bucketName}" must be 52 characters or less.`, + ); + } + + this.bucket = new Bucket(this, "XaBucket", bucketProps); + + this.createManagementRole(this.bucket.bucketName, this.bucket.bucketArn, [ + "s3:GetBucketPolicy", + "s3:PutBucketPolicy", + "s3:DeleteBucketPolicy", + ]); + + new CfnOutput(this, "XaBucketName", { + value: this.bucket.bucketName, + description: "Name of the cross-account managed S3 bucket", + }); + } +} diff --git a/lib/s3/index.ts b/lib/s3/index.ts new file mode 100644 index 0000000..e604058 --- /dev/null +++ b/lib/s3/index.ts @@ -0,0 +1,5 @@ +export { CrossAccountS3Bucket } from "./bucket"; +export type { CrossAccountS3BucketProps } from "./bucket"; + +export { CrossAccountS3BucketManager } from "./manager"; +export type { CrossAccountS3BucketManagerProps } from "./manager"; diff --git a/lib/s3/manager.ts b/lib/s3/manager.ts new file mode 100644 index 0000000..267153f --- /dev/null +++ b/lib/s3/manager.ts @@ -0,0 +1,74 @@ +import path from "path"; + +import { Stack } from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { CrossAccountManager } from "../cross-account"; +import { AllowCloudfrontBaseProps } from "../cross-account/xa-manager"; + +export interface AllowCloudfrontProps extends AllowCloudfrontBaseProps { + bucketName: string; +} + +export interface CrossAccountS3BucketManagerProps { + xaBucketName: string; + xaAwsId: string; + managerTimeout?: number; + callerTimeout?: number; +} + +/** + * CrossAccountS3BucketManager creates a Lambda function and calls it at deployment + * time using an AwsCustomResource (Lambda:InvokeFunction). + * + * @remarks + * - `xaBucketName` should be the name of the S3 bucket in the other account whose + * policy we want to manage. + * - `xaAwsId` should be the ID of the AWS account the aforementioned bucket + * belongs to. + * - `managerTimeout` is optional and specifies the number of seconds for the manager + * Lambda Function's timeout (defaults to 30). + * - `callerTimeout` is optional and specifies the number of seconds for the + * AwsCustomResource's timeout (defaults to 30). + * - To use this construct, first register the resources that need to access a given + * S3 Bucket using one of the following static registry functions: + * - allowCloudfront(bucketName, cloudfrontId) + */ +export class CrossAccountS3BucketManager extends CrossAccountManager { + constructor( + scope: Construct, + id: string, + props: CrossAccountS3BucketManagerProps, + ) { + const { xaBucketName, xaAwsId, managerTimeout, callerTimeout } = props; + super(scope, id, { + resourceIdentifier: xaBucketName, + xaAwsId, + managerTimeout, + callerTimeout, + subclassDir: path.join(__dirname, "../../lambda-code/s3"), + }); + } + + /** + * Grant access to the given cross-account S3 bucket to the specified Cloudfront + * Distribution ID + * Optionally specify a list of actions (default: ["s3:GetObject"]) + */ + public static allowCloudfront(props: AllowCloudfrontProps) { + const { + scope, + distributionId, + bucketName, + actions = ["s3:GetObject"], + } = props; + const stack = Stack.of(scope); + const manager = this; + super.registerCloudfrontAccessor({ + stack, + manager, + targetIdentifier: bucketName, + distributionId, + actions, + }); + } +} diff --git a/package-lock.json b/package-lock.json index 3f9186a..655b231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@types/node": "22.7.9", "aws-cdk-lib": "2.215.0", "constructs": "^10.0.0", - "husky": "^9.1.7", + "husky": "^8.0.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", "typescript": "~5.6.3" @@ -2367,16 +2367,16 @@ } }, "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", "dev": true, "license": "MIT", "bin": { - "husky": "bin.js" + "husky": "lib/bin.js" }, "engines": { - "node": ">=18" + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/typicode" diff --git a/package.json b/package.json index cd9a8bb..24fe4ec 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@doceight/xa-cdk", - "version": "0.0.1", + "version": "1.0.0", "description": "CDK constructs for facilitating cross-account resource access", "repository": { "type": "git", - "url": "https://github.com/DocEight/xa-cdk.git" + "url": "git+https://github.com/DocEight/xa-cdk.git" }, "license": "MIT", "keywords": [ @@ -18,7 +18,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc", + "build": "tsc && cp -r lambda-code dist/", "watch": "tsc -w", "test": "jest", "prepare": "husky install" @@ -38,7 +38,11 @@ "constructs": "^10.0.0" }, "files": [ - "dist", - "lambda-code" - ] + "dist" + ], + "homepage": "https://github.com/DocEight/xa-cdk#readme", + "bugs": { + "url": "https://github.com/DocEight/xa-cdk/issues" + }, + "author": "doceight" } diff --git a/test/const.ts b/test/const.ts new file mode 100644 index 0000000..4c368a1 --- /dev/null +++ b/test/const.ts @@ -0,0 +1,9 @@ +export const PYTHON_RUNTIME = "python3.13"; + +export const S3_DEFAULT_ACTIONS = ["s3:GetObject"]; +export const KMS_DEFAULT_ACTIONS = [ + "kms:Decrypt", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:DescribeKey", +]; diff --git a/test/cross-account-kms-key.test.ts b/test/cross-account-kms-key.test.ts new file mode 100644 index 0000000..6eba9c4 --- /dev/null +++ b/test/cross-account-kms-key.test.ts @@ -0,0 +1,87 @@ +import { App, Stack } from "aws-cdk-lib/core"; +import { Template } from "aws-cdk-lib/assertions"; + +import { CrossAccountKmsKey } from "../lib/kms"; +import { getAssumeRolePolicyMatcher, getRoleNameMatcher } from "./matchers"; + +test("Single Accessor XAKMS", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const keyId = "test-xakms-single-accessor"; + const alias = "test-xakms-s"; + const xaAwsIds = ["000000000000"]; + // WHEN + new CrossAccountKmsKey(stack, keyId, { + alias, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResource("AWS::KMS::Key", {}); + template.hasResourceProperties("AWS::KMS::Alias", { + AliasName: `alias/${alias}`, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId, xaAwsIds), + }); +}); + +test("Multi Accessor XAKMS", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const keyId = "test-xakms-multi-accessor"; + const alias = "test-xakms-m"; + const xaAwsIds = ["000000000000", "111111111111", "222222222222"]; + // WHEN + new CrossAccountKmsKey(stack, keyId, { + alias, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResource("AWS::KMS::Key", {}); + template.hasResourceProperties("AWS::KMS::Alias", { + AliasName: `alias/${alias}`, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId, xaAwsIds), + }); +}); + +test("Several XAKMSes", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const keyId1 = "test-xakms-1"; + const alias1 = "test-xakms-alias-1"; + const xaAwsIds1 = ["000000000000"]; + const keyId2 = "test-xakms-2"; + const xaAwsIds2 = ["111111111111"]; + // WHEN + new CrossAccountKmsKey(stack, keyId1, { + alias: alias1, + xaAwsIds: xaAwsIds1, + }); + new CrossAccountKmsKey(stack, keyId2, { + xaAwsIds: xaAwsIds2, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResource("AWS::KMS::Key", {}); + template.hasResourceProperties("AWS::KMS::Alias", { + AliasName: `alias/${alias1}`, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId1), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId1, xaAwsIds1), + }); + + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(keyId2), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(keyId2, xaAwsIds2), + }); +}); diff --git a/test/cross-account-kms-manager.test.ts b/test/cross-account-kms-manager.test.ts new file mode 100644 index 0000000..47f1d3a --- /dev/null +++ b/test/cross-account-kms-manager.test.ts @@ -0,0 +1,241 @@ +import { App, Stack } from "aws-cdk-lib/core"; +import { Template } from "aws-cdk-lib/assertions"; +import { CrossAccountKmsKeyManager } from "../lib/kms"; + +import { getPoliciesMatcher, getEventMatcher } from "./matchers"; +import { PYTHON_RUNTIME, KMS_DEFAULT_ACTIONS } from "./const"; + +test("Single XAKMS Manager", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xakms-mgr"; + const xaKeyId = "test-xakms"; + const xaAwsId = "098765432109"; + + // WHEN + new CrossAccountKmsKeyManager(stack, managerId, { + xaKeyId, + xaAwsId, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaKeyId}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaKeyId, xaAwsId), + }); + template.hasResourceProperties("Custom::AWS", {}); +}); + +test("XAKMS Manager single accessor", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xakms-mgr"; + const xaKeyId = "test-xakms"; + const xaAwsId = "098765432109"; + const distributionIds = ["SOME_ID"]; + + // WHEN + CrossAccountKmsKeyManager.allowCloudfront({ + scope: stack, + distributionId: distributionIds[0], + keyId: xaKeyId, + }); + new CrossAccountKmsKeyManager(stack, managerId, { + xaKeyId, + xaAwsId, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaKeyId}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaKeyId, xaAwsId), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + managerId, + distributionIds, + KMS_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + managerId, + distributionIds, + KMS_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + managerId, + distributionIds, + KMS_DEFAULT_ACTIONS, + ), + }); +}); + +test("XAKMS Manager multiple accessors", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xakms-mgr"; + const xaKeyId = "test-xakms"; + const xaAwsId = "098765432109"; + const distributionIds = [ + "SOME_ID", + "ANOTHER_ID", + "YET_ANOTHER_ID", + "SO_MANY_IDS", + ].sort(); + + // WHEN + for (const distributionId of distributionIds) { + CrossAccountKmsKeyManager.allowCloudfront({ + scope: stack, + distributionId, + keyId: xaKeyId, + }); + } + new CrossAccountKmsKeyManager(stack, managerId, { + xaKeyId, + xaAwsId, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaKeyId}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaKeyId, xaAwsId), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + managerId, + distributionIds, + KMS_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + managerId, + distributionIds, + KMS_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + managerId, + distributionIds, + KMS_DEFAULT_ACTIONS, + ), + }); +}); + +test("XAKMS Manager improper usage", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xakms-mgr"; + const xaKeyId = "test-xakms-BAD"; + const xaAwsId = "098765432109"; + + new CrossAccountKmsKeyManager(stack, managerId, { + xaKeyId, + xaAwsId, + }); + + expect(() => + CrossAccountKmsKeyManager.allowCloudfront({ + scope: stack, + distributionId: "bad :(", + keyId: xaKeyId, + }), + ).toThrow(/Cannot register .+ after creation./); +}); + +test("Multiple XAKMS Managers", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId1 = "test-xakms-mgr1"; + const xaKeyId1 = "test-xakms-s1"; + const xaAwsId1 = "123456789012"; + const distributionIds1 = ["SOME_ID"]; + const managerId2 = "test-xakms-mgr2"; + const xaKeyId2 = "test-xakms-m2"; + const xaAwsId2 = "098765432109"; + const distributionIds2 = [ + "SOME_ID", + "ANOTHER_ID", + "YET_ANOTHER_ID", + "SO_MANY_IDS", + ].sort(); + const actions2 = ["kms:ListKeys", ...KMS_DEFAULT_ACTIONS]; + + // WHEN + CrossAccountKmsKeyManager.allowCloudfront({ + scope: stack, + distributionId: distributionIds1[0], + keyId: xaKeyId1, + }); + for (const distributionId of distributionIds2) { + CrossAccountKmsKeyManager.allowCloudfront({ + scope: stack, + distributionId, + keyId: xaKeyId2, + actions: actions2, + }); + } + new CrossAccountKmsKeyManager(stack, managerId1, { + xaKeyId: xaKeyId1, + xaAwsId: xaAwsId1, + }); + new CrossAccountKmsKeyManager(stack, managerId2, { + xaKeyId: xaKeyId2, + xaAwsId: xaAwsId2, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaKeyId1}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaKeyId1, xaAwsId1), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + managerId1, + distributionIds1, + KMS_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + managerId1, + distributionIds1, + KMS_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + managerId1, + distributionIds1, + KMS_DEFAULT_ACTIONS, + ), + }); + + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaKeyId2}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaKeyId2, xaAwsId2), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher("create", managerId2, distributionIds2, actions2), + Update: getEventMatcher("update", managerId2, distributionIds2, actions2), + Delete: getEventMatcher("delete", managerId2, distributionIds2, actions2), + }); +}); diff --git a/test/cross-account-multi-stack.test.ts b/test/cross-account-multi-stack.test.ts new file mode 100644 index 0000000..6ad83f4 --- /dev/null +++ b/test/cross-account-multi-stack.test.ts @@ -0,0 +1,236 @@ +import { App, Stack } from "aws-cdk-lib/core"; +import { Template } from "aws-cdk-lib/assertions"; +import * as cloudfront from "aws-cdk-lib/aws-cloudfront"; +import * as origins from "aws-cdk-lib/aws-cloudfront-origins"; +import * as s3 from "aws-cdk-lib/aws-s3"; +import { CrossAccountKmsKey, CrossAccountKmsKeyManager } from "../lib/kms"; +import { CrossAccountS3Bucket, CrossAccountS3BucketManager } from "../lib/s3"; + +import { + getPoliciesMatcher, + getEventMatcher, + getRoleNameMatcher, + getAssumeRolePolicyMatcher, +} from "./matchers"; +import { + KMS_DEFAULT_ACTIONS, + PYTHON_RUNTIME, + S3_DEFAULT_ACTIONS, +} from "./const"; + +// NOTE: We can't dynamically test on a Cloudfront Distribution's distributionId here +// due to how CDK tokenizes values generated at deployment time +// In an actual deployment scenario these should resolve before being passed into our +// manager Lambdas/custom resources + +test("Cloudfront to S3 (SSE:S3)", () => { + const app = new App(); + const accessedStack = new Stack(app, "accessed-stack"); + const accessedAwsId = "000000000000"; + const xaBucketId = "test-xas3-id"; + const xaBucketName = "test-xas3"; + const accessorStack = new Stack(app, "accessor-stack"); + const accessorAwsId = "111111111111"; + const distributionId = "test-accessor-distribution"; + const managerId = "test-xas3-mgr"; + + // WHEN + new CrossAccountS3Bucket(accessedStack, xaBucketId, { + bucketName: xaBucketName, + xaAwsIds: [accessorAwsId], + }); + const distribution = new cloudfront.Distribution( + accessorStack, + distributionId, + { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl( + s3.Bucket.fromBucketName(accessedStack, "xa-bucket", xaBucketName), + ), + }, + }, + ); + CrossAccountS3BucketManager.allowCloudfront({ + scope: distribution, + bucketName: xaBucketName, + distributionId: distributionId, + }); + new CrossAccountS3BucketManager(accessorStack, managerId, { + xaBucketName, + xaAwsId: accessedAwsId, + }); + + // THEN + const accessedTemplate = Template.fromStack(accessedStack); + const accessorTemplate = Template.fromStack(accessorStack); + + accessedTemplate.hasResourceProperties("AWS::S3::Bucket", { + BucketName: xaBucketName, + }); + accessedTemplate.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(xaBucketId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(xaBucketName, [ + accessorAwsId, + ]), + }); + + accessorTemplate.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + accessorTemplate.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaBucketName}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaBucketName, accessedAwsId), + }); + accessorTemplate.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + managerId, + [distributionId], + S3_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + managerId, + [distributionId], + S3_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + managerId, + [distributionId], + S3_DEFAULT_ACTIONS, + ), + }); +}); + +test("Cloudfront to S3 (SSE:KMS)", () => { + const app = new App(); + const accessedStack = new Stack(app, "accessed-stack"); + const accessedAwsId = "000000000000"; + const xaBucketId = "test-xas3-id"; + const xaBucketName = "test-xas3"; + const xaKeyId = "test-xakms"; + const xaAlias = "test-xakms-alias"; + const accessorStack = new Stack(app, "accessor-stack"); + const accessorAwsId = "111111111111"; + const distributionId = "test-accessor-distribution"; + const s3ManagerId = "test-xas3-mgr"; + const kmsManagerId = "test-xakms-mgr"; + + // WHEN + const key = new CrossAccountKmsKey(accessedStack, xaKeyId, { + alias: xaAlias, + xaAwsIds: [accessorAwsId], + }); + new CrossAccountS3Bucket(accessedStack, xaBucketId, { + bucketName: xaBucketName, + encryptionKey: key.key, + xaAwsIds: [accessorAwsId], + }); + const distribution = new cloudfront.Distribution( + accessorStack, + distributionId, + { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl( + s3.Bucket.fromBucketName(accessedStack, "xa-bucket", xaBucketName), + ), + }, + }, + ); + CrossAccountKmsKeyManager.allowCloudfront({ + scope: distribution, + keyId: xaKeyId, + distributionId: distributionId, + }); + CrossAccountS3BucketManager.allowCloudfront({ + scope: distribution, + bucketName: xaBucketName, + distributionId: distributionId, + }); + new CrossAccountKmsKeyManager(accessorStack, kmsManagerId, { + xaKeyId, + xaAwsId: accessedAwsId, + }); + new CrossAccountS3BucketManager(accessorStack, s3ManagerId, { + xaBucketName, + xaAwsId: accessedAwsId, + }); + + // THEN + const accessedTemplate = Template.fromStack(accessedStack); + const accessorTemplate = Template.fromStack(accessorStack); + + accessedTemplate.hasResource("AWS::KMS::Key", {}); + accessedTemplate.hasResourceProperties("AWS::KMS::Alias", { + AliasName: `alias/${xaAlias}`, + }); + accessedTemplate.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(xaKeyId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(xaKeyId, [ + accessorAwsId, + ]), + }); + + accessedTemplate.hasResourceProperties("AWS::S3::Bucket", { + BucketName: xaBucketName, + }); + accessedTemplate.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(xaBucketId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(xaBucketName, [ + accessorAwsId, + ]), + }); + + accessorTemplate.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + accessorTemplate.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaBucketName}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaBucketName, accessedAwsId), + }); + accessorTemplate.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + s3ManagerId, + [distributionId], + S3_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + s3ManagerId, + [distributionId], + S3_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + s3ManagerId, + [distributionId], + S3_DEFAULT_ACTIONS, + ), + }); + accessorTemplate.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaKeyId}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaKeyId, accessedAwsId), + }); + accessorTemplate.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + kmsManagerId, + [distributionId], + KMS_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + kmsManagerId, + [distributionId], + KMS_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + kmsManagerId, + [distributionId], + KMS_DEFAULT_ACTIONS, + ), + }); +}); diff --git a/test/cross-account-s3-bucket.test.ts b/test/cross-account-s3-bucket.test.ts new file mode 100644 index 0000000..8a7aace --- /dev/null +++ b/test/cross-account-s3-bucket.test.ts @@ -0,0 +1,124 @@ +import { App, Stack } from "aws-cdk-lib/core"; +import { Template } from "aws-cdk-lib/assertions"; + +import { CrossAccountS3Bucket } from "../lib/s3"; +import { getAssumeRolePolicyMatcher, getRoleNameMatcher } from "./matchers"; + +test("Single Accessor XAS3", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId = "test-xas3-single-accessor"; + const bucketName = "test-xas3-s"; + const xaAwsIds = ["000000000000"]; + // WHEN + new CrossAccountS3Bucket(stack, bucketId, { + bucketName, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: "test-xas3-s", + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketName, xaAwsIds), + }); +}); + +test("Multi Accessor XAS3", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId = "test-xas3-multi-accessor"; + const bucketName = "test-xas3-m"; + const xaAwsIds = ["000000000000", "111111111111", "222222222222"]; + // WHEN + new CrossAccountS3Bucket(stack, bucketId, { + bucketName, + xaAwsIds, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: "test-xas3-m", + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketName, xaAwsIds), + }); +}); + +test("Longish XAS3", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId = "test-xas3-loooooooooooong"; + const looongBucketName = + "test-xas3-loooooooooooooooooooooooooooooooooooongish"; + // WHEN + new CrossAccountS3Bucket(stack, bucketId, { + bucketName: looongBucketName, + xaAwsIds: ["000000000000"], + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: looongBucketName, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId), + }); +}); + +test("Loooooooooooong XAS3", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId = "test-xas3-loooooooooooong"; + const looongBucketName = + "test-xas3-loooooooooo00000000000000000000000000oooooooooooooong"; + + expect( + () => + new CrossAccountS3Bucket(stack, bucketId, { + bucketName: looongBucketName, + xaAwsIds: ["000000000000"], + }), + ).toThrow(/must be 52 characters or less/); +}); + +test("Several XAS3s", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const bucketId1 = "test-xas3-1"; + const xaAwsIds1 = ["000000000000"]; + const bucketId2 = "test-xas3-2"; + const xaAwsIds2 = ["111111111111"]; + // WHEN + new CrossAccountS3Bucket(stack, bucketId1, { + bucketName: bucketId1, + xaAwsIds: xaAwsIds1, + }); + new CrossAccountS3Bucket(stack, bucketId2, { + bucketName: bucketId2, + xaAwsIds: xaAwsIds2, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: bucketId1, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId1), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketId1, xaAwsIds1), + }); + template.hasResourceProperties("AWS::S3::Bucket", { + BucketName: bucketId2, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: getRoleNameMatcher(bucketId2), + AssumeRolePolicyDocument: getAssumeRolePolicyMatcher(bucketId2, xaAwsIds2), + }); +}); diff --git a/test/cross-account-s3-manager.test.ts b/test/cross-account-s3-manager.test.ts new file mode 100644 index 0000000..e64b21e --- /dev/null +++ b/test/cross-account-s3-manager.test.ts @@ -0,0 +1,241 @@ +import { App, Stack } from "aws-cdk-lib/core"; +import { Template } from "aws-cdk-lib/assertions"; +import { CrossAccountS3BucketManager } from "../lib/s3"; + +import { getPoliciesMatcher, getEventMatcher } from "./matchers"; +import { PYTHON_RUNTIME, S3_DEFAULT_ACTIONS } from "./const"; + +test("Single XAS3 Manager", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xas3-mgr"; + const xaBucketName = "test-xas3"; + const xaAwsId = "098765432109"; + + // WHEN + new CrossAccountS3BucketManager(stack, managerId, { + xaBucketName, + xaAwsId, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaBucketName}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaBucketName, xaAwsId), + }); + template.hasResourceProperties("Custom::AWS", {}); +}); + +test("XAS3 Manager single accessor", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xas3-mgr"; + const xaBucketName = "test-xas3-s"; + const xaAwsId = "098765432109"; + const distributionIds = ["SOME_ID"]; + + // WHEN + CrossAccountS3BucketManager.allowCloudfront({ + scope: stack, + distributionId: distributionIds[0], + bucketName: xaBucketName, + }); + new CrossAccountS3BucketManager(stack, managerId, { + xaBucketName, + xaAwsId, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaBucketName}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaBucketName, xaAwsId), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + managerId, + distributionIds, + S3_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + managerId, + distributionIds, + S3_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + managerId, + distributionIds, + S3_DEFAULT_ACTIONS, + ), + }); +}); + +test("XAS3 Manager multiple accessors", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xas3-mgr"; + const xaBucketName = "test-xas3-m"; + const xaAwsId = "098765432109"; + const distributionIds = [ + "SOME_ID", + "ANOTHER_ID", + "YET_ANOTHER_ID", + "SO_MANY_IDS", + ].sort(); + + // WHEN + for (const distributionId of distributionIds) { + CrossAccountS3BucketManager.allowCloudfront({ + scope: stack, + distributionId, + bucketName: xaBucketName, + }); + } + new CrossAccountS3BucketManager(stack, managerId, { + xaBucketName, + xaAwsId, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaBucketName}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaBucketName, xaAwsId), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + managerId, + distributionIds, + S3_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + managerId, + distributionIds, + S3_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + managerId, + distributionIds, + S3_DEFAULT_ACTIONS, + ), + }); +}); + +test("XAS3 Manager improper usage", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId = "test-xas3-mgr"; + const xaBucketName = "test-xas3-BAD"; + const xaAwsId = "098765432109"; + + new CrossAccountS3BucketManager(stack, managerId, { + xaBucketName, + xaAwsId, + }); + + expect(() => + CrossAccountS3BucketManager.allowCloudfront({ + scope: stack, + distributionId: "bad :(", + bucketName: xaBucketName, + }), + ).toThrow(/Cannot register .+ after creation./); +}); + +test("Multiple XAS3 Managers", () => { + const app = new App(); + const stack = new Stack(app, "test-stack"); + const managerId1 = "test-xas3-mgr1"; + const xaBucketName1 = "test-xas3-s1"; + const xaAwsId1 = "123456789012"; + const distributionIds1 = ["SOME_ID"]; + const managerId2 = "test-xas3-mgr2"; + const xaBucketName2 = "test-xas3-m2"; + const xaAwsId2 = "098765432109"; + const distributionIds2 = [ + "SOME_ID", + "ANOTHER_ID", + "YET_ANOTHER_ID", + "SO_MANY_IDS", + ].sort(); + const actions2 = ["s3:GetObject", "s3:PutObject"]; + + // WHEN + CrossAccountS3BucketManager.allowCloudfront({ + scope: stack, + distributionId: distributionIds1[0], + bucketName: xaBucketName1, + }); + for (const distributionId of distributionIds2) { + CrossAccountS3BucketManager.allowCloudfront({ + scope: stack, + distributionId, + bucketName: xaBucketName2, + actions: actions2, + }); + } + new CrossAccountS3BucketManager(stack, managerId1, { + xaBucketName: xaBucketName1, + xaAwsId: xaAwsId1, + }); + new CrossAccountS3BucketManager(stack, managerId2, { + xaBucketName: xaBucketName2, + xaAwsId: xaAwsId2, + }); + // THEN + const template = Template.fromStack(stack); + + template.hasResourceProperties("AWS::Lambda::Function", { + Runtime: PYTHON_RUNTIME, + }); + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaBucketName1}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaBucketName1, xaAwsId1), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher( + "create", + managerId1, + distributionIds1, + S3_DEFAULT_ACTIONS, + ), + Update: getEventMatcher( + "update", + managerId1, + distributionIds1, + S3_DEFAULT_ACTIONS, + ), + Delete: getEventMatcher( + "delete", + managerId1, + distributionIds1, + S3_DEFAULT_ACTIONS, + ), + }); + + template.hasResourceProperties("AWS::IAM::Role", { + RoleName: `${xaBucketName2}-xa-mgmt-ex`, + Policies: getPoliciesMatcher(xaBucketName2, xaAwsId2), + }); + template.hasResourceProperties("Custom::AWS", { + Create: getEventMatcher("create", managerId2, distributionIds2, actions2), + Update: getEventMatcher("update", managerId2, distributionIds2, actions2), + Delete: getEventMatcher("delete", managerId2, distributionIds2, actions2), + }); +}); diff --git a/test/matchers.ts b/test/matchers.ts new file mode 100644 index 0000000..6fe8abf --- /dev/null +++ b/test/matchers.ts @@ -0,0 +1,93 @@ +import { Match } from "aws-cdk-lib/assertions"; + +export const getRoleNameMatcher = (resourceId: string) => + Match.objectEquals({ + "Fn::Join": Match.arrayEquals([ + "", + Match.arrayEquals([ + Match.objectEquals({ + Ref: Match.stringLikeRegexp( + `^${resourceId.replaceAll("-", "").replaceAll("_", "")}`, + ), + }), + "-xa-mgmt", + ]), + ]), + }); + +export const getAwsPrincipalMatcher = (resourceName: string, awsId: string) => + Match.objectEquals({ + "Fn::Join": Match.arrayEquals([ + "", + Match.arrayEquals([ + `arn:aws:iam::${awsId}:role/`, + Match.objectEquals({ + Ref: Match.stringLikeRegexp( + `^${resourceName.replaceAll("-", "").replaceAll("_", "")}`, + ), + }), + "-xa-mgmt-ex", + ]), + ]), + }); + +export const getAssumeRolePolicyMatcher = ( + resourceName: string, + xaAwsIds: string[], +) => + Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: "sts:AssumeRole", + Condition: Match.objectEquals({ + StringLike: Match.objectEquals({ + "aws:PrincipalArn": Match.arrayWith( + xaAwsIds.map((id) => getAwsPrincipalMatcher(resourceName, id)), + ), + }), + }), + }), + ]), + Version: "2012-10-17", + }); + +export const getPoliciesMatcher = (resourceName: string, awsId: string) => + Match.arrayWith([ + Match.objectLike({ + PolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectEquals({ + Action: "sts:AssumeRole", + Effect: "Allow", + Resource: `arn:aws:iam::${awsId}:role/${resourceName}-xa-mgmt`, + }), + ]), + }), + }), + ]); + +export const getEventMatcher = ( + operation: string, + managerId: string, + distributionIds: string[], + actions: string[], +) => { + const functionPattern = managerId.replaceAll("-", "").replaceAll("_", ""); + return Match.objectEquals({ + "Fn::Join": Match.arrayEquals([ + "", + Match.arrayWith([ + Match.stringLikeRegexp(".+"), + Match.objectEquals({ Ref: Match.stringLikeRegexp(functionPattern) }), + Match.stringLikeRegexp( + [ + operation, + "cloudfrontAccessors", + ...distributionIds, + ...actions.sort(), + ].join("(.+)"), + ), + ]), + ]), + }); +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..943abc5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "isolatedModules": true, + "lib": ["es2022"], + "outDir": "dist", + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "typeRoots": ["./node_modules/@types"] + }, + "include": ["lib", "index.ts", "lambda-code"] +}