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`.",
+ );
+});