From 1f4603f3ceeafdfa1c910e5caa6047f74939d961 Mon Sep 17 00:00:00 2001 From: Guillermo Roman Date: Fri, 28 Aug 2026 16:14:40 -0500 Subject: [PATCH] feat: add transfer_and_delete_user custom action Adds a resource-scoped connector action that reassigns a user's meetings, webinars, and cloud recordings to another Zoom user, then disassociates or permanently deletes the user, via Zoom's DELETE /v2/users/{userId} transfer query parameters. --- baton_capabilities.json | 3 +- docs/connector.mdx | 8 ++ pkg/connector/actions.go | 163 +++++++++++++++++++++++++++++++++++++++ pkg/zoom/client.go | 56 +++++++++++++- pkg/zoom/models.go | 10 +++ 5 files changed, 237 insertions(+), 3 deletions(-) create mode 100644 pkg/connector/actions.go diff --git a/baton_capabilities.json b/baton_capabilities.json index d42fc33d..515319cb 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -294,7 +294,8 @@ "CAPABILITY_PROVISION", "CAPABILITY_SYNC", "CAPABILITY_ACCOUNT_PROVISIONING", - "CAPABILITY_RESOURCE_DELETE" + "CAPABILITY_RESOURCE_DELETE", + "CAPABILITY_ACTIONS" ], "credentialDetails": { "capabilityAccountProvisioning": { diff --git a/docs/connector.mdx b/docs/connector.mdx index 6c4a44a6..83b01a2d 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -34,6 +34,14 @@ The Zoom connector supports [automatic account provisioning](/product/admin/acco The Zoom connector supports account deprovisioning (user deletion). +### Connector actions + +Connector actions are custom capabilities that extend C1 automations with app-specific operations. You can use connector actions in the [Perform connector action](/product/admin/automations-steps-reference#perform-connector-action) automation step. + +| Action name | Additional fields | Description | +|-------------|-------------------|-------------| +| `transfer_and_delete_user` | `user_id` (resource, required), `action` (string, required: `disassociate` or `delete`), `transfer_email` (string, required if any transfer option is set), `transfer_meeting` (bool), `transfer_webinar` (bool), `transfer_recording` (bool) | Reassigns a user's meetings, webinars, and cloud recordings to another Zoom user, then removes the user from the account | + ## Gather Zoom credentials Configuring the connector requires you to pass in credentials generated in Zoom. Gather these credentials before you move on. diff --git a/pkg/connector/actions.go b/pkg/connector/actions.go new file mode 100644 index 00000000..9d50bc2a --- /dev/null +++ b/pkg/connector/actions.go @@ -0,0 +1,163 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "net/http" + + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/actions" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-zoom/pkg/zoom" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" +) + +const ( + actionTransferAndDeleteUser = "transfer_and_delete_user" + + argUserID = "user_id" + argDeleteAction = "action" + argTransferEmail = "transfer_email" + argTransferMeeting = "transfer_meeting" + argTransferWebinar = "transfer_webinar" + argTransferRecording = "transfer_recording" +) + +var transferAndDeleteUserSchema = &v2.BatonActionSchema{ + Name: actionTransferAndDeleteUser, + DisplayName: "Transfer Data and Delete User", + Description: "Reassigns a user's meetings, webinars, and cloud recordings to another Zoom user, then removes the user from the account. This operation cannot be recovered.", + Arguments: []*config.Field{ + { + Name: argUserID, + DisplayName: "User", + Description: "The Zoom user to remove.", + IsRequired: true, + Field: &config.Field_ResourceIdField{ + ResourceIdField: &config.ResourceIdField{ + Rules: &config.ResourceIDRules{ + AllowedResourceTypeIds: []string{"user"}, + }, + }, + }, + }, + { + Name: argDeleteAction, + DisplayName: "Action", + Description: "Whether to disassociate the user from the account or permanently delete them.", + IsRequired: true, + Field: &config.Field_StringField{ + StringField: &config.StringField{ + Rules: &config.StringRules{ + In: []string{string(zoom.Disassociate), string(zoom.Delete)}, + }, + Options: []*config.StringFieldOption{ + {Name: string(zoom.Disassociate), Value: string(zoom.Disassociate), DisplayName: "Disassociate"}, + {Name: string(zoom.Delete), Value: string(zoom.Delete), DisplayName: "Delete"}, + }, + }, + }, + }, + { + Name: argTransferEmail, + DisplayName: "Transfer To", + Description: "Email of the Zoom user to receive the transferred meetings, webinars, and recordings. Required if any transfer option below is enabled.", + Field: &config.Field_StringField{}, + }, + { + Name: argTransferMeeting, + DisplayName: "Transfer Meetings", + Description: "Transfer the user's scheduled meetings to the Transfer To user.", + Field: &config.Field_BoolField{}, + }, + { + Name: argTransferWebinar, + DisplayName: "Transfer Webinars", + Description: "Transfer the user's scheduled webinars to the Transfer To user.", + Field: &config.Field_BoolField{}, + }, + { + Name: argTransferRecording, + DisplayName: "Transfer Cloud Recordings", + Description: "Transfer the user's cloud recordings to the Transfer To user.", + Field: &config.Field_BoolField{}, + }, + }, + ReturnTypes: []*config.Field{ + {Name: "success", DisplayName: "Success", Field: &config.Field_BoolField{}}, + {Name: "message", DisplayName: "Message", Field: &config.Field_StringField{}}, + }, + ActionType: []v2.ActionType{v2.ActionType_ACTION_TYPE_RESOURCE_DELETE}, +} + +var _ connectorbuilder.ResourceActionProvider = (*userResourceType)(nil) + +func (u *userResourceType) ResourceActions(ctx context.Context, registry actions.ActionRegistry) error { + if err := registry.Register(ctx, transferAndDeleteUserSchema, u.transferAndDeleteUserAction); err != nil { + return fmt.Errorf("baton-zoom: register transfer_and_delete_user action: %w", err) + } + return nil +} + +func (u *userResourceType) transferAndDeleteUserAction( + ctx context.Context, + args *structpb.Struct, +) (*structpb.Struct, annotations.Annotations, error) { + userRef, err := actions.RequireResourceIDArg(args, argUserID) + if err != nil { + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-zoom: transfer_and_delete_user: %v", err) + } + userID := userRef.GetResource() + + deleteAction, err := actions.RequireStringArg(args, argDeleteAction) + if err != nil { + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-zoom: transfer_and_delete_user: %v", err) + } + if deleteAction != string(zoom.Disassociate) && deleteAction != string(zoom.Delete) { + return nil, nil, status.Errorf(codes.InvalidArgument, "baton-zoom: transfer_and_delete_user: action must be %q or %q", zoom.Disassociate, zoom.Delete) + } + + transferEmail, _ := actions.GetStringArg(args, argTransferEmail) + transferMeeting, _ := actions.GetBoolArg(args, argTransferMeeting) + transferWebinar, _ := actions.GetBoolArg(args, argTransferWebinar) + transferRecording, _ := actions.GetBoolArg(args, argTransferRecording) + + if (transferMeeting || transferWebinar || transferRecording) && transferEmail == "" { + return nil, nil, status.Error(codes.InvalidArgument, "baton-zoom: transfer_and_delete_user: transfer_email is required when transfer_meeting, transfer_webinar, or transfer_recording is set") + } + + err = u.client.DeleteUserWithTransfer(ctx, userID, zoom.DeleteUserOptions{ + Action: zoom.DeleteAction(deleteAction), + TransferEmail: transferEmail, + TransferMeeting: transferMeeting, + TransferWebinar: transferWebinar, + TransferRecording: transferRecording, + }) + if err != nil { + if isUserNotFound(err) { + return actions.NewReturnValues( + true, + actions.NewStringReturnField("message", fmt.Sprintf("user %s was already removed from the account", userID)), + ), nil, nil + } + return nil, nil, fmt.Errorf("baton-zoom: transfer_and_delete_user: %s: %w", userID, err) + } + + return actions.NewReturnValues( + true, + actions.NewStringReturnField("message", fmt.Sprintf("user %s data transferred and %sd from the account", userID, deleteAction)), + ), nil, nil +} + +// isUserNotFound reports whether err is a Zoom 404, meaning the user is +// already gone — re-invoking transfer_and_delete_user on an already-deleted +// user must succeed, not fail. +func isUserNotFound(err error) bool { + var apiErr *zoom.APIError + return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound +} diff --git a/pkg/zoom/client.go b/pkg/zoom/client.go index 6db46ed9..3498a5d7 100644 --- a/pkg/zoom/client.go +++ b/pkg/zoom/client.go @@ -19,6 +19,17 @@ type Client struct { baseURL string } +// APIError wraps a non-2xx Zoom API response so callers can inspect the +// status code, e.g. to treat a 404 on delete as "already gone". +type APIError struct { + StatusCode int + Body string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("request failed with status code %d: %s", e.StatusCode, e.Body) +} + const ( defaultBaseURL = "https://api.zoom.us/v2" defaultAuthURL = "https://zoom.us/oauth/token" @@ -411,12 +422,53 @@ func (c *Client) CreateUser(ctx context.Context, newUser *UserCreationBody) (*Us } func (c *Client) DeleteUser(ctx context.Context, userId string) error { + return c.DeleteUserWithTransfer(ctx, userId, DeleteUserOptions{}) +} + +// DeleteUserOptions configures the query parameters DELETE /v2/users/{userId} +// accepts for reassigning a user's meetings, webinars, and cloud recordings to +// another user (TransferEmail) as part of removing them from the account. +type DeleteUserOptions struct { + // Action is Disassociate (unlink the user from the account) or Delete + // (permanently remove the user). Empty defers to Zoom's default (delete). + Action DeleteAction + TransferEmail string + TransferMeeting bool + TransferWebinar bool + TransferRecording bool +} + +// DeleteUserWithTransfer removes a user via DELETE /v2/users/{userId}, +// optionally transferring their meetings, webinars, and cloud recordings to +// opts.TransferEmail first. Zoom requires TransferEmail whenever any of the +// transfer flags is set; the caller is responsible for that validation. +func (c *Client) DeleteUserWithTransfer(ctx context.Context, userId string, opts DeleteUserOptions) error { requestURL, err := url.JoinPath(c.baseURL, "users", userId) if err != nil { return err } - resp, err := c.doRequest(ctx, requestURL, nil, http.MethodDelete, nil, nil) + var params url.Values + if opts.Action != "" || opts.TransferEmail != "" || opts.TransferMeeting || opts.TransferWebinar || opts.TransferRecording { + params = url.Values{} + if opts.Action != "" { + params.Set("action", string(opts.Action)) + } + if opts.TransferEmail != "" { + params.Set("transfer_email", opts.TransferEmail) + } + if opts.TransferMeeting { + params.Set("transfer_meeting", "true") + } + if opts.TransferWebinar { + params.Set("transfer_webinar", "true") + } + if opts.TransferRecording { + params.Set("transfer_recording", "true") + } + } + + resp, err := c.doRequest(ctx, requestURL, nil, http.MethodDelete, params, nil) if err != nil { return err } @@ -497,7 +549,7 @@ func (c *Client) doRequest(ctx context.Context, url string, res interface{}, met } if resp.StatusCode >= 400 { - return nil, fmt.Errorf("request failed with status code %d: %s", resp.StatusCode, string(b)) + return nil, &APIError{StatusCode: resp.StatusCode, Body: string(b)} } if err := json.Unmarshal(b, &res); err != nil { diff --git a/pkg/zoom/models.go b/pkg/zoom/models.go index 463fae6e..212aad26 100644 --- a/pkg/zoom/models.go +++ b/pkg/zoom/models.go @@ -18,6 +18,16 @@ const ( UnassignedUser UserType = 4 // "Unassigned without Meetings Basic" (aka No Meetings License) ) +// DeleteAction selects the outcome of DELETE /v2/users/{userId}: unlink the +// user from the account (Disassociate) while keeping the Zoom user record, or +// remove the user entirely (Delete). +type DeleteAction string + +const ( + Disassociate DeleteAction = "disassociate" + Delete DeleteAction = "delete" +) + type Group struct { ID string `json:"id"` Name string `json:"name"`