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
3 changes: 2 additions & 1 deletion baton_capabilities.json
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,8 @@
"CAPABILITY_PROVISION",
"CAPABILITY_SYNC",
"CAPABILITY_ACCOUNT_PROVISIONING",
"CAPABILITY_RESOURCE_DELETE"
"CAPABILITY_RESOURCE_DELETE",
"CAPABILITY_ACTIONS"
],
"credentialDetails": {
"capabilityAccountProvisioning": {
Expand Down
8 changes: 8 additions & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
163 changes: 163 additions & 0 deletions pkg/connector/actions.go
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
}
Comment on lines +141 to +147

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: any 404 from DELETE /users/{userId} is reported as success: true with "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") when transfer_email names 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 error code/message in APIError.Body and only treat it as already-gone when it refers to userID, or only apply the short-circuit when no transfer fields were supplied.

return nil, nil, fmt.Errorf("baton-zoom: transfer_and_delete_user: %s: %w", userID, err)

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: this path returns a bare wrapped error with no gRPC status code. pkg/zoom uses a raw *http.Client rather than uhttp.BaseHttpClient, so nothing upstream maps the Zoom status onto a code — a 403 or 429 reaches the SDK as Unknown. Since APIError now carries StatusCode, map it here (401→Unauthenticated, 403→PermissionDenied, 429→ResourceExhausted, 5xx→Internal) via uhttp.WrapErrors or status.Error so retry vs. surface is decided correctly.

}

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

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: 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
}
56 changes: 54 additions & 2 deletions pkg/zoom/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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).

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: Zoom's documented default for the action query param on DELETE /v2/users/{userId} is disassociate, not delete. That makes this comment misleading for the DeleteUser path (CAPABILITY_RESOURCE_DELETE), which passes a zero DeleteUserOptions and therefore disassociates rather than permanently deletes.

Suggested change
// (permanently remove the user). Empty defers to Zoom's default (delete).
// (permanently remove the user). Empty defers to Zoom's default
// (disassociate).

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
}
Expand Down Expand Up @@ -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 {
Expand Down
10 changes: 10 additions & 0 deletions pkg/zoom/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading