Skip to content

Commit 8821599

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

File tree

6 files changed

+1341
-0
lines changed

6 files changed

+1341
-0
lines changed

cmd/peribolos/main.go

Lines changed: 254 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")
@@ -932,6 +997,14 @@ func configureOrg(opt options, client github.Client, orgName string, orgConfig o
932997
return fmt.Errorf("failed to configure %s team %s repos: %w", orgName, name, err)
933998
}
934999
}
1000+
1001+
// Configure organization roles
1002+
if !opt.fixOrgRoles {
1003+
logrus.Infof("Skipping organization roles configuration")
1004+
} else if err := configureOrgRoles(client, orgName, orgConfig, githubTeams); err != nil {
1005+
return fmt.Errorf("failed to configure %s organization roles: %w", orgName, err)
1006+
}
1007+
9351008
return nil
9361009
}
9371010

@@ -1453,6 +1526,187 @@ func configureTeamRepos(client teamRepoClient, githubTeams map[string]github.Tea
14531526
return utilerrors.NewAggregate(updateErrors)
14541527
}
14551528

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

0 commit comments

Comments
 (0)