Skip to content

Commit 3776034

Browse files
committed
peribolos: add org roles feature
1 parent 5c78d02 commit 3776034

File tree

6 files changed

+1533
-0
lines changed

6 files changed

+1533
-0
lines changed

cmd/peribolos/main.go

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type options struct {
5858
fixTeamRepos bool
5959
fixRepos bool
6060
fixCollaborators bool
61+
fixOrgRoles bool
6162
ignoreInvitees bool
6263
ignoreSecretTeams bool
6364
allowRepoArchival bool
@@ -94,6 +95,7 @@ func (o *options) parseArgs(flags *flag.FlagSet, args []string) error {
9495
flags.BoolVar(&o.fixTeamRepos, "fix-team-repos", false, "Add/remove team permissions on repos if set")
9596
flags.BoolVar(&o.fixRepos, "fix-repos", false, "Create/update repositories if set")
9697
flags.BoolVar(&o.fixCollaborators, "fix-collaborators", false, "Add/remove/update repository collaborators if set")
98+
flags.BoolVar(&o.fixOrgRoles, "fix-org-roles", false, "Assign/remove organization roles to teams and users if set")
9799
flags.BoolVar(&o.allowRepoArchival, "allow-repo-archival", false, "If set, archiving repos is allowed while updating repos")
98100
flags.BoolVar(&o.allowRepoPublish, "allow-repo-publish", false, "If set, making private repos public is allowed while updating repos")
99101
flags.StringVar(&o.logLevel, "log-level", logrus.InfoLevel.String(), fmt.Sprintf("Logging level, one of %v", logrus.AllLevels))
@@ -147,6 +149,10 @@ func (o *options) parseArgs(flags *flag.FlagSet, args []string) error {
147149
return fmt.Errorf("--fix-team-repos requires --fix-teams")
148150
}
149151

152+
if o.fixOrgRoles && !o.fixTeams {
153+
return fmt.Errorf("--fix-org-roles requires --fix-teams")
154+
}
155+
150156
return nil
151157
}
152158

@@ -209,6 +215,9 @@ type dumpClient interface {
209215
GetRepo(owner, name string) (github.FullRepo, error)
210216
GetRepos(org string, isUser bool) ([]github.Repo, error)
211217
ListDirectCollaboratorsWithPermissions(org, repo string) (map[string]github.RepoPermissionLevel, error)
218+
ListOrganizationRoles(org string) ([]github.OrganizationRole, error)
219+
ListTeamsWithRole(org string, roleID int) ([]github.OrganizationRoleAssignment, error)
220+
ListUsersWithRole(org string, roleID int) ([]github.OrganizationRoleAssignment, error)
212221
BotUser() (*github.UserData, error)
213222
}
214223

@@ -272,6 +281,7 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
272281
idMap := map[int]org.Team{} // metadata for a team
273282
children := map[int][]int{} // what children does it have
274283
var tops []int // what are the top-level teams
284+
slugToName := map[string]string{}
275285

276286
for _, t := range teams {
277287
logger := logrus.WithFields(logrus.Fields{"id": t.ID, "name": t.Name})
@@ -280,6 +290,7 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
280290
logger.Debug("Ignoring secret team.")
281291
continue
282292
}
293+
slugToName[t.Slug] = t.Name
283294
d := t.Description
284295
nt := org.Team{
285296
TeamMetadata: org.TeamMetadata{
@@ -385,6 +396,53 @@ func dumpOrgConfig(client dumpClient, orgName string, ignoreSecretTeams bool, ap
385396
out.Repos[full.Name] = repoConfig
386397
}
387398

399+
// Dump organization roles
400+
roles, err := client.ListOrganizationRoles(orgName)
401+
if err != nil {
402+
return nil, fmt.Errorf("failed to list organization roles: %w", err)
403+
}
404+
logrus.Debugf("Found %d organization roles", len(roles))
405+
if len(roles) > 0 {
406+
out.Roles = make(map[string]org.Role, len(roles))
407+
}
408+
for _, role := range roles {
409+
logrus.WithField("role", role.Name).Debug("Recording organization role.")
410+
411+
// Get teams with this role
412+
teamsWithRole, err := client.ListTeamsWithRole(orgName, role.ID)
413+
if err != nil {
414+
logrus.WithError(err).Warnf("Failed to list teams with role %s", role.Name)
415+
continue
416+
}
417+
418+
// Get users with this role
419+
usersWithRole, err := client.ListUsersWithRole(orgName, role.ID)
420+
if err != nil {
421+
logrus.WithError(err).Warnf("Failed to list users with role %s", role.Name)
422+
continue
423+
}
424+
425+
// Build team and user lists
426+
var teamSlugs []string
427+
for _, team := range teamsWithRole {
428+
if name, ok := slugToName[team.Slug]; ok {
429+
teamSlugs = append(teamSlugs, name)
430+
} else {
431+
teamSlugs = append(teamSlugs, team.Slug)
432+
}
433+
}
434+
435+
var userLogins []string
436+
for _, user := range usersWithRole {
437+
userLogins = append(userLogins, user.Login)
438+
}
439+
440+
out.Roles[role.Name] = org.Role{
441+
Teams: teamSlugs,
442+
Users: userLogins,
443+
}
444+
}
445+
388446
return &out, nil
389447
}
390448

@@ -496,6 +554,8 @@ func configureOrgMembers(opt options, client orgClient, orgName string, orgConfi
496554
}
497555
} else if om.State == github.StatePending {
498556
logrus.Infof("Invited %s to %s as a %s", user, orgName, role)
557+
// Track the new invitation so role assignment can skip this user
558+
invitees.Insert(github.NormLogin(user))
499559
} else {
500560
logrus.Infof("Set %s as a %s of %s", user, role, orgName)
501561
}
@@ -870,6 +930,13 @@ func orgInvitations(opt options, client inviteClient, orgName string) (sets.Set[
870930
}
871931

872932
func configureOrg(opt options, client github.Client, orgName string, orgConfig org.Config) error {
933+
// Validate role configuration early (before any API calls) if we're going to configure roles
934+
if opt.fixOrgRoles {
935+
if err := orgConfig.ValidateRoles(); err != nil {
936+
return fmt.Errorf("invalid role configuration: %w", err)
937+
}
938+
}
939+
873940
// Ensure that metadata is configured correctly.
874941
if !opt.fixOrg {
875942
logrus.Infof("Skipping org metadata configuration")
@@ -889,6 +956,9 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
889956
return fmt.Errorf("failed to configure %s members: %w", orgName, err)
890957
}
891958

959+
// Note: New invitations sent by configureOrgMembers are tracked in the invitees set,
960+
// so role assignment can skip users with pending invitations without an extra API call.
961+
892962
// Create repositories in the org
893963
if !opt.fixRepos {
894964
logrus.Info("Skipping org repositories configuration")
@@ -932,6 +1002,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
9321002
return fmt.Errorf("failed to configure %s team %s repos: %w", orgName, name, err)
9331003
}
9341004
}
1005+
1006+
// Configure organization roles
1007+
if !opt.fixOrgRoles {
1008+
logrus.Infof("Skipping organization roles configuration")
1009+
} else if err := configureOrgRoles(client, orgName, orgConfig, githubTeams, invitees); err != nil {
1010+
return fmt.Errorf("failed to configure %s organization roles: %w", orgName, err)
1011+
}
1012+
9351013
return nil
9361014
}
9371015

@@ -1453,6 +1531,220 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531531
return utilerrors.NewAggregate(updateErrors)
14541532
}
14551533

