gogcli/internal/cmd/groups.go
2026-05-05 08:52:50 +01:00

379 lines
11 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"sort"
"strings"
"google.golang.org/api/cloudidentity/v1"
"github.com/steipete/gogcli/internal/errfmt"
"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"
groupLabelDiscussionForum = "cloudidentity.googleapis.com/groups.discussion_forum"
groupLabelDynamic = "cloudidentity.googleapis.com/groups.dynamic"
)
type GroupsCmd struct {
List GroupsListCmd `cmd:"" name:"list" aliases:"ls" 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" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
}
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, account)
}
// Search for all groups the user belongs to
// Using "groups/-" as parent searches across all groups
fetch := func(pageToken string) ([]*cloudidentity.GroupRelation, string, error) {
call := svc.Groups.Memberships.SearchTransitiveGroups("groups/-").
Query(searchTransitiveGroupsQuery(account)).
PageSize(c.Max).
Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, callErr := call.Do()
if callErr != nil {
return nil, "", wrapCloudIdentityError(callErr, account)
}
return resp.Memberships, resp.NextPageToken, nil
}
memberships, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return 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(memberships))
for _, m := range memberships {
if m == nil {
continue
}
items = append(items, item{
GroupName: m.GroupKey.Id,
DisplayName: m.DisplayName,
Role: getRelationType(m.RelationType),
})
}
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"groups": items,
"nextPageToken": nextPageToken,
}); err != nil {
return err
}
if len(items) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}
if len(memberships) == 0 {
u.Err().Println("No groups found")
return failEmptyExit(c.FailEmpty)
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "GROUP\tNAME\tRELATION")
for _, m := range 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, nextPageToken)
return nil
}
// wrapCloudIdentityError provides helpful error messages for common Cloud Identity API issues.
func wrapCloudIdentityError(err error, account string) error {
errStr := err.Error()
if strings.Contains(errStr, "accessNotConfigured") ||
strings.Contains(errStr, "Cloud Identity API has not been used") {
return errfmt.NewUserFacingError("Cloud Identity API is not enabled; enable it at: https://console.developers.google.com/apis/api/cloudidentity.googleapis.com/overview", err)
}
if strings.Contains(errStr, "insufficientPermissions") ||
strings.Contains(errStr, "insufficient authentication scopes") {
return errfmt.NewUserFacingError("Insufficient permissions for Cloud Identity API; re-authenticate with the cloud-identity.groups.readonly scope: gog auth add <account> --services groups", err)
}
if isConsumerAccount(account) && (strings.Contains(errStr, "invalid argument") || strings.Contains(errStr, "badRequest")) {
return errfmt.NewUserFacingError("Cloud Identity groups require a Google Workspace/Cloud Identity account; consumer accounts (gmail.com/googlemail.com) are not supported.", err)
}
return err
}
func searchTransitiveGroupsQuery(memberKeyID string) string {
memberKeyID = strings.ReplaceAll(strings.TrimSpace(memberKeyID), "'", "\\'")
return fmt.Sprintf(
"member_key_id == '%s' && ('%s' in labels || '%s' in labels)",
memberKeyID,
groupLabelDiscussionForum,
groupLabelDynamic,
)
}
// 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" aliases:"cursor" help:"Page token"`
All bool `name:"all" aliases:"all-pages,allpages" help:"Fetch all pages"`
FailEmpty bool `name:"fail-empty" aliases:"non-empty,require-results" help:"Exit with code 3 if no results"`
}
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, account)
}
// 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
fetch := func(pageToken string) ([]*cloudidentity.Membership, string, error) {
call := svc.Groups.Memberships.List(groupName).
PageSize(c.Max).
Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, callErr := call.Do()
if callErr != nil {
return nil, "", fmt.Errorf("failed to list members: %w", callErr)
}
return resp.Memberships, resp.NextPageToken, nil
}
memberships, nextPageToken, err := loadPagedItems(c.Page, c.All, fetch)
if err != nil {
return 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(memberships))
for _, m := range memberships {
if m == nil || m.PreferredMemberKey == nil {
continue
}
items = append(items, item{
Email: m.PreferredMemberKey.Id,
Role: getMemberRole(m.Roles),
Type: m.Type,
})
}
if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{
"members": items,
"nextPageToken": nextPageToken,
}); err != nil {
return err
}
if len(items) == 0 {
return failEmptyExit(c.FailEmpty)
}
return nil
}
if len(memberships) == 0 {
u.Err().Printf("No members in group %s", groupEmail)
return failEmptyExit(c.FailEmpty)
}
w, flush := tableWriter(ctx)
defer flush()
fmt.Fprintln(w, "EMAIL\tROLE\tTYPE")
for _, m := range 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, 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) {
fetch := func(pageToken string) ([]*cloudidentity.Membership, string, error) {
call := svc.Groups.Memberships.List(groupName).PageSize(pageSize).Context(ctx)
if strings.TrimSpace(pageToken) != "" {
call = call.PageToken(pageToken)
}
resp, err := call.Do()
if err != nil {
return nil, "", err
}
return resp.Memberships, resp.NextPageToken, nil
}
return collectAllPages("", fetch)
}