Skip to content

Commit a508906

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

File tree

6 files changed

+1366
-0
lines changed

6 files changed

+1366
-0
lines changed

cmd/peribolos/main.go

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

@@ -932,6 +990,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
932990
return fmt.Errorf("failed to configure %s team %s repos: %w", orgName, name, err)
933991
}
934992
}
993+
994+
// Configure organization roles
995+
if !opt.fixOrgRoles {
996+
logrus.Infof("Skipping organization roles configuration")
997+
} else if err := configureOrgRoles(client, orgName, orgConfig, githubTeams); err != nil {
998+
return fmt.Errorf("failed to configure %s organization roles: %w", orgName, err)
999+
}
1000+
9351001
return nil
9361002
}
9371003

@@ -1453,6 +1519,190 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531519
return utilerrors.NewAggregate(updateErrors)
14541520
}
14551521

1522+
type orgRolesClient interface {
1523+
ListOrganizationRoles(org string) ([]github.OrganizationRole, error)
1524+
AssignOrganizationRoleToTeam(org, teamSlug string, roleID int) error
1525+
RemoveOrganizationRoleFromTeam(org, teamSlug string, roleID int) error
1526+
AssignOrganizationRoleToUser(org, user string, roleID int) error
1527+
RemoveOrganizationRoleFromUser(org, user string, roleID int) error
1528+
ListTeamsWithRole(org string, roleID int) ([]github.OrganizationRoleAssignment, error)
1529+
ListUsersWithRole(org string, roleID int) ([]github.OrganizationRoleAssignment, error)
1530+
}
1531+
1532+
// configureOrgRoles configures organization roles for teams and users
1533+
func configureOrgRoles(client orgRolesClient, orgName string, orgConfig org.Config, githubTeams map[string]github.Team) error {
1534+
if len(orgConfig.Roles) == 0 {
1535+
logrus.Debugf("No organization roles configured for %s", orgName)
1536+
return nil
1537+
}
1538+
1539+
// Validate role configuration (teams exist, users are org members)
1540+
if err := orgConfig.ValidateRoles(); err != nil {
1541+
return fmt.Errorf("invalid role configuration: %w", err)
1542+
}
1543+
1544+
// Get current organization roles
1545+
roles, err := client.ListOrganizationRoles(orgName)
1546+
if err != nil {
1547+
return fmt.Errorf("failed to list organization roles: %w", err)
1548+
}
1549+
1550+
// Create a map of role names (lowercase) to role IDs for case-insensitive matching
1551+
roleMap := make(map[string]int)
1552+
roleOriginalNames := make(map[string]string) // lowercase -> original name for error messages
1553+
for _, role := range roles {
1554+
lower := strings.ToLower(role.Name)
1555+
roleMap[lower] = role.ID
1556+
roleOriginalNames[lower] = role.Name
1557+
}
1558+
1559+
var allErrors []error
1560+
1561+
// Configure each role
1562+
for roleName, roleConfig := range orgConfig.Roles {
1563+
roleID, exists := roleMap[strings.ToLower(roleName)]
1564+
if !exists {
1565+
return fmt.Errorf("role %q does not exist in organization %s - create the role in GitHub before assigning it (available roles: check GitHub organization settings)", roleName, orgName)
1566+
}
1567+
1568+
// Configure team role assignments
1569+
if err := configureRoleTeamAssignments(client, orgName, roleName, roleID, roleConfig.Teams, githubTeams); err != nil {
1570+
allErrors = append(allErrors, fmt.Errorf("failed to configure team assignments for role %s: %w", roleName, err))
1571+
}
1572+
1573+
// Configure user role assignments
1574+
if err := configureRoleUserAssignments(client, orgName, roleName, roleID, roleConfig.Users); err != nil {
1575+
allErrors = append(allErrors, fmt.Errorf("failed to configure user assignments for role %s: %w", roleName, err))
1576+
}
1577+
}
1578+
1579+
return utilerrors.NewAggregate(allErrors)
1580+
}
1581+
1582+
// configureRoleTeamAssignments configures team assignments for a specific role
1583+
func configureRoleTeamAssignments(client orgRolesClient, orgName, roleName string, roleID int, wantTeams []string, githubTeams map[string]github.Team) error {
1584+
// Get current team assignments for this role
1585+
currentTeams, err := client.ListTeamsWithRole(orgName, roleID)
1586+
if err != nil {
1587+
return fmt.Errorf("failed to list teams with role %s: %w", roleName, err)
1588+
}
1589+
1590+
// If we want no teams and have no teams, we're done
1591+
if len(wantTeams) == 0 && len(currentTeams) == 0 {
1592+
return nil
1593+
}
1594+
1595+
// Build a map of normalized team name to team slug for the teams we have in config
1596+
// This allows resolving "MyTeam" (config name) to "my-team" (GitHub slug)
1597+
normalizedTeams := make(map[string]string)
1598+
for name, team := range githubTeams {
1599+
normalizedTeams[strings.ToLower(name)] = team.Slug
1600+
}
1601+
1602+
// Create sets for comparison using slugs
1603+
wantSet := sets.New[string]()
1604+
for _, teamName := range wantTeams {
1605+
// Resolve config team name to slug
1606+
if slug, ok := normalizedTeams[strings.ToLower(teamName)]; ok {
1607+
wantSet.Insert(slug)
1608+
} else {
1609+
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)
1610+
}
1611+
}
1612+
1613+
haveSet := sets.New[string]()
1614+
for _, team := range currentTeams {
1615+
haveSet.Insert(team.Slug)
1616+
}
1617+
1618+
// Teams to add
1619+
var errors []error
1620+
toAdd := wantSet.Difference(haveSet)
1621+
for teamSlug := range toAdd {
1622+
if err := client.AssignOrganizationRoleToTeam(orgName, teamSlug, roleID); err != nil {
1623+
errors = append(errors, fmt.Errorf("failed to assign role %s to team %s: %w", roleName, teamSlug, err))
1624+
logrus.WithError(err).Warnf("Failed to assign role %s to team %s", roleName, teamSlug)
1625+
} else {
1626+
logrus.Infof("Assigned role %s to team %s", roleName, teamSlug)
1627+
}
1628+
}
1629+
1630+
// Teams to remove
1631+
toRemove := haveSet.Difference(wantSet)
1632+
for teamSlug := range toRemove {
1633+
if err := client.RemoveOrganizationRoleFromTeam(orgName, teamSlug, roleID); err != nil {
1634+
errors = append(errors, fmt.Errorf("failed to remove role %s from team %s: %w", roleName, teamSlug, err))
1635+
logrus.WithError(err).Warnf("Failed to remove role %s from team %s", roleName, teamSlug)
1636+
} else {
1637+
logrus.Infof("Removed role %s from team %s", roleName, teamSlug)
1638+
}
1639+
}
1640+
1641+
return utilerrors.NewAggregate(errors)
1642+
}
1643+
1644+
// configureRoleUserAssignments configures user assignments for a specific role
1645+
func configureRoleUserAssignments(client orgRolesClient, orgName, roleName string, roleID int, wantUsers []string) error {
1646+
// Get current user assignments for this role
1647+
currentUsers, err := client.ListUsersWithRole(orgName, roleID)
1648+
if err != nil {
1649+
return fmt.Errorf("failed to list users with role %s: %w", roleName, err)
1650+
}
1651+
1652+
// If we want no users and have no users, we're done
1653+
if len(wantUsers) == 0 && len(currentUsers) == 0 {
1654+
return nil
1655+
}
1656+
1657+
// Create maps to preserve original casing while comparing normalized usernames
1658+
wantMap := make(map[string]string) // normalized -> original
1659+
for _, user := range wantUsers {
1660+
wantMap[github.NormLogin(user)] = user
1661+
}
1662+
1663+
haveMap := make(map[string]string) // normalized -> original
1664+
for _, user := range currentUsers {
1665+
haveMap[github.NormLogin(user.Login)] = user.Login
1666+
}
1667+
1668+
// Create sets for comparison with normalized usernames
1669+
wantSet := sets.New[string]()
1670+
for normalized := range wantMap {
1671+
wantSet.Insert(normalized)
1672+
}
1673+
haveSet := sets.New[string]()
1674+
for normalized := range haveMap {
1675+
haveSet.Insert(normalized)
1676+
}
1677+
1678+
// Users to add
1679+
var errors []error
1680+
toAdd := wantSet.Difference(haveSet)
1681+
for normalizedUser := range toAdd {
1682+
originalUser := wantMap[normalizedUser]
1683+
if err := client.AssignOrganizationRoleToUser(orgName, originalUser, roleID); err != nil {
1684+
errors = append(errors, fmt.Errorf("failed to assign role %s to user %s: %w", roleName, originalUser, err))
1685+
logrus.WithError(err).Warnf("Failed to assign role %s to user %s", roleName, originalUser)
1686+
} else {
1687+
logrus.Infof("Assigned role %s to user %s", roleName, originalUser)
1688+
}
1689+
}
1690+
1691+
// Users to remove
1692+
toRemove := haveSet.Difference(wantSet)
1693+
for normalizedUser := range toRemove {
1694+
originalUser := haveMap[normalizedUser]
1695+
if err := client.RemoveOrganizationRoleFromUser(orgName, originalUser, roleID); err != nil {
1696+
errors = append(errors, fmt.Errorf("failed to remove role %s from user %s: %w", roleName, originalUser, err))
1697+
logrus.WithError(err).Warnf("Failed to remove role %s from user %s", roleName, originalUser)
1698+
} else {
1699+
logrus.Infof("Removed role %s from user %s", roleName, originalUser)
1700+
}
1701+
}
1702+
1703+
return utilerrors.NewAggregate(errors)
1704+
}
1705+
14561706
// teamMembersClient can list/remove/update people to a team.
14571707
type teamMembersClient interface {
14581708
ListTeamMembersBySlug(org, teamSlug, role string) ([]github.TeamMember, error)

0 commit comments

Comments
 (0)