Skip to content
Merged
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
9 changes: 8 additions & 1 deletion docs/commands/agent-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ Policies attach to workspaces via `policy_id`. To assign a policy to an agent ac
nylas workspace update <workspace-id> --policy-id <policy-id>
```

To detach a policy from a workspace (plan maximums apply afterwards):

```bash
nylas workspace update <workspace-id> --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
Expand All @@ -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 <workspace-id> --policy-id <other-id>`
detach with `nylas workspace update <workspace-id> --policy-id ""` (or swap
in another policy with `--policy-id <other-id>`)

## See Also

Expand Down
34 changes: 34 additions & 0 deletions internal/adapters/nylas/admin_workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion internal/cli/workspace/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workspace-id> --policy-id <policy-id>
nylas workspace update <workspace-id> --policy-id ""
nylas workspace update <workspace-id> --rules-ids rule1,rule2`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions internal/domain/workspace.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions internal/domain/workspace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading