-
Notifications
You must be signed in to change notification settings - Fork 0
[CXH-2366] - Add Zoom ownership transfer custom action #33
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: this path returns a bare wrapped error with no gRPC status code. |
||
| } | ||
|
|
||
| return actions.NewReturnValues( | ||
| true, | ||
| actions.NewStringReturnField("message", fmt.Sprintf("user %s data transferred and %sd from the account", userID, deleteAction)), | ||
| ), nil, nil | ||
|
Comment on lines
+151
to
+154
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: all three transfer flags are optional, so the common "just remove the user" invocation returns "user X data transferred and deleted from the account" when nothing was transferred. Consider building the message conditionally on whether any transfer flag was set, so the automation record reflects what actually happened. |
||
| } | ||
|
|
||
| // 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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). | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Suggestion: Zoom's documented default for the
Suggested change
|
||||||||
| 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 { | ||||||||
|
|
||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Suggestion: any 404 from
DELETE /users/{userId}is reported assuccess: truewith "already removed", but the target user is not the only thing this endpoint can fail to find — Zoom also 404s (code 1001, "User not exist") whentransfer_emailnames a user that isn't in the account. In that case the user is not removed and nothing is transferred, yet the automation records success and moves on. Consider narrowing the short-circuit: inspect the Zoom errorcode/message inAPIError.Bodyand only treat it as already-gone when it refers touserID, or only apply the short-circuit when no transfer fields were supplied.