From d2aef1b4e63eab931cb21032d7446d7e2bed4f70 Mon Sep 17 00:00:00 2001 From: Qasim Date: Fri, 12 Jun 2026 16:53:08 -0400 Subject: [PATCH] TW-5383: support detaching a workspace policy via --policy-id "" The API requires policy_id to be a valid UUID or JSON null in PATCH /v3/workspaces/{id} - empty string is rejected. A custom MarshalJSON on UpdateWorkspaceRequest serializes a PolicyID pointing at "" as null (detach), keeps nil omitting the field, and leaves rule_ids behavior unchanged. Agent Studio workspace PATCH gains detach support through the same marshaler. Docs and help text updated. --- docs/commands/agent-policy.md | 9 ++- .../adapters/nylas/admin_workspaces_test.go | 34 ++++++++++ internal/cli/workspace/commands.go | 5 +- internal/domain/workspace.go | 21 ++++++ internal/domain/workspace_test.go | 66 +++++++++++++++++++ 5 files changed, 133 insertions(+), 2 deletions(-) diff --git a/docs/commands/agent-policy.md b/docs/commands/agent-policy.md index e3ca9a8..d035660 100644 --- a/docs/commands/agent-policy.md +++ b/docs/commands/agent-policy.md @@ -133,6 +133,12 @@ Policies attach to workspaces via `policy_id`. To assign a policy to an agent ac nylas workspace update --policy-id ``` +To detach a policy from a workspace (plan maximums apply afterwards): + +```bash +nylas workspace update --policy-id "" +``` + The API auto-creates a default workspace when an agent account is created (and may create a starter "Default Policy" mirroring your plan's maximums). A workspace with no `policy_id` attached runs at your billing plan's maximum @@ -152,7 +158,8 @@ If `nylas agent policy delete` fails: - the policy is still attached to one or more agent workspaces (the CLI blocks the delete to avoid leaving dangling `policy_id` references) - run `nylas agent policy list` to see the attached workspace mappings, then - detach with `nylas workspace update --policy-id ` + detach with `nylas workspace update --policy-id ""` (or swap + in another policy with `--policy-id `) ## See Also diff --git a/internal/adapters/nylas/admin_workspaces_test.go b/internal/adapters/nylas/admin_workspaces_test.go index d49eb7e..24c0a0f 100644 --- a/internal/adapters/nylas/admin_workspaces_test.go +++ b/internal/adapters/nylas/admin_workspaces_test.go @@ -79,6 +79,40 @@ func TestHTTPClient_AssignWorkspaceGrants_Remove(t *testing.T) { assert.Equal(t, []string{"grant-2"}, result.GrantsRemoved) } +// Detaching a policy requires the PATCH body to carry "policy_id": null — the +// API rejects "" with "policy_id must be a valid UUID or null", and omitting +// the field leaves the stale attachment in place. +func TestHTTPClient_UpdateWorkspace_DetachPolicy(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/workspaces/ws-1", r.URL.Path) + assert.Equal(t, "PATCH", r.Method) + + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + raw, ok := body["policy_id"] + require.True(t, ok, "policy_id must be present in the detach PATCH") + assert.Equal(t, "null", string(raw), "policy_id must be JSON null, not %s", raw) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"workspace_id": "ws-1"}, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + detach := "" + result, err := client.UpdateWorkspace(context.Background(), "ws-1", &domain.UpdateWorkspaceRequest{ + PolicyID: &detach, + }) + + require.NoError(t, err) + assert.Equal(t, "ws-1", result.ID) +} + func TestHTTPClient_AssignWorkspaceGrants_Validation(t *testing.T) { client := nylas.NewHTTPClient() client.SetCredentials("client-id", "secret", "api-key") diff --git a/internal/cli/workspace/commands.go b/internal/cli/workspace/commands.go index f9f491c..59a3dfa 100644 --- a/internal/cli/workspace/commands.go +++ b/internal/cli/workspace/commands.go @@ -148,8 +148,11 @@ func newUpdateCmd() *cobra.Command { Short: "Update a workspace", Long: `Update a workspace's policy or rules. +Pass an empty --policy-id to detach the current policy (plan maximums apply). + Examples: nylas workspace update --policy-id + nylas workspace update --policy-id "" nylas workspace update --rules-ids rule1,rule2`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -186,7 +189,7 @@ Examples: }, } - cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to attach") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to attach (empty string detaches the current policy)") cmd.Flags().StringSliceVar(&rulesIDs, "rules-ids", nil, "Rule IDs to attach (comma-separated)") return cmd diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go index 0f60e9e..649e9c5 100644 --- a/internal/domain/workspace.go +++ b/internal/domain/workspace.go @@ -1,5 +1,7 @@ package domain +import "encoding/json" + // Workspace represents a grant workspace. For provider=nylas accounts, this is // the attachment point for policy and rule relationships. type Workspace struct { @@ -25,11 +27,30 @@ type CreateWorkspaceRequest struct { } // UpdateWorkspaceRequest updates workspace policy/rule attachments. +// PolicyID semantics: nil leaves the policy untouched; a pointer to the empty +// string detaches it (the API accepts a UUID or null, never ""). type UpdateWorkspaceRequest struct { PolicyID *string `json:"policy_id,omitempty"` RulesIDs *[]string `json:"rule_ids,omitempty"` } +// MarshalJSON serializes a PolicyID pointing at the empty string as JSON null, +// which is the only detach signal the API accepts. +func (r UpdateWorkspaceRequest) MarshalJSON() ([]byte, error) { + out := make(map[string]any, 2) + if r.PolicyID != nil { + if *r.PolicyID == "" { + out["policy_id"] = nil + } else { + out["policy_id"] = *r.PolicyID + } + } + if r.RulesIDs != nil { + out["rule_ids"] = *r.RulesIDs + } + return json.Marshal(out) +} + // WorkspaceAssignRequest moves grants into or out of a workspace via the // manual-assign endpoint. The API requires these exact field names; assigning // a grant moves it even if it currently belongs to another workspace, while diff --git a/internal/domain/workspace_test.go b/internal/domain/workspace_test.go index a443e8b..2fb4022 100644 --- a/internal/domain/workspace_test.go +++ b/internal/domain/workspace_test.go @@ -31,6 +31,72 @@ func TestUpdateWorkspaceRequest_RuleIDsWireName(t *testing.T) { } } +func TestUpdateWorkspaceRequest_PolicyIDWireFormat(t *testing.T) { + // The API accepts "policy_id" as a UUID or null, never "" — detaching a + // policy requires an explicit JSON null. A pointer to the empty string is + // the detach signal; nil still omits the field so rule-only updates don't + // clobber the attached policy. + policy := "policy-1" + empty := "" + + tests := []struct { + name string + policyID *string + want string // expected raw JSON for "policy_id"; "" means absent + }{ + {name: "nil omits the field", policyID: nil, want: ""}, + {name: "empty string serializes as null (detach)", policyID: &empty, want: "null"}, + {name: "value serializes as string", policyID: &policy, want: `"policy-1"`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(UpdateWorkspaceRequest{PolicyID: tt.policyID}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got, ok := raw["policy_id"] + if tt.want == "" { + if ok { + t.Fatalf("policy_id must be omitted when PolicyID is nil; got %s", data) + } + return + } + if !ok || string(got) != tt.want { + t.Fatalf("policy_id must serialize as %s; got %s", tt.want, data) + } + }) + } +} + +func TestUpdateWorkspaceRequest_RulesAndPolicyTogether(t *testing.T) { + // A pointer to an EMPTY rules slice must serialize as "rule_ids":[] — the + // API replaces the whole list, so [] is how the last rule gets detached + // (rule.go's detach flow depends on this). Only a nil pointer omits the + // field. Empty-slice-to-null/omit would silently break rule detachment. + policy := "policy-1" + rules := []string{} + + data, err := json.Marshal(UpdateWorkspaceRequest{PolicyID: &policy, RulesIDs: &rules}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if string(raw["policy_id"]) != `"policy-1"` { + t.Fatalf("policy_id must survive alongside rule_ids; got %s", data) + } + if string(raw["rule_ids"]) != "[]" { + t.Fatalf("empty rules slice must serialize as \"rule_ids\":[] (clears the last rule); got %s", data) + } +} + func TestWorkspace_DefaultWireField(t *testing.T) { // The server marks the connector's default workspace with "default": true. // Dropping the field hides which workspace new agent accounts attach to.