Skip to content

Commit 1aabf3d

Browse files
committed
peribolos: add org roles feature
1 parent 8287407 commit 1aabf3d

File tree

6 files changed

+1529
-0
lines changed

6 files changed

+1529
-0
lines changed

cmd/peribolos/main.go

Lines changed: 289 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

@@ -870,6 +928,13 @@ func orgInvitations(opt options, client inviteClient, orgName string) (sets.Set[
870928
}
871929

872930
func configureOrg(opt options, client github.Client, orgName string, orgConfig org.Config) error {
931+
// Validate role configuration early (before any API calls) if we're going to configure roles
932+
if opt.fixOrgRoles {
933+
if err := orgConfig.ValidateRoles(); err != nil {
934+
return fmt.Errorf("invalid role configuration: %w", err)
935+
}
936+
}
937+
873938
// Ensure that metadata is configured correctly.
874939
if !opt.fixOrg {
875940
logrus.Infof("Skipping org metadata configuration")
@@ -889,6 +954,15 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
889954
return fmt.Errorf("failed to configure %s members: %w", orgName, err)
890955
}
891956

957+
// Re-fetch invitees after configuring org members, as new invitations may have been sent.
958+
// This is needed for role assignment - users with pending invitations cannot be assigned roles.
959+
if opt.fixOrgRoles && opt.fixOrgMembers {
960+
invitees, err = orgInvitations(opt, client, orgName)
961+
if err != nil {
962+
return fmt.Errorf("failed to re-fetch %s invitations: %w", orgName, err)
963+
}
964+
}
965+
892966
// Create repositories in the org
893967
if !opt.fixRepos {
894968
logrus.Info("Skipping org repositories configuration")
@@ -932,6 +1006,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
9321006
return fmt.Errorf("failed to configure %s team %s repos: %w", orgName, name, err)
9331007
}
9341008
}
1009+
1010+
// Configure organization roles
1011+
if !opt.fixOrgRoles {
1012+
logrus.Infof("Skipping organization roles configuration")
1013+
} else if err := configureOrgRoles(client, orgName, orgConfig, githubTeams, invitees); err != nil {
1014+
return fmt.Errorf("failed to configure %s organization roles: %w", orgName, err)
1015+
}
1016+
9351017
return nil
9361018
}
9371019

@@ -1453,6 +1535,213 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531535
return utilerrors.NewAggregate(updateErrors)
14541536
}
14551537

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

0 commit comments

Comments
 (0)