Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
222de7c
feat(github): support team hierarchy in GH_TEAM_ALLOWLIST
hussein-mimi Apr 5, 2026
f386f36
test(github): add tests for fetchDescendantTeams and checkUserPermiss…
hussein-mimi Apr 7, 2026
f32da28
fix(github): propagate GetChildTeams through ClientProxy and Instrume…
hussein-mimi Apr 9, 2026
f57331f
fix(allowlist): propagate matched parent team to per-project filter
hussein-mimi Apr 9, 2026
b1cd614
refactor(vcs): add GetChildTeams to Client interface with stubs for n…
hussein-mimi Apr 12, 2026
699401d
fix(dockerfile): bump openssh-server to 1:9.2p1-2+deb12u9
hussein-mimi Apr 12, 2026
3bf6370
Merge branch 'main' into feat/github-team-hierarchy-allowlist
hussein-mimi Apr 12, 2026
75cae5f
fix(allowlist): address Copilot review feedback
hussein-mimi Apr 12, 2026
7d45c40
fix(allowlist): address second round of Copilot review feedback
hussein-mimi Apr 12, 2026
0479bb8
test(allowlist): assert exact elements in error-skip traversal test
hussein-mimi Apr 12, 2026
e618f8e
fix: remove redundant childTeamFetcher type assertion in checkUserPer…
hussein-mimi Apr 12, 2026
3d53209
test: add multi-page pagination test for GetChildTeams
hussein-mimi Apr 12, 2026
4133a20
test: add cycle detection test for fetchDescendantTeams
hussein-mimi Apr 12, 2026
d5d19bf
Merge branch 'main' into feat/github-team-hierarchy-allowlist
hussein-mimi Apr 13, 2026
dfca196
Merge branch 'main' into feat/github-team-hierarchy-allowlist
hussein-mimi Apr 16, 2026
6cfec46
Merge branch 'main' into feat/github-team-hierarchy-allowlist
hussein-mimi Apr 19, 2026
46279b9
Merge branch 'main' into feat/github-team-hierarchy-allowlist
hussein-mimi Apr 21, 2026
30a53ea
Merge branch 'main' into feat/github-team-hierarchy-allowlist
hussein-mimi Apr 24, 2026
44bb779
Merge branch 'main' into feat/github-team-hierarchy-allowlist
hussein-mimi Apr 27, 2026
5b6cd44
refactor: remove redundant childTeamFetcher interface
hussein-mimi Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 95 additions & 9 deletions server/events/command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"

"github.com/drmaxgit/go-azuredevops/azuredevops"
"github.com/google/go-github/v83/github"
Expand Down Expand Up @@ -165,7 +166,7 @@ func (c *DefaultCommandRunner) RunAutoplanCommand(baseRepo models.Repo, headRepo
return
}

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
Expand Down Expand Up @@ -262,8 +263,62 @@ 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
Comment thread
hussein-mimi marked this conversation as resolved.
}

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
}

// 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) {
if c.TeamAllowlistChecker == nil || !c.TeamAllowlistChecker.HasRules() {
// allowlist restriction is not enabled
return true, nil
Expand All @@ -273,15 +328,46 @@ 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.
const maxHierarchyDepth = 20
for _, allowedTeam := range c.TeamAllowlistChecker.AllTeams() {
if allowedTeam == "*" {
continue
}
// Only expand teams that actually grant permission for this command.
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
}
descendantSet := make(map[string]struct{}, len(descendants))
for _, desc := range descendants {
descendantSet[strings.ToLower(desc)] = struct{}{}
}
Comment thread
hussein-mimi marked this conversation as resolved.
for _, userTeam := range user.Teams {
if _, ok := descendantSet[strings.ToLower(userTeam)]; ok {
Comment thread
hussein-mimi marked this conversation as resolved.
// Add the matched allowlisted parent team to user.Teams so that
// per-project allowlist filters (which check direct membership)
// also pass for this user.
user.Teams = append(user.Teams, allowedTeam)
Comment thread
hussein-mimi marked this conversation as resolved.
return true, nil
}
}
}
Comment thread
hussein-mimi marked this conversation as resolved.
return true, nil
return false, nil
}

// checkVarFilesInPlanCommandAllowlisted checks if paths in a 'plan' command are allowlisted.
Expand Down Expand Up @@ -326,7 +412,7 @@ func (c *DefaultCommandRunner) RunCommentCommand(baseRepo models.Repo, maybeHead
return
}

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
Expand Down
231 changes: 231 additions & 0 deletions server/events/command_runner_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,245 @@
package events

import (
"errors"
"testing"

"github.com/runatlantis/atlantis/server/events/command"
"github.com/runatlantis/atlantis/server/events/models"
"github.com/runatlantis/atlantis/server/events/vcs"
"github.com/runatlantis/atlantis/server/logging"
. "github.com/runatlantis/atlantis/testing"
)

