diff --git a/API.md b/API.md index 87a75dd..83bbc1c 100644 --- a/API.md +++ b/API.md @@ -157,10 +157,10 @@ There can be only one (per AWS Account). ##### `repo`Required ```typescript -public readonly repo: string; +public readonly repo: string | string[]; ``` -- *Type:* `string` +- *Type:* `string` | `string`[] Repository name (slug) without the owner. @@ -381,10 +381,10 @@ There can be only one (per AWS Account). ##### `repo`Required ```typescript -public readonly repo: string; +public readonly repo: string | string[]; ``` -- *Type:* `string` +- *Type:* `string` | `string`[] Repository name (slug) without the owner. diff --git a/README.md b/README.md index cb0314d..2d81f49 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,27 @@ const uploadRole = new GithubActionsRole(scope, "UploadRole", { myBucket.grantWrite(uploadRole); ``` +You can also pass multiple trusted repositories with the `trustedRepositories` property: + +```ts +import { GithubActionsRole } from "aws-cdk-github-oidc"; + +const uploadRole = new GithubActionsRole(stack, 'TestRole', { + provider, + trustedRepositories: [ + { + owner: 'octo-org', + repo: 'octo-repo1', + }, + { + owner: 'octo-org', + repo: 'octo-repo2', + filter: 'ref:refs/tags/v*', + }, + ], +}); +``` + You may pass in any `iam.RoleProps` into the construct's props, except `assumedBy` which will be defined by this construct (CDK will fail if you do): ```ts diff --git a/src/role.ts b/src/role.ts index da7eea4..02a73dd 100644 --- a/src/role.ts +++ b/src/role.ts @@ -19,6 +19,52 @@ export interface GithubConfiguration { */ readonly provider: IGithubActionsIdentityProvider; + /** + * Repository owner (organization or username). + * + * Use `trustedRepositories` if you want to provide multiple repo configurations + * + * @example + * 'octo-org' + */ + readonly owner?: string; + + /** + * Repository name (slug) without the owner. + * + * Use `trustedRepositories` if you want to provide multiple repo configurations + * + * @example + * 'octo-repo' + */ + readonly repo?: string; + + /** + * Subject condition filter, appended after `repo:${owner}/${repo}:` string in IAM Role trust relationship. + * + * @default + * '*' + * + * You may use this value to only allow Github to assume the role on specific branches, tags, environments, pull requests etc. + * @example + * 'ref:refs/tags/v*' + * 'ref:refs/heads/demo-branch' + * 'pull_request' + * 'environment:Production' + * + * @see https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#examples + */ + readonly filter?: string; + + /** + * Provide multiple trusted repositories allowed to assume this role. + * + * @default - required if top-level owner/repo/filter not set + */ + readonly trustedRepositories?: TrustedRepository[]; +} + +export interface TrustedRepository { /** * Repository owner (organization or username). * @@ -102,6 +148,7 @@ export class GithubActionsRole extends iam.Role { delete extractProps.owner; delete extractProps.repo; delete extractProps.filter; + delete extractProps.trustedRepositories; return extractProps; } @@ -112,6 +159,20 @@ export class GithubActionsRole extends iam.Role { } } + /** Validates conflicting props aren't set simultaneously */ + private static validateProps(scope: Construct, props: GithubActionsRoleProps) { + const topLevelPropsSet = props.owner || props.repo || props.filter; + const trustedRepositoriesSet = props.trustedRepositories && props.trustedRepositories.length > 0 || false; + + if (topLevelPropsSet && trustedRepositoriesSet) { + cdk.Annotations.of(scope).addError('Cannot set both top-level owner/repo/filter and trustedRepositories. Use one or the other.'); + } + + if (!trustedRepositoriesSet && (props.owner === undefined || props.repo === undefined)) { + cdk.Annotations.of(scope).addError("If you don't provide `trustedRepositories`, you must provide `owner` and `repo`."); + } + } + /** Validates the Github repository name (without owner). */ private static validateRepo(scope: Construct, repo: string): void { if (repo === '') { @@ -120,8 +181,8 @@ export class GithubActionsRole extends iam.Role { } /** Formats the `sub` value used in trust policy. */ - private static formatSubject(props: GithubConfiguration): string { - const { owner, repo, filter = '*' } = props; + private static formatSubject(trustedRepository: TrustedRepository): string { + const { owner, repo, filter = '*' } = trustedRepository; return `repo:${owner}/${repo}:${filter}`; } @@ -146,25 +207,32 @@ export class GithubActionsRole extends iam.Role { */ constructor(scope: Construct, id: string, props: GithubActionsRoleProps) { - const { provider, owner, repo } = props; + const { provider, owner, repo, trustedRepositories } = props; + + // Validate props + GithubActionsRole.validateProps(scope, props); - // Perform validations - GithubActionsRole.validateOwner(scope, owner); - GithubActionsRole.validateRepo(scope, repo); + // Unify the two ways of defining trusted repositories + const subjects = trustedRepositories || [{ owner: owner!, repo: repo!, filter: props.filter }]; + + // Perform validations on each trusted repository + subjects.forEach((subject) => { + GithubActionsRole.validateOwner(scope, subject.owner); + GithubActionsRole.validateRepo(scope, subject.repo); + }); - // Prepare values - const subject = GithubActionsRole.formatSubject(props); + // Extract IAM Role props const roleProps = GithubActionsRole.extractRoleProps(props); // The actual IAM Role creation super(scope, id, { ...roleProps, assumedBy: new iam.WebIdentityPrincipal(provider.openIdConnectProviderArn, { - StringLike: { + 'ForAnyValue:StringLike': { // Only allow specified subjects to assume this role - [`${GithubActionsIdentityProvider.issuer}:sub`]: subject, + [`${GithubActionsIdentityProvider.issuer}:sub`]: subjects.map((subject) => GithubActionsRole.formatSubject(subject)), }, - StringEquals: { + 'StringEquals': { // Audience is always sts.amazonaws.com with AWS official Github Action // https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services#adding-the-identity-provider-to-aws [`${GithubActionsIdentityProvider.issuer}:aud`]: 'sts.amazonaws.com', diff --git a/test/role.test.ts b/test/role.test.ts index fb88449..888d926 100644 --- a/test/role.test.ts +++ b/test/role.test.ts @@ -26,10 +26,69 @@ test('Role with defaults', () => { Action: 'sts:AssumeRoleWithWebIdentity', Effect: 'Allow', Condition: { - StringLike: { - 'token.actions.githubusercontent.com:sub': 'repo:octo-org/octo-repo:*', + 'ForAnyValue:StringLike': { + 'token.actions.githubusercontent.com:sub': ['repo:octo-org/octo-repo:*'], }, - StringEquals: { + 'StringEquals': { + 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', + }, + }, + Principal: { + Federated: { + 'Fn::Join': [ + '', + [ + 'arn:aws:iam::', + { + Ref: 'AWS::AccountId', + }, + ':oidc-provider/token.actions.githubusercontent.com', + ], + ], + }, + }, + }), + ]), + }), + }); +}); + +test('Role with multiple trusted repositories', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app); + const provider = GithubActionsIdentityProvider.fromAccount(stack, 'GithubProvider'); + + new GithubActionsRole(stack, 'TestRole', { + provider, + trustedRepositories: [ + { + owner: 'octo-org', + repo: 'octo-repo1', + }, + { + owner: 'octo-org', + repo: 'octo-repo2', + filter: 'ref:refs/tags/v*', + }, + ], + }); + + const template = Template.fromStack(stack); + + template.hasResourceProperties('AWS::IAM::Role', { + AssumeRolePolicyDocument: Match.objectLike({ + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'sts:AssumeRoleWithWebIdentity', + Effect: 'Allow', + Condition: { + 'ForAnyValue:StringLike': { + 'token.actions.githubusercontent.com:sub': [ + 'repo:octo-org/octo-repo1:*', + 'repo:octo-org/octo-repo2:ref:refs/tags/v*', + ], + }, + 'StringEquals': { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', }, }, @@ -88,10 +147,10 @@ test('Role with custom props', () => { Action: 'sts:AssumeRoleWithWebIdentity', Effect: 'Allow', Condition: { - StringLike: { - 'token.actions.githubusercontent.com:sub': 'repo:octo-org/octo-repo:ref:refs/tags/v*', + 'ForAnyValue:StringLike': { + 'token.actions.githubusercontent.com:sub': ['repo:octo-org/octo-repo:ref:refs/tags/v*'], }, - StringEquals: { + 'StringEquals': { 'token.actions.githubusercontent.com:aud': 'sts.amazonaws.com', }, }, @@ -187,3 +246,41 @@ test('Role with invalid repo', () => { 'Invalid Github Repository Name "". May not be empty string.', ); }); + +test('Role with top-level props and trustedRepositories set', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app); + const provider = GithubActionsIdentityProvider.fromAccount(stack, 'GithubProvider'); + + new GithubActionsRole(stack, 'TestRole', { + provider, + owner: 'octo-org', + repo: 'octo-repo', + trustedRepositories: [ + { + owner: 'octo-org', + repo: 'octo-repo', + }, + ], + }); + + expect(stack.node.metadata).toHaveLength(1); + expect(stack.node.metadata[0].data).toBe( + 'Cannot set both top-level owner/repo/filter and trustedRepositories. Use one or the other.', + ); +}); + +test('Role without trustedRepositories or top-level props set', () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app); + const provider = GithubActionsIdentityProvider.fromAccount(stack, 'GithubProvider'); + + new GithubActionsRole(stack, 'TestRole', { + provider, + }); + + expect(stack.node.metadata).toHaveLength(1); + expect(stack.node.metadata[0].data).toBe( + "If you don't provide `trustedRepositories`, you must provide `owner` and `repo`.", + ); +});