From 0932159c95ecfda2e6054e35b950e88b2eec6c21 Mon Sep 17 00:00:00 2001 From: Dalton Alexandre <166029845+dl-alexandre@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:31:59 -0800 Subject: [PATCH 1/2] feat(admin): add user and group management via Admin Directory API Implements Google Admin SDK Directory API support for Workspace user and group management with domain-wide delegation. New commands: - gog admin users list --domain example.com - gog admin users get user@example.com - gog admin users create user@example.com --given John --family Doe - gog admin users suspend user@example.com - gog admin groups list --domain example.com - gog admin groups members list group@example.com - gog admin groups members add group@example.com user@example.com --role MEMBER - gog admin groups members remove group@example.com user@example.com Closes #340 Note: This is a focused implementation of core user/group management. For full GAM feature parity (PR #179), additional work would be needed for: alerts, licenses, org units, printers, reports, vault, SSO, etc. --- internal/cmd/admin.go | 710 ++++++++++++++++++++++++++ internal/cmd/root.go | 1 + internal/googleapi/admin_directory.go | 22 + internal/googleauth/service.go | 12 + internal/googleauth/service_test.go | 4 +- 5 files changed, 747 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/admin.go create mode 100644 internal/googleapi/admin_directory.go diff --git a/internal/cmd/admin.go b/internal/cmd/admin.go new file mode 100644 index 00000000..db8e4fba --- /dev/null +++ b/internal/cmd/admin.go @@ -0,0 +1,710 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/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 newAdminDirectoryService = googleapi.NewAdminDirectory + +const ( + adminRoleMember = "MEMBER" + adminRoleOwner = "OWNER" + adminRoleManager = "MANAGER" +) + +// AdminCmd provides Google Workspace admin commands using the Admin SDK Directory API. +// Requires domain-wide delegation with a service account. +type AdminCmd struct { + Users AdminUsersCmd `cmd:"" name:"users" help:"Manage Workspace users"` + Groups AdminGroupsCmd `cmd:"" name:"groups" help:"Manage Workspace groups"` +} + +// AdminUsersCmd manages Workspace users. +type AdminUsersCmd struct { + List AdminUsersListCmd `cmd:"" name:"list" aliases:"ls" help:"List users in a domain"` + Get AdminUsersGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get user details"` + Create AdminUsersCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new user"` + Suspend AdminUsersSuspendCmd `cmd:"" name:"suspend" help:"Suspend a user account"` +} + +// AdminUsersListCmd lists users in a Workspace domain. +type AdminUsersListCmd struct { + Domain string `name:"domain" help:"Domain to list users from (e.g., example.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 *AdminUsersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + domain := strings.TrimSpace(c.Domain) + if domain == "" { + return usage("domain required (e.g., --domain example.com)") + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + fetch := func(pageToken string) ([]*admin.User, string, error) { + call := svc.Users.List(). + Domain(domain). + MaxResults(c.Max). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Do() + if err != nil { + return nil, "", wrapAdminDirectoryError(err, account) + } + return resp.Users, resp.NextPageToken, nil + } + + var users []*admin.User + nextPageToken := "" + if c.All { + all, err := collectAllPages(c.Page, fetch) + if err != nil { + return err + } + users = all + } else { + var err error + users, nextPageToken, err = fetch(c.Page) + if err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + type item struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Suspended bool `json:"suspended"` + Admin bool `json:"admin"` + } + items := make([]item, 0, len(users)) + for _, u := range users { + if u == nil { + continue + } + items = append(items, item{ + Email: u.PrimaryEmail, + Name: u.Name.FullName, + Suspended: u.Suspended, + Admin: u.IsAdmin, + }) + } + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "users": items, + "nextPageToken": nextPageToken, + }); err != nil { + return err + } + if len(items) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(users) == 0 { + u.Err().Println("No users found") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tNAME\tSUSPENDED\tADMIN") + for _, user := range users { + if user == nil { + continue + } + suspended := "no" + if user.Suspended { + suspended = "yes" + } + isAdmin := "no" + if user.IsAdmin { + isAdmin = "yes" + } + name := "" + if user.Name != nil { + name = user.Name.FullName + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(user.PrimaryEmail), + sanitizeTab(name), + suspended, + isAdmin, + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +// AdminUsersGetCmd gets details for a specific user. +type AdminUsersGetCmd struct { + UserEmail string `arg:"" name:"userEmail" help:"User email (e.g., user@example.com)"` +} + +func (c *AdminUsersGetCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + userEmail := strings.TrimSpace(c.UserEmail) + if userEmail == "" { + return usage("user email required") + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + user, err := svc.Users.Get(userEmail).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + type item struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + GivenName string `json:"givenName,omitempty"` + FamilyName string `json:"familyName,omitempty"` + Suspended bool `json:"suspended"` + Admin bool `json:"admin"` + Aliases []string `json:"aliases,omitempty"` + OrgUnitPath string `json:"orgUnitPath,omitempty"` + Creation string `json:"creationTime,omitempty"` + LastLogin string `json:"lastLoginTime,omitempty"` + } + var aliases []string + if user.Aliases != nil { + aliases = user.Aliases + } + name := "" + givenName := "" + familyName := "" + if user.Name != nil { + name = user.Name.FullName + givenName = user.Name.GivenName + familyName = user.Name.FamilyName + } + return outfmt.WriteJSON(ctx, os.Stdout, item{ + Email: user.PrimaryEmail, + Name: name, + GivenName: givenName, + FamilyName: familyName, + Suspended: user.Suspended, + Admin: user.IsAdmin, + Aliases: aliases, + OrgUnitPath: user.OrgUnitPath, + Creation: user.CreationTime, + LastLogin: user.LastLoginTime, + }) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintf(w, "Email:\t%s\n", user.PrimaryEmail) + if user.Name != nil { + fmt.Fprintf(w, "Name:\t%s\n", user.Name.FullName) + fmt.Fprintf(w, "Given Name:\t%s\n", user.Name.GivenName) + fmt.Fprintf(w, "Family Name:\t%s\n", user.Name.FamilyName) + } + fmt.Fprintf(w, "Suspended:\t%t\n", user.Suspended) + fmt.Fprintf(w, "Admin:\t%t\n", user.IsAdmin) + fmt.Fprintf(w, "Org Unit:\t%s\n", user.OrgUnitPath) + fmt.Fprintf(w, "Created:\t%s\n", user.CreationTime) + fmt.Fprintf(w, "Last Login:\t%s\n", user.LastLoginTime) + if len(user.Aliases) > 0 { + fmt.Fprintf(w, "Aliases:\t%s\n", strings.Join(user.Aliases, ", ")) + } + return nil +} + +// AdminUsersCreateCmd creates a new Workspace user. +type AdminUsersCreateCmd struct { + Email string `arg:"" name:"email" help:"User email (e.g., user@example.com)"` + GivenName string `name:"given" help:"Given (first) name"` + FamilyName string `name:"family" help:"Family (last) name"` + Password string `name:"password" help:"Initial password"` + ChangePwd bool `name:"change-password" help:"Require password change on first login"` + OrgUnit string `name:"org-unit" help:"Organization unit path"` + Admin bool `name:"admin" help:"Make user an admin"` +} + +func (c *AdminUsersCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + email := strings.TrimSpace(c.Email) + if email == "" { + return usage("email required") + } + + user := &admin.User{ + PrimaryEmail: email, + Name: &admin.UserName{ + GivenName: c.GivenName, + FamilyName: c.FamilyName, + }, + Password: c.Password, + ChangePasswordAtNextLogin: c.ChangePwd, + IsAdmin: c.Admin, + } + if c.OrgUnit != "" { + user.OrgUnitPath = c.OrgUnit + } + + if dryRunErr := dryRunExit(ctx, flags, "create user", user); dryRunErr != nil { + return dryRunErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + created, err := svc.Users.Insert(user).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "email": created.PrimaryEmail, + "id": created.Id, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Created user: %s (ID: %s)", created.PrimaryEmail, created.Id) + return nil +} + +// AdminUsersSuspendCmd suspends a Workspace user account. +type AdminUsersSuspendCmd struct { + UserEmail string `arg:"" name:"userEmail" help:"User email to suspend"` +} + +func (c *AdminUsersSuspendCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + userEmail := strings.TrimSpace(c.UserEmail) + if userEmail == "" { + return usage("user email required") + } + + if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("suspend user %s", userEmail)); confirmErr != nil { + return confirmErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + user := &admin.User{ + Suspended: true, + } + + updated, err := svc.Users.Update(userEmail, user).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "email": updated.PrimaryEmail, + "suspended": updated.Suspended, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Suspended user: %s", updated.PrimaryEmail) + return nil +} + +// AdminGroupsCmd manages Workspace groups. +type AdminGroupsCmd struct { + List AdminGroupsListCmd `cmd:"" name:"list" aliases:"ls" help:"List groups in a domain"` + Members AdminGroupsMembersCmd `cmd:"" name:"members" help:"Manage group members"` +} + +// AdminGroupsListCmd lists groups in a Workspace domain. +type AdminGroupsListCmd struct { + Domain string `name:"domain" help:"Domain to list groups from (e.g., example.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 *AdminGroupsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAccount(flags) + if err != nil { + return err + } + + domain := strings.TrimSpace(c.Domain) + if domain == "" { + return usage("domain required (e.g., --domain example.com)") + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + fetch := func(pageToken string) ([]*admin.Group, string, error) { + call := svc.Groups.List(). + Domain(domain). + MaxResults(c.Max). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Do() + if err != nil { + return nil, "", wrapAdminDirectoryError(err, account) + } + return resp.Groups, resp.NextPageToken, nil + } + + var groups []*admin.Group + nextPageToken := "" + if c.All { + all, err := collectAllPages(c.Page, fetch) + if err != nil { + return err + } + groups = all + } else { + var err error + groups, nextPageToken, err = fetch(c.Page) + if err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + type item struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DirectMembersCount int64 `json:"directMembersCount"` + } + items := make([]item, 0, len(groups)) + for _, g := range groups { + if g == nil { + continue + } + items = append(items, item{ + Email: g.Email, + Name: g.Name, + Description: g.Description, + DirectMembersCount: g.DirectMembersCount, + }) + } + 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(groups) == 0 { + u.Err().Println("No groups found") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tNAME\tMEMBERS\tDESCRIPTION") + for _, group := range groups { + if group == nil { + continue + } + desc := group.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", + sanitizeTab(group.Email), + sanitizeTab(group.Name), + group.DirectMembersCount, + sanitizeTab(desc), + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +// AdminGroupsMembersCmd manages group membership. +type AdminGroupsMembersCmd struct { + List AdminGroupsMembersListCmd `cmd:"" name:"list" aliases:"ls" help:"List group members"` + Add AdminGroupsMembersAddCmd `cmd:"" name:"add" aliases:"invite" help:"Add a member to a group"` + Remove AdminGroupsMembersRemoveCmd `cmd:"" name:"remove" aliases:"rm,del,delete" help:"Remove a member from a group"` +} + +// AdminGroupsMembersListCmd lists members of a group. +type AdminGroupsMembersListCmd struct { + GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@example.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 *AdminGroupsMembersListCmd) 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 := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + fetch := func(pageToken string) ([]*admin.Member, string, error) { + call := svc.Members.List(groupEmail). + MaxResults(c.Max). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + resp, err := call.Do() + if err != nil { + return nil, "", wrapAdminDirectoryError(err, account) + } + return resp.Members, resp.NextPageToken, nil + } + + var members []*admin.Member + nextPageToken := "" + if c.All { + all, err := collectAllPages(c.Page, fetch) + if err != nil { + return err + } + members = all + } else { + var err error + members, nextPageToken, err = fetch(c.Page) + 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(members)) + for _, m := range members { + if m == nil { + continue + } + items = append(items, item{ + Email: m.Email, + Role: m.Role, + 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(members) == 0 { + u.Err().Println("No members found") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tROLE\tTYPE") + for _, m := range members { + if m == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(m.Email), + sanitizeTab(m.Role), + sanitizeTab(m.Type), + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +// AdminGroupsMembersAddCmd adds a member to a group. +type AdminGroupsMembersAddCmd struct { + GroupEmail string `arg:"" name:"groupEmail" help:"Group email"` + MemberEmail string `arg:"" name:"memberEmail" help:"Member email to add"` + Role string `name:"role" help:"Member role (MEMBER, MANAGER, OWNER)" default:"MEMBER"` +} + +func (c *AdminGroupsMembersAddCmd) 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) + memberEmail := strings.TrimSpace(c.MemberEmail) + if groupEmail == "" || memberEmail == "" { + return usage("group email and member email required") + } + + role := strings.ToUpper(c.Role) + if role != adminRoleMember && role != adminRoleManager && role != adminRoleOwner { + return usage("role must be MEMBER, MANAGER, or OWNER") + } + + member := &admin.Member{ + Email: memberEmail, + Role: role, + } + + if dryRunErr := dryRunExit(ctx, flags, fmt.Sprintf("add %s to %s as %s", memberEmail, groupEmail, role), member); dryRunErr != nil { + return dryRunErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + created, err := svc.Members.Insert(groupEmail, member).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "email": created.Email, + "role": created.Role, + }) + } + + u.Out().Printf("Added %s to %s as %s", created.Email, groupEmail, created.Role) + return nil +} + +// AdminGroupsMembersRemoveCmd removes a member from a group. +type AdminGroupsMembersRemoveCmd struct { + GroupEmail string `arg:"" name:"groupEmail" help:"Group email"` + MemberEmail string `arg:"" name:"memberEmail" help:"Member email to remove"` +} + +func (c *AdminGroupsMembersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAccount(flags) + if err != nil { + return err + } + + groupEmail := strings.TrimSpace(c.GroupEmail) + memberEmail := strings.TrimSpace(c.MemberEmail) + if groupEmail == "" || memberEmail == "" { + return usage("group email and member email required") + } + + if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", memberEmail, groupEmail)); confirmErr != nil { + return confirmErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + err = svc.Members.Delete(groupEmail, memberEmail).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "removed": true, + "email": memberEmail, + "group": groupEmail, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Removed %s from %s", memberEmail, groupEmail) + return nil +} + +// wrapAdminDirectoryError provides helpful error messages for common Admin SDK issues. +func wrapAdminDirectoryError(err error, account string) error { + errStr := err.Error() + if strings.Contains(errStr, "accessNotConfigured") || + strings.Contains(errStr, "Admin SDK API has not been used") { + return errfmt.NewUserFacingError("Admin SDK API is not enabled; enable it at: https://console.developers.google.com/apis/api/admin.googleapis.com/overview", err) + } + if strings.Contains(errStr, "insufficientPermissions") || + strings.Contains(errStr, "insufficient authentication scopes") || + strings.Contains(errStr, "Not Authorized") { + return errfmt.NewUserFacingError("Insufficient permissions for Admin SDK API; ensure your service account has domain-wide delegation enabled with admin.directory.user and admin.directory.group scopes", err) + } + if strings.Contains(errStr, "domain_wide_delegation") || + strings.Contains(errStr, "invalid_grant") { + return errfmt.NewUserFacingError("Domain-wide delegation not configured or invalid; ensure your service account has domain-wide delegation enabled in Google Workspace Admin Console", err) + } + if isConsumerAccount(account) { + return errfmt.NewUserFacingError("Admin SDK Directory API requires a Google Workspace account with domain-wide delegation; consumer accounts (gmail.com/googlemail.com) are not supported.", err) + } + return err +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 26ea5ee3..c898444b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -63,6 +63,7 @@ type CLI struct { Auth AuthCmd `cmd:"" help:"Auth and credentials"` Groups GroupsCmd `cmd:"" aliases:"group" help:"Google Groups"` + Admin AdminCmd `cmd:"" help:"Google Workspace Admin (Directory API) - requires domain-wide delegation"` Drive DriveCmd `cmd:"" aliases:"drv" help:"Google Drive"` Docs DocsCmd `cmd:"" aliases:"doc" help:"Google Docs (export via Drive)"` Slides SlidesCmd `cmd:"" aliases:"slide" help:"Google Slides"` diff --git a/internal/googleapi/admin_directory.go b/internal/googleapi/admin_directory.go new file mode 100644 index 00000000..624e564a --- /dev/null +++ b/internal/googleapi/admin_directory.go @@ -0,0 +1,22 @@ +package googleapi + +import ( + "context" + "fmt" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/googleauth" +) + +// NewAdminDirectory creates an Admin SDK Directory service for user and group management. +// This API requires domain-wide delegation with a service account to manage Workspace users. +func NewAdminDirectory(ctx context.Context, email string) (*admin.Service, error) { + if opts, err := optionsForAccount(ctx, googleauth.ServiceAdmin, email); err != nil { + return nil, fmt.Errorf("admin directory options: %w", err) + } else if svc, err := admin.NewService(ctx, opts...); err != nil { + return nil, fmt.Errorf("create admin directory service: %w", err) + } else { + return svc, nil + } +} diff --git a/internal/googleauth/service.go b/internal/googleauth/service.go index b3c3afe8..75f9578b 100644 --- a/internal/googleauth/service.go +++ b/internal/googleauth/service.go @@ -25,6 +25,7 @@ const ( ServiceAppScript Service = "appscript" ServiceGroups Service = "groups" ServiceKeep Service = "keep" + ServiceAdmin Service = "admin" ) const ( @@ -83,6 +84,7 @@ var serviceOrder = []Service{ ServiceAppScript, ServiceGroups, ServiceKeep, + ServiceAdmin, } var serviceInfoByService = map[Service]serviceInfo{ @@ -212,6 +214,16 @@ var serviceInfoByService = map[Service]serviceInfo{ apis: []string{"Keep API"}, note: "Workspace only; service account (domain-wide delegation)", }, + ServiceAdmin: { + scopes: []string{ + "https://www.googleapis.com/auth/admin.directory.user", + "https://www.googleapis.com/auth/admin.directory.group", + "https://www.googleapis.com/auth/admin.directory.group.member", + }, + user: false, + apis: []string{"Admin SDK Directory API"}, + note: "Workspace only; service account with domain-wide delegation required", + }, } func ParseService(s string) (Service, error) { diff --git a/internal/googleauth/service_test.go b/internal/googleauth/service_test.go index 94e25823..c439f107 100644 --- a/internal/googleauth/service_test.go +++ b/internal/googleauth/service_test.go @@ -65,7 +65,7 @@ func TestExtractCodeAndState_Errors(t *testing.T) { func TestAllServices(t *testing.T) { svcs := AllServices() - if len(svcs) != 15 { + if len(svcs) != 16 { t.Fatalf("unexpected: %v", svcs) } seen := make(map[Service]bool) @@ -74,7 +74,7 @@ func TestAllServices(t *testing.T) { seen[s] = true } - for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep} { + for _, want := range []Service{ServiceGmail, ServiceCalendar, ServiceChat, ServiceClassroom, ServiceDrive, ServiceDocs, ServiceSlides, ServiceContacts, ServiceTasks, ServicePeople, ServiceSheets, ServiceForms, ServiceAppScript, ServiceGroups, ServiceKeep, ServiceAdmin} { if !seen[want] { t.Fatalf("missing %q", want) } From fa900013cfd2675a5bcc9877a2ee2376f95a6d70 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 8 Mar 2026 02:29:21 +0000 Subject: [PATCH 2/2] fix(admin): harden directory command support (#403) (thanks @dl-alexandre) --- CHANGELOG.md | 1 + README.md | 15 + internal/cmd/admin.go | 702 --------------------------------- internal/cmd/admin_common.go | 52 +++ internal/cmd/admin_groups.go | 340 ++++++++++++++++ internal/cmd/admin_test.go | 184 +++++++++ internal/cmd/admin_users.go | 345 ++++++++++++++++ internal/cmd/confirm.go | 2 +- internal/cmd/docs_sed_brace.go | 2 +- 9 files changed, 939 insertions(+), 704 deletions(-) create mode 100644 internal/cmd/admin_common.go create mode 100644 internal/cmd/admin_groups.go create mode 100644 internal/cmd/admin_test.go create mode 100644 internal/cmd/admin_users.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 37466d0a..c8ee14f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.12.0 - Unreleased ### Added +- Admin: add Workspace Admin Directory commands for users and groups, including user list/get/create/suspend and group membership list/add/remove. (#403) — thanks @dl-alexandre. - Sheets: add named range management (`sheets named-ranges`) and let range-based Sheets commands accept named range names where GridRange-backed operations are needed. (#278) — thanks @TheCrazyLex. - Docs: add `--pageless` to `docs create`, `docs write`, and `docs update` to switch documents into pageless mode after writes. (#300) — thanks @shohei-majima. - Contacts: add `--relation type=person` to contact create/update, include relations in text `contacts get`, and cover relation payload updates. (#351) — thanks @karbassi. diff --git a/README.md b/README.md index 7b1a3731..73b7b9bf 100644 --- a/README.md +++ b/README.md @@ -1105,6 +1105,21 @@ gog chat dm send user@company.com --text "ping" Note: Chat commands require a Google Workspace account (consumer @gmail.com accounts are not supported). +### Admin + +```bash +# Requires a Workspace service account with domain-wide delegation. +gog admin users list --domain example.com +gog admin users get user@example.com +gog admin users create user@example.com --given Ada --family Lovelace --password 'TempPass123!' +gog admin users suspend user@example.com --force + +gog admin groups list --domain example.com +gog admin groups members list engineering@example.com +gog admin groups members add engineering@example.com user@example.com --role MEMBER +gog admin groups members remove engineering@example.com user@example.com --force +``` + ### Groups (Google Workspace) ```bash diff --git a/internal/cmd/admin.go b/internal/cmd/admin.go index db8e4fba..085cc692 100644 --- a/internal/cmd/admin.go +++ b/internal/cmd/admin.go @@ -1,710 +1,8 @@ package cmd -import ( - "context" - "fmt" - "os" - "strings" - - admin "google.golang.org/api/admin/directory/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 newAdminDirectoryService = googleapi.NewAdminDirectory - -const ( - adminRoleMember = "MEMBER" - adminRoleOwner = "OWNER" - adminRoleManager = "MANAGER" -) - // AdminCmd provides Google Workspace admin commands using the Admin SDK Directory API. // Requires domain-wide delegation with a service account. type AdminCmd struct { Users AdminUsersCmd `cmd:"" name:"users" help:"Manage Workspace users"` Groups AdminGroupsCmd `cmd:"" name:"groups" help:"Manage Workspace groups"` } - -// AdminUsersCmd manages Workspace users. -type AdminUsersCmd struct { - List AdminUsersListCmd `cmd:"" name:"list" aliases:"ls" help:"List users in a domain"` - Get AdminUsersGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get user details"` - Create AdminUsersCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new user"` - Suspend AdminUsersSuspendCmd `cmd:"" name:"suspend" help:"Suspend a user account"` -} - -// AdminUsersListCmd lists users in a Workspace domain. -type AdminUsersListCmd struct { - Domain string `name:"domain" help:"Domain to list users from (e.g., example.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 *AdminUsersListCmd) Run(ctx context.Context, flags *RootFlags) error { - u := ui.FromContext(ctx) - account, err := requireAccount(flags) - if err != nil { - return err - } - - domain := strings.TrimSpace(c.Domain) - if domain == "" { - return usage("domain required (e.g., --domain example.com)") - } - - svc, err := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - fetch := func(pageToken string) ([]*admin.User, string, error) { - call := svc.Users.List(). - Domain(domain). - MaxResults(c.Max). - Context(ctx) - if strings.TrimSpace(pageToken) != "" { - call = call.PageToken(pageToken) - } - resp, err := call.Do() - if err != nil { - return nil, "", wrapAdminDirectoryError(err, account) - } - return resp.Users, resp.NextPageToken, nil - } - - var users []*admin.User - nextPageToken := "" - if c.All { - all, err := collectAllPages(c.Page, fetch) - if err != nil { - return err - } - users = all - } else { - var err error - users, nextPageToken, err = fetch(c.Page) - if err != nil { - return err - } - } - - if outfmt.IsJSON(ctx) { - type item struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` - Suspended bool `json:"suspended"` - Admin bool `json:"admin"` - } - items := make([]item, 0, len(users)) - for _, u := range users { - if u == nil { - continue - } - items = append(items, item{ - Email: u.PrimaryEmail, - Name: u.Name.FullName, - Suspended: u.Suspended, - Admin: u.IsAdmin, - }) - } - if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ - "users": items, - "nextPageToken": nextPageToken, - }); err != nil { - return err - } - if len(items) == 0 { - return failEmptyExit(c.FailEmpty) - } - return nil - } - - if len(users) == 0 { - u.Err().Println("No users found") - return failEmptyExit(c.FailEmpty) - } - - w, flush := tableWriter(ctx) - defer flush() - fmt.Fprintln(w, "EMAIL\tNAME\tSUSPENDED\tADMIN") - for _, user := range users { - if user == nil { - continue - } - suspended := "no" - if user.Suspended { - suspended = "yes" - } - isAdmin := "no" - if user.IsAdmin { - isAdmin = "yes" - } - name := "" - if user.Name != nil { - name = user.Name.FullName - } - fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", - sanitizeTab(user.PrimaryEmail), - sanitizeTab(name), - suspended, - isAdmin, - ) - } - printNextPageHint(u, nextPageToken) - return nil -} - -// AdminUsersGetCmd gets details for a specific user. -type AdminUsersGetCmd struct { - UserEmail string `arg:"" name:"userEmail" help:"User email (e.g., user@example.com)"` -} - -func (c *AdminUsersGetCmd) Run(ctx context.Context, flags *RootFlags) error { - account, err := requireAccount(flags) - if err != nil { - return err - } - - userEmail := strings.TrimSpace(c.UserEmail) - if userEmail == "" { - return usage("user email required") - } - - svc, err := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - user, err := svc.Users.Get(userEmail).Context(ctx).Do() - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - if outfmt.IsJSON(ctx) { - type item struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` - GivenName string `json:"givenName,omitempty"` - FamilyName string `json:"familyName,omitempty"` - Suspended bool `json:"suspended"` - Admin bool `json:"admin"` - Aliases []string `json:"aliases,omitempty"` - OrgUnitPath string `json:"orgUnitPath,omitempty"` - Creation string `json:"creationTime,omitempty"` - LastLogin string `json:"lastLoginTime,omitempty"` - } - var aliases []string - if user.Aliases != nil { - aliases = user.Aliases - } - name := "" - givenName := "" - familyName := "" - if user.Name != nil { - name = user.Name.FullName - givenName = user.Name.GivenName - familyName = user.Name.FamilyName - } - return outfmt.WriteJSON(ctx, os.Stdout, item{ - Email: user.PrimaryEmail, - Name: name, - GivenName: givenName, - FamilyName: familyName, - Suspended: user.Suspended, - Admin: user.IsAdmin, - Aliases: aliases, - OrgUnitPath: user.OrgUnitPath, - Creation: user.CreationTime, - LastLogin: user.LastLoginTime, - }) - } - - w, flush := tableWriter(ctx) - defer flush() - fmt.Fprintf(w, "Email:\t%s\n", user.PrimaryEmail) - if user.Name != nil { - fmt.Fprintf(w, "Name:\t%s\n", user.Name.FullName) - fmt.Fprintf(w, "Given Name:\t%s\n", user.Name.GivenName) - fmt.Fprintf(w, "Family Name:\t%s\n", user.Name.FamilyName) - } - fmt.Fprintf(w, "Suspended:\t%t\n", user.Suspended) - fmt.Fprintf(w, "Admin:\t%t\n", user.IsAdmin) - fmt.Fprintf(w, "Org Unit:\t%s\n", user.OrgUnitPath) - fmt.Fprintf(w, "Created:\t%s\n", user.CreationTime) - fmt.Fprintf(w, "Last Login:\t%s\n", user.LastLoginTime) - if len(user.Aliases) > 0 { - fmt.Fprintf(w, "Aliases:\t%s\n", strings.Join(user.Aliases, ", ")) - } - return nil -} - -// AdminUsersCreateCmd creates a new Workspace user. -type AdminUsersCreateCmd struct { - Email string `arg:"" name:"email" help:"User email (e.g., user@example.com)"` - GivenName string `name:"given" help:"Given (first) name"` - FamilyName string `name:"family" help:"Family (last) name"` - Password string `name:"password" help:"Initial password"` - ChangePwd bool `name:"change-password" help:"Require password change on first login"` - OrgUnit string `name:"org-unit" help:"Organization unit path"` - Admin bool `name:"admin" help:"Make user an admin"` -} - -func (c *AdminUsersCreateCmd) Run(ctx context.Context, flags *RootFlags) error { - account, err := requireAccount(flags) - if err != nil { - return err - } - - email := strings.TrimSpace(c.Email) - if email == "" { - return usage("email required") - } - - user := &admin.User{ - PrimaryEmail: email, - Name: &admin.UserName{ - GivenName: c.GivenName, - FamilyName: c.FamilyName, - }, - Password: c.Password, - ChangePasswordAtNextLogin: c.ChangePwd, - IsAdmin: c.Admin, - } - if c.OrgUnit != "" { - user.OrgUnitPath = c.OrgUnit - } - - if dryRunErr := dryRunExit(ctx, flags, "create user", user); dryRunErr != nil { - return dryRunErr - } - - svc, err := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - created, err := svc.Users.Insert(user).Context(ctx).Do() - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ - "email": created.PrimaryEmail, - "id": created.Id, - }) - } - - u := ui.FromContext(ctx) - u.Out().Printf("Created user: %s (ID: %s)", created.PrimaryEmail, created.Id) - return nil -} - -// AdminUsersSuspendCmd suspends a Workspace user account. -type AdminUsersSuspendCmd struct { - UserEmail string `arg:"" name:"userEmail" help:"User email to suspend"` -} - -func (c *AdminUsersSuspendCmd) Run(ctx context.Context, flags *RootFlags) error { - account, err := requireAccount(flags) - if err != nil { - return err - } - - userEmail := strings.TrimSpace(c.UserEmail) - if userEmail == "" { - return usage("user email required") - } - - if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("suspend user %s", userEmail)); confirmErr != nil { - return confirmErr - } - - svc, err := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - user := &admin.User{ - Suspended: true, - } - - updated, err := svc.Users.Update(userEmail, user).Context(ctx).Do() - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ - "email": updated.PrimaryEmail, - "suspended": updated.Suspended, - }) - } - - u := ui.FromContext(ctx) - u.Out().Printf("Suspended user: %s", updated.PrimaryEmail) - return nil -} - -// AdminGroupsCmd manages Workspace groups. -type AdminGroupsCmd struct { - List AdminGroupsListCmd `cmd:"" name:"list" aliases:"ls" help:"List groups in a domain"` - Members AdminGroupsMembersCmd `cmd:"" name:"members" help:"Manage group members"` -} - -// AdminGroupsListCmd lists groups in a Workspace domain. -type AdminGroupsListCmd struct { - Domain string `name:"domain" help:"Domain to list groups from (e.g., example.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 *AdminGroupsListCmd) Run(ctx context.Context, flags *RootFlags) error { - u := ui.FromContext(ctx) - account, err := requireAccount(flags) - if err != nil { - return err - } - - domain := strings.TrimSpace(c.Domain) - if domain == "" { - return usage("domain required (e.g., --domain example.com)") - } - - svc, err := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - fetch := func(pageToken string) ([]*admin.Group, string, error) { - call := svc.Groups.List(). - Domain(domain). - MaxResults(c.Max). - Context(ctx) - if strings.TrimSpace(pageToken) != "" { - call = call.PageToken(pageToken) - } - resp, err := call.Do() - if err != nil { - return nil, "", wrapAdminDirectoryError(err, account) - } - return resp.Groups, resp.NextPageToken, nil - } - - var groups []*admin.Group - nextPageToken := "" - if c.All { - all, err := collectAllPages(c.Page, fetch) - if err != nil { - return err - } - groups = all - } else { - var err error - groups, nextPageToken, err = fetch(c.Page) - if err != nil { - return err - } - } - - if outfmt.IsJSON(ctx) { - type item struct { - Email string `json:"email"` - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - DirectMembersCount int64 `json:"directMembersCount"` - } - items := make([]item, 0, len(groups)) - for _, g := range groups { - if g == nil { - continue - } - items = append(items, item{ - Email: g.Email, - Name: g.Name, - Description: g.Description, - DirectMembersCount: g.DirectMembersCount, - }) - } - 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(groups) == 0 { - u.Err().Println("No groups found") - return failEmptyExit(c.FailEmpty) - } - - w, flush := tableWriter(ctx) - defer flush() - fmt.Fprintln(w, "EMAIL\tNAME\tMEMBERS\tDESCRIPTION") - for _, group := range groups { - if group == nil { - continue - } - desc := group.Description - if len(desc) > 50 { - desc = desc[:47] + "..." - } - fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", - sanitizeTab(group.Email), - sanitizeTab(group.Name), - group.DirectMembersCount, - sanitizeTab(desc), - ) - } - printNextPageHint(u, nextPageToken) - return nil -} - -// AdminGroupsMembersCmd manages group membership. -type AdminGroupsMembersCmd struct { - List AdminGroupsMembersListCmd `cmd:"" name:"list" aliases:"ls" help:"List group members"` - Add AdminGroupsMembersAddCmd `cmd:"" name:"add" aliases:"invite" help:"Add a member to a group"` - Remove AdminGroupsMembersRemoveCmd `cmd:"" name:"remove" aliases:"rm,del,delete" help:"Remove a member from a group"` -} - -// AdminGroupsMembersListCmd lists members of a group. -type AdminGroupsMembersListCmd struct { - GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@example.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 *AdminGroupsMembersListCmd) 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 := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - fetch := func(pageToken string) ([]*admin.Member, string, error) { - call := svc.Members.List(groupEmail). - MaxResults(c.Max). - Context(ctx) - if strings.TrimSpace(pageToken) != "" { - call = call.PageToken(pageToken) - } - resp, err := call.Do() - if err != nil { - return nil, "", wrapAdminDirectoryError(err, account) - } - return resp.Members, resp.NextPageToken, nil - } - - var members []*admin.Member - nextPageToken := "" - if c.All { - all, err := collectAllPages(c.Page, fetch) - if err != nil { - return err - } - members = all - } else { - var err error - members, nextPageToken, err = fetch(c.Page) - 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(members)) - for _, m := range members { - if m == nil { - continue - } - items = append(items, item{ - Email: m.Email, - Role: m.Role, - 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(members) == 0 { - u.Err().Println("No members found") - return failEmptyExit(c.FailEmpty) - } - - w, flush := tableWriter(ctx) - defer flush() - fmt.Fprintln(w, "EMAIL\tROLE\tTYPE") - for _, m := range members { - if m == nil { - continue - } - fmt.Fprintf(w, "%s\t%s\t%s\n", - sanitizeTab(m.Email), - sanitizeTab(m.Role), - sanitizeTab(m.Type), - ) - } - printNextPageHint(u, nextPageToken) - return nil -} - -// AdminGroupsMembersAddCmd adds a member to a group. -type AdminGroupsMembersAddCmd struct { - GroupEmail string `arg:"" name:"groupEmail" help:"Group email"` - MemberEmail string `arg:"" name:"memberEmail" help:"Member email to add"` - Role string `name:"role" help:"Member role (MEMBER, MANAGER, OWNER)" default:"MEMBER"` -} - -func (c *AdminGroupsMembersAddCmd) 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) - memberEmail := strings.TrimSpace(c.MemberEmail) - if groupEmail == "" || memberEmail == "" { - return usage("group email and member email required") - } - - role := strings.ToUpper(c.Role) - if role != adminRoleMember && role != adminRoleManager && role != adminRoleOwner { - return usage("role must be MEMBER, MANAGER, or OWNER") - } - - member := &admin.Member{ - Email: memberEmail, - Role: role, - } - - if dryRunErr := dryRunExit(ctx, flags, fmt.Sprintf("add %s to %s as %s", memberEmail, groupEmail, role), member); dryRunErr != nil { - return dryRunErr - } - - svc, err := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - created, err := svc.Members.Insert(groupEmail, member).Context(ctx).Do() - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ - "email": created.Email, - "role": created.Role, - }) - } - - u.Out().Printf("Added %s to %s as %s", created.Email, groupEmail, created.Role) - return nil -} - -// AdminGroupsMembersRemoveCmd removes a member from a group. -type AdminGroupsMembersRemoveCmd struct { - GroupEmail string `arg:"" name:"groupEmail" help:"Group email"` - MemberEmail string `arg:"" name:"memberEmail" help:"Member email to remove"` -} - -func (c *AdminGroupsMembersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { - account, err := requireAccount(flags) - if err != nil { - return err - } - - groupEmail := strings.TrimSpace(c.GroupEmail) - memberEmail := strings.TrimSpace(c.MemberEmail) - if groupEmail == "" || memberEmail == "" { - return usage("group email and member email required") - } - - if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", memberEmail, groupEmail)); confirmErr != nil { - return confirmErr - } - - svc, err := newAdminDirectoryService(ctx, account) - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - err = svc.Members.Delete(groupEmail, memberEmail).Context(ctx).Do() - if err != nil { - return wrapAdminDirectoryError(err, account) - } - - if outfmt.IsJSON(ctx) { - return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ - "removed": true, - "email": memberEmail, - "group": groupEmail, - }) - } - - u := ui.FromContext(ctx) - u.Out().Printf("Removed %s from %s", memberEmail, groupEmail) - return nil -} - -// wrapAdminDirectoryError provides helpful error messages for common Admin SDK issues. -func wrapAdminDirectoryError(err error, account string) error { - errStr := err.Error() - if strings.Contains(errStr, "accessNotConfigured") || - strings.Contains(errStr, "Admin SDK API has not been used") { - return errfmt.NewUserFacingError("Admin SDK API is not enabled; enable it at: https://console.developers.google.com/apis/api/admin.googleapis.com/overview", err) - } - if strings.Contains(errStr, "insufficientPermissions") || - strings.Contains(errStr, "insufficient authentication scopes") || - strings.Contains(errStr, "Not Authorized") { - return errfmt.NewUserFacingError("Insufficient permissions for Admin SDK API; ensure your service account has domain-wide delegation enabled with admin.directory.user and admin.directory.group scopes", err) - } - if strings.Contains(errStr, "domain_wide_delegation") || - strings.Contains(errStr, "invalid_grant") { - return errfmt.NewUserFacingError("Domain-wide delegation not configured or invalid; ensure your service account has domain-wide delegation enabled in Google Workspace Admin Console", err) - } - if isConsumerAccount(account) { - return errfmt.NewUserFacingError("Admin SDK Directory API requires a Google Workspace account with domain-wide delegation; consumer accounts (gmail.com/googlemail.com) are not supported.", err) - } - return err -} diff --git a/internal/cmd/admin_common.go b/internal/cmd/admin_common.go new file mode 100644 index 00000000..128ce689 --- /dev/null +++ b/internal/cmd/admin_common.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "strings" + + "github.com/steipete/gogcli/internal/errfmt" + "github.com/steipete/gogcli/internal/googleapi" +) + +var newAdminDirectoryService = googleapi.NewAdminDirectory + +const ( + adminRoleMember = "MEMBER" + adminRoleOwner = "OWNER" + adminRoleManager = "MANAGER" +) + +func requireAdminAccount(flags *RootFlags) (string, error) { + account, err := requireAccount(flags) + if err != nil { + return "", err + } + if isConsumerAccount(account) { + return "", errfmt.NewUserFacingError( + "Admin SDK Directory API requires a Google Workspace account with domain-wide delegation; consumer accounts (gmail.com/googlemail.com) are not supported.", + nil, + ) + } + return account, nil +} + +// wrapAdminDirectoryError provides helpful error messages for common Admin SDK issues. +func wrapAdminDirectoryError(err error, account string) error { + errStr := err.Error() + if strings.Contains(errStr, "accessNotConfigured") || + strings.Contains(errStr, "Admin SDK API has not been used") { + return errfmt.NewUserFacingError("Admin SDK API is not enabled; enable it at: https://console.developers.google.com/apis/api/admin.googleapis.com/overview", err) + } + if strings.Contains(errStr, "insufficientPermissions") || + strings.Contains(errStr, "insufficient authentication scopes") || + strings.Contains(errStr, "Not Authorized") { + return errfmt.NewUserFacingError("Insufficient permissions for Admin SDK API; ensure your service account has domain-wide delegation enabled with admin.directory.user, admin.directory.group, and admin.directory.group.member scopes", err) + } + if strings.Contains(errStr, "domain_wide_delegation") || + strings.Contains(errStr, "invalid_grant") { + return errfmt.NewUserFacingError("Domain-wide delegation not configured or invalid; ensure your service account has domain-wide delegation enabled in Google Workspace Admin Console", err) + } + if isConsumerAccount(account) { + return errfmt.NewUserFacingError("Admin SDK Directory API requires a Google Workspace account with domain-wide delegation; consumer accounts (gmail.com/googlemail.com) are not supported.", err) + } + return err +} diff --git a/internal/cmd/admin_groups.go b/internal/cmd/admin_groups.go new file mode 100644 index 00000000..ffa51fce --- /dev/null +++ b/internal/cmd/admin_groups.go @@ -0,0 +1,340 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// AdminGroupsCmd manages Workspace groups. +type AdminGroupsCmd struct { + List AdminGroupsListCmd `cmd:"" name:"list" aliases:"ls" help:"List groups in a domain"` + Members AdminGroupsMembersCmd `cmd:"" name:"members" help:"Manage group members"` +} + +type AdminGroupsListCmd struct { + Domain string `name:"domain" help:"Domain to list groups from (e.g., example.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 *AdminGroupsListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + domain := strings.TrimSpace(c.Domain) + if domain == "" { + return usage("domain required (e.g., --domain example.com)") + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + fetch := func(pageToken string) ([]*admin.Group, string, error) { + call := svc.Groups.List(). + Domain(domain). + MaxResults(c.Max). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + resp, fetchErr := call.Do() + if fetchErr != nil { + return nil, "", wrapAdminDirectoryError(fetchErr, account) + } + return resp.Groups, resp.NextPageToken, nil + } + + var groups []*admin.Group + nextPageToken := "" + if c.All { + all, collectErr := collectAllPages(c.Page, fetch) + if collectErr != nil { + return collectErr + } + groups = all + } else { + groups, nextPageToken, err = fetch(c.Page) + if err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + type item struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + DirectMembersCount int64 `json:"directMembersCount"` + } + items := make([]item, 0, len(groups)) + for _, group := range groups { + if group == nil { + continue + } + items = append(items, item{ + Email: group.Email, + Name: group.Name, + Description: group.Description, + DirectMembersCount: group.DirectMembersCount, + }) + } + 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(groups) == 0 { + u.Err().Println("No groups found") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tNAME\tMEMBERS\tDESCRIPTION") + for _, group := range groups { + if group == nil { + continue + } + desc := group.Description + if len(desc) > 50 { + desc = desc[:47] + "..." + } + fmt.Fprintf(w, "%s\t%s\t%d\t%s\n", + sanitizeTab(group.Email), + sanitizeTab(group.Name), + group.DirectMembersCount, + sanitizeTab(desc), + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +type AdminGroupsMembersCmd struct { + List AdminGroupsMembersListCmd `cmd:"" name:"list" aliases:"ls" help:"List group members"` + Add AdminGroupsMembersAddCmd `cmd:"" name:"add" aliases:"invite" help:"Add a member to a group"` + Remove AdminGroupsMembersRemoveCmd `cmd:"" name:"remove" aliases:"rm,del,delete" help:"Remove a member from a group"` +} + +type AdminGroupsMembersListCmd struct { + GroupEmail string `arg:"" name:"groupEmail" help:"Group email (e.g., engineering@example.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 *AdminGroupsMembersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + groupEmail := strings.TrimSpace(c.GroupEmail) + if groupEmail == "" { + return usage("group email required") + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + fetch := func(pageToken string) ([]*admin.Member, string, error) { + call := svc.Members.List(groupEmail). + MaxResults(c.Max). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + resp, fetchErr := call.Do() + if fetchErr != nil { + return nil, "", wrapAdminDirectoryError(fetchErr, account) + } + return resp.Members, resp.NextPageToken, nil + } + + var members []*admin.Member + nextPageToken := "" + if c.All { + all, collectErr := collectAllPages(c.Page, fetch) + if collectErr != nil { + return collectErr + } + members = all + } else { + members, nextPageToken, err = fetch(c.Page) + 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(members)) + for _, member := range members { + if member == nil { + continue + } + items = append(items, item{ + Email: member.Email, + Role: member.Role, + Type: member.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(members) == 0 { + u.Err().Println("No members found") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tROLE\tTYPE") + for _, member := range members { + if member == nil { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", + sanitizeTab(member.Email), + sanitizeTab(member.Role), + sanitizeTab(member.Type), + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +type AdminGroupsMembersAddCmd struct { + GroupEmail string `arg:"" name:"groupEmail" help:"Group email"` + MemberEmail string `arg:"" name:"memberEmail" help:"Member email to add"` + Role string `name:"role" help:"Member role (MEMBER, MANAGER, OWNER)" default:"MEMBER"` +} + +func (c *AdminGroupsMembersAddCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + groupEmail := strings.TrimSpace(c.GroupEmail) + memberEmail := strings.TrimSpace(c.MemberEmail) + if groupEmail == "" || memberEmail == "" { + return usage("group email and member email required") + } + + role := strings.ToUpper(c.Role) + if role != adminRoleMember && role != adminRoleManager && role != adminRoleOwner { + return usage("role must be MEMBER, MANAGER, or OWNER") + } + + member := &admin.Member{ + Email: memberEmail, + Role: role, + } + + if dryRunErr := dryRunExit(ctx, flags, fmt.Sprintf("add %s to %s as %s", memberEmail, groupEmail, role), member); dryRunErr != nil { + return dryRunErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + created, err := svc.Members.Insert(groupEmail, member).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "email": created.Email, + "role": created.Role, + }) + } + + u.Out().Printf("Added %s to %s as %s", created.Email, groupEmail, created.Role) + return nil +} + +type AdminGroupsMembersRemoveCmd struct { + GroupEmail string `arg:"" name:"groupEmail" help:"Group email"` + MemberEmail string `arg:"" name:"memberEmail" help:"Member email to remove"` +} + +func (c *AdminGroupsMembersRemoveCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + groupEmail := strings.TrimSpace(c.GroupEmail) + memberEmail := strings.TrimSpace(c.MemberEmail) + if groupEmail == "" || memberEmail == "" { + return usage("group email and member email required") + } + + if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("remove %s from %s", memberEmail, groupEmail)); confirmErr != nil { + return confirmErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if err := svc.Members.Delete(groupEmail, memberEmail).Context(ctx).Do(); err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "removed": true, + "email": memberEmail, + "group": groupEmail, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Removed %s from %s", memberEmail, groupEmail) + return nil +} diff --git a/internal/cmd/admin_test.go b/internal/cmd/admin_test.go new file mode 100644 index 00000000..b5fade8b --- /dev/null +++ b/internal/cmd/admin_test.go @@ -0,0 +1,184 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + admin "google.golang.org/api/admin/directory/v1" + "google.golang.org/api/option" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +func TestRequireAdminAccount_ConsumerBlocked(t *testing.T) { + account, err := requireAdminAccount(&RootFlags{Account: "user@gmail.com"}) + if err == nil { + t.Fatal("expected error") + } + if account != "" { + t.Fatalf("expected empty account, got %q", account) + } + if !strings.Contains(err.Error(), "Google Workspace account") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWrapAdminDirectoryError_MapsPermissions(t *testing.T) { + err := wrapAdminDirectoryError(errors.New("insufficient authentication scopes"), "svc@example.com") + if err == nil || !strings.Contains(err.Error(), "admin.directory.group.member") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestAdminUsersCreate_ValidationErrors(t *testing.T) { + ctx := context.Background() + flags := &RootFlags{Account: "svc@example.com"} + + tests := []struct { + name string + cmd AdminUsersCreateCmd + want string + }{ + {name: "missing email", cmd: AdminUsersCreateCmd{GivenName: "Ada", FamilyName: "Lovelace", Password: "pw"}, want: "email required"}, + {name: "missing given", cmd: AdminUsersCreateCmd{Email: "ada@example.com", FamilyName: "Lovelace", Password: "pw"}, want: "--given required"}, + {name: "missing family", cmd: AdminUsersCreateCmd{Email: "ada@example.com", GivenName: "Ada", Password: "pw"}, want: "--family required"}, + {name: "missing password", cmd: AdminUsersCreateCmd{Email: "ada@example.com", GivenName: "Ada", FamilyName: "Lovelace"}, want: "--password required"}, + {name: "admin unsupported", cmd: AdminUsersCreateCmd{Email: "ada@example.com", GivenName: "Ada", FamilyName: "Lovelace", Password: "pw", Admin: true}, want: "--admin is not supported"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := tc.cmd.Run(ctx, flags); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Run() error = %v, want substring %q", err, tc.want) + } + }) + } +} + +func TestAdminUsersList_JSON_AllowsNilName(t *testing.T) { + origNew := newAdminDirectoryService + t.Cleanup(func() { newAdminDirectoryService = origNew }) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/users")) { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "users": []map[string]any{ + { + "primaryEmail": "ada@example.com", + "suspended": false, + "isAdmin": true, + }, + }, + }) + })) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAdminDirectoryService = func(context.Context, string) (*admin.Service, error) { return svc, nil } + + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := (&AdminUsersListCmd{Domain: "example.com"}).Run(ctx, &RootFlags{Account: "svc@example.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + var parsed struct { + Users []struct { + Email string `json:"email"` + Name string `json:"name"` + Admin bool `json:"admin"` + } `json:"users"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(parsed.Users) != 1 || parsed.Users[0].Email != "ada@example.com" || parsed.Users[0].Name != "" || !parsed.Users[0].Admin { + t.Fatalf("unexpected users: %#v", parsed.Users) + } +} + +func TestAdminGroupsMembersAdd_JSON(t *testing.T) { + origNew := newAdminDirectoryService + t.Cleanup(func() { newAdminDirectoryService = origNew }) + + var gotRole string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !(r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/members")) { + http.NotFound(w, r) + return + } + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + gotRole, _ = body["role"].(string) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "email": "dev@example.com", + "role": gotRole, + }) + })) + defer srv.Close() + + svc, err := admin.NewService(context.Background(), + option.WithoutAuthentication(), + option.WithHTTPClient(srv.Client()), + option.WithEndpoint(srv.URL+"/"), + ) + if err != nil { + t.Fatalf("NewService: %v", err) + } + newAdminDirectoryService = func(context.Context, string) (*admin.Service, error) { return svc, nil } + + u, uiErr := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + ctx := outfmt.WithMode(ui.WithUI(context.Background(), u), outfmt.Mode{JSON: true}) + + out := captureStdout(t, func() { + if err := (&AdminGroupsMembersAddCmd{ + GroupEmail: "eng@example.com", + MemberEmail: "dev@example.com", + Role: "owner", + }).Run(ctx, &RootFlags{Account: "svc@example.com"}); err != nil { + t.Fatalf("Run: %v", err) + } + }) + + if gotRole != adminRoleOwner { + t.Fatalf("unexpected role sent: %q", gotRole) + } + var parsed struct { + Email string `json:"email"` + Role string `json:"role"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if parsed.Email != "dev@example.com" || parsed.Role != adminRoleOwner { + t.Fatalf("unexpected response: %#v", parsed) + } +} diff --git a/internal/cmd/admin_users.go b/internal/cmd/admin_users.go new file mode 100644 index 00000000..dea841ed --- /dev/null +++ b/internal/cmd/admin_users.go @@ -0,0 +1,345 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + admin "google.golang.org/api/admin/directory/v1" + + "github.com/steipete/gogcli/internal/outfmt" + "github.com/steipete/gogcli/internal/ui" +) + +// AdminUsersCmd manages Workspace users. +type AdminUsersCmd struct { + List AdminUsersListCmd `cmd:"" name:"list" aliases:"ls" help:"List users in a domain"` + Get AdminUsersGetCmd `cmd:"" name:"get" aliases:"info,show" help:"Get user details"` + Create AdminUsersCreateCmd `cmd:"" name:"create" aliases:"add,new" help:"Create a new user"` + Suspend AdminUsersSuspendCmd `cmd:"" name:"suspend" help:"Suspend a user account"` +} + +type AdminUsersListCmd struct { + Domain string `name:"domain" help:"Domain to list users from (e.g., example.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 *AdminUsersListCmd) Run(ctx context.Context, flags *RootFlags) error { + u := ui.FromContext(ctx) + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + domain := strings.TrimSpace(c.Domain) + if domain == "" { + return usage("domain required (e.g., --domain example.com)") + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + fetch := func(pageToken string) ([]*admin.User, string, error) { + call := svc.Users.List(). + Domain(domain). + MaxResults(c.Max). + Context(ctx) + if strings.TrimSpace(pageToken) != "" { + call = call.PageToken(pageToken) + } + resp, fetchErr := call.Do() + if fetchErr != nil { + return nil, "", wrapAdminDirectoryError(fetchErr, account) + } + return resp.Users, resp.NextPageToken, nil + } + + var users []*admin.User + nextPageToken := "" + if c.All { + all, collectErr := collectAllPages(c.Page, fetch) + if collectErr != nil { + return collectErr + } + users = all + } else { + users, nextPageToken, err = fetch(c.Page) + if err != nil { + return err + } + } + + if outfmt.IsJSON(ctx) { + type item struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + Suspended bool `json:"suspended"` + Admin bool `json:"admin"` + } + items := make([]item, 0, len(users)) + for _, user := range users { + if user == nil { + continue + } + name := "" + if user.Name != nil { + name = user.Name.FullName + } + items = append(items, item{ + Email: user.PrimaryEmail, + Name: name, + Suspended: user.Suspended, + Admin: user.IsAdmin, + }) + } + if err := outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "users": items, + "nextPageToken": nextPageToken, + }); err != nil { + return err + } + if len(items) == 0 { + return failEmptyExit(c.FailEmpty) + } + return nil + } + + if len(users) == 0 { + u.Err().Println("No users found") + return failEmptyExit(c.FailEmpty) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintln(w, "EMAIL\tNAME\tSUSPENDED\tADMIN") + for _, user := range users { + if user == nil { + continue + } + suspended := "no" + if user.Suspended { + suspended = "yes" + } + isAdmin := "no" + if user.IsAdmin { + isAdmin = "yes" + } + name := "" + if user.Name != nil { + name = user.Name.FullName + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + sanitizeTab(user.PrimaryEmail), + sanitizeTab(name), + suspended, + isAdmin, + ) + } + printNextPageHint(u, nextPageToken) + return nil +} + +type AdminUsersGetCmd struct { + UserEmail string `arg:"" name:"userEmail" help:"User email (e.g., user@example.com)"` +} + +func (c *AdminUsersGetCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + userEmail := strings.TrimSpace(c.UserEmail) + if userEmail == "" { + return usage("user email required") + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + user, err := svc.Users.Get(userEmail).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + type item struct { + Email string `json:"email"` + Name string `json:"name,omitempty"` + GivenName string `json:"givenName,omitempty"` + FamilyName string `json:"familyName,omitempty"` + Suspended bool `json:"suspended"` + Admin bool `json:"admin"` + Aliases []string `json:"aliases,omitempty"` + OrgUnitPath string `json:"orgUnitPath,omitempty"` + Creation string `json:"creationTime,omitempty"` + LastLogin string `json:"lastLoginTime,omitempty"` + } + var aliases []string + if user.Aliases != nil { + aliases = user.Aliases + } + name := "" + givenName := "" + familyName := "" + if user.Name != nil { + name = user.Name.FullName + givenName = user.Name.GivenName + familyName = user.Name.FamilyName + } + return outfmt.WriteJSON(ctx, os.Stdout, item{ + Email: user.PrimaryEmail, + Name: name, + GivenName: givenName, + FamilyName: familyName, + Suspended: user.Suspended, + Admin: user.IsAdmin, + Aliases: aliases, + OrgUnitPath: user.OrgUnitPath, + Creation: user.CreationTime, + LastLogin: user.LastLoginTime, + }) + } + + w, flush := tableWriter(ctx) + defer flush() + fmt.Fprintf(w, "Email:\t%s\n", user.PrimaryEmail) + if user.Name != nil { + fmt.Fprintf(w, "Name:\t%s\n", user.Name.FullName) + fmt.Fprintf(w, "Given Name:\t%s\n", user.Name.GivenName) + fmt.Fprintf(w, "Family Name:\t%s\n", user.Name.FamilyName) + } + fmt.Fprintf(w, "Suspended:\t%t\n", user.Suspended) + fmt.Fprintf(w, "Admin:\t%t\n", user.IsAdmin) + fmt.Fprintf(w, "Org Unit:\t%s\n", user.OrgUnitPath) + fmt.Fprintf(w, "Created:\t%s\n", user.CreationTime) + fmt.Fprintf(w, "Last Login:\t%s\n", user.LastLoginTime) + if len(user.Aliases) > 0 { + fmt.Fprintf(w, "Aliases:\t%s\n", strings.Join(user.Aliases, ", ")) + } + return nil +} + +type AdminUsersCreateCmd struct { + Email string `arg:"" name:"email" help:"User email (e.g., user@example.com)"` + GivenName string `name:"given" help:"Given (first) name"` + FamilyName string `name:"family" help:"Family (last) name"` + Password string `name:"password" help:"Initial password"` //nolint:gosec // CLI input for initial admin-provisioned passwords. + ChangePwd bool `name:"change-password" help:"Require password change on first login"` + OrgUnit string `name:"org-unit" help:"Organization unit path"` + Admin bool `name:"admin" help:"Not supported; assign admin roles separately after user creation"` +} + +func (c *AdminUsersCreateCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + email := strings.TrimSpace(c.Email) + givenName := strings.TrimSpace(c.GivenName) + familyName := strings.TrimSpace(c.FamilyName) + password := strings.TrimSpace(c.Password) + if email == "" { + return usage("email required") + } + if givenName == "" { + return usage("--given required") + } + if familyName == "" { + return usage("--family required") + } + if password == "" { + return usage("--password required") + } + if c.Admin { + return usage("--admin is not supported; assign admin roles separately after user creation") + } + + user := &admin.User{ + PrimaryEmail: email, + Name: &admin.UserName{ + GivenName: givenName, + FamilyName: familyName, + }, + Password: password, + ChangePasswordAtNextLogin: c.ChangePwd, + } + if c.OrgUnit != "" { + user.OrgUnitPath = c.OrgUnit + } + + if dryRunErr := dryRunExit(ctx, flags, "create user", user); dryRunErr != nil { + return dryRunErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + created, err := svc.Users.Insert(user).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "email": created.PrimaryEmail, + "id": created.Id, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Created user: %s (ID: %s)", created.PrimaryEmail, created.Id) + return nil +} + +type AdminUsersSuspendCmd struct { + UserEmail string `arg:"" name:"userEmail" help:"User email to suspend"` +} + +func (c *AdminUsersSuspendCmd) Run(ctx context.Context, flags *RootFlags) error { + account, err := requireAdminAccount(flags) + if err != nil { + return err + } + + userEmail := strings.TrimSpace(c.UserEmail) + if userEmail == "" { + return usage("user email required") + } + + if confirmErr := confirmDestructive(ctx, flags, fmt.Sprintf("suspend user %s", userEmail)); confirmErr != nil { + return confirmErr + } + + svc, err := newAdminDirectoryService(ctx, account) + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + updated, err := svc.Users.Update(userEmail, &admin.User{Suspended: true}).Context(ctx).Do() + if err != nil { + return wrapAdminDirectoryError(err, account) + } + + if outfmt.IsJSON(ctx) { + return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{ + "email": updated.PrimaryEmail, + "suspended": updated.Suspended, + }) + } + + u := ui.FromContext(ctx) + u.Out().Printf("Suspended user: %s", updated.PrimaryEmail) + return nil +} diff --git a/internal/cmd/confirm.go b/internal/cmd/confirm.go index 1537b98c..4ce964ae 100644 --- a/internal/cmd/confirm.go +++ b/internal/cmd/confirm.go @@ -35,7 +35,7 @@ func confirmDestructive(ctx context.Context, flags *RootFlags, action string) er return fmt.Errorf("read confirmation: %w", readErr) } ans := strings.TrimSpace(strings.ToLower(line)) - if ans == "y" || ans == "yes" { + if ans == "y" || ans == sendAsYes { return nil } return &ExitError{Code: 1, Err: errors.New("cancelled")} diff --git a/internal/cmd/docs_sed_brace.go b/internal/cmd/docs_sed_brace.go index b3adf44c..2e0cbd46 100644 --- a/internal/cmd/docs_sed_brace.go +++ b/internal/cmd/docs_sed_brace.go @@ -284,7 +284,7 @@ func parseBraceKeyValue(key, val string, expr *braceExpr) error { } case "check": switch strings.ToLower(val) { - case "y", "yes", boolTrue, "1": + case "y", sendAsYes, boolTrue, "1": t := true expr.Check = &t case "n", "no", boolFalse, "0":