// childTeamVCSClient combines vcs.NotConfiguredVCSClient (satisfying vcs.Client)
// with a configurable team hierarchy map, so tests can exercise GetChildTeams
// without a real VCS connection.
type childTeamVCSClient struct {
vcs.NotConfiguredVCSClient
children map[string][]string
errOn map[string]bool
}

func (c *childTeamVCSClient) GetChildTeams(_ logging.SimpleLogging, _ models.Repo, teamSlug string) ([]string, error) {
if c.errOn[teamSlug] {
return nil, errors.New("API error for " + teamSlug)
}
return c.children[teamSlug], nil
}

func TestFetchDescendantTeams(t *testing.T) {
logger := logging.NewNoopLogger(t)
repo := models.Repo{Owner: "test-org"}
Comment thread
hussein-mimi marked this conversation as resolved.

t.Run("leaf team returns empty", func(t *testing.T) {
fetcher := &childTeamVCSClient{children: map[string][]string{}}
result, err := fetchDescendantTeams(fetcher, logger, repo, "leaf-team", 20)
Ok(t, err)
Equals(t, 0, len(result))
})

t.Run("single level of children", func(t *testing.T) {
fetcher := &childTeamVCSClient{children: map[string][]string{
"parent": {"child-a", "child-b"},
}}
result, err := fetchDescendantTeams(fetcher, logger, repo, "parent", 20)
Ok(t, err)
Equals(t, []string{"child-a", "child-b"}, result)
})

t.Run("multiple levels of nesting", func(t *testing.T) {
fetcher := &childTeamVCSClient{children: map[string][]string{
"grandparent": {"parent"},
"parent": {"child"},
}}
result, err := fetchDescendantTeams(fetcher, logger, repo, "grandparent", 20)
Ok(t, err)
Equals(t, []string{"parent", "child"}, result)
})

t.Run("maxDepth=0 returns nothing", func(t *testing.T) {
fetcher := &childTeamVCSClient{children: map[string][]string{
"parent": {"child"},
}}
result, err := fetchDescendantTeams(fetcher, logger, repo, "parent", 0)
Ok(t, err)
Equals(t, []string(nil), result)
})

t.Run("maxDepth=1 returns only direct children", func(t *testing.T) {
fetcher := &childTeamVCSClient{children: map[string][]string{
"grandparent": {"parent"},
"parent": {"child"},
}}
result, err := fetchDescendantTeams(fetcher, logger, repo, "grandparent", 1)
Ok(t, err)
Equals(t, []string{"parent"}, result)
})

t.Run("error at root propagates", func(t *testing.T) {
fetcher := &childTeamVCSClient{
children: map[string][]string{},
errOn: map[string]bool{"parent": true},
}
_, err := fetchDescendantTeams(fetcher, logger, repo, "parent", 20)
Assert(t, err != nil, "expected error to propagate from root team")
})

t.Run("error in recursive call is logged and skipped", func(t *testing.T) {
// parent fetches OK; fetching child-a's children errors.
// child-b and its subtree should still be traversed.
fetcher := &childTeamVCSClient{
children: map[string][]string{
"parent": {"child-a", "child-b"},
"child-b": {"grandchild-b"},
},
errOn: map[string]bool{"child-a": true},
}
result, err := fetchDescendantTeams(fetcher, logger, repo, "parent", 20)
Ok(t, err)
// child-a is included (direct child of parent); its subtree is skipped on error.
// child-b and grandchild-b are also traversed successfully.
Equals(t, []string{"child-a", "child-b", "grandchild-b"}, result)
})

t.Run("cycle is handled without infinite loop", func(t *testing.T) {
// a -> b -> a forms a cycle; visited set should break it.
fetcher := &childTeamVCSClient{children: map[string][]string{
"team-a": {"team-b"},
"team-b": {"team-a"},
}}
result, err := fetchDescendantTeams(fetcher, logger, repo, "team-a", 20)
Ok(t, err)
Equals(t, []string{"team-b"}, result)
})
Comment thread
hussein-mimi marked this conversation as resolved.
}

