diff --git a/audit-access/.gitignore b/audit-access/.gitignore new file mode 100644 index 0000000..5d1e554 --- /dev/null +++ b/audit-access/.gitignore @@ -0,0 +1,18 @@ +# Binary +audit-access + +# Test coverage +*.out +*.test + +# Build artifacts +dist/ +build/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.claude diff --git a/audit-access/README.md b/audit-access/README.md new file mode 100644 index 0000000..54d814c --- /dev/null +++ b/audit-access/README.md @@ -0,0 +1,173 @@ +# GitHub Organization Access Audit Tool + +A CLI tool to audit GitHub organization membership and repository access permissions. This tool helps with governance and security auditing by providing visibility into who has elevated access across all repositories in an organization. + +## Purpose + +This tool is designed to help the cert-manager project (and other organizations) maintain proper governance by regularly auditing access levels. It lists all users who have triage, write, maintain, or admin permissions across repositories, excluding read-only access. + +## Prerequisites + +- Go 1.21 or later +- GitHub Personal Access Token with appropriate scopes + +## GitHub Token Setup + +You need a GitHub Personal Access Token with the following scopes: + +- `repo` - Full control of private repositories (required to read collaborators) +- `read:org` - Read org and team membership, read org projects + +The token can be created in: https://github.com/settings/personal-access-tokens + +It must be "owned" by the org you're auditing, and you must set the token as an environment variable: + +```bash +export GITHUB_TOKEN=ghp_your_token_here +``` + +## Usage + +### Basic Usage + +Audit all repositories in an organization: + +```bash +export GITHUB_TOKEN=ghp_your_token_here +./audit-access org cert-manager +``` + +### Command-line Options + +```bash +./audit-access org [organization-name] [flags] + +Flags: + -f, --format string Output format: table, json, or csv (default "table") + -p, --permission string Filter by permission level: triage, write, maintain, or admin + -r, --repo string Audit specific repository instead of all + -a, --include-archived Include archived repositories + -s, --sort-by string Sort output by 'user' or 'repo' (default "user") + -h, --help Help for org + --version Show version information +``` + +### Examples + +**Audit with table output (default):** +```bash +./audit-access org cert-manager +``` + +**Export to JSON:** +```bash +./audit-access org cert-manager --format json > access-report.json +``` + +**Export to CSV:** +```bash +./audit-access org cert-manager --format csv > access-report.csv +``` + +**Filter by permission level:** +```bash +# Show only users with admin access +./audit-access org cert-manager --permission admin + +# Show only users with write access +./audit-access org cert-manager --permission write +``` + +**Audit a specific repository:** +```bash +./audit-access org cert-manager --repo trust-manager +``` + +**Include archived repositories:** +```bash +./audit-access org cert-manager --include-archived +``` + +**Sort by repository instead of user:** +```bash +# Group results by repository (default is by user) +./audit-access org cert-manager --sort-by repo +``` + +## Output Formats + +### Table (default) + +``` ++----------+------------------+------------+ +| USERNAME | REPOSITORY | PERMISSION | ++----------+------------------+------------+ +| alice | cert-manager | admin | +| alice | trust-manager | write | +| bob | cert-manager | maintain | +| charlie | approver-policy | triage | ++----------+------------------+------------+ +``` + +### JSON + +**Sorted by user (default):** +```json +[ + { + "username": "alice", + "repositories": [ + {"name": "cert-manager", "permission": "admin"}, + {"name": "trust-manager", "permission": "write"} + ] + }, + { + "username": "bob", + "repositories": [ + {"name": "cert-manager", "permission": "maintain"} + ] + } +] +``` + +**Sorted by repository (`--sort-by repo`):** +```json +[ + { + "repository": "cert-manager", + "users": [ + {"username": "alice", "permission": "admin"}, + {"username": "bob", "permission": "maintain"} + ] + }, + { + "repository": "trust-manager", + "users": [ + {"username": "alice", "permission": "write"} + ] + } +] +``` + +### CSV + +```csv +Username,Repository,Permission +alice,cert-manager,admin +alice,trust-manager,write +bob,cert-manager,maintain +charlie,approver-policy,triage +``` + +## Troubleshooting + +### Rate Limiting + +GitHub API has rate limits (5,000 requests/hour for authenticated requests). The tool displays your current rate limit status when it starts. If you hit the rate limit, wait until the reset time shown in the error message. + +### Permission Denied + +If you see a 403 or 404 error: + +1. Verify your token has the required scopes (`repo` and `read:org`) +2. Verify you have sufficient access to the organization diff --git a/audit-access/cmd/audit.go b/audit-access/cmd/audit.go new file mode 100644 index 0000000..4ebd043 --- /dev/null +++ b/audit-access/cmd/audit.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "context" + "fmt" + "log/slog" + "time" + + ghclient "github.com/cert-manager/community/security/audit-access/pkg/github" + "github.com/cert-manager/community/security/audit-access/pkg/report" + "github.com/spf13/cobra" +) + +var ( + format string + permissionFilter string + repoFilter string + includeArchived bool + sortBy string +) + +func Execute(version string, log *slog.Logger) error { + rootCmd := &cobra.Command{ + Use: "audit-access", + Short: "Audit GitHub organization access permissions", + Long: `A CLI tool to audit GitHub organization membership and repository access. +Lists all users with triage, write, maintain, or admin permissions across repositories.`, + Version: version, + } + + auditCmd := &cobra.Command{ + Use: "org [organization-name]", + Short: "Audit access permissions for a GitHub organization", + Long: `Audits all repositories in a GitHub organization and lists users with elevated permissions. + +Requires GITHUB_TOKEN environment variable with 'repo' and 'read:org' scopes.`, + Args: cobra.ExactArgs(1), + RunE: runAudit(log), + } + + auditCmd.Flags().StringVarP(&format, "format", "f", "table", "Output format (table, json, csv)") + auditCmd.Flags().StringVarP(&permissionFilter, "permission", "p", "", "Filter by permission level (triage, write, maintain, admin)") + auditCmd.Flags().StringVarP(&repoFilter, "repo", "r", "", "Audit specific repository instead of all") + auditCmd.Flags().BoolVarP(&includeArchived, "include-archived", "a", false, "Include archived repositories") + auditCmd.Flags().StringVarP(&sortBy, "sort-by", "s", "user", "Sort output by 'user' or 'repo'") + + rootCmd.AddCommand(auditCmd) + return rootCmd.Execute() +} + +func runAudit(logger *slog.Logger) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + org := args[0] + + // Validate permission filter if provided + if permissionFilter != "" { + validPerms := map[string]bool{ + "triage": true, + "write": true, + "maintain": true, + "admin": true, + } + if !validPerms[permissionFilter] { + return fmt.Errorf("invalid permission filter: %s (valid: triage, write, maintain, admin)", permissionFilter) + } + } + + // Validate sort-by option + if sortBy != "user" && sortBy != "repo" { + return fmt.Errorf("invalid sort-by option: %s (valid: user, repo)", sortBy) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) + defer cancel() + + // Create GitHub client + logger.Info("initializing GitHub client") + client, err := ghclient.NewClient(ctx, logger) + if err != nil { + return err + } + + // Validate token + if err := client.ValidateToken(ctx); err != nil { + return err + } + + // Build access map + userAccesses, err := client.BuildAccessMap(ctx, org, repoFilter, permissionFilter, includeArchived) + if err != nil { + return err + } + + if len(userAccesses) == 0 { + logger.Info("no users found with elevated permissions") + return nil + } + + logger.Info("found users with elevated permissions", "count", len(userAccesses)) + + // Format and output results + return report.Format(userAccesses, format, sortBy) + } +} diff --git a/audit-access/go.mod b/audit-access/go.mod new file mode 100644 index 0000000..f53d2be --- /dev/null +++ b/audit-access/go.mod @@ -0,0 +1,15 @@ +module github.com/cert-manager/community/security/audit-access + +go 1.26.1 + +require ( + github.com/google/go-github/v84 v84.0.0 + github.com/spf13/cobra v1.10.2 + golang.org/x/oauth2 v0.36.0 +) + +require ( + github.com/google/go-querystring v1.2.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/audit-access/go.sum b/audit-access/go.sum new file mode 100644 index 0000000..de484d0 --- /dev/null +++ b/audit-access/go.sum @@ -0,0 +1,19 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v84 v84.0.0 h1:I/0Xn5IuChMe8TdmI2bbim5nyhaRFJ7DEdzmD2w+yVA= +github.com/google/go-github/v84 v84.0.0/go.mod h1:WwYL1z1ajRdlaPszjVu/47x1L0PXukJBn73xsiYrRRQ= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/audit-access/main.go b/audit-access/main.go new file mode 100644 index 0000000..a46b478 --- /dev/null +++ b/audit-access/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/cert-manager/community/security/audit-access/cmd" +) + +var version = "dev" + +func main() { + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + if err := cmd.Execute(version, logger); err != nil { + logger.Error("execution failed", "error", err) + os.Exit(1) + } +} diff --git a/audit-access/pkg/github/client.go b/audit-access/pkg/github/client.go new file mode 100644 index 0000000..931d0ee --- /dev/null +++ b/audit-access/pkg/github/client.go @@ -0,0 +1,114 @@ +package github + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "time" + + "github.com/google/go-github/v84/github" + "golang.org/x/oauth2" +) + +type Client struct { + client *github.Client + token string + logger *slog.Logger +} + +// NewClient creates a new GitHub client with authentication +func NewClient(ctx context.Context, logger *slog.Logger) (*Client, error) { + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return nil, fmt.Errorf("GITHUB_TOKEN environment variable not set") + } + + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + + client := github.NewClient(tc) + + return &Client{ + client: client, + token: token, + logger: logger, + }, nil +} + +// ValidateToken validates the GitHub token and checks for required permissions +func (c *Client) ValidateToken(ctx context.Context) error { + // Test authentication by getting the current user + user, resp, err := c.client.Users.Get(ctx, "") + if err != nil { + if resp != nil && resp.StatusCode == 401 { + return fmt.Errorf("GitHub token has expired or is invalid") + } + return fmt.Errorf("failed to authenticate with GitHub: %w", err) + } + + // Check rate limit + rateLimit, _, err := c.client.RateLimit.Get(ctx) + if err != nil { + return fmt.Errorf("failed to check rate limit: %w", err) + } + + if rateLimit.Core.Remaining == 0 { + resetTime := rateLimit.Core.Reset.Time + return fmt.Errorf("rate limit exceeded, resets at %s", resetTime.Format(time.RFC3339)) + } + + // Check token scopes + scopes := resp.Header.Get("X-OAuth-Scopes") + if scopes == "" { + // If no scopes header, the token might be a classic token without specific scopes + // We'll proceed and let API calls fail if permissions are insufficient + } + + c.logger.Info("authenticated", + "user", user.GetLogin(), + "rate_limit_remaining", rateLimit.Core.Remaining, + "rate_limit_total", rateLimit.Core.Limit, + "rate_limit_resets", rateLimit.Core.Reset.Time.Format("15:04:05")) + + return nil +} + +// handleRateLimit checks rate limit and returns helpful error if exceeded +func (c *Client) handleRateLimit(resp *github.Response, err error) error { + if resp != nil && resp.Rate.Remaining == 0 { + resetTime := resp.Rate.Reset.Time + return fmt.Errorf("rate limit exceeded, resets at %s", resetTime.Format(time.RFC3339)) + } + return err +} + +// GetClient returns the underlying GitHub client +func (c *Client) GetClient() *github.Client { + return c.client +} + +// checkResponse checks the HTTP response and returns helpful error messages +func checkResponse(resp *http.Response, err error) error { + if err != nil { + return err + } + + switch resp.StatusCode { + case 401: + return fmt.Errorf("authentication failed: token is invalid or expired") + case 403: + if resp.Header.Get("X-RateLimit-Remaining") == "0" { + resetTime := resp.Header.Get("X-RateLimit-Reset") + return fmt.Errorf("rate limit exceeded, resets at %s", resetTime) + } + return fmt.Errorf("forbidden: token lacks required permissions (needs 'repo' and 'read:org' scopes)") + case 404: + return fmt.Errorf("not found: organization or repository does not exist, or token lacks access") + } + + return nil +} diff --git a/audit-access/pkg/github/permissions.go b/audit-access/pkg/github/permissions.go new file mode 100644 index 0000000..6a578c3 --- /dev/null +++ b/audit-access/pkg/github/permissions.go @@ -0,0 +1,182 @@ +package github + +import ( + "context" + "fmt" + + "github.com/google/go-github/v84/github" +) + +// RepoPermission represents a user's permission on a specific repository +type RepoPermission struct { + RepoName string `json:"name"` + Permission string `json:"permission"` +} + +// UserAccess represents a user and their repository access +type UserAccess struct { + Username string `json:"username"` + Repos []RepoPermission `json:"repositories"` +} + +// FetchOrgRepositories fetches all repositories in an organization +func (c *Client) FetchOrgRepositories(ctx context.Context, org string, includeArchived bool) ([]*github.Repository, error) { + c.logger.Info("fetching repositories", "organization", org) + + var allRepos []*github.Repository + opt := &github.RepositoryListByOrgOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + for { + repos, resp, err := c.client.Repositories.ListByOrg(ctx, org, opt) + if err != nil { + return nil, c.handleRateLimit(resp, fmt.Errorf("failed to fetch repositories: %w", err)) + } + + for _, repo := range repos { + // Skip archived repos unless explicitly requested + if !includeArchived && repo.GetArchived() { + continue + } + allRepos = append(allRepos, repo) + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + c.logger.Info("found repositories", "count", len(allRepos)) + return allRepos, nil +} + +// FetchRepoCollaborators fetches all collaborators for a specific repository +func (c *Client) FetchRepoCollaborators(ctx context.Context, org, repo string) ([]*github.User, map[string]string, error) { + var allCollaborators []*github.User + permissions := make(map[string]string) + + opt := &github.ListCollaboratorsOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + for { + collaborators, resp, err := c.client.Repositories.ListCollaborators(ctx, org, repo, opt) + if err != nil { + return nil, nil, c.handleRateLimit(resp, fmt.Errorf("failed to fetch collaborators for %s: %w", repo, err)) + } + + for _, collab := range collaborators { + allCollaborators = append(allCollaborators, collab) + // Store the permission level for this user + if collab.Permissions != nil { + perm := getPermissionLevel(collab.Permissions) + permissions[collab.GetLogin()] = perm + } + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return allCollaborators, permissions, nil +} + +// getPermissionLevel determines the highest permission level from the permissions struct +func getPermissionLevel(perms *github.RepositoryPermissions) string { + if perms.GetAdmin() { + return "admin" + } + if perms.GetMaintain() { + return "maintain" + } + if perms.GetPush() { + return "write" + } + if perms.GetTriage() { + return "triage" + } + if perms.GetPull() { + return "read" + } + return "unknown" +} + +// BuildAccessMap aggregates user access across all repositories +func (c *Client) BuildAccessMap(ctx context.Context, org string, repoFilter string, permFilter string, includeArchived bool) ([]UserAccess, error) { + var repos []*github.Repository + var err error + + if repoFilter != "" { + // Fetch single repository + c.logger.Info("fetching repository", "organization", org, "repository", repoFilter) + repo, resp, err := c.client.Repositories.Get(ctx, org, repoFilter) + if err != nil { + return nil, c.handleRateLimit(resp, fmt.Errorf("failed to fetch repository: %w", err)) + } + repos = []*github.Repository{repo} + } else { + // Fetch all repositories + repos, err = c.FetchOrgRepositories(ctx, org, includeArchived) + if err != nil { + return nil, err + } + } + + // Map of username -> UserAccess + userAccessMap := make(map[string]*UserAccess) + + for i, repo := range repos { + c.logger.Info("processing repository", "progress", fmt.Sprintf("%d/%d", i+1, len(repos)), "repository", repo.GetName()) + + collaborators, permissions, err := c.FetchRepoCollaborators(ctx, org, repo.GetName()) + if err != nil { + c.logger.Warn("failed to fetch collaborators", "repository", repo.GetName(), "error", err) + continue + } + + for _, collab := range collaborators { + username := collab.GetLogin() + permission := permissions[username] + + // Filter by permission level if specified + if permFilter != "" && permission != permFilter { + continue + } + + // Skip read-only access (we only want triage, write, maintain, admin) + if permission == "read" || permission == "unknown" { + continue + } + + // Add or update user access + if userAccess, exists := userAccessMap[username]; exists { + userAccess.Repos = append(userAccess.Repos, RepoPermission{ + RepoName: repo.GetName(), + Permission: permission, + }) + } else { + userAccessMap[username] = &UserAccess{ + Username: username, + Repos: []RepoPermission{ + { + RepoName: repo.GetName(), + Permission: permission, + }, + }, + } + } + } + } + + // Convert map to slice + var userAccesses []UserAccess + for _, ua := range userAccessMap { + userAccesses = append(userAccesses, *ua) + } + + return userAccesses, nil +} diff --git a/audit-access/pkg/report/formatter.go b/audit-access/pkg/report/formatter.go new file mode 100644 index 0000000..571745c --- /dev/null +++ b/audit-access/pkg/report/formatter.go @@ -0,0 +1,183 @@ +package report + +import ( + "encoding/csv" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/cert-manager/community/security/audit-access/pkg/github" +) + +// AccessEntry represents a single user-repo-permission entry +type AccessEntry struct { + Username string + RepoName string + Permission string +} + +// Format formats and outputs the user access data in the specified format +func Format(userAccesses []github.UserAccess, format, sortBy string) error { + // Convert to flat list of entries for easier sorting + var entries []AccessEntry + for _, ua := range userAccesses { + for _, repo := range ua.Repos { + entries = append(entries, AccessEntry{ + Username: ua.Username, + RepoName: repo.RepoName, + Permission: repo.Permission, + }) + } + } + + // Sort based on sortBy parameter + if sortBy == "repo" { + // Sort by repository first, then by username + sort.Slice(entries, func(i, j int) bool { + if entries[i].RepoName != entries[j].RepoName { + return entries[i].RepoName < entries[j].RepoName + } + return entries[i].Username < entries[j].Username + }) + } else { + // Sort by username first, then by repository (default) + sort.Slice(entries, func(i, j int) bool { + if entries[i].Username != entries[j].Username { + return entries[i].Username < entries[j].Username + } + return entries[i].RepoName < entries[j].RepoName + }) + } + + switch strings.ToLower(format) { + case "json": + if sortBy == "repo" { + return FormatJSONByRepo(entries) + } + return FormatJSONByUser(entries) + case "csv": + return FormatCSV(entries) + case "table": + return FormatTable(entries) + default: + return fmt.Errorf("unsupported format: %s (supported: table, json, csv)", format) + } +} + +// FormatTable outputs the data as an ASCII table +func FormatTable(entries []AccessEntry) error { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + + // Print header + fmt.Fprintln(w, "USERNAME\tREPOSITORY\tPERMISSION") + fmt.Fprintln(w, "--------\t----------\t----------") + + // Print data + for _, entry := range entries { + fmt.Fprintf(w, "%s\t%s\t%s\n", entry.Username, entry.RepoName, entry.Permission) + } + + return w.Flush() +} + +// FormatJSONByUser outputs the data as JSON grouped by user +func FormatJSONByUser(entries []AccessEntry) error { + // Group by user + userMap := make(map[string][]github.RepoPermission) + for _, entry := range entries { + userMap[entry.Username] = append(userMap[entry.Username], github.RepoPermission{ + RepoName: entry.RepoName, + Permission: entry.Permission, + }) + } + + // Convert to slice + var userAccesses []github.UserAccess + for username, repos := range userMap { + userAccesses = append(userAccesses, github.UserAccess{ + Username: username, + Repos: repos, + }) + } + + // Sort by username + sort.Slice(userAccesses, func(i, j int) bool { + return userAccesses[i].Username < userAccesses[j].Username + }) + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(userAccesses) +} + +// RepoAccess represents a repository and its users +type RepoAccess struct { + RepoName string `json:"repository"` + Users []UserPermInfo `json:"users"` +} + +// UserPermInfo represents a user and their permission +type UserPermInfo struct { + Username string `json:"username"` + Permission string `json:"permission"` +} + +// FormatJSONByRepo outputs the data as JSON grouped by repository +func FormatJSONByRepo(entries []AccessEntry) error { + // Group by repository + repoMap := make(map[string][]UserPermInfo) + for _, entry := range entries { + repoMap[entry.RepoName] = append(repoMap[entry.RepoName], UserPermInfo{ + Username: entry.Username, + Permission: entry.Permission, + }) + } + + // Convert to slice + var repoAccesses []RepoAccess + for repoName, users := range repoMap { + repoAccesses = append(repoAccesses, RepoAccess{ + RepoName: repoName, + Users: users, + }) + } + + // Sort by repository name + sort.Slice(repoAccesses, func(i, j int) bool { + return repoAccesses[i].RepoName < repoAccesses[j].RepoName + }) + + // Sort users within each repo + for i := range repoAccesses { + sort.Slice(repoAccesses[i].Users, func(a, b int) bool { + return repoAccesses[i].Users[a].Username < repoAccesses[i].Users[b].Username + }) + } + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(repoAccesses) +} + +// FormatCSV outputs the data as CSV +func FormatCSV(entries []AccessEntry) error { + writer := csv.NewWriter(os.Stdout) + defer writer.Flush() + + // Write header + if err := writer.Write([]string{"Username", "Repository", "Permission"}); err != nil { + return fmt.Errorf("failed to write CSV header: %w", err) + } + + // Write data + for _, entry := range entries { + if err := writer.Write([]string{entry.Username, entry.RepoName, entry.Permission}); err != nil { + return fmt.Errorf("failed to write CSV row: %w", err) + } + } + + return nil +}