gogcli/internal/cmd/groups.go
2026-01-09 21:05:05 +01:00

332 lines
8.5 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"sort"
"strings"
"google.golang.org/api/cloudidentity/v1"
"github.com/steipete/gogcli/internal/googleapi"
"github.com/steipete/gogcli/internal/outfmt"
"github.com/steipete/gogcli/internal/ui"
)
var newCloudIdentityService = googleapi.NewCloudIdentityGroups
const (
groupRoleOwner = "OWNER"
groupRoleManager = "MANAGER"
groupRoleMember = "MEMBER"
)
type GroupsCmd struct {
List GroupsListCmd `cmd:"" name:"list" help:"List groups you belong to"`
Members GroupsMembersCmd `cmd:"" name:"members" help:"List members of a group"`
}
type GroupsListCmd struct {
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" help:"Page token"`
}
func (c *GroupsListCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
svc, err := newCloudIdentityService(ctx, account)
if err != nil {
return wrapCloudIdentityError(err)
}
// Search for all groups the user belongs to
// Using "groups/-" as parent searches across all groups
resp, err := svc.Groups.Memberships.SearchTransitiveGroups("groups/-").
Query("member_key_id == '" + account + "'").
PageSize(c.Max).
PageToken(c.Page).
Context(ctx).
Do()
if err != nil {
return wrapCloudIdentityError(err)
}
if outfmt.IsJSON(ctx) {
type item struct {
GroupName string `json:"groupName"`
DisplayName string `json:"displayName,omitempty"`
Role string `json:"role,omitempty"`
}
items := make([]item, 0, len(resp.Memberships))
for _, m := range resp.Memberships {
if m == nil {
continue
}
items = append(items, item{
GroupName: m.GroupKey.Id,
DisplayName: m.DisplayName,
Role: getRelationType(m.RelationType),
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"groups": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Memberships) == 0 {
u.Err().Println("No groups found")
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "GROUP\tNAME\tRELATION")
for _, m := range resp.Memberships {
if m == nil {
continue
}
fmt.Fprintf(w, "%s\t%s\t%s\n",
sanitizeTab(m.GroupKey.Id),
sanitizeTab(m.DisplayName),
sanitizeTab(getRelationType(m.RelationType)),
)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
// wrapCloudIdentityError provides helpful error messages for common Cloud Identity API issues.
func wrapCloudIdentityError(err error) error {
errStr := err.Error()
if strings.Contains(errStr, "accessNotConfigured") ||
strings.Contains(errStr, "Cloud Identity API has not been used") {
return fmt.Errorf("cloud Identity API is not enabled; enable it at: https://console.developers.google.com/apis/api/cloudidentity.googleapis.com/overview (%w)", err)
}
if strings.Contains(errStr, "insufficientPermissions") ||
strings.Contains(errStr, "insufficient authentication scopes") {
return fmt.Errorf("insufficient permissions for Cloud Identity API; re-authenticate with the cloud-identity.groups.readonly scope: gog auth add <account> --services groups\n\nOriginal error: %w", err)
}
return err
}
// getRelationType returns a human-readable relation type.
func getRelationType(relationType string) string {
switch relationType {
case "DIRECT":
return "direct"
case "INDIRECT":
return "indirect"
default:
return relationType
}
}
type GroupsMembersCmd struct {
GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@company.com)"`
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" help:"Page token"`
}
func (c *GroupsMembersCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
account, err := requireAccount(flags)
if err != nil {
return err
}
groupEmail := strings.TrimSpace(c.GroupEmail)
if groupEmail == "" {
return usage("group email required")
}
svc, err := newCloudIdentityService(ctx, account)
if err != nil {
return wrapCloudIdentityError(err)
}
// First, look up the group by email to get its resource name
groupName, err := lookupGroupByEmail(ctx, svc, groupEmail)
if err != nil {
return fmt.Errorf("failed to find group %q: %w", groupEmail, err)
}
// List members of the group
resp, err := svc.Groups.Memberships.List(groupName).
PageSize(c.Max).
PageToken(c.Page).
Context(ctx).
Do()
if err != nil {
return fmt.Errorf("failed to list members: %w", err)
}
if outfmt.IsJSON(ctx) {
type item struct {
Email string `json:"email"`
Role string `json:"role"`
Type string `json:"type"`
}
items := make([]item, 0, len(resp.Memberships))
for _, m := range resp.Memberships {
if m == nil || m.PreferredMemberKey == nil {
continue
}
items = append(items, item{
Email: m.PreferredMemberKey.Id,
Role: getMemberRole(m.Roles),
Type: m.Type,
})
}
return outfmt.WriteJSON(os.Stdout, map[string]any{
"members": items,
"nextPageToken": resp.NextPageToken,
})
}
if len(resp.Memberships) == 0 {
u.Err().Printf("No members in group %s\n", groupEmail)
return nil
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "EMAIL\tROLE\tTYPE")
for _, m := range resp.Memberships {
if m == nil || m.PreferredMemberKey == nil {
continue
}
fmt.Fprintf(w, "%s\t%s\t%s\n",
sanitizeTab(m.PreferredMemberKey.Id),
sanitizeTab(getMemberRole(m.Roles)),
sanitizeTab(m.Type),
)
}
printNextPageHint(u, resp.NextPageToken)
return nil
}
// lookupGroupByEmail finds a group by its email address and returns its resource name.
func lookupGroupByEmail(ctx context.Context, svc *cloudidentity.Service, email string) (string, error) {
resp, err := svc.Groups.Lookup().
GroupKeyId(email).
Context(ctx).
Do()
if err != nil {
return "", err
}
return resp.Name, nil
}
// getMemberRole extracts the role from membership roles.
func getMemberRole(roles []*cloudidentity.MembershipRole) string {
if len(roles) == 0 {
return groupRoleMember
}
// Return the highest role (OWNER > MANAGER > MEMBER)
for _, r := range roles {
if r.Name == groupRoleOwner {
return groupRoleOwner
}
}
for _, r := range roles {
if r.Name == groupRoleManager {
return groupRoleManager
}
}
return groupRoleMember
}
// truncate shortens a string to maxLen, adding "..." if truncated.
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
func collectGroupMemberEmails(ctx context.Context, svc *cloudidentity.Service, groupEmail string) ([]string, error) {
seenGroups := make(map[string]bool)
emails := make(map[string]bool)
if err := collectGroupMemberEmailsRecursive(ctx, svc, groupEmail, seenGroups, emails); err != nil {
return nil, err
}
results := make([]string, 0, len(emails))
for email := range emails {
results = append(results, email)
}
sort.Strings(results)
return results, nil
}
func collectGroupMemberEmailsRecursive(ctx context.Context, svc *cloudidentity.Service, groupEmail string, seenGroups map[string]bool, emails map[string]bool) error {
groupEmail = strings.TrimSpace(groupEmail)
if groupEmail == "" {
return nil
}
if seenGroups[groupEmail] {
return nil
}
seenGroups[groupEmail] = true
groupName, err := lookupGroupByEmail(ctx, svc, groupEmail)
if err != nil {
return fmt.Errorf("lookup group %q: %w", groupEmail, err)
}
memberships, err := listGroupMemberships(ctx, svc, groupName, 200)
if err != nil {
return fmt.Errorf("list members for %q: %w", groupEmail, err)
}
for _, m := range memberships {
if m == nil || m.PreferredMemberKey == nil {
continue
}
email := strings.TrimSpace(m.PreferredMemberKey.Id)
if email == "" || !strings.Contains(email, "@") {
continue
}
switch m.Type {
case "GROUP":
if err := collectGroupMemberEmailsRecursive(ctx, svc, email, seenGroups, emails); err != nil {
return err
}
case "USER", "":
emails[email] = true
}
}
return nil
}
func listGroupMemberships(ctx context.Context, svc *cloudidentity.Service, groupName string, pageSize int64) ([]*cloudidentity.Membership, error) {
var memberships []*cloudidentity.Membership
pageToken := ""
for {
call := svc.Groups.Memberships.List(groupName).PageSize(pageSize).Context(ctx)
if pageToken != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, err
}
memberships = append(memberships, resp.Memberships...)
if resp.NextPageToken == "" {
break
}
pageToken = resp.NextPageToken
}
return memberships, nil
}