func TestCheckUserPermissions(t *testing.T) {
logger := logging.NewNoopLogger(t)
repo := models.Repo{Owner: "test-org"}

t.Run("no rules allows everyone", func(t *testing.T) {
cr := &DefaultCommandRunner{
Logger: logger,
TeamAllowlistChecker: &command.DefaultTeamAllowlistChecker{},
}
user := models.User{Username: "alice", Teams: []string{"some-team"}}
ok, err := cr.checkUserPermissions(repo, &user, "plan")
Ok(t, err)
Assert(t, ok, "expected allowed when no rules")
})

t.Run("fast path: direct team member is allowed", func(t *testing.T) {
checker, _ := command.NewTeamAllowlistChecker("dev-team:plan")
cr := &DefaultCommandRunner{
Logger: logger,
TeamAllowlistChecker: checker,
VCSClient: &vcs.NotConfiguredVCSClient{}, // GetChildTeams returns nil (no hierarchy support)
}
user := models.User{Username: "alice", Teams: []string{"dev-team"}}
ok, err := cr.checkUserPermissions(repo, &user, "plan")
Ok(t, err)
Assert(t, ok, "expected direct member to be allowed")
})

t.Run("fast path: non-member without hierarchy support is rejected", func(t *testing.T) {
checker, _ := command.NewTeamAllowlistChecker("dev-team:plan")
cr := &DefaultCommandRunner{
Logger: logger,
TeamAllowlistChecker: checker,
VCSClient: &vcs.NotConfiguredVCSClient{}, // GetChildTeams returns nil (no hierarchy support)
}
user := models.User{Username: "alice", Teams: []string{"other-team"}}
ok, err := cr.checkUserPermissions(repo, &user, "plan")
Ok(t, err)
Assert(t, !ok, "expected non-member to be rejected when no hierarchy support")
})

hierarchyCases := map[string]struct {
allowlist string
userTeams []string
hierarchy map[string][]string
cmdName string
expectAllow bool
}{
"slow path: user in direct child team is allowed": {
allowlist: "parent-team:plan",
userTeams: []string{"child-team"},
hierarchy: map[string][]string{"parent-team": {"child-team"}},
cmdName: "plan",
expectAllow: true,
},
"slow path: user in grandchild team is allowed": {
allowlist: "grandparent-team:plan",
userTeams: []string{"grandchild-team"},
hierarchy: map[string][]string{
"grandparent-team": {"parent-team"},
"parent-team": {"grandchild-team"},
},
cmdName: "plan",
expectAllow: true,
},
"slow path: user not in any descendant is rejected": {
allowlist: "parent-team:plan",
userTeams: []string{"unrelated-team"},
hierarchy: map[string][]string{"parent-team": {"child-team"}},
cmdName: "plan",
expectAllow: false,
},
"slow path: user in child team but wrong command is rejected": {
allowlist: "parent-team:apply",
userTeams: []string{"child-team"},
hierarchy: map[string][]string{"parent-team": {"child-team"}},
cmdName: "plan",
expectAllow: false,
},
// *:plan allows everyone including users with no team memberships.
// The fast path handles this via IsCommandAllowedForAnyTeam's zero-team wildcard check.
"fast path: wildcard team rule allows user with no teams": {
allowlist: "*:plan",
userTeams: []string{},
hierarchy: map[string][]string{},
cmdName: "plan",
expectAllow: true,
},
}

for name, tc := range hierarchyCases {
t.Run(name, func(t *testing.T) {
checker, err := command.NewTeamAllowlistChecker(tc.allowlist)
Ok(t, err)
cr := &DefaultCommandRunner{
Logger: logger,
TeamAllowlistChecker: checker,
VCSClient: &childTeamVCSClient{children: tc.hierarchy},
}
user := models.User{Username: "testuser", Teams: tc.userTeams}
ok, checkErr := cr.checkUserPermissions(repo, &user, tc.cmdName)
Ok(t, checkErr)
Equals(t, tc.expectAllow, ok)
})
}

t.Run("slow path: matched parent team is appended to user.Teams", func(t *testing.T) {
checker, err := command.NewTeamAllowlistChecker("parent-team:plan")
Ok(t, err)
cr := &DefaultCommandRunner{
Logger: logger,
TeamAllowlistChecker: checker,
VCSClient: &childTeamVCSClient{
children: map[string][]string{"parent-team": {"child-team"}},
},
}
user := models.User{Username: "alice", Teams: []string{"child-team"}}
ok, checkErr := cr.checkUserPermissions(repo, &user, "plan")
Ok(t, checkErr)
Assert(t, ok, "expected child team member to be allowed")
// The matched parent team should be appended so per-project allowlist checks pass.
Assert(t, len(user.Teams) == 2, "expected user.Teams to contain both child-team and parent-team")
Equals(t, "parent-team", user.Teams[1])
})
}

func TestApplyUpdateCommitStatus(t *testing.T) {
cases := map[string]struct {
cmd command.Name
Expand Down
4 changes: 4 additions & 0 deletions server/events/vcs/azuredevops/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -454,3 +454,7 @@ func (g *Client) GetCloneURL(_ logging.SimpleLogging, VCSHostType models.VCSHost
func (g *Client) GetPullLabels(_ logging.SimpleLogging, _ models.Repo, _ models.PullRequest) ([]string, error) {
return nil, fmt.Errorf("not yet implemented")
}

func (g *Client) GetChildTeams(_ logging.SimpleLogging, _ models.Repo, _ string) ([]string, error) {
return nil, nil
}
Loading
Loading