-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(github): support team hierarchy in GH_TEAM_ALLOWLIST #6365
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
Changes from all commits
222de7c
f386f36
f32da28
f57331f
b1cd614
699401d
3bf6370
75cae5f
7d45c40
0479bb8
e618f8e
3d53209
4133a20
d5d19bf
dfca196
6cfec46
46279b9
30a53ea
44bb779
5b6cd44
f5efeaa
3ce40a6
fa51f2d
f125cbb
92f3b90
608c19e
992e4e1
79fe11b
d0f622e
09c6dc9
3168239
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import ( | |
| "fmt" | ||
| "slices" | ||
| "strconv" | ||
| "strings" | ||
|
|
||
| "github.com/drmaxgit/go-azuredevops/azuredevops" | ||
| "github.com/google/go-github/v88/github" | ||
|
|
@@ -155,15 +156,17 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo | |
| log.Err("Unable to fetch user teams: %s", err) | ||
| return | ||
| } | ||
| directUserTeams := append([]string(nil), user.Teams...) | ||
|
|
||
| ok, err := c.checkUserPermissions(baseRepo, user, "plan") | ||
| ok, err := c.checkUserPermissions(baseRepo, &user, "plan") | ||
| if err != nil { | ||
| log.Err("Unable to check user permissions: %s", err) | ||
| return | ||
| } | ||
| if !ok { | ||
| return | ||
| } | ||
| c.addPolicyCheckHierarchyTeamsForPlan(baseRepo, &user, command.Plan, directUserTeams) | ||
| } | ||
|
|
||
| ctx := &command.Context{ | ||
|
|
@@ -253,8 +256,130 @@ func (c *DefaultCommandRunner) commentUserDoesNotHavePermissions(baseRepo models | |
| } | ||
| } | ||
|
|
||
| // checkUserPermissions checks if the user has permissions to execute the command | ||
| func (c *DefaultCommandRunner) checkUserPermissions(repo models.Repo, user models.User, cmdName string) (bool, error) { | ||
| // fetchDescendantTeams fetches all descendant team slugs for the given team up to maxDepth | ||
| // levels deep using an iterative BFS with a visited set to avoid duplicate API calls and | ||
| // handle any cycles in unexpected hierarchy configurations. | ||
| func fetchDescendantTeams(fetcher vcs.Client, logger logging.SimpleLogging, repo models.Repo, teamSlug string, maxDepth int) ([]string, error) { | ||
| if maxDepth <= 0 { | ||
| return nil, nil | ||
| } | ||
|
|
||
| type queueItem struct { | ||
| slug string | ||
| depth int | ||
| } | ||
|
|
||
| visited := map[string]struct{}{teamSlug: {}} | ||
| queue := []queueItem{{slug: teamSlug, depth: 0}} | ||
| var result []string | ||
|
|
||
| for i := 0; i < len(queue); i++ { | ||
| current := queue[i] | ||
|
|
||
| if current.depth >= maxDepth { | ||
| continue | ||
| } | ||
|
|
||
| children, err := fetcher.GetChildTeams(logger, repo, current.slug) | ||
| if err != nil { | ||
| if current.slug == teamSlug { | ||
| return nil, err | ||
| } | ||
| logger.Warn("Could not fetch child teams for '%s': %s", current.slug, err) | ||
| continue | ||
| } | ||
|
|
||
| for _, child := range children { | ||
| if _, ok := visited[child]; ok { | ||
| continue | ||
| } | ||
| visited[child] = struct{}{} | ||
| result = append(result, child) | ||
| queue = append(queue, queueItem{slug: child, depth: current.depth + 1}) | ||
| } | ||
| } | ||
|
|
||
| return result, nil | ||
| } | ||
|
|
||
| func teamSet(teams []string) map[string]struct{} { | ||
| result := make(map[string]struct{}, len(teams)) | ||
| for _, team := range teams { | ||
| result[strings.ToLower(team)] = struct{}{} | ||
| } | ||
| return result | ||
| } | ||
|
|
||
| func (c *DefaultCommandRunner) addHierarchyTeamsForCommand(repo models.Repo, user *models.User, cmdName string) { | ||
| c.addHierarchyTeamsForCommandForTeams(repo, user, cmdName, user.Teams) | ||
| } | ||
|
|
||
| func (c *DefaultCommandRunner) addHierarchyTeamsForCommandForTeams(repo models.Repo, user *models.User, cmdName string, teams []string) { | ||
| if c.TeamAllowlistChecker == nil || !c.TeamAllowlistChecker.HasRules() { | ||
| return | ||
| } | ||
|
|
||
| ctx := models.TeamAllowlistCheckerContext{ | ||
| BaseRepo: repo, | ||
| CommandName: cmdName, | ||
| Log: c.Logger, | ||
| Pull: models.PullRequest{}, | ||
| User: *user, | ||
| Verbose: false, | ||
| API: false, | ||
| } | ||
|
|
||
| // Only direct user teams should authorize hierarchy grants. Parent teams inferred | ||
| // during this pass are appended for downstream direct-membership filters, not for | ||
| // chaining additional hierarchy grants. | ||
| directUserTeams := teamSet(teams) | ||
| currentUserTeams := teamSet(user.Teams) | ||
|
|
||
| const maxHierarchyDepth = 20 | ||
| for _, allowedTeam := range c.TeamAllowlistChecker.AllTeams() { | ||
| if allowedTeam == "*" { | ||
| continue | ||
| } | ||
| normalizedAllowedTeam := strings.ToLower(allowedTeam) | ||
| if _, ok := currentUserTeams[normalizedAllowedTeam]; ok { | ||
| continue | ||
| } | ||
| if !c.TeamAllowlistChecker.IsCommandAllowedForTeam(ctx, allowedTeam, cmdName) { | ||
| continue | ||
| } | ||
| descendants, err := fetchDescendantTeams(c.VCSClient, c.Logger, repo, allowedTeam, maxHierarchyDepth) | ||
| if err != nil { | ||
| c.Logger.Warn("Could not fetch child teams for '%s': %s", allowedTeam, err) | ||
| continue | ||
| } | ||
| for _, descendant := range descendants { | ||
| if _, ok := directUserTeams[strings.ToLower(descendant)]; !ok { | ||
| continue | ||
| } | ||
| user.Teams = append(user.Teams, allowedTeam) | ||
| currentUserTeams[normalizedAllowedTeam] = struct{}{} | ||
| break | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func (c *DefaultCommandRunner) addPolicyCheckHierarchyTeamsForPlan(repo models.Repo, user *models.User, cmdName command.Name, directUserTeams []string) { | ||
| if cmdName != command.Plan { | ||
| return | ||
| } | ||
| c.addHierarchyTeamsForCommandForTeams(repo, user, command.PolicyCheck.String(), directUserTeams) | ||
| } | ||
|
|
||
| // checkUserPermissions checks if the user has permissions to execute the command. | ||
| // It first checks direct team membership against the allowlist. If that fails, | ||
| // it expands each allowlisted team to include all its descendant teams (up to | ||
| // 20 levels deep) via GetChildTeams on the VCS client and re-checks. | ||
| // Non-GitHub VCS providers return nil from GetChildTeams, so the expansion | ||
| // loop is effectively a no-op for them. | ||
| // When a match is found via hierarchy, the matched allowlisted parent team is appended to | ||
| // user.Teams so that subsequent per-project allowlist checks (which use direct membership | ||
| // only) also pass. | ||
| func (c *DefaultCommandRunner) checkUserPermissions(repo models.Repo, user *models.User, cmdName string) (bool, error) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why was the signature changed to make user a pointer type? It seems like all callers of this function are taking a pointer at the call site, and the user is immediately dereferenced below, what is that gaining?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. actually all changes done by claude, i just want to solve this bug, can we get this PR merged, so we start using it in production ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| if c.TeamAllowlistChecker == nil || !c.TeamAllowlistChecker.HasRules() { | ||
| // allowlist restriction is not enabled | ||
| return true, nil | ||
|
|
@@ -264,15 +389,23 @@ func (c *DefaultCommandRunner) checkUserPermissions(repo models.Repo, user model | |
| CommandName: cmdName, | ||
| Log: c.Logger, | ||
| Pull: models.PullRequest{}, | ||
| User: user, | ||
| User: *user, | ||
| Verbose: false, | ||
| API: false, | ||
| } | ||
| ok := c.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, user.Teams, cmdName) | ||
| if !ok { | ||
| return false, nil | ||
|
|
||
| // Fast path: user is a direct member of an allowlisted team. | ||
| if c.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, user.Teams, cmdName) { | ||
| return true, nil | ||
| } | ||
|
|
||
| // Slow path: check if the user belongs to a descendant team of any allowlisted team. | ||
| c.addHierarchyTeamsForCommand(repo, user, cmdName) | ||
| ctx.User = *user | ||
| if c.TeamAllowlistChecker.IsCommandAllowedForAnyTeam(ctx, user.Teams, cmdName) { | ||
| return true, nil | ||
| } | ||
|
hussein-mimi marked this conversation as resolved.
|
||
| return true, nil | ||
| return false, nil | ||
| } | ||
|
|
||
| // checkVarFilesInPlanCommandAllowlisted checks if paths in a 'plan' command are allowlisted. | ||
|
|
@@ -316,8 +449,9 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead | |
| c.Logger.Err("Unable to fetch user teams: %s", err) | ||
| return | ||
| } | ||
| directUserTeams := append([]string(nil), user.Teams...) | ||
|
|
||
| ok, err := c.checkUserPermissions(baseRepo, user, cmd.Name.String()) | ||
| ok, err := c.checkUserPermissions(baseRepo, &user, cmd.Name.String()) | ||
| if err != nil { | ||
| c.Logger.Err("Unable to check user permissions: %s", err) | ||
| return | ||
|
|
@@ -326,6 +460,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead | |
| c.commentUserDoesNotHavePermissions(baseRepo, pullNum, user, cmd) | ||
| return | ||
| } | ||
| c.addPolicyCheckHierarchyTeamsForPlan(baseRepo, &user, cmd.Name, directUserTeams) | ||
| } | ||
|
|
||
| // Check if the provided var files in a 'plan' command are allowlisted | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.