From 0633c6b32173d2cd4b7283c6e0f46b4b09bbaf03 Mon Sep 17 00:00:00 2001 From: Ben Limmer <630449+blimmer@users.noreply.github.com> Date: Sat, 25 Mar 2023 16:58:29 -0600 Subject: [PATCH] feat: initial implementation (#1) --- .github/workflows/release.yml | 2 +- .projen/tasks.json | 3 +- .projenrc.js | 6 +- .vscode/settings.json | 3 + API.md | 448 ++++++++++++++++++++++++++++++ README.md | 103 ++++++- package.json | 4 +- src/CircleCiOidcProvider.ts | 50 ++++ src/CircleCiOidcRole.ts | 90 ++++++ src/index.ts | 7 +- test/CircleCiOidcProvider.test.ts | 28 ++ test/CircleCiOidcRole.test.ts | 147 ++++++++++ test/hello.test.ts | 5 - 13 files changed, 879 insertions(+), 17 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 API.md create mode 100644 src/CircleCiOidcProvider.ts create mode 100644 src/CircleCiOidcRole.ts create mode 100644 test/CircleCiOidcProvider.test.ts create mode 100644 test/CircleCiOidcRole.test.ts delete mode 100644 test/hello.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0475824..69fe79d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,7 +71,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_REF: ${{ github.ref }} - run: errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q "Release.tag_name already exists" $errout; then cat $errout; exit $exitcode; fi + run: errout=$(mktemp); gh release create $(cat dist/releasetag.txt) -R $GITHUB_REPOSITORY -F dist/changelog.md -t $(cat dist/releasetag.txt) --target $GITHUB_REF -p 2> $errout && true; exitcode=$?; if [ $exitcode -ne 0 ] && ! grep -q "Release.tag_name already exists" $errout; then cat $errout; exit $exitcode; fi release_npm: name: Publish to npm needs: release diff --git a/.projen/tasks.json b/.projen/tasks.json index 57e5a46..f865c6f 100644 --- a/.projen/tasks.json +++ b/.projen/tasks.json @@ -178,7 +178,8 @@ "name": "release", "description": "Prepare a release from \"main\" branch", "env": { - "RELEASE": "true" + "RELEASE": "true", + "PRERELEASE": "beta" }, "steps": [ { diff --git a/.projenrc.js b/.projenrc.js index a42f0d5..715e905 100644 --- a/.projenrc.js +++ b/.projenrc.js @@ -4,8 +4,10 @@ const project = new awscdk.AwsCdkConstructLibrary({ authorAddress: 'hello@benlimmer.com', cdkVersion: '2.1.0', defaultReleaseBranch: 'main', - name: 'circleci-oidc', - repositoryUrl: 'https://github.com/blimmer/circleci-oidc.git', + name: '@blimmer/cdk-circleci-oidc', + repositoryUrl: 'https://github.com/blimmer/cdk-circleci-oidc.git', + + prerelease: 'beta', // deps: [], /* Runtime dependencies of this module. */ // description: undefined, /* The description is just a string that helps people understand the purpose of the package. */ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..30fd6c0 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "jest.jestCommandLine": "yarn projen test" +} diff --git a/API.md b/API.md new file mode 100644 index 0000000..8871564 --- /dev/null +++ b/API.md @@ -0,0 +1,448 @@ +# API Reference + +## Constructs + +### CircleCiOidcProvider + +This construct creates a CircleCI ODIC provider to allow AWS access from CircleCI jobs. + +You'll need to instantiate +this construct once per AWS account you want to use CircleCI OIDC with. + +To create a role that can be assumed by CircleCI jobs, use the `CircleCiOidcRole` construct. + +#### Initializers + +```typescript +import { CircleCiOidcProvider } from '@blimmer/cdk-circleci-oidc' + +new CircleCiOidcProvider(scope: Construct, id: string, props: CircleCiOidcProviderProps) +``` + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| scope | constructs.Construct | *No description.* | +| id | string | *No description.* | +| props | CircleCiOidcProviderProps | *No description.* | + +--- + +##### `scope`Required + +- *Type:* constructs.Construct + +--- + +##### `id`Required + +- *Type:* string + +--- + +##### `props`Required + +- *Type:* CircleCiOidcProviderProps + +--- + +#### Methods + +| **Name** | **Description** | +| --- | --- | +| toString | Returns a string representation of this construct. | + +--- + +##### `toString` + +```typescript +public toString(): string +``` + +Returns a string representation of this construct. + +#### Static Functions + +| **Name** | **Description** | +| --- | --- | +| isConstruct | Checks if `x` is a construct. | + +--- + +##### ~~`isConstruct`~~ + +```typescript +import { CircleCiOidcProvider } from '@blimmer/cdk-circleci-oidc' + +CircleCiOidcProvider.isConstruct(x: any) +``` + +Checks if `x` is a construct. + +###### `x`Required + +- *Type:* any + +Any object. + +--- + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| node | constructs.Node | The tree node. | +| organizationId | string | *No description.* | +| provider | aws-cdk-lib.aws_iam.CfnOIDCProvider | *No description.* | + +--- + +##### `node`Required + +```typescript +public readonly node: Node; +``` + +- *Type:* constructs.Node + +The tree node. + +--- + +##### `organizationId`Required + +```typescript +public readonly organizationId: string; +``` + +- *Type:* string + +--- + +##### `provider`Required + +```typescript +public readonly provider: CfnOIDCProvider; +``` + +- *Type:* aws-cdk-lib.aws_iam.CfnOIDCProvider + +--- + + +### CircleCiOidcRole + +This construct creates a CircleCI ODIC provider to allow AWS access from CircleCI jobs. + +You'll need to instantiate +this construct once per AWS account you want to use CircleCI OIDC with. + +To create a role that can be assumed by CircleCI jobs, use the `CircleCiOidcRole` construct. + +#### Initializers + +```typescript +import { CircleCiOidcRole } from '@blimmer/cdk-circleci-oidc' + +new CircleCiOidcRole(scope: Construct, id: string, props: CircleCiOidcRoleProps) +``` + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| scope | constructs.Construct | *No description.* | +| id | string | *No description.* | +| props | CircleCiOidcRoleProps | *No description.* | + +--- + +##### `scope`Required + +- *Type:* constructs.Construct + +--- + +##### `id`Required + +- *Type:* string + +--- + +##### `props`Required + +- *Type:* CircleCiOidcRoleProps + +--- + +#### Methods + +| **Name** | **Description** | +| --- | --- | +| toString | Returns a string representation of this construct. | + +--- + +##### `toString` + +```typescript +public toString(): string +``` + +Returns a string representation of this construct. + +#### Static Functions + +| **Name** | **Description** | +| --- | --- | +| isConstruct | Checks if `x` is a construct. | + +--- + +##### ~~`isConstruct`~~ + +```typescript +import { CircleCiOidcRole } from '@blimmer/cdk-circleci-oidc' + +CircleCiOidcRole.isConstruct(x: any) +``` + +Checks if `x` is a construct. + +###### `x`Required + +- *Type:* any + +Any object. + +--- + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| node | constructs.Node | The tree node. | +| role | aws-cdk-lib.aws_iam.Role | *No description.* | + +--- + +##### `node`Required + +```typescript +public readonly node: Node; +``` + +- *Type:* constructs.Node + +The tree node. + +--- + +##### `role`Required + +```typescript +public readonly role: Role; +``` + +- *Type:* aws-cdk-lib.aws_iam.Role + +--- + + +## Structs + +### CircleCiOidcProviderProps + +#### Initializer + +```typescript +import { CircleCiOidcProviderProps } from '@blimmer/cdk-circleci-oidc' + +const circleCiOidcProviderProps: CircleCiOidcProviderProps = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| organizationId | string | The ID of your CircleCI organization. | +| circleCiOidcThumbprints | string[] | The OIDC thumbprints used by the provider. | + +--- + +##### `organizationId`Required + +```typescript +public readonly organizationId: string; +``` + +- *Type:* string + +The ID of your CircleCI organization. + +This is typically in a UUID format. You can find this ID in the CircleCI +dashboard UI under the "Organization Settings" tab. + +--- + +##### `circleCiOidcThumbprints`Optional + +```typescript +public readonly circleCiOidcThumbprints: string[]; +``` + +- *Type:* string[] + +The OIDC thumbprints used by the provider. + +You should not need to provide this value unless CircleCI suddenly +rotates their OIDC thumbprints (e.g., in response to a security incident). + +If you do need to generate this thumbprint, you can follow the instructions here: +https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html + +--- + +### CircleCiOidcRoleProps + +#### Initializer + +```typescript +import { CircleCiOidcRoleProps } from '@blimmer/cdk-circleci-oidc' + +const circleCiOidcRoleProps: CircleCiOidcRoleProps = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| circleCiOidcProvider | CircleCiOidcProvider \| ManualCircleCiOidcProviderProps | *No description.* | +| circleCiProjectIds | string[] | Provide the UUID(s) of the CircleCI project(s) you want to be allowed to use this role. | +| description | string | *No description.* | +| inlinePolicies | {[ key: string ]: aws-cdk-lib.aws_iam.PolicyDocument} | *No description.* | +| managedPolicies | aws-cdk-lib.aws_iam.IManagedPolicy[] | *No description.* | +| roleName | string | You can pass an explicit role name if you'd like, since you need to reference the Role ARN within your CircleCI configuration. | + +--- + +##### `circleCiOidcProvider`Required + +```typescript +public readonly circleCiOidcProvider: CircleCiOidcProvider | ManualCircleCiOidcProviderProps; +``` + +- *Type:* CircleCiOidcProvider | ManualCircleCiOidcProviderProps + +--- + +##### `circleCiProjectIds`Optional + +```typescript +public readonly circleCiProjectIds: string[]; +``` + +- *Type:* string[] +- *Default:* All CircleCI projects in the provider's organization + +Provide the UUID(s) of the CircleCI project(s) you want to be allowed to use this role. + +If you don't provide this +value, the role will be allowed to be assumed by any CircleCI project in your organization. You can find a +project's ID in the CircleCI dashboard UI under the "Project Settings" tab. It's usually in a UUID format. + +--- + +##### `description`Optional + +```typescript +public readonly description: string; +``` + +- *Type:* string + +--- + +##### `inlinePolicies`Optional + +```typescript +public readonly inlinePolicies: {[ key: string ]: PolicyDocument}; +``` + +- *Type:* {[ key: string ]: aws-cdk-lib.aws_iam.PolicyDocument} + +--- + +##### `managedPolicies`Optional + +```typescript +public readonly managedPolicies: IManagedPolicy[]; +``` + +- *Type:* aws-cdk-lib.aws_iam.IManagedPolicy[] + +--- + +##### `roleName`Optional + +```typescript +public readonly roleName: string; +``` + +- *Type:* string +- *Default:* CloudFormation will auto-generate you a role name + +You can pass an explicit role name if you'd like, since you need to reference the Role ARN within your CircleCI configuration. + +--- + +### ManualCircleCiOidcProviderProps + +If you're using the {@link CircleCiOidcProvider} construct, pass it instead of these manually-defined props. + +#### Initializer + +```typescript +import { ManualCircleCiOidcProviderProps } from '@blimmer/cdk-circleci-oidc' + +const manualCircleCiOidcProviderProps: ManualCircleCiOidcProviderProps = { ... } +``` + +#### Properties + +| **Name** | **Type** | **Description** | +| --- | --- | --- | +| organizationId | string | The ID of your CircleCI organization. | +| provider | aws-cdk-lib.aws_iam.IOpenIdConnectProvider | The CircleCI OIDC provider. | + +--- + +##### `organizationId`Required + +```typescript +public readonly organizationId: string; +``` + +- *Type:* string + +The ID of your CircleCI organization. + +This is typically in a UUID format. You can find this ID in the CircleCI +dashboard UI under the "Organization Settings" tab. + +--- + +##### `provider`Required + +```typescript +public readonly provider: IOpenIdConnectProvider; +``` + +- *Type:* aws-cdk-lib.aws_iam.IOpenIdConnectProvider + +The CircleCI OIDC provider. + +You can either manually create it or import it. + +--- + + + diff --git a/README.md b/README.md index dcf15b5..c971b6f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,104 @@ # CircleCI OIDC -TODO +This repository contains constructs to communicate between CircleCI and AWS via an Open ID Connect (OIDC) provider. +The process is described in [this CircleCI blog post](https://circleci.com/blog/openid-connect-identity-tokens/). + +## Security Benefits + +By using the OpenID Connect provider, you can communicate with AWS from CircleCI without saving static credentials +(e.g., `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`) in your CircleCI project settings or a context. Removing +static credentials, especially in light of the early 2023 [breach](https://circleci.com/blog/jan-4-2023-incident-report/), +is a best practice for security. + +## Quick Start + +Install the package: + +```bash +npm install @blimmer/cdk-circleci-oidc + +or + +yarn add @blimmer/cdk-circleci-oidc +``` + +Then, create the provider and role(s). + +```typescript +import { Stack, StackProps } from 'aws-cdk-lib'; +import { CircleCiOidcProvider, CircleCiOidcRole } from '@blimmer/cdk-circleci-oidc'; +import { Construct } from 'constructs'; +import { ManagedPolicy, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Bucket } from 'aws-cdk-lib/aws-s3'; + +export class CircleCiStack extends Stack { + readonly provider: CircleCiOidcProvider; // export for use in other stacks + + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + this.provider = new CircleCiOidcProvider(this, 'OidcProvider', { + // Find your organization ID in the CircleCI dashboard under "Organization Settings" + organizationId: '11111111-2222-3333-4444-555555555555', + }); + + const myCircleCiRole = new CircleCiOidcRole(this, 'MyCircleCiRole', { + provider: this.provider, + roleName: "MyCircleCiRole", + + // Pass some managed policies to the role + managedPolicies: [ + ManagedPolicy.fromAwsManagedPolicyName('AmazonS3ReadOnlyAccess'), + ], + }) + + // You can also access the role from the construct. This allows adding roles and using `grant` methods after the + // construct has been created. + myCircleCiRole.role.addToPolicy(new PolicyStatement({ + actions: ['s3:ListAllMyBuckets'], + resources: ['*'], + })); + + const bucket = new Bucket(this, 'MyBucket'); + bucket.grantRead(myCircleCiRole.role); + } +} +``` + +Now, in your `.circleci/config.yml` file, you can use the [AWS CLI Orb](https://circleci.com/developer/orbs/orb/circleci/aws-cli) +to assume your new role. + +```yaml +version: 2.1 + +orbs: + aws-cli: circleci/aws-cli@3.1.4 # https://circleci.com/developer/orbs/orb/circleci/aws-cli + +workflows: + version: 2 + build: + jobs: + - oidc-job: + context: oidc-assumption # You _must_ use a context, even if it doesn't contain any secrets (see https://circleci.com/docs/openid-connect-tokens/#openid-connect-id-token-availability) + +jobs: + oidc-job: + docker: + - image: cimg/base:stable + steps: + - checkout + # https://circleci.com/developer/orbs/orb/circleci/aws-cli#commands-setup + - aws-cli/setup: + role-arn: 'arn:aws:iam::123456789101:role/MyCircleCiRole' + - run: + name: List S3 Buckets + command: aws s3 ls +``` + +## Usage + +For detailed API docs, see [API.md](/API.md). + +## Contributing + +Contributions, issues, and feedback are welcome! diff --git a/package.json b/package.json index 410996b..9e43c05 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "circleci-oidc", + "name": "@blimmer/cdk-circleci-oidc", "repository": { "type": "git", - "url": "https://github.com/blimmer/circleci-oidc.git" + "url": "https://github.com/blimmer/cdk-circleci-oidc.git" }, "scripts": { "build": "npx projen build", diff --git a/src/CircleCiOidcProvider.ts b/src/CircleCiOidcProvider.ts new file mode 100644 index 0000000..b6c93da --- /dev/null +++ b/src/CircleCiOidcProvider.ts @@ -0,0 +1,50 @@ +import { CfnOIDCProvider } from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; + +export interface CircleCiOidcProviderProps { + /** + * The ID of your CircleCI organization. This is typically in a UUID format. You can find this ID in the CircleCI + * dashboard UI under the "Organization Settings" tab. + */ + readonly organizationId: string; + + /** + * The OIDC thumbprints used by the provider. You should not need to provide this value unless CircleCI suddenly + * rotates their OIDC thumbprints (e.g., in response to a security incident). + * + * If you do need to generate this thumbprint, you can follow the instructions here: + * https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html + */ + readonly circleCiOidcThumbprints?: string[]; +} + +/** + * This construct creates a CircleCI ODIC provider to allow AWS access from CircleCI jobs. You'll need to instantiate + * this construct once per AWS account you want to use CircleCI OIDC with. + * + * To create a role that can be assumed by CircleCI jobs, use the `CircleCiOidcRole` construct. + */ +export class CircleCiOidcProvider extends Construct { + public readonly provider: CfnOIDCProvider; + public readonly organizationId: string; + + constructor(scope: Construct, id: string, props: CircleCiOidcProviderProps) { + super(scope, id); + + const { + organizationId, + circleCiOidcThumbprints = ['9e99a48a9960b14926bb7f3b02e22da2b0ab7280'], + } = props; + + // The L2 construct uses a Custom Resource, which is slow and has a few known issues + // (see https://github.com/aws/aws-cdk/issues/21197#issuecomment-1312843734) + // Therefore, we use the L1 OIDC provider construct directly instead. + this.provider = new CfnOIDCProvider(this, 'CircleCiOidcProvider', { + url: `https://oidc.circleci.com/org/${organizationId}`, + clientIdList: [organizationId], + thumbprintList: circleCiOidcThumbprints, + }); + + this.organizationId = organizationId; + } +} diff --git a/src/CircleCiOidcRole.ts b/src/CircleCiOidcRole.ts new file mode 100644 index 0000000..d9d4d25 --- /dev/null +++ b/src/CircleCiOidcRole.ts @@ -0,0 +1,90 @@ +import { Condition, IManagedPolicy, IOpenIdConnectProvider, OpenIdConnectPrincipal, OpenIdConnectProvider, PolicyDocument, Role } from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; +import { CircleCiOidcProvider } from './CircleCiOidcProvider'; + +/** + * If you're using the {@link CircleCiOidcProvider} construct, pass it instead of these manually-defined props. + */ +export interface ManualCircleCiOidcProviderProps { + /** + * The CircleCI OIDC provider. You can either manually create it or import it. + */ + readonly provider: IOpenIdConnectProvider; + + /** + * The ID of your CircleCI organization. This is typically in a UUID format. You can find this ID in the CircleCI + * dashboard UI under the "Organization Settings" tab. + */ + readonly organizationId: string; +} + +export interface CircleCiOidcRoleProps { + readonly circleCiOidcProvider: CircleCiOidcProvider | ManualCircleCiOidcProviderProps; + + /** + * Provide the UUID(s) of the CircleCI project(s) you want to be allowed to use this role. If you don't provide this + * value, the role will be allowed to be assumed by any CircleCI project in your organization. You can find a + * project's ID in the CircleCI dashboard UI under the "Project Settings" tab. It's usually in a UUID format. + * + * @default - All CircleCI projects in the provider's organization + */ + readonly circleCiProjectIds?: string[]; + + /** + * You can pass an explicit role name if you'd like, since you need to reference the Role ARN within your CircleCI + * configuration. + * + * @default - CloudFormation will auto-generate you a role name + */ + readonly roleName?: string; + + readonly managedPolicies?: IManagedPolicy[]; + readonly inlinePolicies?: { + [name: string]: PolicyDocument; + }; + readonly description?: string; +} + +/** + * This construct creates a CircleCI ODIC provider to allow AWS access from CircleCI jobs. You'll need to instantiate + * this construct once per AWS account you want to use CircleCI OIDC with. + * + * To create a role that can be assumed by CircleCI jobs, use the `CircleCiOidcRole` construct. + */ +export class CircleCiOidcRole extends Construct { + readonly role: Role; + + constructor(scope: Construct, id: string, props: CircleCiOidcRoleProps) { + super(scope, id); + + const { circleCiProjectIds, circleCiOidcProvider, ...roleProps } = props; + const { provider, organizationId } = this.extractOpenIdConnectProvider(circleCiOidcProvider); + const oidcUrl = `oidc.circleci.com/org/${organizationId}`; + + this.role = new Role(this, 'CircleCiOidcRole', { + assumedBy: new OpenIdConnectPrincipal(provider, { + StringEquals: { [`${oidcUrl}:aud`]: organizationId }, + ...this.generateProjectCondition(oidcUrl, organizationId, circleCiProjectIds), + }), + ...roleProps, + }); + } + + private extractOpenIdConnectProvider(provider: CircleCiOidcProvider | ManualCircleCiOidcProviderProps) { + if (provider instanceof CircleCiOidcProvider) { + return { provider: OpenIdConnectProvider.fromOpenIdConnectProviderArn(this, 'ImportOidcProvider', provider.provider.attrArn), organizationId: provider.organizationId }; + } else { + return provider; + } + } + + private generateProjectCondition(oidcUrl: string, organizationId: string, circleCiProjectIds?: string[]): Condition { + if (!circleCiProjectIds || circleCiProjectIds.length === 0) { + return {}; + } + + return { + StringLike: { [`${oidcUrl}:sub`]: circleCiProjectIds.map((projectId) => `org/${organizationId}/project/${projectId}/*`) }, + }; + } +} diff --git a/src/index.ts b/src/index.ts index fb2fabc..4d11816 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,2 @@ -export class Hello { - public sayHello() { - return 'hello, world!'; - } -} +export * from './CircleCiOidcProvider'; +export * from './CircleCiOidcRole'; diff --git a/test/CircleCiOidcProvider.test.ts b/test/CircleCiOidcProvider.test.ts new file mode 100644 index 0000000..a6bc305 --- /dev/null +++ b/test/CircleCiOidcProvider.test.ts @@ -0,0 +1,28 @@ +import { App, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { CircleCiOidcProvider } from '../src'; + +describe('CircleCiOidcProvider', () => { + it('uses the organization ID as the client ID', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new CircleCiOidcProvider(stack, 'CircleCiOidcProvider', { + organizationId: '1234', + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::OIDCProvider', { + ClientIdList: ['1234'], + }); + }); + it('uses a default thumbprint list', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new CircleCiOidcProvider(stack, 'CircleCiOidcProvider', { + organizationId: '1234', + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::OIDCProvider', { + ThumbprintList: ['9e99a48a9960b14926bb7f3b02e22da2b0ab7280'], + }); + }); +}); diff --git a/test/CircleCiOidcRole.test.ts b/test/CircleCiOidcRole.test.ts new file mode 100644 index 0000000..c5a9904 --- /dev/null +++ b/test/CircleCiOidcRole.test.ts @@ -0,0 +1,147 @@ +import { App, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { OpenIdConnectProvider } from 'aws-cdk-lib/aws-iam'; +import { Queue } from 'aws-cdk-lib/aws-sqs'; +import { CircleCiOidcProvider, CircleCiOidcRole } from '../src'; + +describe('CircleCiOidcRole', () => { + it('uses the organization ID and arn from the CircleCiOidcProvider construct', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const provider = new CircleCiOidcProvider(stack, 'CircleCiOidcProvider', { + organizationId: '1234', + }); + new CircleCiOidcRole(stack, 'CircleCiOidcRole', { + circleCiOidcProvider: provider, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + Match.objectLike({ + Effect: 'Allow', + Action: 'sts:AssumeRoleWithWebIdentity', + Condition: { + StringEquals: { + 'oidc.circleci.com/org/1234:aud': '1234', + }, + }, + Principal: { + Federated: { + 'Fn::GetAtt': [ + 'CircleCiOidcProviderBE49A2E7', + 'Arn', + ], + }, + }, + }), + ], + }, + }); + }); + + it('allows passing a provider arn and organization id', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new CircleCiOidcRole(stack, 'CircleCiOidcRole', { + circleCiOidcProvider: { provider: OpenIdConnectProvider.fromOpenIdConnectProviderArn(stack, 'ImportProvider', 'arn:aws:iam::12345678910:oidc-provider/circleci'), organizationId: '1234' }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + Match.objectLike({ + Effect: 'Allow', + Action: 'sts:AssumeRoleWithWebIdentity', + Condition: { + StringEquals: { + 'oidc.circleci.com/org/1234:aud': '1234', + }, + }, + Principal: { + Federated: 'arn:aws:iam::12345678910:oidc-provider/circleci', + }, + }), + ], + }, + }); + }); + + it('allows limiting the role to specific CircleCI projects', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const provider = new CircleCiOidcProvider(stack, 'CircleCiOidcProvider', { + organizationId: '1234', + }); + new CircleCiOidcRole(stack, 'CircleCiOidcRole', { + circleCiOidcProvider: provider, + circleCiProjectIds: ['1234', '5678'], + }); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: { + Statement: [ + Match.objectLike({ + Effect: 'Allow', + Action: 'sts:AssumeRoleWithWebIdentity', + Condition: { + StringEquals: { + 'oidc.circleci.com/org/1234:aud': '1234', + }, + StringLike: { + 'oidc.circleci.com/org/1234:sub': [ + 'org/1234/project/1234/*', + 'org/1234/project/5678/*', + ], + }, + }, + }), + ], + }, + }); + }); + + it('allows adding to the role', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const provider = new CircleCiOidcProvider(stack, 'CircleCiOidcProvider', { + organizationId: '1234', + }); + const { role } = new CircleCiOidcRole(stack, 'CircleCiOidcRole', { + circleCiOidcProvider: provider, + }); + + const queue = new Queue(stack, 'Queue'); + queue.grantConsumeMessages(role); + + Template.fromStack(stack).hasResourceProperties('AWS::IAM::Policy', { + // Attached to the role + Roles: [ + { + Ref: 'CircleCiOidcRoleDC0C8DDB', + }, + ], + PolicyDocument: { + Statement: [ + // Granted access to the queue + { + Effect: 'Allow', + Action: [ + 'sqs:ReceiveMessage', + 'sqs:ChangeMessageVisibility', + 'sqs:GetQueueUrl', + 'sqs:DeleteMessage', + 'sqs:GetQueueAttributes', + ], + Resource: { + 'Fn::GetAtt': [ + 'Queue4A7E3555', + 'Arn', + ], + }, + }, + ], + }, + }); + }); +}); diff --git a/test/hello.test.ts b/test/hello.test.ts deleted file mode 100644 index acbacd4..0000000 --- a/test/hello.test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Hello } from '../src'; - -test('hello', () => { - expect(new Hello().sayHello()).toBe('hello, world!'); -}); \ No newline at end of file