332 lines
8.5 KiB
Go
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
|
|
}
|