diff --git a/CHANGELOG.md b/CHANGELOG.md index 37466d0aa..c8ee14f1f 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 7b1a3731d..73b7b9bfd 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 new file mode 100644 index 000000000..085cc692f --- /dev/null +++ b/internal/cmd/admin.go @@ -0,0 +1,8 @@ +package cmd + +// 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"` +} diff --git a/internal/cmd/admin_common.go b/internal/cmd/admin_common.go new file mode 100644 index 000000000..128ce6890 --- /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 000000000..ffa51fce0 --- /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 000000000..b5fade8b2 --- /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 000000000..dea841ed8 --- /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 1537b98c5..4ce964ae9 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 b3adf44c1..2e0cbd462 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": diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 26ea5ee38..c898444bd 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 000000000..624e564a4 --- /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 b3c3afe8b..75f9578b8 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 94e258238..c439f107b 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) }