-
Notifications
You must be signed in to change notification settings - Fork 2
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
Conversation
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.
- 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" | ||
} | ||
} | ||
|
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 { | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
} 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; | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
const teamOrg = orgTeam.split('/')[0]; | ||
const teamSlug = orgTeam.split('/')[1]; | ||
return new TeamMembers(teamOrg, teamSlug, github); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.'); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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}.' |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
- GH team descriptor
- 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.
There was a problem hiding this comment.
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).
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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
- 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" | ||
} | ||
} | ||
|
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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?
- The usage of teams here needs github-token-minter in each of the repository.
- github-token-minter needs Terraform (https://github.com/abcxyz/github-token-minter?tab=readme-ov-file#deploy-the-service)?
There was a problem hiding this comment.
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 }}'
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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' && |
There was a problem hiding this comment.
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?)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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} |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
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 { | ||
|
There was a problem hiding this comment.
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.
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; |
There was a problem hiding this comment.
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.
} 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; | ||
} |
There was a problem hiding this comment.
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.
const teamOrg = orgTeam.split('/')[0]; | ||
const teamSlug = orgTeam.split('/')[1]; | ||
return new TeamMembers(teamOrg, teamSlug, github); |
There was a problem hiding this comment.
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.'); |
There was a problem hiding this comment.
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: |
There was a problem hiding this comment.
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.
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}.' |
There was a problem hiding this comment.
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).
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 |
There was a problem hiding this comment.
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.
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:
See the README for more on each of these inputs.
Fixes #364 and #361