Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type GoogleWorkspaceClient struct {
UserService *directoryAdmin.Service
UserProvisioningService *directoryAdmin.Service
UserSecurityService *directoryAdmin.Service
UserAliasService *directoryAdmin.Service

// Directory – groups
GroupService *directoryAdmin.Service
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down
4 changes: 4 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
93 changes: 93 additions & 0 deletions pkg/connector/user_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The alias argument is user-provided input included verbatim in the error message via %s. While mail.ParseAddress validates it's a valid email address, consider that this string also flows into the generic error at line 810 (fmt.Errorf("...failed to delete alias %s for user %s: %w", alias, userId, err)). This is consistent with other handlers in this file, so low risk, but worth noting that user-supplied values appear in error strings.

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
}
198 changes: 198 additions & 0 deletions pkg/connector/user_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading