Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,13 +396,17 @@ GithubOrganization labels:
| `repo-guard.cloudoperators.dev/removeTeam` | "true"/"false" | Allows the controller to remove teams that are out of policy. | Disabled (must be "true" to remove) |
| `repo-guard.cloudoperators.dev/addRepositoryTeam` | "true"/"false" | Allows setting default team permissions on repositories. | Disabled (must be "true" to add) |
| `repo-guard.cloudoperators.dev/removeRepositoryTeam` | "true"/"false" | Allows removing default team permissions from repositories. | Disabled (must be "true" to remove) |
| `repo-guard.cloudoperators.dev/removeOrganizationMember` | "true"/"false" | Allows the controller to remove org members who are not in any GitHub team. All team-member lists must be fetched successfully; if any single fetch fails (non-rate-limit error), no removals are generated (safety rail). If the organization has no teams, no removals are generated either. See `spec.protectedMembers` to exempt specific logins. | Disabled (must be "true" to remove) |
| `repo-guard.cloudoperators.dev/removeRepositoryDirectCollaborator` | "true"/"false" | Allows the controller to remove direct repository collaborators who are not covered by any team with access to that repository. See `spec.protectedMembers` to exempt specific logins. | Disabled (must be "true" to remove) |
| `repo-guard.cloudoperators.dev/dryRun` | "true"/"false" | When "true", no changes are made on GitHub; status shows planned operations. | "false" |
| `repo-guard.cloudoperators.dev/cleanOperations` | "complete"/"failed" | When in dryRun, set to "complete" to purge completed operations from status, or "failed" to purge failed ones. The label is removed automatically after cleanup. | Not set |
| `repo-guard.cloudoperators.dev/failedTTL` | Go duration (e.g., 1h, 30m) | Automatically clears failed operations and failed status after the duration since last status timestamp. | Not set |
| `repo-guard.cloudoperators.dev/completedTTL` | Go duration (e.g., 24h) | Automatically clears completed operations after the duration since last status timestamp. | Not set |

Note: GithubOrganization also supports the annotation `repo-guard.cloudoperators.dev/skipDefaultRepositoryTeams` to skip applying default team permissions on a comma-separated list of repositories.

Note: `spec.protectedMembers` (a list of GitHub logins) exempts specific accounts from both `removeOrganizationMember` and `removeRepositoryDirectCollaborator`. Use it to protect bot accounts, the GitHub App installation user, and any emergency escape-hatch accounts.

GithubTeam labels:

| Key | Allowed values | Description | Default |
Expand Down
261 changes: 258 additions & 3 deletions api/v1/githuborganization_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ type GithubOrganizationSpec struct {
DefaultPublicRepositoryTeams []GithubTeamWithPermission `json:"defaultPublicRepositoryTeams,omitempty"`
DefaultPrivateRepositoryTeams []GithubTeamWithPermission `json:"defaultPrivateRepositoryTeams,omitempty"`

// ProtectedMembers is a list of GitHub logins that must never be removed by
// the removeOrganizationMember or removeRepositoryDirectCollaborator features.
// Use this to protect bot accounts (including the GitHub App installation user)
// or human escape-hatch accounts.
// +optional
ProtectedMembers []string `json:"protectedMembers,omitempty"`

InstallationID int64 `json:"installationID,omitempty"`
}

Expand Down Expand Up @@ -112,10 +119,37 @@ type GithubOrganizationStatus struct {
}

type GithubOrganizationStatusOperations struct {
OrganizationOwnerOperations []GithubUserOperation `json:"organizationOwnerOperations,omitempty"`
GithubTeamOperations []GithubTeamOperation `json:"teamOperations,omitempty"`
RepositoryTeamOperations []GithubRepoTeamOperation `json:"repoOperations,omitempty"`
OrganizationOwnerOperations []GithubUserOperation `json:"organizationOwnerOperations,omitempty"`
OrganizationMemberOperations []GithubUserOperation `json:"organizationMemberOperations,omitempty"`
GithubTeamOperations []GithubTeamOperation `json:"teamOperations,omitempty"`
RepositoryTeamOperations []GithubRepoTeamOperation `json:"repoOperations,omitempty"`
RepositoryCollaboratorOperations []GithubRepoUserOperation `json:"repoCollaboratorOperations,omitempty"`
}

// GithubRepoUserOperation represents a pending/completed operation on a direct
// repository collaborator (e.g., removing a non-team direct collaborator).
type GithubRepoUserOperation struct {
Operation GithubRepoUserOperationType `json:"operation,omitempty"`
Repo string `json:"repo,omitempty"`
User string `json:"user,omitempty"`
State GithubRepoUserOperationState `json:"state,omitempty"`
Error string `json:"error,omitempty"`
Timestamp metav1.Time `json:"timestamp,omitempty"`
}
Comment thread
onuryilmaz marked this conversation as resolved.

type GithubRepoUserOperationType string

const GithubRepoUserOperationTypeRemove GithubRepoUserOperationType = "remove"

type GithubRepoUserOperationState string

const (
GithubRepoUserOperationStatePending GithubRepoUserOperationState = "pending"
GithubRepoUserOperationStateComplete GithubRepoUserOperationState = "complete"
GithubRepoUserOperationStateFailed GithubRepoUserOperationState = "failed"
GithubRepoUserOperationStateSkipped GithubRepoUserOperationState = "skipped"
)

type GithubOrganizationState string

const (
Expand Down Expand Up @@ -591,6 +625,12 @@ func (g GithubOrganization) PendingOperationsFound() bool {
}
}

for _, op := range g.Status.Operations.OrganizationMemberOperations {
if op.State == GithubUserOperationStatePending {
return true
}
}

for _, op := range g.Status.Operations.RepositoryTeamOperations {
if op.State == GithubRepoTeamOperationStatePending {
return true
Expand All @@ -602,6 +642,12 @@ func (g GithubOrganization) PendingOperationsFound() bool {
}
}

for _, op := range g.Status.Operations.RepositoryCollaboratorOperations {
if op.State == GithubRepoUserOperationStatePending {
return true
}
}

return false

}
Expand All @@ -614,6 +660,12 @@ func (g GithubOrganization) FailedOperationsFound() bool {
}
}

for _, op := range g.Status.Operations.OrganizationMemberOperations {
if op.State == GithubUserOperationStateFailed {
return true
}
}

for _, op := range g.Status.Operations.RepositoryTeamOperations {
if op.State == GithubRepoTeamOperationStateFailed {
return true
Expand All @@ -625,6 +677,12 @@ func (g GithubOrganization) FailedOperationsFound() bool {
}
}

for _, op := range g.Status.Operations.RepositoryCollaboratorOperations {
if op.State == GithubRepoUserOperationStateFailed {
return true
}
}

return false

}
Expand All @@ -643,6 +701,15 @@ func (g GithubOrganization) CleanCompletedOperations() (GithubOrganizationStatus
}
}

newOrganizationMemberOperations := []GithubUserOperation{}
for _, op := range g.Status.Operations.OrganizationMemberOperations {
if op.State == GithubUserOperationStateComplete {
cleaned = true
} else {
newOrganizationMemberOperations = append(newOrganizationMemberOperations, op)
}
}

newRepositoryTeamOperations := []GithubRepoTeamOperation{}
for _, op := range g.Status.Operations.RepositoryTeamOperations {

Expand All @@ -662,9 +729,20 @@ func (g GithubOrganization) CleanCompletedOperations() (GithubOrganizationStatus
}
}

newRepositoryCollaboratorOperations := []GithubRepoUserOperation{}
for _, op := range g.Status.Operations.RepositoryCollaboratorOperations {
if op.State == GithubRepoUserOperationStateComplete {
cleaned = true
} else {
newRepositoryCollaboratorOperations = append(newRepositoryCollaboratorOperations, op)
}
}

newStatus.Operations.OrganizationOwnerOperations = newOrganizationOwnerOperations
newStatus.Operations.OrganizationMemberOperations = newOrganizationMemberOperations
newStatus.Operations.RepositoryTeamOperations = newRepositoryTeamOperations
newStatus.Operations.GithubTeamOperations = newGithubTeamOperations
newStatus.Operations.RepositoryCollaboratorOperations = newRepositoryCollaboratorOperations

return *newStatus, cleaned

Expand All @@ -684,6 +762,15 @@ func (g GithubOrganization) CleanFailedOperations() (GithubOrganizationStatus, b
}
}

newOrganizationMemberOperations := []GithubUserOperation{}
for _, op := range g.Status.Operations.OrganizationMemberOperations {
if op.State == GithubUserOperationStateFailed {
cleaned = true
} else {
newOrganizationMemberOperations = append(newOrganizationMemberOperations, op)
}
}

newRepositoryTeamOperations := []GithubRepoTeamOperation{}
for _, op := range g.Status.Operations.RepositoryTeamOperations {

Expand All @@ -703,9 +790,20 @@ func (g GithubOrganization) CleanFailedOperations() (GithubOrganizationStatus, b
}
}

newRepositoryCollaboratorOperations := []GithubRepoUserOperation{}
for _, op := range g.Status.Operations.RepositoryCollaboratorOperations {
if op.State == GithubRepoUserOperationStateFailed {
cleaned = true
} else {
newRepositoryCollaboratorOperations = append(newRepositoryCollaboratorOperations, op)
}
}

newStatus.Operations.OrganizationOwnerOperations = newOrganizationOwnerOperations
newStatus.Operations.OrganizationMemberOperations = newOrganizationMemberOperations
newStatus.Operations.RepositoryTeamOperations = newRepositoryTeamOperations
newStatus.Operations.GithubTeamOperations = newGithubTeamOperations
newStatus.Operations.RepositoryCollaboratorOperations = newRepositoryCollaboratorOperations

return *newStatus, cleaned

Expand Down Expand Up @@ -776,3 +874,160 @@ func (g GithubOrganization) RepoChangeCalculator(exceptions []GithubTeamReposito
}

const GITHUB_ORG_ANNOTATION_SKIP_DEFAULT_TEAM_REPOSITORY = "repo-guard.cloudoperators.dev/skipDefaultRepositoryTeams"

// OrganizationMemberChangeCalculator computes remove operations for org members
// that are not in any GitHub team, not an org owner, and not in the protected list.
//
// Safety rail: if teamObservationsCount == 0 (no teams were successfully observed),
// no remove operations are generated regardless of the member list. This prevents
// mass-removal when the GitHub API is temporarily unavailable for team data.
func (g *GithubOrganization) OrganizationMemberChangeCalculator(
orgMembers []string,
orgOwners []string,
teamMembers map[string]struct{},
protected []string,
teamObservationsCount int,
) (bool, GithubOrganizationStatus) {
newStatus := g.Status.DeepCopy()

// Safety rail: no team data observed — skip generating remove ops
if teamObservationsCount == 0 {
return false, *newStatus
}

ownerSet := make(map[string]struct{}, len(orgOwners))
for _, o := range orgOwners {
ownerSet[strings.ToLower(o)] = struct{}{}
}

protectedSet := make(map[string]struct{}, len(protected))
for _, p := range protected {
protectedSet[strings.ToLower(p)] = struct{}{}
}

// Build set of users with existing pending ops to avoid duplicates
pendingSet := make(map[string]struct{})
for _, op := range g.Status.Operations.OrganizationMemberOperations {
if op.State == GithubUserOperationStatePending || op.State == GithubUserOperationStateFailed {
pendingSet[strings.ToLower(op.User)] = struct{}{}
}
}

changed := false
for _, member := range orgMembers {
login := strings.ToLower(member)
// skip org owners
if _, isOwner := ownerSet[login]; isOwner {
continue
}
// skip protected members
if _, isProt := protectedSet[login]; isProt {
continue
}
// skip if already in a team
if _, inTeam := teamMembers[login]; inTeam {
continue
}
// skip if already has a pending or failed op
if _, hasPending := pendingSet[login]; hasPending {
continue
}
newStatus.Operations.OrganizationMemberOperations = append(
newStatus.Operations.OrganizationMemberOperations,
GithubUserOperation{
Operation: GithubUserOperationTypeRemove,
User: member,
State: GithubUserOperationStatePending,
Timestamp: metav1.Now(),
},
)
pendingSet[login] = struct{}{}
changed = true
}
Comment thread
onuryilmaz marked this conversation as resolved.

if changed {
newStatus.OrganizationStatus = GithubOrganizationStatePendingOperations
newStatus.OrganizationStatusError = ""
newStatus.OrganizationStatusTimestamp = metav1.Now()
}
Comment thread
onuryilmaz marked this conversation as resolved.

return changed, *newStatus
}

