From 71c5e8d611fac382dc7de45095bfc113a68ec1d8 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 22:45:14 +0000 Subject: [PATCH] Add delete_user_alias action to Google Workspace connector Adds a new connector action that deletes an email alias from a Google Workspace user account. This enables offboarding automations to remove aliases created automatically when a user's primary email is changed. Changes: - Add UserAliasService field to GoogleWorkspaceClient - Add DeleteUserAlias client method calling UsersAliasesService.Delete - Initialize alias service with admin.directory.user.alias write scope - Add delete_user_alias action schema accepting user_id and alias args - Handle 404 as idempotent success (alias already removed) - Add comprehensive tests for the new action Fixes: CXH-1554 --- pkg/client/client.go | 16 +++ pkg/connector/connector.go | 4 + pkg/connector/user_actions.go | 93 ++++++++++++++ pkg/connector/user_actions_test.go | 198 +++++++++++++++++++++++++++++ 4 files changed, 311 insertions(+) diff --git a/pkg/client/client.go b/pkg/client/client.go index 00acf216..8d13d01a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -27,6 +27,7 @@ type GoogleWorkspaceClient struct { UserService *directoryAdmin.Service UserProvisioningService *directoryAdmin.Service UserSecurityService *directoryAdmin.Service + UserAliasService *directoryAdmin.Service // Directory – groups GroupService *directoryAdmin.Service @@ -232,6 +233,21 @@ func (c *GoogleWorkspaceClient) DeleteAsp(ctx context.Context, userId string, co return nil } +// --------------------------------------------------------------------------- +// Users – aliases (requires UserAliasService) +// --------------------------------------------------------------------------- + +func (c *GoogleWorkspaceClient) DeleteUserAlias(ctx context.Context, userKey, alias string) error { + if c.UserAliasService == nil { + return errServiceNotAvailable("user alias service") + } + err := c.UserAliasService.Users.Aliases.Delete(userKey, alias).Context(ctx).Do() + if err != nil { + return wrapGoogleApiErrorWithContext(err, fmt.Sprintf("failed to delete alias %s for user: %s", alias, userKey)) + } + return nil +} + // --------------------------------------------------------------------------- // Groups – read // --------------------------------------------------------------------------- diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 8b3ab7e9..0f6eadc8 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -604,6 +604,10 @@ func (c *GoogleWorkspace) newClient(ctx context.Context) *gwclient.GoogleWorkspa if err != nil { logServiceInitError(l, err, directoryAdmin.AdminDirectoryUserSecurityScope, "user security operations") } + client.UserAliasService, err = c.getDirectoryService(ctx, directoryAdmin.AdminDirectoryUserAliasScope) + if err != nil { + logServiceInitError(l, err, directoryAdmin.AdminDirectoryUserAliasScope, "user alias operations") + } client.GroupService, err = c.getDirectoryService(ctx, directoryAdmin.AdminDirectoryGroupReadonlyScope) if err != nil { diff --git a/pkg/connector/user_actions.go b/pkg/connector/user_actions.go index d06ad549..63c0641f 100644 --- a/pkg/connector/user_actions.go +++ b/pkg/connector/user_actions.go @@ -185,6 +185,38 @@ var ( ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_UNSPECIFIED}, } + deleteUserAliasActionSchema = &v2.BatonActionSchema{ + Name: "delete_user_alias", + DisplayName: "Delete User Alias", + Description: "Deletes an email alias from a Google Workspace user account. " + + "This removes the alias so that mail sent to that address is no longer delivered to the user.", + Arguments: []*config.Field{ + { + Name: "user_id", + DisplayName: "User ID", + Description: "The resource ID of the user whose alias should be deleted.", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + { + Name: "alias", + DisplayName: "Alias Email", + Description: "The email alias to delete (e.g., 'old-name@example.com').", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + }, + ReturnTypes: []*config.Field{ + { + Name: "success", + DisplayName: "Success", + Description: "Whether the alias was deleted successfully.", + Field: &config.Field_BoolField{}, + }, + }, + ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_UNSPECIFIED}, + } + deleteAllApplicationPasswordsActionSchema = &v2.BatonActionSchema{ Name: "delete_all_application_passwords", DisplayName: "Delete All Application Passwords", @@ -238,6 +270,9 @@ func (o *userResourceType) ResourceActions(ctx context.Context, registry actions if err := o.registerUpdateUserManagerAction(ctx, registry); err != nil { return err } + if err := o.registerDeleteUserAliasAction(ctx, registry); err != nil { + return err + } return nil } @@ -719,3 +754,61 @@ func (o *userResourceType) updateUserManagerActionHandler(ctx context.Context, a return actions.NewReturnValues(true, resourceRv), nil, nil } + +func (o *userResourceType) registerDeleteUserAliasAction(ctx context.Context, registry actions.ActionRegistry) error { + return registry.Register(ctx, deleteUserAliasActionSchema, o.deleteUserAliasActionHandler) +} + +func (o *userResourceType) deleteUserAliasActionHandler(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + if o.client.UserAliasService == nil { + return nil, nil, fmt.Errorf("google-workspace: user alias service not available - requires %s scope", admin.AdminDirectoryUserAliasScope) + } + + userId, err := extractUserId(args, l, "delete_user_alias") + if err != nil { + return nil, nil, err + } + + aliasValue, ok := args.Fields["alias"] + if !ok || aliasValue == nil { + l.Debug("google-workspace: user action handler: missing alias argument", zap.Any("args", args)) + return nil, nil, fmt.Errorf("missing alias argument") + } + aliasField, ok := aliasValue.GetKind().(*structpb.Value_StringValue) + if !ok || aliasField.StringValue == "" { + return nil, nil, fmt.Errorf("invalid alias argument") + } + alias := aliasField.StringValue + + if _, err := mail.ParseAddress(alias); err != nil { + return nil, nil, fmt.Errorf("invalid alias email address: %s", alias) + } + + err = o.client.DeleteUserAlias(ctx, userId, alias) + if err != nil { + gerr := &googleapi.Error{} + if errors.As(err, &gerr) { + if gerr.Code == http.StatusNotFound { + l.Debug("google-workspace: alias already deleted or does not exist", + zap.String("user_id", userId), + zap.String("alias", alias)) + return actions.NewReturnValues(true), nil, nil + } + if gerr.Code == http.StatusForbidden { + return nil, nil, fmt.Errorf( + "google-workspace: failed to delete alias (403 Forbidden). "+ + "This may be due to: 1) missing OAuth scope %s, "+ + "2) insufficient admin permissions: %w", + admin.AdminDirectoryUserAliasScope, err) + } + } + return nil, nil, fmt.Errorf("google-workspace: failed to delete alias %s for user %s: %w", alias, userId, err) + } + + l.Debug("google-workspace: user action handler: deleted alias", + zap.String("user_id", userId), + zap.String("alias", alias)) + + return actions.NewReturnValues(true), nil, nil +} diff --git a/pkg/connector/user_actions_test.go b/pkg/connector/user_actions_test.go index cfc0b14b..20703ddd 100644 --- a/pkg/connector/user_actions_test.go +++ b/pkg/connector/user_actions_test.go @@ -954,3 +954,201 @@ func TestUpdateUserManager_UserNotFound(t *testing.T) { t.Fatalf("expected error message to contain 'not found', got: %v", err) } } + +// Tests for delete_user_alias action + +type testAliasState struct { + mtx sync.Mutex + aliases map[string]map[string]bool // userKey -> set of alias emails + delCount int +} + +func newTestAliasServer(state *testAliasState) *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/admin/directory/v1/users/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/admin/directory/v1/users/") + parts := strings.Split(path, "/") + + if len(parts) == 3 && parts[1] == "aliases" && r.Method == http.MethodDelete { + state.mtx.Lock() + defer state.mtx.Unlock() + state.delCount++ + userKey := parts[0] + alias := parts[2] + + userAliases, ok := state.aliases[userKey] + if !ok { + http.Error(w, `{"error":{"code":404,"message":"Resource Not Found: userKey"}}`, http.StatusNotFound) + return + } + if !userAliases[alias] { + http.Error(w, `{"error":{"code":404,"message":"Resource Not Found: alias"}}`, http.StatusNotFound) + return + } + delete(userAliases, alias) + w.WriteHeader(http.StatusOK) + return + } + + http.Error(w, "not found", http.StatusNotFound) + }) + + return httptest.NewServer(mux) +} + +func newTestUserResourceTypeWithAlias(t *testing.T, server *httptest.Server) *userResourceType { + t.Helper() + aliasDir := newTestDirectoryService(t, server.URL, server.Client()) + return &userResourceType{ + resourceType: resourceTypeUser, + client: &gwclient.GoogleWorkspaceClient{ + UserAliasService: aliasDir, + }, + customerId: "test-customer", + domain: "", + } +} + +func TestDeleteUserAlias_Success(t *testing.T) { + state := &testAliasState{ + aliases: map[string]map[string]bool{ + "user123": {"old-name@example.com": true, "other@example.com": true}, + }, + } + server := newTestAliasServer(state) + defer server.Close() + + userRT := newTestUserResourceTypeWithAlias(t, server) + + args := &structpb.Struct{Fields: map[string]*structpb.Value{ + "user_id": {Kind: &structpb.Value_StringValue{StringValue: "user123"}}, + "alias": {Kind: &structpb.Value_StringValue{StringValue: "old-name@example.com"}}, + }} + + resp, _, err := userRT.deleteUserAliasActionHandler(context.Background(), args) + if err != nil { + t.Fatalf("deleteUserAlias: %v", err) + } + + if !resp.GetFields()["success"].GetBoolValue() { + t.Fatalf("expected success to be true") + } + + if state.delCount != 1 { + t.Fatalf("expected 1 DELETE call, got %d", state.delCount) + } + + if state.aliases["user123"]["old-name@example.com"] { + t.Fatalf("expected alias to be deleted") + } + if !state.aliases["user123"]["other@example.com"] { + t.Fatalf("expected other alias to remain") + } +} + +func TestDeleteUserAlias_NotFound_Idempotent(t *testing.T) { + state := &testAliasState{ + aliases: map[string]map[string]bool{ + "user123": {}, + }, + } + server := newTestAliasServer(state) + defer server.Close() + + userRT := newTestUserResourceTypeWithAlias(t, server) + + args := &structpb.Struct{Fields: map[string]*structpb.Value{ + "user_id": {Kind: &structpb.Value_StringValue{StringValue: "user123"}}, + "alias": {Kind: &structpb.Value_StringValue{StringValue: "nonexistent@example.com"}}, + }} + + resp, _, err := userRT.deleteUserAliasActionHandler(context.Background(), args) + if err != nil { + t.Fatalf("deleteUserAlias should succeed when alias not found (idempotent): %v", err) + } + + if !resp.GetFields()["success"].GetBoolValue() { + t.Fatalf("expected success to be true") + } +} + +func TestDeleteUserAlias_MissingUserId(t *testing.T) { + state := &testAliasState{aliases: map[string]map[string]bool{}} + server := newTestAliasServer(state) + defer server.Close() + + userRT := newTestUserResourceTypeWithAlias(t, server) + + args := &structpb.Struct{Fields: map[string]*structpb.Value{ + "alias": {Kind: &structpb.Value_StringValue{StringValue: "old@example.com"}}, + }} + + _, _, err := userRT.deleteUserAliasActionHandler(context.Background(), args) + if err == nil { + t.Fatalf("expected error for missing user_id") + } + if !strings.Contains(err.Error(), "missing user_id") { + t.Fatalf("expected error message to contain 'missing user_id', got: %v", err) + } +} + +func TestDeleteUserAlias_MissingAlias(t *testing.T) { + state := &testAliasState{aliases: map[string]map[string]bool{}} + server := newTestAliasServer(state) + defer server.Close() + + userRT := newTestUserResourceTypeWithAlias(t, server) + + args := &structpb.Struct{Fields: map[string]*structpb.Value{ + "user_id": {Kind: &structpb.Value_StringValue{StringValue: "user123"}}, + }} + + _, _, err := userRT.deleteUserAliasActionHandler(context.Background(), args) + if err == nil { + t.Fatalf("expected error for missing alias") + } + if !strings.Contains(err.Error(), "missing alias") { + t.Fatalf("expected error message to contain 'missing alias', got: %v", err) + } +} + +func TestDeleteUserAlias_InvalidAliasEmail(t *testing.T) { + state := &testAliasState{aliases: map[string]map[string]bool{}} + server := newTestAliasServer(state) + defer server.Close() + + userRT := newTestUserResourceTypeWithAlias(t, server) + + args := &structpb.Struct{Fields: map[string]*structpb.Value{ + "user_id": {Kind: &structpb.Value_StringValue{StringValue: "user123"}}, + "alias": {Kind: &structpb.Value_StringValue{StringValue: "not-an-email"}}, + }} + + _, _, err := userRT.deleteUserAliasActionHandler(context.Background(), args) + if err == nil { + t.Fatalf("expected error for invalid email") + } + if !strings.Contains(err.Error(), "invalid alias email") { + t.Fatalf("expected error message to contain 'invalid alias email', got: %v", err) + } +} + +func TestDeleteUserAlias_NoAliasService(t *testing.T) { + userRT := &userResourceType{ + client: &gwclient.GoogleWorkspaceClient{UserAliasService: nil}, + } + + args := &structpb.Struct{Fields: map[string]*structpb.Value{ + "user_id": {Kind: &structpb.Value_StringValue{StringValue: "user123"}}, + "alias": {Kind: &structpb.Value_StringValue{StringValue: "old@example.com"}}, + }} + + _, _, err := userRT.deleteUserAliasActionHandler(context.Background(), args) + if err == nil { + t.Fatalf("expected error when alias service is nil") + } + if !strings.Contains(err.Error(), "user alias service not available") { + t.Fatalf("expected error about missing service, got: %v", err) + } +}