1534+
type orgRolesClient interface {
1535+
ListOrganizationRoles(org string) ([]github.OrganizationRole, error)
1536+
AssignOrganizationRoleToTeam(org, teamSlug string, roleID int) error
1537+
RemoveOrganizationRoleFromTeam(org, teamSlug string, roleID int) error
1538+
AssignOrganizationRoleToUser(org, user string, roleID int) error
1539+
RemoveOrganizationRoleFromUser(org, user string, roleID int) error
1540+
ListTeamsWithRole(org string, roleID int) ([]github.OrganizationRoleAssignment, error)
1541+
ListUsersWithRole(org string, roleID int) ([]github.OrganizationRoleAssignment, error)
1542+
}
1543+
1544+
// configureOrgRoles configures organization roles for teams and users
1545+
func configureOrgRoles(client orgRolesClient, orgName string, orgConfig org.Config, githubTeams map[string]github.Team, invitees sets.Set[string]) error {
1546+
// Note: Role configuration is validated at the start of configureOrg() before any API calls
1547+
1548+
// Get current organization roles from GitHub
1549+
roles, err := client.ListOrganizationRoles(orgName)
1550+
if err != nil {
1551+
return fmt.Errorf("failed to list organization roles: %w", err)
1552+
}
1553+
1554+
if len(roles) == 0 {
1555+
logrus.Debugf("No organization roles exist in %s", orgName)
1556+
return nil
1557+
}
1558+
1559+
// Create a map of configured role names (lowercase) for quick lookup
1560+
configuredRoles := make(map[string]org.Role)
1561+
for roleName, roleConfig := range orgConfig.Roles {
1562+
configuredRoles[strings.ToLower(roleName)] = roleConfig
1563+
}
1564+
1565+
unconfiguredCount := len(roles) - len(configuredRoles)
1566+
logrus.Debugf("Processing %d organization roles (%d configured, %d unconfigured to check for cleanup)",
1567+
len(roles), len(configuredRoles), unconfiguredCount)
1568+
1569+
var allErrors []error
1570+
1571+
// Iterate over ALL GitHub roles to handle both configured and unconfigured roles
1572+
for _, role := range roles {
1573+
roleNameLower := strings.ToLower(role.Name)
1574+
1575+
if roleConfig, isConfigured := configuredRoles[roleNameLower]; isConfigured {
1576+
// Role is in config - sync to match desired state
1577+
if err := configureRoleTeamAssignments(client, orgName, role.Name, role.ID, roleConfig.Teams, githubTeams); err != nil {
1578+
allErrors = append(allErrors, fmt.Errorf("failed to configure team assignments for role %s: %w", role.Name, err))
1579+
}
1580+
if err := configureRoleUserAssignments(client, orgName, role.Name, role.ID, roleConfig.Users, invitees); err != nil {
1581+
allErrors = append(allErrors, fmt.Errorf("failed to configure user assignments for role %s: %w", role.Name, err))
1582+
}
1583+
} else {
1584+
// Role is NOT in config - remove all assignments (clean up orphaned assignments)
1585+
logrus.Debugf("Role %q not in config, checking for assignments to clean up", role.Name)
1586+
if err := configureRoleTeamAssignments(client, orgName, role.Name, role.ID, []string{}, githubTeams); err != nil {
1587+
allErrors = append(allErrors, fmt.Errorf("failed to remove team assignments for unconfigured role %s: %w", role.Name, err))
1588+
}
1589+
if err := configureRoleUserAssignments(client, orgName, role.Name, role.ID, []string{}, invitees); err != nil {
1590+
allErrors = append(allErrors, fmt.Errorf("failed to remove user assignments for unconfigured role %s: %w", role.Name, err))
1591+
}
1592+
}
1593+
}
1594+
1595+
// Check if any configured roles don't exist in GitHub
1596+
for roleName := range orgConfig.Roles {
1597+
found := false
1598+
for _, role := range roles {
1599+
if strings.EqualFold(role.Name, roleName) {
1600+
found = true
1601+
break
1602+
}
1603+
}
1604+
if !found {
1605+
return fmt.Errorf("role %q does not exist in organization %s - create the role in GitHub before assigning it", roleName, orgName)
1606+
}
1607+
}
1608+
1609+
return utilerrors.NewAggregate(allErrors)
1610+
}
1611+
1612+
// configureRoleTeamAssignments configures team assignments for a specific role
1613+
func configureRoleTeamAssignments(client orgRolesClient, orgName, roleName string, roleID int, wantTeams []string, githubTeams map[string]github.Team) error {
1614+
// Get current team assignments for this role
1615+
currentTeams, err := client.ListTeamsWithRole(orgName, roleID)
1616+
if err != nil {
1617+
return fmt.Errorf("failed to list teams with role %s: %w", roleName, err)
1618+
}
1619+
1620+
// If we want no teams and have no teams, we're done
1621+
if len(wantTeams) == 0 && len(currentTeams) == 0 {
1622+
return nil
1623+
}
1624+
1625+
// Build a map of normalized team name to team slug for the teams we have in config
1626+
// This allows resolving "MyTeam" (config name) to "my-team" (GitHub slug)
1627+
normalizedTeams := make(map[string]string)
1628+
for name, team := range githubTeams {
1629+
normalizedTeams[strings.ToLower(name)] = team.Slug
1630+
}
1631+
1632+
// Create sets for comparison using slugs
1633+
wantSet := sets.New[string]()
1634+
for _, teamName := range wantTeams {
1635+
// Resolve config team name to slug
1636+
if slug, ok := normalizedTeams[strings.ToLower(teamName)]; ok {
1637+
wantSet.Insert(slug)
1638+
} else {
1639+
return fmt.Errorf("team %q referenced in role %q could not be resolved to a GitHub team slug - ensure the team exists in your teams configuration and was successfully created", teamName, roleName)
1640+
}
1641+
}
1642+
1643+
haveSet := sets.New[string]()
1644+
for _, team := range currentTeams {
1645+
haveSet.Insert(team.Slug)
1646+
}
1647+
1648+
// Teams to add
1649+
var errors []error
1650+
toAdd := wantSet.Difference(haveSet)
1651+
for teamSlug := range toAdd {
1652+
if err := client.AssignOrganizationRoleToTeam(orgName, teamSlug, roleID); err != nil {
1653+
errors = append(errors, fmt.Errorf("failed to assign role %s to team %s: %w", roleName, teamSlug, err))
1654+
logrus.WithError(err).Warnf("Failed to assign role %s to team %s", roleName, teamSlug)
1655+
} else {
1656+
logrus.Infof("Assigned role %s to team %s", roleName, teamSlug)
1657+
}
1658+
}
1659+
1660+
// Teams to remove
1661+
toRemove := haveSet.Difference(wantSet)
1662+
for teamSlug := range toRemove {
1663+
if err := client.RemoveOrganizationRoleFromTeam(orgName, teamSlug, roleID); err != nil {
1664+
errors = append(errors, fmt.Errorf("failed to remove role %s from team %s: %w", roleName, teamSlug, err))
1665+
logrus.WithError(err).Warnf("Failed to remove role %s from team %s", roleName, teamSlug)
1666+
} else {
1667+
logrus.Infof("Removed role %s from team %s", roleName, teamSlug)
1668+
}
1669+
}
1670+
1671+
return utilerrors.NewAggregate(errors)
1672+
}
1673+
1674+
// configureRoleUserAssignments configures user assignments for a specific role
1675+
func configureRoleUserAssignments(client orgRolesClient, orgName, roleName string, roleID int, wantUsers []string, invitees sets.Set[string]) error {
1676+
// Get current user assignments for this role
1677+
currentUsers, err := client.ListUsersWithRole(orgName, roleID)
1678+
if err != nil {
1679+
return fmt.Errorf("failed to list users with role %s: %w", roleName, err)
1680+
}
1681+
1682+
// Create maps to preserve original casing while comparing normalized usernames
1683+
wantMap := make(map[string]string) // normalized -> original
1684+
for _, user := range wantUsers {
1685+
wantMap[github.NormLogin(user)] = user
1686+
}
1687+
1688+
// Only consider DIRECT assignments when building haveMap.
1689+
// Users with "indirect" assignment have the role via team membership and should not be
1690+
// removed just because they're not in the users list - they keep the role through their team.
1691+
haveMap := make(map[string]string) // normalized -> original
1692+
for _, user := range currentUsers {
1693+
if user.Assignment == "indirect" {
1694+
logrus.Debugf("Skipping indirect role assignment for user %s (has role via team membership)", user.Login)
1695+
continue
1696+
}
1697+
haveMap[github.NormLogin(user.Login)] = user.Login
1698+
}
1699+
1700+
// If we want no direct users and have no direct users, we're done
1701+
if len(wantUsers) == 0 && len(haveMap) == 0 {
1702+
return nil
1703+
}
1704+
1705+
// Create sets for comparison with normalized usernames
1706+
wantSet := sets.New[string]()
1707+
for normalized := range wantMap {
1708+
wantSet.Insert(normalized)
1709+
}
1710+
haveSet := sets.New[string]()
1711+
for normalized := range haveMap {
1712+
haveSet.Insert(normalized)
1713+
}
1714+
1715+
// Users to add
1716+
var errors []error
1717+
toAdd := wantSet.Difference(haveSet)
1718+
for normalizedUser := range toAdd {
1719+
originalUser := wantMap[normalizedUser]
1720+
// Skip users who have pending org invitations - they must accept before we can assign roles
1721+
if invitees.Has(normalizedUser) {
1722+
logrus.Infof("Waiting for %s to accept org invitation before assigning role %s", originalUser, roleName)
1723+
continue
1724+
}
1725+
if err := client.AssignOrganizationRoleToUser(orgName, originalUser, roleID); err != nil {
1726+
errors = append(errors, fmt.Errorf("failed to assign role %s to user %s: %w", roleName, originalUser, err))
1727+
logrus.WithError(err).Warnf("Failed to assign role %s to user %s", roleName, originalUser)
1728+
} else {
1729+
logrus.Infof("Assigned role %s to user %s", roleName, originalUser)
1730+
}
1731+
}
1732+
1733+
// Users to remove
1734+
toRemove := haveSet.Difference(wantSet)
1735+
for normalizedUser := range toRemove {
1736+
originalUser := haveMap[normalizedUser]
1737+
if err := client.RemoveOrganizationRoleFromUser(orgName, originalUser, roleID); err != nil {
1738+
errors = append(errors, fmt.Errorf("failed to remove role %s from user %s: %w", roleName, originalUser, err))
1739+
logrus.WithError(err).Warnf("Failed to remove role %s from user %s", roleName, originalUser)
1740+
} else {
1741+
logrus.Infof("Removed role %s from user %s", roleName, originalUser)
1742+
}
1743+
}
1744+
1745+
return utilerrors.NewAggregate(errors)
1746+
}
1747+
14561748
// teamMembersClient can list/remove/update people to a team.
14571749
type teamMembersClient interface {
14581750
ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error)

0 commit comments

Comments
 (0)