// RepositoryDirectCollaboratorChangeCalculator computes remove operations for
// direct repository collaborators that are not a member of any team with access
// to that repo, not an org owner, and not in the protected list.
func (g *GithubOrganization) RepositoryDirectCollaboratorChangeCalculator(
repoCollaborators map[string][]string,
repoTeamMembers map[string]map[string]struct{},
orgOwners []string,
protected []string,
) (bool, GithubOrganizationStatus) {
newStatus := g.Status.DeepCopy()

ownerSet := make(map[string]struct{}, len(orgOwners))
for _, o := range orgOwners {
ownerSet[strings.ToLower(o)] = struct{}{}
}

protectedSet := make(map[string]struct{}, len(protected))
for _, p := range protected {
protectedSet[strings.ToLower(p)] = struct{}{}
}

// Build set of (repo, user) pairs with existing pending ops to avoid duplicates
type repoUser struct{ repo, user string }
pendingSet := make(map[repoUser]struct{})
for _, op := range g.Status.Operations.RepositoryCollaboratorOperations {
if op.State == GithubRepoUserOperationStatePending || op.State == GithubRepoUserOperationStateFailed {
pendingSet[repoUser{repo: op.Repo, user: strings.ToLower(op.User)}] = struct{}{}
}
}

changed := false
for repo, collaborators := range repoCollaborators {
teamMembersForRepo := repoTeamMembers[repo]

for _, collab := range collaborators {
login := strings.ToLower(collab)

// skip org owners
if _, isOwner := ownerSet[login]; isOwner {
continue
}
// skip protected members
if _, isProt := protectedSet[login]; isProt {
continue
}
// skip if in a team that has access to this repo
if _, inTeam := teamMembersForRepo[login]; inTeam {
continue
}
// skip if already has a pending or failed op
if _, hasPending := pendingSet[repoUser{repo: repo, user: login}]; hasPending {
continue
}

newStatus.Operations.RepositoryCollaboratorOperations = append(
newStatus.Operations.RepositoryCollaboratorOperations,
GithubRepoUserOperation{
Operation: GithubRepoUserOperationTypeRemove,
Repo: repo,
User: collab,
State: GithubRepoUserOperationStatePending,
Timestamp: metav1.Now(),
},
)
pendingSet[repoUser{repo: repo, user: login}] = struct{}{}
changed = true
}
Comment thread
onuryilmaz marked this conversation as resolved.
}

if changed {
newStatus.OrganizationStatus = GithubOrganizationStatePendingOperations
newStatus.OrganizationStatusError = ""
newStatus.OrganizationStatusTimestamp = metav1.Now()
}
Comment thread
onuryilmaz marked this conversation as resolved.

return changed, *newStatus
}
Loading
Loading