Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for GitHub teams to multi-approvers #396

Closed
wants to merge 2 commits into from

Conversation

ian-shafer
Copy link
Contributor

@ian-shafer ian-shafer commented Feb 6, 2025

This change also updates the allowed inputs to the multi-approvers
workflow.

Teams Support

Support for GitHub teams requires github-token-minter (Minty) to
generate a token with elevated priviledges to access the team membership
REST APIs.

New Inputs

The original org-members-path has been deprecated. It will continue to
be supported in the near future. Users can migrate from org-members-path
by using the new members-url iput.

Three inputs are supported:

  1. members-url
  2. local-members-path
  3. team

See the README for more on each of these inputs.

Fixes #364 and #361

This change also updates the allowed inputs to the multi-approvers
workflow.

Teams Support
========

Support for GitHub teams requires github-token-minter (Minty) to
generate a token with elevated priviledges to access the team membership
REST APIs.

New Inputs
========

The original org-members-path has been deprecated. It will continue to
be supported in the near future. Users can migrate from org-members-path
by using the new members-url iput.

Three inputs are supported:

1. members-url
2. local-members-path
3. team

See the README for more on each of these inputs.
@ian-shafer ian-shafer requested review from a team as code owners February 6, 2025 18:50
Comment on lines +125 to +156
- name: 'Authenticate to Google Cloud'
id: 'minty-auth'
if: |-
${{ inputs.team != '' }}
uses: 'google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f' # ratchet:google-github-actions/auth@v2
with:
create_credentials_file: false
export_environment_variables: false
workload_identity_provider: '${{ vars.TOKEN_MINTER_WIF_PROVIDER }}'
service_account: '${{ vars.TOKEN_MINTER_WIF_SERVICE_ACCOUNT }}'
token_format: 'id_token'
id_token_audience: '${{ vars.TOKEN_MINTER_SERVICE_AUDIENCE }}'
id_token_include_email: true

- name: 'Mint Token'
id: 'mint-token'
if: |-
${{ inputs.team != '' }}
uses: 'abcxyz/github-token-minter/.github/actions/minty@main' # ratchet:exclude
with:
id_token: '${{ steps.minty-auth.outputs.id_token }}'
service_url: '${{ vars.TOKEN_MINTER_SERVICE_URL }}'
requested_permissions: |-
{
"scope": "actions-testing-multi-approvers",
"repositories": ["actions-testing"],
"permissions": {
"members": "read",
"pull_requests": "read"
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be done from the calling workflow. The minty instance referenced from the variables specified here is the abcxyz instance and a caller from another org will have no way to change that, rendering this somewhat unusable.

The current WIF pool is deployed with an assertion that prevents requests from all other orgs which would block your mint token request as well.

Usage of this workflow should look something like:

-> auth
-> minty
-> multi-approvers (passing minty token in)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I spent some time digging into this. We're not currently able to pass secrets from one workflow job to another. Some related facts:

The minty token is marked as a secret here.

This is actually supported (see Example: Masking and passing a secret between jobs or workflows), but it's very cumbersome and probably more than we want to require for this. It requires setting up a secrets store like Vault.

We've only used the Minty steps and the generated token within the same job. I was trying to do it across jobs because the multi-approvers workflow is a reusable workflow and therefore it needs to be called in it's own separate job (see docs).

We could look into turning the multi-approvers reusable workflow into an action or multiple actions. I'm not sure if this would work.

Would it be possible to just pass the required values for the two minty actions?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caller of the workflow needs to setup/configure Minty and pass in the resulting GITHUB_TOKEN to this action.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI, Brad and I talked and I'm going to turn the multi-approvers workflow into an action. This will allow the Minty actions and multi-approvers to run in the same job and removes the need to pass a secret across jobs.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd really prefer to do that ~now before we have people start depending on the workflow version. I also think, eventually, we'll want to move this into it's own repo like we did with terraform-linter (but that can be a follow-up).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry I didn't make that clear. This PR will not be merged. I'll close this PR now to make it clear.

RE moving this into its own repo. Why not do that now? I'm not sure how much work it is to setup a new repo, but I'd prefer to do it now to avoid migration costs in the future (all clients will have to migrate to use the new action).

}
/** Implements approvedCount using the contains method. */
class AbstractMembers {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't really write a lot of js so we don't have a linter yet, but there shouldn't be an extra newline here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in a different PR where I will use an Action instead of a Workflow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue.

Comment on lines +11 to +39
submittedReviews
// Remove the PR user.
.filter((r) => r.user.login !== prLogin)
// Only consider users in the org.
.filter((r) => this.contains(r.user.login))
// Sort chronologically ascending. Note that a reviewer can submit multiple reviews.
.sort((a, b) => new Date(a.submitted_at) - new Date(b.submitted_at))
.forEach((r) => {
const reviewerLogin = r.user.login;

// Set state if it does not exist.
if (!Object.hasOwn(reviewStateByLogin, reviewerLogin)) {
reviewStateByLogin[reviewerLogin] = r.state;
return;
}

// Always update state if not approved.
if (reviewStateByLogin[reviewerLogin] !== APPROVED) {
reviewStateByLogin[reviewerLogin] = r.state;
return;
}

// Do not update approved state for a comment.
if (reviewStateByLogin[reviewerLogin] === APPROVED && r.state !== COMMENTED) {
reviewStateByLogin[reviewerLogin] = r.state;
}
})

return Object.values(reviewStateByLogin).filter((s) => s === APPROVED).length;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is super weird and would be a lot clearer with a single for loop using the quick continue pattern.

I also don't really understand why we're putting things into a map, then returning the value? It seems like a lot of unnecessary filtering and iteration.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in a different PR where I will use an Action instead of a Workflow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue.

Comment on lines +82 to +91
} catch (error) {
if (error.status === 404) {
// We can get here for two reasons:
// 1) The user is not a member
// 2) The team does not exist
// Either way, it's safe to return false here.
return false;
}
throw error;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really log the error (this.core.debug) or something.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in a different PR where I will use an Action instead of a Workflow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue.

Comment on lines +102 to +104
const teamOrg = orgTeam.split('/')[0];
const teamSlug = orgTeam.split('/')[1];
return new TeamMembers(teamOrg, teamSlug, github);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Double split is inefficient. This will also potentially be undefined if the orgTeam doesn't contain a slash.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will address this in a different PR where I will use an Action instead of a Workflow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is a bug that will cause a runtime panic and/or unexpected behavior. It needs to be fixed prior to merging. We do not merge known-bugs into HEAD.

if (membersPath) {
return new JsonMembers(membersPath);
}
throw new Error('Neither orgTeam nor membersPath is set.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if both are provided? I would expect that to be an error.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the README I said that at least one had to be set and that if multiple are set then an undefined (yet consistent) input would be chosen.

But I agree with you. It'll be less error-prone to only allow one.

I'll address this in a different PR where I will use an Action instead of a Workflow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue. We need exhaustive behavior with unit tests to verify the expected behavior.

Comment on lines -24 to +33
required: true
description: 'DEPRECATED: Use members-url with https://raw.githubusercontent.com/ prepeneded instead.'
type: 'string'
members-url:
description: 'Full URL to JSON members file. URL must be publicly accessible.'
type: 'string'
local-members-path:
description: 'Path to JSON members file. See README for more.'
type: 'string'
team:
description: 'The GitHub org and team slug that should be used for determining external vs. internal users. Format is ${org}/${team-slug}.'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like we really need to simplify these inputs. Why do we need so many?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need at least two to support GH teams and a members JSON file.

  1. GH team descriptor
  2. JSON URL

For the JSON URL, it's nice to allow a path to the local repo for manual testing / development / maintenance. This isn't required, but it alleviates the need to find a public place to put a JSON file when testing in a private repo like abcxyz/actions-testsing.

Also note, the JSON members files is nice because it does not require a github-token-minter (Minty) setup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to allow specifying a members JSON at all? That seems useful for local testing, but should never be used in a real scenario. We would never actually want someone manually managing the list of internal members in the organization, since that represents a substantial risk of being out of sync and creating insider risk.

I am strongly opposed to complicating a public API surface just for testing. These kinds of things should be unit tests anywhere (where we fake the response from the GitHub API).

Comment on lines +60 to +64
if [[ -z "${{ inputs.org-members-path }}" &&
-z "${{ inputs.members-url }}" &&
-z "${{ inputs.local-members-path }}" &&
-z "${{ inputs.team }}" ]]; then
echo "Invalid inputs. One of org-members-path, members-url, local-members-path, or team must be set." 1>&2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't access inputs directly in a script (it's a shell injection vuln). Set inputs as envvars and access the envvars.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Moving this to an Action from a Workflow means that I'll test inputs in the JS instead of a Workflow step so this should be moot.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please fix before merging, regardless of the future. We cannot have known security exploits sitting in code at HEAD.

Comment on lines +125 to +156
- name: 'Authenticate to Google Cloud'
id: 'minty-auth'
if: |-
${{ inputs.team != '' }}
uses: 'google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f' # ratchet:google-github-actions/auth@v2
with:
create_credentials_file: false
export_environment_variables: false
workload_identity_provider: '${{ vars.TOKEN_MINTER_WIF_PROVIDER }}'
service_account: '${{ vars.TOKEN_MINTER_WIF_SERVICE_ACCOUNT }}'
token_format: 'id_token'
id_token_audience: '${{ vars.TOKEN_MINTER_SERVICE_AUDIENCE }}'
id_token_include_email: true

- name: 'Mint Token'
id: 'mint-token'
if: |-
${{ inputs.team != '' }}
uses: 'abcxyz/github-token-minter/.github/actions/minty@main' # ratchet:exclude
with:
id_token: '${{ steps.minty-auth.outputs.id_token }}'
service_url: '${{ vars.TOKEN_MINTER_SERVICE_URL }}'
requested_permissions: |-
{
"scope": "actions-testing-multi-approvers",
"repositories": ["actions-testing"],
"permissions": {
"members": "read",
"pull_requests": "read"
}
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The caller of the workflow needs to setup/configure Minty and pass in the resulting GITHUB_TOKEN to this action.

##### Teams

If you use the `team` input, you will have to setup
[github-token-minter](https://github.com/abcxyz/github-token-minter) to provide
Copy link

@suztomo suztomo Feb 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Am I right about the requirements below?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also see this code though:

          # Use the Minty token if it was generated, otherwise use the default.
          github-token: '${{ steps.mint-token.outputs.token || github.token }}'

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, use of the team input will require github-token-minter. You won't need one per repo, but you will need one somewhere. @bradegler can speak more to this.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradegler Does this require Google teams need to setup Terraform?

if: |-
# Check that the request is coming from the expected repository.
# The ID should be replaced with yours.
assertion.repository_id == '12345678' &&
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use repository name, such as "google-cloud-java" for https://github.com/googleapis/google-cloud-java? (I've never referenced a GitHub repository with a number. Is there a benefit in this?)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repository names are mutable and subject to name squatting attacks. Using the repository id is the correct way to ensure the request is coming from the repository that it is expected to come from.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. That's worth mentioning here.

of these inputs are set, only one will be used. Which one will be used is
undefined, but deterministic.

**team**: This is actually the org and team formateted as ${org}/${team-slug}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we specify multiple teams? In the repositories we (Cloud SDK) maintain, it's norm to have the service team and the Cloud SDK team maintain a repository together (e.g., java-storage)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@suztomo We really just need one team of all Googlers who are members of the organization. More granular approvals can be achieved with a CODEOWNERS file where you can specify multiple teams.

Copy link

@suztomo suztomo Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, one organization team, if it exists, suffices the needs.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to allow for an org input where internal approvers are members of the org instead of a team?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, organization membership makes sense to me. It can tell the organization already for the repository URL.

local-members-path:
description: 'Path to JSON members file. See README for more.'
type: 'string'
team:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to configure organization as well? We would like to configure this GitHub action at the organization level.

@meltsufin @suztomo

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I proposed this above. I'll chat with my team to see if this is a possibility

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sakthivelmanii what is the use case? You cannot assign CODEOWNERS teams outside of the organization, so I don't understand the use case for why you'd want to query teams outside of the org.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @sakthivelmanii is asking for all members of a GH org (/orgs/{org}/members) to be treated as internal reviewers. @sakthivelmanii is this correct?

@ian-shafer
Copy link
Contributor Author

FYI, I will eventually close this PR in favor of another where we use an Action instead of a Workflow for multi-approvers logic.

}
/** Implements approvedCount using the contains method. */
class AbstractMembers {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue.

Comment on lines +11 to +39
submittedReviews
// Remove the PR user.
.filter((r) => r.user.login !== prLogin)
// Only consider users in the org.
.filter((r) => this.contains(r.user.login))
// Sort chronologically ascending. Note that a reviewer can submit multiple reviews.
.sort((a, b) => new Date(a.submitted_at) - new Date(b.submitted_at))
.forEach((r) => {
const reviewerLogin = r.user.login;

// Set state if it does not exist.
if (!Object.hasOwn(reviewStateByLogin, reviewerLogin)) {
reviewStateByLogin[reviewerLogin] = r.state;
return;
}

// Always update state if not approved.
if (reviewStateByLogin[reviewerLogin] !== APPROVED) {
reviewStateByLogin[reviewerLogin] = r.state;
return;
}

// Do not update approved state for a comment.
if (reviewStateByLogin[reviewerLogin] === APPROVED && r.state !== COMMENTED) {
reviewStateByLogin[reviewerLogin] = r.state;
}
})

return Object.values(reviewStateByLogin).filter((s) => s === APPROVED).length;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue.

Comment on lines +82 to +91
} catch (error) {
if (error.status === 404) {
// We can get here for two reasons:
// 1) The user is not a member
// 2) The team does not exist
// Either way, it's safe to return false here.
return false;
}
throw error;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue.

Comment on lines +102 to +104
const teamOrg = orgTeam.split('/')[0];
const teamSlug = orgTeam.split('/')[1];
return new TeamMembers(teamOrg, teamSlug, github);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is a bug that will cause a runtime panic and/or unexpected behavior. It needs to be fixed prior to merging. We do not merge known-bugs into HEAD.

if (membersPath) {
return new JsonMembers(membersPath);
}
throw new Error('Neither orgTeam nor membersPath is set.');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please create a follow-up issue. We need exhaustive behavior with unit tests to verify the expected behavior.

local-members-path:
description: 'Path to JSON members file. See README for more.'
type: 'string'
team:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sakthivelmanii what is the use case? You cannot assign CODEOWNERS teams outside of the organization, so I don't understand the use case for why you'd want to query teams outside of the org.

Comment on lines -24 to +33
required: true
description: 'DEPRECATED: Use members-url with https://raw.githubusercontent.com/ prepeneded instead.'
type: 'string'
members-url:
description: 'Full URL to JSON members file. URL must be publicly accessible.'
type: 'string'
local-members-path:
description: 'Path to JSON members file. See README for more.'
type: 'string'
team:
description: 'The GitHub org and team slug that should be used for determining external vs. internal users. Format is ${org}/${team-slug}.'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to allow specifying a members JSON at all? That seems useful for local testing, but should never be used in a real scenario. We would never actually want someone manually managing the list of internal members in the organization, since that represents a substantial risk of being out of sync and creating insider risk.

I am strongly opposed to complicating a public API surface just for testing. These kinds of things should be unit tests anywhere (where we fake the response from the GitHub API).

Comment on lines +60 to +64
if [[ -z "${{ inputs.org-members-path }}" &&
-z "${{ inputs.members-url }}" &&
-z "${{ inputs.local-members-path }}" &&
-z "${{ inputs.team }}" ]]; then
echo "Invalid inputs. One of org-members-path, members-url, local-members-path, or team must be set." 1>&2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please fix before merging, regardless of the future. We cannot have known security exploits sitting in code at HEAD.

@ian-shafer ian-shafer closed this Feb 11, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Multi-approvers workflow to use list of public organization members
7 participants