diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index a11d61d..cb73b87 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -31,6 +31,7 @@ import ( "github.com/nylas/cli/internal/cli/update" "github.com/nylas/cli/internal/cli/webhook" "github.com/nylas/cli/internal/cli/workflow" + "github.com/nylas/cli/internal/cli/workspace" "github.com/nylas/cli/internal/ui" ) @@ -66,6 +67,7 @@ func main() { rootCmd.AddCommand(chat.NewChatCmd()) rootCmd.AddCommand(update.NewUpdateCmd()) rootCmd.AddCommand(workflow.NewWorkflowCmd()) + rootCmd.AddCommand(workspace.NewWorkspaceCmd()) if err := cli.Execute(); err != nil { cli.LogAuditError(err) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index f4ad18f..5a854e2 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -458,20 +458,17 @@ Create and manage Nylas-managed agent accounts backed by provider `nylas`. nylas agent account list # List agent accounts nylas agent account create # Create agent account nylas agent account create --app-password PW # Create account with IMAP/SMTP app password -nylas agent account create --policy-id # Create account attached to a policy nylas agent account update [agent-id|email] --app-password PW # Add or rotate IMAP/SMTP app password nylas agent account get # Show one agent account nylas agent account delete # Delete/revoke agent account nylas agent account delete --yes # Skip confirmation -nylas agent policy list # List policy for default agent account -nylas agent policy list --all # List all policies attached to agent accounts +nylas agent policy list # List all policies nylas agent policy create --name NAME # Create a policy nylas agent policy get # Show one policy nylas agent policy read # Read one policy nylas agent policy update --name NAME # Update a policy nylas agent policy delete --yes # Delete an unattached policy -nylas agent rule list # List rules for default agent policy -nylas agent rule list --all # List all rules attached to agent policies +nylas agent rule list # List all rules nylas agent rule read # Read one rule nylas agent rule get # Show one rule nylas agent rule create --name NAME --condition from.domain,is,example.com --action mark_as_spam # Create a rule from common flags diff --git a/docs/commands/agent-policy.md b/docs/commands/agent-policy.md index 6689b27..0301285 100644 --- a/docs/commands/agent-policy.md +++ b/docs/commands/agent-policy.md @@ -2,13 +2,12 @@ Detailed reference for `nylas agent policy`. -Agent policies are filtered through `provider=nylas` agent accounts in the CLI, even though the underlying policy objects are application-level resources. +Policies are application-level resources backed by `/v3/policies`. They attach to workspaces via `policy_id`. ## Commands ```bash nylas agent policy list -nylas agent policy list --all nylas agent policy create --name "Strict Policy" nylas agent policy create --data-file policy.json nylas agent policy get @@ -18,45 +17,14 @@ nylas agent policy update --data-file update.json nylas agent policy delete --yes ``` -## Scope Model - -The CLI intentionally treats policies as an agent-scoped surface: - -- `nylas agent policy list` shows only the policy attached to the current default `provider=nylas` grant -- `nylas agent policy list --all` shows only policies referenced by at least one `provider=nylas` agent account -- text output includes the attached agent email and grant ID so you can see which agent account uses which policy - -This means: - -- a policy can exist in the application but still not appear under `nylas agent policy` -- a policy with no attached `provider=nylas` account is hidden from the agent policy list - ## Listing Policies -### Default Agent Policy - ```bash nylas agent policy list nylas agent policy list --json ``` -Behavior: - -- resolves the current default local grant -- requires that default grant to be `provider=nylas` -- returns the single attached policy for that grant - -### All Agent Policies - -```bash -nylas agent policy list --all -nylas agent policy list --all --json -``` - -Behavior: - -- lists all policies referenced by at least one `provider=nylas` agent account -- text output includes one `Agent:` line per attached agent account +Lists all policies from `/v3/policies`. Text output shows which workspace has each policy attached. ## Reading Policies @@ -156,37 +124,29 @@ nylas agent policy delete --yes Safety rule: -- delete is rejected if any `provider=nylas` agent account still references the policy - -To remove a policy from active use: - -1. create or choose another policy -2. create future agent accounts with `--policy-id ` -3. remove or rotate away the attached agent accounts that still reference the old policy -4. delete the now-unattached policy +- delete is rejected if any `provider=nylas` agent workspace still references the policy -## Relationship to Agent Accounts +## Relationship to Workspaces -Policies are primarily attached at agent account creation time: +Policies attach to workspaces via `policy_id`. To assign a policy to an agent account's workspace: ```bash -nylas agent account create me@yourapp.nylas.email --policy-id +nylas workspace update --policy-id ``` -The CLI now has `nylas agent account update`, but it currently manages mutable account settings such as `--app-password`, not `settings.policy_id`. In practice, policy attachment remains a create-time workflow on the agent account surface. +The API auto-creates a default workspace and policy when an agent account is created. ## Troubleshooting If `nylas agent policy list` returns nothing: -- make sure your default local grant is a `provider=nylas` account -- verify the agent account actually has a `settings.policy_id` -- try `nylas auth list` to confirm which grant is marked default +- no policies have been explicitly created via `/v3/policies` +- the API auto-creates a default policy on the workspace, but it does not appear in `/v3/policies` If `nylas agent policy delete` fails: -- the policy is still attached to one or more `provider=nylas` agent accounts -- run `nylas agent policy list --all` to see the attached agent mappings +- the policy is still attached to one or more agent workspaces +- run `nylas agent policy list` to see the attached workspace mappings ## See Also diff --git a/docs/commands/agent-rule.md b/docs/commands/agent-rule.md index 3fafaab..23d409f 100644 --- a/docs/commands/agent-rule.md +++ b/docs/commands/agent-rule.md @@ -2,14 +2,12 @@ Detailed reference for `nylas agent rule`. -Agent rules are filtered through policies that are attached to `provider=nylas` agent accounts. The CLI hides rules that are outside that agent scope. +Rules are backed by `/v3/rules` and attach to workspaces via `rules_ids[]`. ## Commands ```bash nylas agent rule list -nylas agent rule list --policy-id -nylas agent rule list --all nylas agent rule get nylas agent rule read nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam @@ -19,52 +17,14 @@ nylas agent rule update --name "Updated Rule" nylas agent rule delete --yes ``` -## Scope Model - -The CLI resolves rules through agent policy attachment: - -- `nylas agent rule list` uses the policy attached to the current default `provider=nylas` grant -- `nylas agent rule list --policy-id ` uses that specific policy within the agent scope -- `nylas agent rule list --all` shows rules reachable from any policy attached to any `provider=nylas` agent account -- `get`, `read`, `update`, and `delete` validate that the rule is reachable from the selected agent scope before operating on it - -This prevents the agent command surface from mutating rules that are only in non-agent policy usage. - ## Listing Rules -### Rules for the Default Agent Policy - ```bash nylas agent rule list nylas agent rule list --json ``` -Behavior: - -- resolves the default local `provider=nylas` grant -- finds the policy attached to that grant -- returns the rules attached to that policy -- skips stale policy rule references that no longer exist in `/v3/rules` - -### Rules for a Specific Agent Policy - -```bash -nylas agent rule list --policy-id -``` - -Use this when you want to inspect one policy without changing your default grant. - -### All Agent Rules - -```bash -nylas agent rule list --all -nylas agent rule list --all --json -``` - -Behavior: - -- shows only rules referenced by policies attached to `provider=nylas` accounts -- text output includes policy and agent account references +Lists all rules from `/v3/rules`. Text output shows which workspace has each rule attached. ## Reading Rules @@ -77,12 +37,7 @@ nylas agent rule read --json Notes: - `get` and `read` are aliases -- text output expands the rule into readable sections for: - - trigger - - match logic - - actions - - policy references - - agent account references +- text output expands the rule into readable sections for trigger, match logic, actions, and workspace references - `--json` returns the raw rule payload ## Creating Rules @@ -123,144 +78,28 @@ Available common flags: - `--enabled` - `--disabled` - `--trigger` -- `--policy-id` - `--match-operator all|any` - repeatable `--condition` - repeatable `--action` -Defaults when creating from flags: - -- `trigger=inbound` -- `enabled=true` -- `match.operator=all` - -Supported triggers: - -- `inbound` -- `outbound` - -Supported fields: - -- inbound: `from.address`, `from.domain`, `from.tld` -- outbound: `from.address`, `from.domain`, `from.tld`, `recipient.address`, `recipient.domain`, `recipient.tld`, `outbound.type` - -Supported operators: - -- all string fields: `is`, `is_not`, `contains`, `in_list` -- `outbound.type`: `is`, `is_not` - -Supported actions: - -- `block` -- `mark_as_spam` -- `assign_to_folder=` -- `mark_as_read` -- `mark_as_starred` -- `archive` -- `trash` - -### `--condition` - -Format: - -```bash ---condition ,, -``` - -Examples: - -```bash ---condition from.domain,is,example.com ---condition from.address,is,ceo@example.com ---condition recipient.domain,is,example.com ---condition outbound.type,is,reply ---condition from.domain,in_list,example.com,example.org -``` - -Important: - -- condition values are treated as strings by default -- values like `true` and `123` stay strings -- there is no implicit JSON coercion for condition values -- `in_list` expects additional comma-separated values, for example `field,in_list,list-a,list-b` -- `outbound.type` only supports `compose` and `reply` - -### `--action` - -Formats: - -```bash ---action ---action = -``` - -Examples: - -```bash ---action mark_as_spam ---action mark_as_read ---action assign_to_folder=vip ---action archive -``` - -Action values are also treated as strings by default. - -### Full JSON Create +### Raw JSON ```bash nylas agent rule create --data-file rule.json -nylas agent rule create --data '{"name":"Block Example","enabled":true,"trigger":"inbound","match":{"operator":"all","conditions":[{"field":"from.domain","operator":"is","value":"example.com"}]},"actions":[{"type":"mark_as_spam"}]}' +nylas agent rule create --data '{"name":"Block Example","trigger":"inbound","match":{"operator":"any","conditions":[{"field":"from.domain","operator":"is","value":"example.com"}]},"actions":[{"type":"mark_as_spam"}]}' ``` -Use JSON when the rule structure is more complex than the common flags make comfortable. +The rule is created via `/v3/rules` then attached to the default grant's workspace `rules_ids[]`. ## Updating Rules -### Simple Top-Level Updates - ```bash nylas agent rule update --name "Updated Rule" -nylas agent rule update --description "Block example.org" -nylas agent rule update --priority 20 --enabled -``` - -### Replacing Conditions and Actions with Flags - -```bash -nylas agent rule update \ - --match-operator any \ - --condition from.domain,is,example.org \ - --condition from.tld,is,org \ - --action mark_as_spam -``` - -```bash -nylas agent rule update \ - --trigger outbound \ - --match-operator any \ - --condition recipient.domain,is,example.org \ - --condition outbound.type,is,reply \ - --action archive +nylas agent rule update --condition from.domain,is,example.org --action mark_as_starred +nylas agent rule update --data-file update.json --json ``` -Behavior: - -- `--condition` replaces the rule's condition set -- `--action` replaces the rule's action set -- existing `match.operator` is preserved unless you explicitly pass `--match-operator` - -### Partial JSON Update - -```bash -nylas agent rule update --data-file update.json -nylas agent rule update --data '{"description":"Updated via JSON"}' -``` - -Recommended workflow: - -1. `nylas agent rule read --json` -2. edit the payload you need -3. `nylas agent rule update --data-file update.json` +Updates the rule directly via `/v3/rules/{id}`. ## Deleting Rules @@ -268,48 +107,34 @@ Recommended workflow: nylas agent rule delete --yes ``` -Safety rules: - -- delete is rejected if the rule is referenced outside the current `provider=nylas` agent scope -- delete is rejected if removing the rule would leave an attached agent policy with zero rules - -These checks are there to prevent accidental breakage of active agent policy configuration. - -## Relationship to Policies +The `--yes` flag is required to confirm deletion. -Rules are attached to policies, and policies are attached to agent accounts. - -Practical flow: +Behavior: +- detaches the rule from all agent workspaces that reference it +- deletes the rule via `/v3/rules/{id}` +- rolls back workspace changes if the delete fails -1. create or choose a policy -2. create a rule and attach it to that policy in the same command -3. create an agent account with that policy using `--policy-id` +## Relationship to Workspaces -The CLI scope always follows that chain: +Rules attach to workspaces via `rules_ids[]`. The practical flow: -- agent account -- policy -- rules reachable from that policy +1. create a workspace: `nylas workspace create --name "My Workspace"` +2. create a policy: `nylas agent policy create --name "Strict Policy"` +3. attach policy to workspace: `nylas workspace update --policy-id ` +4. create agent account (auto-assigns to default workspace) +5. create rules: `nylas agent rule create --name "Block" --condition ... --action ...` ## Troubleshooting If `nylas agent rule list` returns nothing: -- make sure your default grant is `provider=nylas` -- confirm that default agent account has a policy attached -- confirm the policy actually has rules attached -- if the policy only references deleted rules, `list` now returns an empty result instead of failing - -If `nylas agent rule read` or `update` says the rule is not found: - -- the rule may exist in the application but outside the current agent scope -- or the policy may still reference a deleted rule ID -- try `nylas agent rule list --all` to see what is reachable from agent accounts +- confirm rules have been created via `/v3/rules` +- check if your default grant is `provider=nylas` -If `nylas agent rule delete` is rejected: +If `nylas agent rule delete` fails: -- the rule is shared outside the current agent scope, or -- deleting it would leave an attached policy with no remaining rules +- verify the rule ID exists +- check if the rule is attached to workspaces ## See Also diff --git a/docs/commands/agent.md b/docs/commands/agent.md index 4d51806..aeb9966 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -42,9 +42,11 @@ Agent Accounts (2) 1. support@yourapp.nylas.email active ID: 11111111-1111-1111-1111-111111111111 + Workspace ID: aaaaaaaa-1111-1111-1111-111111111111 2. me@yourapp.nylas.email active ID: 22222222-2222-2222-2222-222222222222 + Workspace ID: bbbbbbbb-2222-2222-2222-222222222222 ``` ## Create Agent Account @@ -52,16 +54,20 @@ Agent Accounts (2) ```bash nylas agent account create me@yourapp.nylas.email nylas agent account create me@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' -nylas agent account create me@yourapp.nylas.email --policy-id 12345678-1234-1234-1234-123456789012 nylas agent account create support@yourapp.nylas.email --json ``` Behavior: - always creates a grant with `provider=nylas` - automatically creates the `nylas` connector first if it does not exist +- the API auto-creates a default workspace and policy for the account - stores the created grant locally like other authenticated accounts - optionally sets `settings.app_password` on the grant for IMAP/SMTP mail client access -- optionally sets `settings.policy_id` on the grant so the new account starts with an attached policy + +To attach a custom policy after creation: +```bash +nylas workspace update --policy-id +``` **Example output:** ```bash @@ -91,14 +97,6 @@ Requirements: When set, the agent account email becomes the mail-client username and the app password is used for IMAP/SMTP authentication. -### `--policy-id` - -Use `--policy-id` when you want the new agent account to start with a specific policy already attached. - -```bash -nylas agent account create me@yourapp.nylas.email --policy-id 12345678-1234-1234-1234-123456789012 -``` - ## Show Agent Account ```bash @@ -150,7 +148,6 @@ This reports: ```bash nylas agent policy list -nylas agent policy list --all nylas agent policy create --name "Strict Policy" nylas agent policy create --data '{"name":"Strict Policy","rules":["rule-123"]}' nylas agent policy create --data-file policy.json @@ -162,8 +159,7 @@ nylas agent policy delete 12345678-1234-1234-1234-123456789012 --yes ``` Summary: -- `list` resolves the default `provider=nylas` grant and shows its attached policy -- `list --all` shows only policies that are actually referenced by `provider=nylas` agent accounts +- `list` shows all policies from `/v3/policies` with workspace annotations - `get` and `read` are aliases - `delete` refuses to remove a policy that is still attached to any `provider=nylas` agent account @@ -173,8 +169,6 @@ Summary: ```bash nylas agent rule list -nylas agent rule list --policy-id -nylas agent rule list --all nylas agent rule read nylas agent rule get nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action mark_as_spam @@ -187,13 +181,11 @@ nylas agent rule delete --yes ``` Summary: -- `list` uses the policy attached to the current default `provider=nylas` grant unless `--policy-id` is passed -- `list --all` shows only rules reachable from policies attached to `provider=nylas` accounts -- `list` skips stale policy rule references and returns only rules that still exist -- `create` supports common-case flags like `--name`, repeatable `--condition`, and repeatable `--action` -- both inbound and outbound rule triggers are supported on the agent rule surface +- `list` shows all rules from `/v3/rules` with workspace annotations +- `create` supports common-case flags like `--name`, repeatable `--condition`, and repeatable `--action`; attaches the rule to the default grant's workspace +- both inbound and outbound rule triggers are supported - `get` and `read` are aliases -- `update` and `delete` refuse to operate on rules that are outside the current `provider=nylas` agent scope +- `delete` detaches the rule from workspaces before deleting **Details:** [Agent rule reference](agent-rule.md) diff --git a/internal/adapters/nylas/admin.go b/internal/adapters/nylas/admin.go index bc5de13..0d8bee6 100644 --- a/internal/adapters/nylas/admin.go +++ b/internal/adapters/nylas/admin.go @@ -305,6 +305,92 @@ func (c *HTTPClient) DeleteConnector(ctx context.Context, connectorID string) er return c.doDelete(ctx, queryURL) } +// ListWorkspaces retrieves all workspaces. +func (c *HTTPClient) ListWorkspaces(ctx context.Context) ([]domain.Workspace, error) { + queryURL := fmt.Sprintf("%s/v3/workspaces", c.baseURL) + + var result struct { + Data []domain.Workspace `json:"data"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + return result.Data, nil +} + +// GetWorkspace retrieves a grant workspace. +func (c *HTTPClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { + if err := validateRequired("workspace ID", workspaceID); err != nil { + return nil, err + } + + queryURL := fmt.Sprintf("%s/v3/workspaces/%s", c.baseURL, url.PathEscape(workspaceID)) + + var result struct { + Data domain.Workspace `json:"data"` + } + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrWorkspaceNotFound); err != nil { + return nil, err + } + return &result.Data, nil +} + +// CreateWorkspace creates a new workspace. +func (c *HTTPClient) CreateWorkspace(ctx context.Context, req *domain.CreateWorkspaceRequest) (*domain.Workspace, error) { + if req == nil { + return nil, fmt.Errorf("create workspace request is required") + } + + queryURL := fmt.Sprintf("%s/v3/workspaces", c.baseURL) + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data domain.Workspace `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + return &result.Data, nil +} + +// UpdateWorkspace updates workspace policy/rule attachments. +func (c *HTTPClient) UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) { + if err := validateRequired("workspace ID", workspaceID); err != nil { + return nil, err + } + if req == nil { + return nil, fmt.Errorf("update workspace request is required") + } + + queryURL := fmt.Sprintf("%s/v3/workspaces/%s", c.baseURL, url.PathEscape(workspaceID)) + + resp, err := c.doJSONRequest(ctx, "PATCH", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data domain.Workspace `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + return &result.Data, nil +} + +// DeleteWorkspace deletes a workspace. +func (c *HTTPClient) DeleteWorkspace(ctx context.Context, workspaceID string) error { + if err := validateRequired("workspace ID", workspaceID); err != nil { + return err + } + queryURL := fmt.Sprintf("%s/v3/workspaces/%s", c.baseURL, url.PathEscape(workspaceID)) + return c.doDelete(ctx, queryURL) +} + // Admin Credentials // ListCredentials retrieves all credentials for a connector. diff --git a/internal/adapters/nylas/agent.go b/internal/adapters/nylas/agent.go index 8da113b..7dbf2ea 100644 --- a/internal/adapters/nylas/agent.go +++ b/internal/adapters/nylas/agent.go @@ -42,7 +42,7 @@ func (c *HTTPClient) GetAgentAccount(ctx context.Context, grantID string) (*doma } // CreateAgentAccount creates a new managed agent account grant. -func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { +func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { queryURL := fmt.Sprintf("%s/v3/connect/custom", c.baseURL) settings := map[string]any{ @@ -51,14 +51,14 @@ func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword, if appPassword != "" { settings["app_password"] = appPassword } - if policyID != "" { - settings["policy_id"] = policyID - } payload := map[string]any{ "provider": string(domain.ProviderNylas), "settings": settings, } + if workspaceID != "" { + payload["workspace_id"] = workspaceID + } resp, err := c.doJSONRequest(ctx, "POST", queryURL, payload) if err != nil { @@ -97,9 +97,6 @@ func (c *HTTPClient) UpdateAgentAccount(ctx context.Context, grantID, email, app queryURL := fmt.Sprintf("%s/v3/grants/%s", c.baseURL, url.PathEscape(grantID)) settings := make(map[string]any) settings["email"] = email - if grant.Settings.PolicyID != "" { - settings["policy_id"] = grant.Settings.PolicyID - } if appPassword != "" { settings["app_password"] = appPassword } diff --git a/internal/adapters/nylas/agent_test.go b/internal/adapters/nylas/agent_test.go index d150a5e..1ee1129 100644 --- a/internal/adapters/nylas/agent_test.go +++ b/internal/adapters/nylas/agent_test.go @@ -254,7 +254,7 @@ func TestCreateAgentAccount(t *testing.T) { require.True(t, ok) assert.Equal(t, "agent@example.com", settings["email"]) assert.Equal(t, "ValidAgentPass123ABC!", settings["app_password"]) - assert.Equal(t, "policy-123", settings["policy_id"]) + assert.Equal(t, "workspace-123", payload["workspace_id"]) response := map[string]any{ "data": map[string]any{ @@ -262,10 +262,8 @@ func TestCreateAgentAccount(t *testing.T) { "email": "agent@example.com", "provider": "nylas", "grant_status": "valid", + "workspace_id": "workspace-123", "created_at": time.Now().Unix(), - "settings": map[string]any{ - "policy_id": "policy-123", - }, }, } @@ -278,11 +276,12 @@ func TestCreateAgentAccount(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "ValidAgentPass123ABC!", "policy-123") + account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "ValidAgentPass123ABC!", "workspace-123") require.NoError(t, err) assert.Equal(t, "agent-new", account.ID) assert.Equal(t, "agent@example.com", account.Email) - assert.Equal(t, "policy-123", account.Settings.PolicyID) + assert.Equal(t, "workspace-123", account.WorkspaceID) + assert.Empty(t, account.Settings.PolicyID) } func TestUpdateAgentAccount(t *testing.T) { @@ -320,7 +319,7 @@ func TestUpdateAgentAccount(t *testing.T) { require.True(t, ok) assert.Equal(t, "agent@example.com", settings["email"]) assert.Equal(t, "ValidAgentPass123ABC!", settings["app_password"]) - assert.Equal(t, "policy-123", settings["policy_id"]) + assert.NotContains(t, settings, "policy_id") response := map[string]any{ "data": map[string]any{ @@ -492,8 +491,12 @@ func TestCreateAgentAccount_DirectResponseFallback(t *testing.T) { assert.Equal(t, "agent@example.com", account.Email) } -func TestCreateAgentAccount_DoesNotInventPolicyID(t *testing.T) { +func TestCreateAgentAccount_OmitsWorkspaceIDWhenEmpty(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.NotContains(t, payload, "workspace_id") + response := map[string]any{ "data": map[string]any{ "id": "agent-new", @@ -513,9 +516,9 @@ func TestCreateAgentAccount_DoesNotInventPolicyID(t *testing.T) { client.baseURL = server.URL client.SetCredentials("", "", "test-api-key") - account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "", "policy-123") + account, err := client.CreateAgentAccount(context.Background(), "agent@example.com", "", "") require.NoError(t, err) - assert.Equal(t, "", account.Settings.PolicyID) + assert.Equal(t, "agent-new", account.ID) } func TestUpdateAgentAccount_RejectsNonNylasGrantBeforePatch(t *testing.T) { diff --git a/internal/adapters/nylas/demo_admin.go b/internal/adapters/nylas/demo_admin.go index 7962b34..78e4ab3 100644 --- a/internal/adapters/nylas/demo_admin.go +++ b/internal/adapters/nylas/demo_admin.go @@ -207,6 +207,45 @@ func (d *DemoClient) DeleteConnector(ctx context.Context, connectorID string) er return nil } +func (d *DemoClient) ListWorkspaces(ctx context.Context) ([]domain.Workspace, error) { + return []domain.Workspace{ + {ID: "workspace-demo-1", Name: "Demo Agent Workspace", PolicyID: "policy-demo-1"}, + }, nil +} + +func (d *DemoClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { + return &domain.Workspace{ + ID: workspaceID, + Name: "Demo Agent Workspace", + PolicyID: "policy-demo-1", + RulesIDs: []string{"rule-demo-1"}, + }, nil +} + +func (d *DemoClient) CreateWorkspace(ctx context.Context, req *domain.CreateWorkspaceRequest) (*domain.Workspace, error) { + return &domain.Workspace{ + ID: "workspace-demo-new", + Name: req.Name, + PolicyID: req.PolicyID, + RulesIDs: req.RulesIDs, + }, nil +} + +func (d *DemoClient) UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) { + workspace := &domain.Workspace{ID: workspaceID, Name: "Demo Agent Workspace"} + if req.PolicyID != nil { + workspace.PolicyID = *req.PolicyID + } + if req.RulesIDs != nil { + workspace.RulesIDs = append([]string(nil), (*req.RulesIDs)...) + } + return workspace, nil +} + +func (d *DemoClient) DeleteWorkspace(ctx context.Context, workspaceID string) error { + return nil +} + func (d *DemoClient) ListCredentials(ctx context.Context, connectorID string) ([]domain.ConnectorCredential, error) { return []domain.ConnectorCredential{ {ID: "cred-demo-1", Name: "OAuth Demo Credential", CredentialType: "oauth"}, diff --git a/internal/adapters/nylas/demo_agent.go b/internal/adapters/nylas/demo_agent.go index 97f97af..9bc95e8 100644 --- a/internal/adapters/nylas/demo_agent.go +++ b/internal/adapters/nylas/demo_agent.go @@ -13,9 +13,7 @@ func (d *DemoClient) ListAgentAccounts(ctx context.Context) ([]domain.AgentAccou Provider: domain.ProviderNylas, Email: "demo-agent@example.com", GrantStatus: "valid", - Settings: domain.AgentAccountSettings{ - PolicyID: "policy-demo-1", - }, + WorkspaceID: "workspace-demo-1", }, }, nil } @@ -26,21 +24,17 @@ func (d *DemoClient) GetAgentAccount(ctx context.Context, grantID string) (*doma Provider: domain.ProviderNylas, Email: "demo-agent@example.com", GrantStatus: "valid", - Settings: domain.AgentAccountSettings{ - PolicyID: "policy-demo-1", - }, + WorkspaceID: "workspace-demo-1", }, nil } -func (d *DemoClient) CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { +func (d *DemoClient) CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: "agent-demo-new", Provider: domain.ProviderNylas, Email: email, GrantStatus: "valid", - Settings: domain.AgentAccountSettings{ - PolicyID: policyID, - }, + WorkspaceID: "workspace-demo-new", }, nil } @@ -50,7 +44,7 @@ func (d *DemoClient) UpdateAgentAccount(ctx context.Context, grantID, email, app Provider: domain.ProviderNylas, Email: email, GrantStatus: "valid", - Settings: domain.AgentAccountSettings{PolicyID: "policy-demo-1"}, + WorkspaceID: "workspace-demo-1", }, nil } diff --git a/internal/adapters/nylas/managed_grants.go b/internal/adapters/nylas/managed_grants.go index 212259e..4cc96f0 100644 --- a/internal/adapters/nylas/managed_grants.go +++ b/internal/adapters/nylas/managed_grants.go @@ -13,6 +13,7 @@ type managedGrantResponse struct { Email string `json:"email"` Provider domain.Provider `json:"provider"` GrantStatus string `json:"grant_status"` + WorkspaceID string `json:"workspace_id,omitempty"` CreatedAt domain.UnixTime `json:"created_at"` UpdatedAt domain.UnixTime `json:"updated_at"` CredentialID string `json:"credential_id,omitempty"` @@ -98,6 +99,7 @@ func convertManagedGrantToAgentAccount(grant managedGrantResponse) domain.AgentA Provider: grant.Provider, Email: grant.Email, GrantStatus: grant.GrantStatus, + WorkspaceID: grant.WorkspaceID, CreatedAt: grant.CreatedAt, UpdatedAt: grant.UpdatedAt, CredentialID: grant.CredentialID, diff --git a/internal/adapters/nylas/mock_admin.go b/internal/adapters/nylas/mock_admin.go index 55de91e..4fa09a6 100644 --- a/internal/adapters/nylas/mock_admin.go +++ b/internal/adapters/nylas/mock_admin.go @@ -120,6 +120,45 @@ func (m *MockClient) DeleteConnector(ctx context.Context, connectorID string) er return nil } +func (m *MockClient) ListWorkspaces(ctx context.Context) ([]domain.Workspace, error) { + return []domain.Workspace{ + {ID: "workspace-1", Name: "Agent Workspace", PolicyID: "policy-1"}, + }, nil +} + +func (m *MockClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { + return &domain.Workspace{ + ID: workspaceID, + Name: "Agent Workspace", + PolicyID: "policy-1", + RulesIDs: []string{"rule-1"}, + }, nil +} + +func (m *MockClient) CreateWorkspace(ctx context.Context, req *domain.CreateWorkspaceRequest) (*domain.Workspace, error) { + return &domain.Workspace{ + ID: "workspace-new", + Name: req.Name, + PolicyID: req.PolicyID, + RulesIDs: req.RulesIDs, + }, nil +} + +func (m *MockClient) UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) { + workspace := &domain.Workspace{ID: workspaceID, Name: "Agent Workspace"} + if req.PolicyID != nil { + workspace.PolicyID = *req.PolicyID + } + if req.RulesIDs != nil { + workspace.RulesIDs = append([]string(nil), (*req.RulesIDs)...) + } + return workspace, nil +} + +func (m *MockClient) DeleteWorkspace(ctx context.Context, workspaceID string) error { + return nil +} + func (m *MockClient) ListCredentials(ctx context.Context, connectorID string) ([]domain.ConnectorCredential, error) { return []domain.ConnectorCredential{ {ID: "cred-1", Name: "OAuth Credential", CredentialType: "oauth"}, diff --git a/internal/adapters/nylas/mock_agent.go b/internal/adapters/nylas/mock_agent.go index d7501ae..8140eec 100644 --- a/internal/adapters/nylas/mock_agent.go +++ b/internal/adapters/nylas/mock_agent.go @@ -13,9 +13,7 @@ func (m *MockClient) ListAgentAccounts(ctx context.Context) ([]domain.AgentAccou Provider: domain.ProviderNylas, Email: "agent@example.com", GrantStatus: "valid", - Settings: domain.AgentAccountSettings{ - PolicyID: "policy-1", - }, + WorkspaceID: "workspace-1", }, }, nil } @@ -26,21 +24,17 @@ func (m *MockClient) GetAgentAccount(ctx context.Context, grantID string) (*doma Provider: domain.ProviderNylas, Email: "agent@example.com", GrantStatus: "valid", - Settings: domain.AgentAccountSettings{ - PolicyID: "policy-1", - }, + WorkspaceID: "workspace-1", }, nil } -func (m *MockClient) CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { +func (m *MockClient) CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { return &domain.AgentAccount{ ID: "agent-new", Provider: domain.ProviderNylas, Email: email, GrantStatus: "valid", - Settings: domain.AgentAccountSettings{ - PolicyID: policyID, - }, + WorkspaceID: "workspace-new", }, nil } @@ -50,7 +44,7 @@ func (m *MockClient) UpdateAgentAccount(ctx context.Context, grantID, email, app Provider: domain.ProviderNylas, Email: email, GrantStatus: "valid", - Settings: domain.AgentAccountSettings{PolicyID: "policy-1"}, + WorkspaceID: "workspace-1", }, nil } diff --git a/internal/air/handlers_rules_policy.go b/internal/air/handlers_rules_policy.go index c592f29..bacda81 100644 --- a/internal/air/handlers_rules_policy.go +++ b/internal/air/handlers_rules_policy.go @@ -1,6 +1,7 @@ package air import ( + "context" "net/http" "strings" @@ -40,7 +41,13 @@ func (s *Server) handleListPolicies(w http.ResponseWriter, r *http.Request) { return } - policyID := strings.TrimSpace(account.Settings.PolicyID) + policyID, err := s.resolveAccountPolicyID(ctx, account) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Failed to resolve workspace policy: " + err.Error(), + }) + return + } if policyID == "" { writeJSON(w, http.StatusOK, PoliciesResponse{Policies: []domain.Policy{}}) return @@ -88,28 +95,13 @@ func (s *Server) handleListRules(w http.ResponseWriter, r *http.Request) { return } - policyID := strings.TrimSpace(account.Settings.PolicyID) - if policyID == "" { - writeJSON(w, http.StatusOK, RulesResponse{Rules: []domain.Rule{}}) - return - } - - policy, err := s.nylasClient.GetPolicy(ctx, policyID) + ruleIDs, err := s.resolveAccountRuleIDs(ctx, account) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "Failed to fetch policy for rules: " + err.Error(), + "error": "Failed to resolve workspace rules: " + err.Error(), }) return } - - ruleIDs := make(map[string]struct{}, len(policy.Rules)) - for _, ruleID := range policy.Rules { - ruleID = strings.TrimSpace(ruleID) - if ruleID == "" { - continue - } - ruleIDs[ruleID] = struct{}{} - } if len(ruleIDs) == 0 { writeJSON(w, http.StatusOK, RulesResponse{Rules: []domain.Rule{}}) return @@ -123,9 +115,14 @@ func (s *Server) handleListRules(w http.ResponseWriter, r *http.Request) { return } + ruleSet := make(map[string]struct{}, len(ruleIDs)) + for _, id := range ruleIDs { + ruleSet[id] = struct{}{} + } + rules := make([]domain.Rule, 0, len(ruleIDs)) for _, rule := range allRules { - if _, ok := ruleIDs[rule.ID]; !ok { + if _, ok := ruleSet[rule.ID]; !ok { continue } rules = append(rules, rule) @@ -134,6 +131,38 @@ func (s *Server) handleListRules(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, RulesResponse{Rules: rules}) } +func (s *Server) resolveAccountPolicyID(ctx context.Context, account *domain.AgentAccount) (string, error) { + if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { + ws, err := s.nylasClient.GetWorkspace(ctx, wsID) + if err != nil { + return "", err + } + if ws != nil { + return strings.TrimSpace(ws.PolicyID), nil + } + } + return strings.TrimSpace(account.Settings.PolicyID), nil +} + +func (s *Server) resolveAccountRuleIDs(ctx context.Context, account *domain.AgentAccount) ([]string, error) { + if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { + ws, err := s.nylasClient.GetWorkspace(ctx, wsID) + if err != nil { + return nil, err + } + if ws != nil { + var ids []string + for _, id := range ws.RulesIDs { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + return ids, nil + } + } + return nil, nil +} + func demoPolicies() []domain.Policy { return []domain.Policy{ { diff --git a/internal/cli/agent/agent_scope.go b/internal/cli/agent/agent_scope.go index cb0c21f..a9f9b4e 100644 --- a/internal/cli/agent/agent_scope.go +++ b/internal/cli/agent/agent_scope.go @@ -12,9 +12,11 @@ import ( ) type agentPolicyScope struct { - AllPolicies []domain.Policy - AgentPolicies []domain.Policy - PolicyRefsByID map[string][]policyAgentAccountRef + AllPolicies []domain.Policy + AgentPolicies []domain.Policy + PolicyRefsByID map[string][]policyAgentAccountRef + WorkspacesByID map[string]*domain.Workspace + RuleIDsByPolicy map[string][]string } func loadAgentPolicyScope(ctx context.Context, client ports.NylasClient) (*agentPolicyScope, error) { @@ -27,21 +29,72 @@ func loadAgentPolicyScope(ctx context.Context, client ports.NylasClient) (*agent if err != nil { return nil, err } - policies, err = upsertPoliciesForAgentAccounts(ctx, client, policies, accounts) + workspacesByID, err := loadAgentWorkspaces(ctx, client, accounts) + if err != nil { + return nil, err + } + policies, err = upsertPoliciesForAgentAccountsWithWorkspaces(ctx, client, policies, accounts, workspacesByID) if err != nil { return nil, err } - refsByPolicyID := buildPolicyAccountRefs(accounts) + refsByPolicyID := buildPolicyAccountRefsWithWorkspaces(accounts, workspacesByID) agentPolicies := filterPoliciesWithAgentAccounts(policies, refsByPolicyID) return &agentPolicyScope{ - AllPolicies: policies, - AgentPolicies: agentPolicies, - PolicyRefsByID: refsByPolicyID, + AllPolicies: policies, + AgentPolicies: agentPolicies, + PolicyRefsByID: refsByPolicyID, + WorkspacesByID: workspacesByID, + RuleIDsByPolicy: buildWorkspaceRuleIDsByPolicy(accounts, workspacesByID), }, nil } +func loadAgentWorkspaces(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, accounts []domain.AgentAccount) (map[string]*domain.Workspace, error) { + workspacesByID := make(map[string]*domain.Workspace) + for _, account := range accounts { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + continue + } + if _, seen := workspacesByID[workspaceID]; seen { + continue + } + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, common.WrapGetError("workspace", err) + } + if workspace == nil { + return nil, common.NewUserError("workspace not found", "The API returned an empty workspace response") + } + workspacesByID[workspaceID] = workspace + } + return workspacesByID, nil +} + +func buildWorkspaceRuleIDsByPolicy(accounts []domain.AgentAccount, workspacesByID map[string]*domain.Workspace) map[string][]string { + ruleIDsByPolicy := make(map[string][]string) + for _, account := range accounts { + workspace := workspacesByID[strings.TrimSpace(account.WorkspaceID)] + if workspace == nil { + continue + } + policyID := strings.TrimSpace(workspace.PolicyID) + if policyID == "" { + continue + } + if _, ok := ruleIDsByPolicy[policyID]; !ok { + ruleIDsByPolicy[policyID] = []string{} + } + for _, ruleID := range workspace.RulesIDs { + ruleIDsByPolicy[policyID] = appendUniqueString(ruleIDsByPolicy[policyID], ruleID) + } + } + return ruleIDsByPolicy +} + func listAgentAccountsForPolicyScope(ctx context.Context, client ports.NylasClient) ([]domain.AgentAccount, error) { accounts, err := client.ListAgentAccounts(ctx) if err != nil { @@ -98,6 +151,12 @@ func upsertAgentAccount(accounts []domain.AgentAccount, account domain.AgentAcco func upsertPoliciesForAgentAccounts(ctx context.Context, client interface { GetPolicy(context.Context, string) (*domain.Policy, error) }, policies []domain.Policy, accounts []domain.AgentAccount) ([]domain.Policy, error) { + return upsertPoliciesForAgentAccountsWithWorkspaces(ctx, client, policies, accounts, nil) +} + +func upsertPoliciesForAgentAccountsWithWorkspaces(ctx context.Context, client interface { + GetPolicy(context.Context, string) (*domain.Policy, error) +}, policies []domain.Policy, accounts []domain.AgentAccount, workspacesByID map[string]*domain.Workspace) ([]domain.Policy, error) { merged := append([]domain.Policy(nil), policies...) seenPolicyIDs := make(map[string]struct{}, len(merged)) for _, policy := range merged { @@ -106,6 +165,9 @@ func upsertPoliciesForAgentAccounts(ctx context.Context, client interface { for _, account := range accounts { policyID := strings.TrimSpace(account.Settings.PolicyID) + if workspace := workspacesByID[strings.TrimSpace(account.WorkspaceID)]; workspace != nil { + policyID = strings.TrimSpace(workspace.PolicyID) + } if policyID == "" { continue } @@ -126,44 +188,3 @@ func upsertPoliciesForAgentAccounts(ctx context.Context, client interface { return merged, nil } - -func resolveAgentPolicyFromScope(ctx context.Context, client ports.NylasClient, scope *agentPolicyScope, policyID string) (*domain.Policy, []policyAgentAccountRef, error) { - policyID = strings.TrimSpace(policyID) - if policyID != "" { - policy := findPolicyByID(scope.AgentPolicies, policyID) - if policy == nil { - return nil, nil, common.NewUserError( - "policy is not attached to a nylas agent account", - "Use 'nylas agent policy list --all' to inspect provider=nylas policies", - ) - } - - return policy, scope.PolicyRefsByID[policyID], nil - } - - account, err := resolveDefaultAgentAccount(ctx, client) - if err != nil { - return nil, nil, err - } - - defaultPolicyID := strings.TrimSpace(account.Settings.PolicyID) - if defaultPolicyID == "" { - return nil, nil, common.NewUserError( - "default agent account does not have a policy", - "Pass --policy-id or attach a policy to the active provider=nylas account first", - ) - } - - policy := findPolicyByID(scope.AgentPolicies, defaultPolicyID) - if policy == nil { - return nil, nil, common.NewUserError( - "default agent account policy is not attached to a nylas agent account", - "Use 'nylas agent policy list --all' to inspect provider=nylas policies", - ) - } - - return policy, []policyAgentAccountRef{{ - GrantID: account.ID, - Email: account.Email, - }}, nil -} diff --git a/internal/cli/agent/agent_scope_test.go b/internal/cli/agent/agent_scope_test.go index f2bae34..1f519a9 100644 --- a/internal/cli/agent/agent_scope_test.go +++ b/internal/cli/agent/agent_scope_test.go @@ -20,6 +20,18 @@ func (c policyLookupClient) GetPolicy(ctx context.Context, policyID string) (*do return &policy, nil } +type workspaceLookupClient struct { + workspaces map[string]*domain.Workspace + err error +} + +func (c workspaceLookupClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { + if c.err != nil { + return nil, c.err + } + return c.workspaces[workspaceID], nil +} + func TestUpsertAgentAccount(t *testing.T) { accounts := []domain.AgentAccount{ { @@ -99,3 +111,61 @@ func TestUpsertPoliciesForAgentAccounts(t *testing.T) { } assert.Len(t, policies, 1) } + +func TestLoadAgentWorkspacesFailsClosedOnLookupError(t *testing.T) { + accounts := []domain.AgentAccount{{ + ID: "grant-1", + Provider: domain.ProviderNylas, + WorkspaceID: "workspace-1", + }} + + workspaces, err := loadAgentWorkspaces(context.Background(), workspaceLookupClient{err: domain.ErrWorkspaceNotFound}, accounts) + + assert.Error(t, err) + assert.Nil(t, workspaces) +} + +func TestBuildWorkspaceRuleIDsByPolicyKeepsEmptyWorkspaceRules(t *testing.T) { + accounts := []domain.AgentAccount{{ + ID: "grant-1", + Provider: domain.ProviderNylas, + WorkspaceID: "workspace-1", + }} + workspacesByID := map[string]*domain.Workspace{ + "workspace-1": {ID: "workspace-1", PolicyID: "policy-1", RulesIDs: nil}, + } + + ruleIDsByPolicy := buildWorkspaceRuleIDsByPolicy(accounts, workspacesByID) + + ruleIDs, ok := ruleIDsByPolicy["policy-1"] + assert.True(t, ok) + assert.Empty(t, ruleIDs) +} + +func TestUpsertPoliciesForAgentAccountsUsesWorkspacePolicy(t *testing.T) { + policies := []domain.Policy{{ID: "policy-existing", Name: "Existing"}} + accounts := []domain.AgentAccount{{ + ID: "grant-fresh", + Provider: domain.ProviderNylas, + WorkspaceID: "workspace-1", + Settings: domain.AgentAccountSettings{ + PolicyID: "legacy-policy", + }, + }} + workspacesByID := map[string]*domain.Workspace{ + "workspace-1": {ID: "workspace-1", PolicyID: "policy-fresh"}, + } + client := policyLookupClient{ + policies: map[string]domain.Policy{ + "policy-fresh": {ID: "policy-fresh", Name: "Fresh"}, + }, + } + + updated, err := upsertPoliciesForAgentAccountsWithWorkspaces(context.Background(), client, policies, accounts, workspacesByID) + + assert.NoError(t, err) + if assert.Len(t, updated, 2) { + assert.Equal(t, "policy-existing", updated[0].ID) + assert.Equal(t, "policy-fresh", updated[1].ID) + } +} diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index 04f3496..20a5675 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -2,7 +2,6 @@ package agent import ( "bytes" - "context" "net/http" "os" "testing" @@ -12,14 +11,6 @@ import ( "github.com/stretchr/testify/assert" ) -type ruleExistenceClient struct { - getRule func(context.Context, string) (*domain.Rule, error) -} - -func (c ruleExistenceClient) GetRule(ctx context.Context, ruleID string) (*domain.Rule, error) { - return c.getRule(ctx, ruleID) -} - func TestNewAgentCmd(t *testing.T) { cmd := NewAgentCmd() @@ -43,7 +34,6 @@ func TestCreateCmd(t *testing.T) { assert.Equal(t, "create ", cmd.Use) assert.NotNil(t, cmd.Flags().Lookup("app-password")) - assert.NotNil(t, cmd.Flags().Lookup("policy-id")) assert.Contains(t, cmd.Long, "provider=nylas") } @@ -134,9 +124,7 @@ func TestPolicyListCmd(t *testing.T) { cmd := newPolicyListCmd() assert.Equal(t, "list", cmd.Use) - assert.NotNil(t, cmd.Flags().Lookup("all")) - assert.Contains(t, cmd.Long, "provider=nylas account") - assert.Contains(t, cmd.Flags().Lookup("all").Usage, "provider=nylas accounts") + assert.Contains(t, cmd.Long, "/v3/policies") } func TestPolicyReadCmd(t *testing.T) { @@ -150,17 +138,13 @@ func TestRuleListCmd(t *testing.T) { cmd := newRuleListCmd() assert.Equal(t, "list", cmd.Use) - assert.NotNil(t, cmd.Flags().Lookup("all")) - assert.NotNil(t, cmd.Flags().Lookup("policy-id")) - assert.Contains(t, cmd.Long, "default grant") + assert.Contains(t, cmd.Long, "/v3/rules") } func TestRuleReadCmd(t *testing.T) { cmd := newRuleReadCmd() assert.Equal(t, "read ", cmd.Use) - assert.NotNil(t, cmd.Flags().Lookup("all")) - assert.NotNil(t, cmd.Flags().Lookup("policy-id")) assert.Contains(t, cmd.Long, "Read details for a single rule") } @@ -410,106 +394,6 @@ func TestResolvePolicyForAgentOps(t *testing.T) { } } -func TestBuildRuleRefsByID(t *testing.T) { - refsByRuleID := buildRuleRefsByID( - []domain.Policy{ - {ID: "policy-b", Name: "Beta", Rules: []string{"rule-1"}}, - {ID: "policy-a", Name: "Alpha", Rules: []string{"rule-1", "rule-2", "rule-1"}}, - }, - map[string][]policyAgentAccountRef{ - "policy-a": {{ - GrantID: "grant-a", - Email: "alpha@example.com", - }}, - "policy-b": {{ - GrantID: "grant-b", - Email: "beta@example.com", - }}, - }, - ) - - if assert.Len(t, refsByRuleID["rule-1"], 2) { - assert.Equal(t, "Alpha", refsByRuleID["rule-1"][0].PolicyName) - assert.Equal(t, "Beta", refsByRuleID["rule-1"][1].PolicyName) - } - if assert.Len(t, refsByRuleID["rule-2"], 1) { - assert.Equal(t, "policy-a", refsByRuleID["rule-2"][0].PolicyID) - } -} - -func TestRuleReferencedOutsideAgentScope(t *testing.T) { - allPolicies := []domain.Policy{ - {ID: "policy-agent", Rules: []string{"rule-1"}}, - {ID: "policy-other", Rules: []string{"rule-1"}}, - } - agentPolicies := []domain.Policy{ - {ID: "policy-agent", Rules: []string{"rule-1"}}, - } - - assert.True(t, ruleReferencedOutsideAgentScope(allPolicies, agentPolicies, "rule-1")) - assert.False(t, ruleReferencedOutsideAgentScope(allPolicies, agentPolicies, "rule-2")) -} - -func TestPoliciesLeftEmptyByRuleRemoval(t *testing.T) { - client := ruleExistenceClient{ - getRule: func(ctx context.Context, ruleID string) (*domain.Rule, error) { - switch ruleID { - case "rule-1", "rule-2", "rule-3": - return &domain.Rule{ID: ruleID}, nil - default: - return nil, domain.ErrRuleNotFound - } - }, - } - - blocking, err := policiesLeftEmptyByRuleRemoval(context.Background(), client, []domain.Policy{ - {ID: "policy-last", Name: "Last Rule", Rules: []string{"rule-1"}}, - {ID: "policy-shared", Name: "Has Spare", Rules: []string{"rule-1", "rule-2"}}, - {ID: "policy-other", Name: "Other Rule", Rules: []string{"rule-3"}}, - }, "rule-1") - assert.NoError(t, err) - - if assert.Len(t, blocking, 1) { - assert.Equal(t, "policy-last", blocking[0].ID) - assert.Equal(t, "Last Rule", blocking[0].Name) - } -} - -func TestPoliciesLeftEmptyByRuleRemoval_IgnoresDanglingReferences(t *testing.T) { - client := ruleExistenceClient{ - getRule: func(ctx context.Context, ruleID string) (*domain.Rule, error) { - if ruleID == "rule-1" { - return &domain.Rule{ID: ruleID}, nil - } - return nil, domain.ErrRuleNotFound - }, - } - - blocking, err := policiesLeftEmptyByRuleRemoval(context.Background(), client, []domain.Policy{ - {ID: "policy-last", Name: "Last Live Rule", Rules: []string{"rule-1", "missing-rule"}}, - }, "rule-1") - assert.NoError(t, err) - - if assert.Len(t, blocking, 1) { - assert.Equal(t, "policy-last", blocking[0].ID) - } -} - -func TestPoliciesLeftEmptyByRuleRemoval_PropagatesLookupErrors(t *testing.T) { - client := ruleExistenceClient{ - getRule: func(ctx context.Context, ruleID string) (*domain.Rule, error) { - return nil, context.DeadlineExceeded - }, - } - - blocking, err := policiesLeftEmptyByRuleRemoval(context.Background(), client, []domain.Policy{ - {ID: "policy-last", Name: "Last Rule", Rules: []string{"rule-1", "rule-2"}}, - }, "rule-1") - - assert.Nil(t, blocking) - assert.ErrorIs(t, err, context.DeadlineExceeded) -} - func captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/internal/cli/agent/create.go b/internal/cli/agent/create.go index a9057be..965a60c 100644 --- a/internal/cli/agent/create.go +++ b/internal/cli/agent/create.go @@ -15,7 +15,6 @@ import ( func newCreateCmd() *cobra.Command { var appPassword string - var policyID string cmd := &cobra.Command{ Use: "create ", @@ -23,26 +22,28 @@ func newCreateCmd() *cobra.Command { Long: `Create a new Nylas agent account. This command always creates a provider=nylas grant. If the nylas connector -does not exist yet, it will be created automatically first. +does not exist yet, it will be created automatically first. The API +automatically creates a default workspace and policy for the account. + +To attach a custom policy after creation: + nylas workspace update --policy-id Examples: nylas agent account create me@yourapp.nylas.email nylas agent account create support@yourapp.nylas.email --json - nylas agent account create debug@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!' - nylas agent account create routed@yourapp.nylas.email --policy-id `, + nylas agent account create debug@yourapp.nylas.email --app-password 'ValidAgentPass123ABC!'`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runCreate(args[0], appPassword, policyID, common.IsJSON(cmd)) + return runCreate(args[0], appPassword, common.IsJSON(cmd)) }, } cmd.Flags().StringVar(&appPassword, "app-password", "", "Optional IMAP/SMTP app password for mail-client access") - cmd.Flags().StringVar(&policyID, "policy-id", "", "Optional policy ID to attach to the created agent account") return cmd } -func runCreate(email, appPassword, policyID string, jsonOutput bool) error { +func runCreate(email, appPassword string, jsonOutput bool) error { email = strings.TrimSpace(email) if email == "" { common.PrintError("Email address cannot be empty") @@ -56,15 +57,13 @@ func runCreate(email, appPassword, policyID string, jsonOutput bool) error { common.PrintError(err.Error()) return err } - policyID = strings.TrimSpace(policyID) - _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { connector, err := ensureNylasConnector(ctx, client) if err != nil { return struct{}{}, common.WrapCreateError("nylas connector", err) } - account, err := createAgentAccountWithFallback(ctx, client, email, appPassword, policyID) + account, err := createAgentAccountWithFallback(ctx, client, email, appPassword) if err != nil { return struct{}{}, common.WrapCreateError("agent account", err) } @@ -95,18 +94,14 @@ func runCreate(email, appPassword, policyID string, jsonOutput bool) error { return err } -func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClient, email, appPassword, policyID string) (*domain.AgentAccount, error) { - account, err := client.CreateAgentAccount(ctx, email, appPassword, policyID) +func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClient, email, appPassword string) (*domain.AgentAccount, error) { + account, err := client.CreateAgentAccount(ctx, email, appPassword, "") if err == nil || appPassword == "" || !shouldRetryAgentCreateWithoutPassword(err) { return account, err } existingAccount, lookupErr := findExistingAgentAccountByEmail(ctx, client, email) if lookupErr == nil && existingAccount != nil { - if err := validateExistingAgentAccountPolicy(existingAccount, policyID); err != nil { - return nil, err - } - updated, updateErr := client.UpdateAgentAccount(ctx, existingAccount.ID, email, appPassword) if updateErr == nil { return updated, nil @@ -115,7 +110,7 @@ func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClien return nil, fmt.Errorf("failed to set app password on existing agent account %s: %w", email, updateErr) } - account, retryErr := client.CreateAgentAccount(ctx, email, "", policyID) + account, retryErr := client.CreateAgentAccount(ctx, email, "", "") if retryErr != nil { return nil, fmt.Errorf("failed to create agent account after retrying without app password: %w", retryErr) } @@ -161,33 +156,6 @@ func findExistingAgentAccountByEmail(ctx context.Context, client ports.AgentClie return nil, nil } -func validateExistingAgentAccountPolicy(account *domain.AgentAccount, requestedPolicyID string) error { - if account == nil { - return nil - } - - requestedPolicyID = strings.TrimSpace(requestedPolicyID) - if requestedPolicyID == "" { - return nil - } - - currentPolicyID := strings.TrimSpace(account.Settings.PolicyID) - if currentPolicyID == requestedPolicyID { - return nil - } - if currentPolicyID == "" { - return common.NewUserError( - "existing agent account is not attached to the requested policy", - fmt.Sprintf("Agent account %s already exists without a policy; create fallback cannot attach it to policy %s. Attach the policy separately, then run 'nylas agent account update %s --app-password '.", account.Email, requestedPolicyID, account.ID), - ) - } - - return common.NewUserError( - "existing agent account is attached to a different policy", - fmt.Sprintf("Agent account %s already exists on policy %s; create fallback cannot change it to policy %s. Update the policy assignment separately, then run 'nylas agent account update %s --app-password '.", account.Email, currentPolicyID, requestedPolicyID, account.ID), - ) -} - func shouldRetryAgentCreateWithoutPassword(err error) bool { if err == nil { return false diff --git a/internal/cli/agent/create_test.go b/internal/cli/agent/create_test.go index 1399cea..835d234 100644 --- a/internal/cli/agent/create_test.go +++ b/internal/cli/agent/create_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/domain" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -14,7 +13,7 @@ import ( type stubAgentClient struct { listFn func(ctx context.Context) ([]domain.AgentAccount, error) - createFn func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) + createFn func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) updateFn func(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) deleteFn func(ctx context.Context, grantID string) error } @@ -30,11 +29,11 @@ func (s stubAgentClient) GetAgentAccount(ctx context.Context, grantID string) (* return nil, nil } -func (s stubAgentClient) CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { +func (s stubAgentClient) CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { if s.createFn == nil { return nil, nil } - return s.createFn(ctx, email, appPassword, policyID) + return s.createFn(ctx, email, appPassword, workspaceID) } func (s stubAgentClient) UpdateAgentAccount(ctx context.Context, grantID, email, appPassword string) (*domain.AgentAccount, error) { @@ -62,7 +61,7 @@ func TestCreateAgentAccountWithFallback_ReturnsRetryError(t *testing.T) { createCalls := 0 client := stubAgentClient{ - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ switch createCalls { case 1: @@ -83,7 +82,6 @@ func TestCreateAgentAccountWithFallback_ReturnsRetryError(t *testing.T) { client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.Error(t, err) @@ -112,7 +110,7 @@ func TestCreateAgentAccountWithFallback_SkipsCleanupForExistingGrant(t *testing. Settings: domain.AgentAccountSettings{PolicyID: "policy-123"}, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ if appPassword != "" { return nil, initialErr @@ -135,7 +133,6 @@ func TestCreateAgentAccountWithFallback_SkipsCleanupForExistingGrant(t *testing. client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.Error(t, err) @@ -163,7 +160,7 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutRetryCreate(t Settings: domain.AgentAccountSettings{PolicyID: "policy-123"}, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ assert.Equal(t, "ValidAgentPass123ABC!", appPassword) return nil, initialErr @@ -186,7 +183,6 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutRetryCreate(t client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.NoError(t, err) @@ -196,7 +192,7 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutRetryCreate(t assert.Equal(t, 1, updateCalls) } -func TestCreateAgentAccountWithFallback_RejectsExistingGrantWithoutRequestedPolicy(t *testing.T) { +func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutCheckingPolicy(t *testing.T) { initialErr := &domain.APIError{ StatusCode: http.StatusBadRequest, Message: "settings.app_password is an unknown field", @@ -212,7 +208,7 @@ func TestCreateAgentAccountWithFallback_RejectsExistingGrantWithoutRequestedPoli Provider: domain.ProviderNylas, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ return nil, initialErr }, @@ -227,20 +223,15 @@ func TestCreateAgentAccountWithFallback_RejectsExistingGrantWithoutRequestedPoli client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) - require.Error(t, err) + require.NoError(t, err) assert.Nil(t, account) - assert.ErrorContains(t, err, "existing agent account is not attached to the requested policy") - var cliErr *common.CLIError - require.ErrorAs(t, err, &cliErr) - assert.Contains(t, cliErr.Suggestion, "create fallback cannot attach it to policy policy-123") assert.Equal(t, 1, createCalls) - assert.Equal(t, 0, updateCalls) + assert.Equal(t, 1, updateCalls) } -func TestCreateAgentAccountWithFallback_RejectsExistingGrantOnDifferentPolicy(t *testing.T) { +func TestCreateAgentAccountWithFallback_UpdatesExistingGrantOnDifferentPolicy(t *testing.T) { initialErr := &domain.APIError{ StatusCode: http.StatusBadRequest, Message: "settings.app_password is an unknown field", @@ -257,7 +248,7 @@ func TestCreateAgentAccountWithFallback_RejectsExistingGrantOnDifferentPolicy(t Settings: domain.AgentAccountSettings{PolicyID: "policy-other"}, }}, nil }, - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ return nil, initialErr }, @@ -272,18 +263,12 @@ func TestCreateAgentAccountWithFallback_RejectsExistingGrantOnDifferentPolicy(t client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) - require.Error(t, err) + require.NoError(t, err) assert.Nil(t, account) - assert.ErrorContains(t, err, "existing agent account is attached to a different policy") - var cliErr *common.CLIError - require.ErrorAs(t, err, &cliErr) - assert.Contains(t, cliErr.Suggestion, "policy-other") - assert.Contains(t, cliErr.Suggestion, "policy-123") assert.Equal(t, 1, createCalls) - assert.Equal(t, 0, updateCalls) + assert.Equal(t, 1, updateCalls) } func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *testing.T) { @@ -298,7 +283,7 @@ func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *test listFn: func(ctx context.Context) ([]domain.AgentAccount, error) { return nil, nil }, - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { if appPassword != "" { return nil, initialErr } @@ -322,7 +307,6 @@ func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *test client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.Error(t, err) @@ -333,14 +317,14 @@ func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *test assert.Equal(t, 0, deleteCalls) } -func TestCreateAgentAccountWithFallback_DoesNotInventPolicyID(t *testing.T) { +func TestCreateAgentAccountWithFallback_DoesNotInventWorkspaceID(t *testing.T) { initialErr := &domain.APIError{ StatusCode: http.StatusBadRequest, Message: "extra fields not permitted: app_password", } client := stubAgentClient{ - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { if appPassword != "" { return nil, initialErr } @@ -364,7 +348,6 @@ func TestCreateAgentAccountWithFallback_DoesNotInventPolicyID(t *testing.T) { client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.NoError(t, err) @@ -380,7 +363,7 @@ func TestCreateAgentAccountWithFallback_DoesNotRetryInvalidPasswordValue(t *test } client := stubAgentClient{ - createFn: func(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) { + createFn: func(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) { createCalls++ return nil, initialErr }, @@ -391,7 +374,6 @@ func TestCreateAgentAccountWithFallback_DoesNotRetryInvalidPasswordValue(t *test client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.Error(t, err) diff --git a/internal/cli/agent/helpers.go b/internal/cli/agent/helpers.go index 040209d..fa698ae 100644 --- a/internal/cli/agent/helpers.go +++ b/internal/cli/agent/helpers.go @@ -12,7 +12,7 @@ import ( "github.com/nylas/cli/internal/ports" ) -func printAgentSummary(account domain.AgentAccount, index int) { +func printAgentSummary(account domain.AgentAccount, index int, policyID, policyLabel string) { createdStr := common.FormatTimeAgo(account.CreatedAt.Time) fmt.Printf("%d. %-40s %s %s\n", index+1, @@ -21,6 +21,39 @@ func printAgentSummary(account domain.AgentAccount, index int) { common.FormatGrantStatus(account.GrantStatus), ) _, _ = common.Dim.Printf(" ID: %s\n", account.ID) + if account.WorkspaceID != "" { + _, _ = common.Dim.Printf(" Workspace ID: %s\n", account.WorkspaceID) + } + if policyID != "" { + _, _ = common.Dim.Printf(" Policy ID: %s %s\n", policyID, policyLabel) + } +} + +type workspacePolicyInfo struct { + ID string + Label string +} + +func resolveWorkspacePolicy(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + GetPolicy(context.Context, string) (*domain.Policy, error) +}, account domain.AgentAccount) workspacePolicyInfo { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + return workspacePolicyInfo{} + } + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil || workspace == nil { + return workspacePolicyInfo{} + } + policyID := strings.TrimSpace(workspace.PolicyID) + if policyID == "" { + return workspacePolicyInfo{} + } + if _, err := client.GetPolicy(ctx, policyID); err != nil { + return workspacePolicyInfo{ID: policyID, Label: "(Default Account Policy)"} + } + return workspacePolicyInfo{ID: policyID} } func printAgentDetails(account domain.AgentAccount) { @@ -34,6 +67,9 @@ func printAgentDetails(account domain.AgentAccount) { if account.CredentialID != "" { fmt.Printf("Credential: %s\n", account.CredentialID) } + if account.WorkspaceID != "" { + fmt.Printf("Workspace ID: %s\n", account.WorkspaceID) + } if account.Settings.PolicyID != "" { fmt.Printf("Policy ID: %s\n", account.Settings.PolicyID) } diff --git a/internal/cli/agent/list.go b/internal/cli/agent/list.go index c30dbe2..a29dea0 100644 --- a/internal/cli/agent/list.go +++ b/internal/cli/agent/list.go @@ -50,7 +50,8 @@ func runList(jsonOutput bool) error { _, _ = common.BoldWhite.Printf("Agent Accounts (%d)\n\n", len(accounts)) for i, account := range accounts { - printAgentSummary(account, i) + info := resolveWorkspacePolicy(ctx, client, account) + printAgentSummary(account, i, info.ID, info.Label) } fmt.Println() diff --git a/internal/cli/agent/policy.go b/internal/cli/agent/policy.go index 42fb0e4..863f2be 100644 --- a/internal/cli/agent/policy.go +++ b/internal/cli/agent/policy.go @@ -12,8 +12,9 @@ import ( ) type policyAgentAccountRef struct { - GrantID string `json:"grant_id"` - Email string `json:"email"` + GrantID string `json:"grant_id"` + Email string `json:"email"` + WorkspaceID string `json:"workspace_id,omitempty"` } type resolvedPolicyScope struct { @@ -28,8 +29,8 @@ func newPolicyCmd() *cobra.Command { Short: "Manage agent policies", Long: `Manage policies used by agent accounts. -Policies are backed by the /v3/policies API and can be attached to agent -accounts via policy_id in grant settings. +Policies are backed by the /v3/policies API. Agent accounts inherit policy +settings from their workspace policy_id attachment. Examples: nylas agent policy list @@ -51,15 +52,24 @@ Examples: } func buildPolicyAccountRefs(accounts []domain.AgentAccount) map[string][]policyAgentAccountRef { + return buildPolicyAccountRefsWithWorkspaces(accounts, nil) +} + +func buildPolicyAccountRefsWithWorkspaces(accounts []domain.AgentAccount, workspacesByID map[string]*domain.Workspace) map[string][]policyAgentAccountRef { refsByPolicyID := make(map[string][]policyAgentAccountRef, len(accounts)) for _, account := range accounts { policyID := strings.TrimSpace(account.Settings.PolicyID) + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspace := workspacesByID[workspaceID]; workspace != nil { + policyID = strings.TrimSpace(workspace.PolicyID) + } if policyID == "" { continue } refsByPolicyID[policyID] = append(refsByPolicyID[policyID], policyAgentAccountRef{ - GrantID: account.ID, - Email: account.Email, + GrantID: account.ID, + Email: account.Email, + WorkspaceID: workspaceID, }) } @@ -93,7 +103,7 @@ func resolvePolicyForAgentOps(scope *agentPolicyScope, policyID string) (*resolv if policy == nil { return nil, common.NewUserError( "policy not found", - "Use 'nylas agent policy list --all' to inspect provider=nylas policies", + "Use 'nylas agent policy list' to inspect policies", ) } diff --git a/internal/cli/agent/policy_create_update_delete.go b/internal/cli/agent/policy_create_update_delete.go index 7860a0f..95bea4c 100644 --- a/internal/cli/agent/policy_create_update_delete.go +++ b/internal/cli/agent/policy_create_update_delete.go @@ -172,7 +172,7 @@ func runPolicyDelete(policyID string) error { if len(attachedAccounts) > 0 { accountSummary := formatPolicyAgentAccounts(attachedAccounts) return struct{}{}, common.NewUserError( - fmt.Sprintf("policy is attached to agent accounts: %s", accountSummary), + fmt.Sprintf("policy is attached to agent workspaces: %s", accountSummary), fmt.Sprintf("Detach or move the listed accounts to another policy before deleting %q", policyID), ) } diff --git a/internal/cli/agent/policy_list_get.go b/internal/cli/agent/policy_list_get.go index c803e5a..e291c96 100644 --- a/internal/cli/agent/policy_list_get.go +++ b/internal/cli/agent/policy_list_get.go @@ -2,7 +2,6 @@ package agent import ( "context" - "errors" "fmt" "strings" @@ -13,101 +12,46 @@ import ( ) func newPolicyListCmd() *cobra.Command { - var allPolicies bool - cmd := &cobra.Command{ Use: "list", - Short: "List policies for the default agent account", - Long: `List policies for the current default agent account. + Short: "List policies", + Long: `List all policies from /v3/policies. -By default, this command resolves the current default grant and shows the -single policy attached to that provider=nylas account. Use --all to list every -policy referenced by a provider=nylas account. +Shows which agent workspace has each policy attached. Examples: nylas agent policy list - nylas agent policy list --all nylas agent policy list --json`, RunE: func(cmd *cobra.Command, args []string) error { - return runPolicyList(common.IsJSON(cmd), allPolicies) + return runPolicyList(common.IsJSON(cmd)) }, } - cmd.Flags().BoolVar(&allPolicies, "all", false, "List all policies referenced by provider=nylas accounts") - return cmd } -func runPolicyList(jsonOutput, allPolicies bool) error { +func runPolicyList(jsonOutput bool) error { _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - if allPolicies { - scope, err := loadAgentPolicyScope(ctx, client) - if err != nil { - return struct{}{}, err - } - policies := scope.AgentPolicies - - if jsonOutput { - return struct{}{}, common.PrintJSON(policies) - } - - if len(policies) == 0 { - common.PrintEmptyStateWithHint("policies attached to nylas agent accounts", "Create or update a provider=nylas account with a policy_id to see it here") - return struct{}{}, nil - } - - _, _ = common.BoldWhite.Printf("Policies (%d)\n\n", len(policies)) - for i, policy := range policies { - printPolicySummary(policy, i, scope.PolicyRefsByID[policy.ID]) - } - fmt.Println() - return struct{}{}, nil - } - - grantID, err := common.GetGrantID(nil) + policies, err := client.ListPolicies(ctx) if err != nil { - return struct{}{}, common.WrapGetError("default grant", err) + return struct{}{}, common.WrapListError("policies", err) } - account, err := client.GetAgentAccount(ctx, grantID) - if err != nil { - if errors.Is(err, domain.ErrInvalidGrant) { - return struct{}{}, common.NewUserError( - "default grant is not a nylas agent account", - "Use 'nylas auth switch ' to select a provider=nylas account, or run 'nylas agent policy list --all'", - ) - } - return struct{}{}, common.WrapGetError("default agent account", err) + if jsonOutput { + return struct{}{}, common.PrintJSON(policies) } - policyID := strings.TrimSpace(account.Settings.PolicyID) - if policyID == "" { - if jsonOutput { - fmt.Println("[]") - return struct{}{}, nil - } - common.PrintEmptyStateWithHint( - "policy on the default agent account", - "Use 'nylas agent policy list --all' to inspect all agent-attached policies", - ) + if len(policies) == 0 { + common.PrintEmptyStateWithHint("policies", "Create one with: nylas agent policy create --name \"Policy Name\"") return struct{}{}, nil } - policy, err := client.GetPolicy(ctx, policyID) - if err != nil { - return struct{}{}, common.WrapGetError("policy", err) - } - policies := []domain.Policy{*policy} - - if jsonOutput { - return struct{}{}, common.PrintJSON(policies) - } + workspaceRefs := buildWorkspacePolicyRefs(ctx, client) _, _ = common.BoldWhite.Printf("Policies (%d)\n\n", len(policies)) - printPolicySummary(*policy, 0, []policyAgentAccountRef{{ - GrantID: account.ID, - Email: account.Email, - }}) + for i, policy := range policies { + printPolicySummary(policy, i, workspaceRefs[policy.ID]) + } fmt.Println() return struct{}{}, nil }) @@ -115,6 +59,40 @@ func runPolicyList(jsonOutput, allPolicies bool) error { return err } +func buildWorkspacePolicyRefs(ctx context.Context, client ports.NylasClient) map[string][]policyAgentAccountRef { + accounts, err := client.ListAgentAccounts(ctx) + if err != nil { + return nil + } + + refs := make(map[string][]policyAgentAccountRef) + seenWorkspaces := make(map[string]*domain.Workspace) + for _, account := range accounts { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + continue + } + workspace, ok := seenWorkspaces[workspaceID] + if !ok { + workspace, _ = client.GetWorkspace(ctx, workspaceID) + seenWorkspaces[workspaceID] = workspace + } + if workspace == nil { + continue + } + policyID := strings.TrimSpace(workspace.PolicyID) + if policyID == "" { + continue + } + refs[policyID] = append(refs[policyID], policyAgentAccountRef{ + GrantID: account.ID, + Email: account.Email, + WorkspaceID: workspaceID, + }) + } + return refs +} + func newPolicyGetCmd() *cobra.Command { cmd := &cobra.Command{ Use: "get ", diff --git a/internal/cli/agent/rule.go b/internal/cli/agent/rule.go index 3cc25e4..dc0d377 100644 --- a/internal/cli/agent/rule.go +++ b/internal/cli/agent/rule.go @@ -1,7 +1,6 @@ package agent import ( - "cmp" "context" "errors" "fmt" @@ -14,35 +13,18 @@ import ( "github.com/spf13/cobra" ) -type rulePolicyRef struct { - PolicyID string - PolicyName string - Accounts []policyAgentAccountRef -} - -type resolvedRuleScope struct { - Rule *domain.Rule - SelectedRefs []rulePolicyRef - AllAgentRefs []rulePolicyRef - AllAgentPolicies []domain.Policy - SharedOutsideAgent bool -} - func newRuleCmd() *cobra.Command { cmd := &cobra.Command{ Use: "rule", Short: "Manage agent rules", - Long: `Manage rules used by policies attached to agent accounts. + Long: `Manage rules attached to agent account workspaces. -Rules are backed by the /v3/rules API. The agent namespace scopes them through -policies that are attached to provider=nylas accounts. This surface manages -both inbound and outbound rules attached to those policies. +Rules are backed by the /v3/rules API. They attach to workspaces via +rules_ids[]. Examples: nylas agent rule list - nylas agent rule list --all nylas agent rule read - nylas agent rule create --data-file rule.json nylas agent rule create --name "Archive outbound mail" --trigger outbound --condition recipient.domain,is,example.com --action archive nylas agent rule update --name "Updated Rule" nylas agent rule delete --yes`, @@ -78,49 +60,6 @@ func resolveDefaultAgentAccount(ctx context.Context, client ports.NylasClient) ( return account, nil } -func resolveAgentPolicy(ctx context.Context, client ports.NylasClient, policyID string) (*domain.Policy, []policyAgentAccountRef, error) { - policyID = strings.TrimSpace(policyID) - if policyID != "" { - scope, err := loadAgentPolicyScope(ctx, client) - if err != nil { - return nil, nil, err - } - - policy := findPolicyByID(scope.AgentPolicies, policyID) - if policy == nil { - return nil, nil, common.NewUserError( - "policy is not attached to a nylas agent account", - "Use 'nylas agent policy list --all' to inspect provider=nylas policies", - ) - } - - return policy, scope.PolicyRefsByID[policyID], nil - } - - account, err := resolveDefaultAgentAccount(ctx, client) - if err != nil { - return nil, nil, err - } - - defaultPolicyID := strings.TrimSpace(account.Settings.PolicyID) - if defaultPolicyID == "" { - return nil, nil, common.NewUserError( - "default agent account does not have a policy", - "Pass --policy-id or attach a policy to the active provider=nylas account first", - ) - } - - policy, err := client.GetPolicy(ctx, defaultPolicyID) - if err != nil { - return nil, nil, common.WrapGetError("policy", err) - } - - return policy, []policyAgentAccountRef{{ - GrantID: account.ID, - Email: account.Email, - }}, nil -} - func findPolicyByID(policies []domain.Policy, policyID string) *domain.Policy { for i := range policies { if policies[i].ID == policyID { @@ -130,142 +69,6 @@ func findPolicyByID(policies []domain.Policy, policyID string) *domain.Policy { return nil } -func buildRuleRefsByID(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef) map[string][]rulePolicyRef { - refsByRuleID := make(map[string][]rulePolicyRef) - for _, policy := range policies { - accounts := refsByPolicyID[policy.ID] - if len(accounts) == 0 { - continue - } - - seen := make(map[string]struct{}, len(policy.Rules)) - for _, ruleID := range policy.Rules { - ruleID = strings.TrimSpace(ruleID) - if ruleID == "" { - continue - } - if _, ok := seen[ruleID]; ok { - continue - } - seen[ruleID] = struct{}{} - - accountRefs := make([]policyAgentAccountRef, len(accounts)) - copy(accountRefs, accounts) - - refsByRuleID[ruleID] = append(refsByRuleID[ruleID], rulePolicyRef{ - PolicyID: policy.ID, - PolicyName: policy.Name, - Accounts: accountRefs, - }) - } - } - - for ruleID, refs := range refsByRuleID { - slices.SortFunc(refs, func(a, b rulePolicyRef) int { - if c := cmp.Compare(strings.ToLower(a.PolicyName), strings.ToLower(b.PolicyName)); c != 0 { - return c - } - return cmp.Compare(a.PolicyID, b.PolicyID) - }) - refsByRuleID[ruleID] = refs - } - - return refsByRuleID -} - -func filterRulesWithAgentPolicies(rules []domain.Rule, refsByRuleID map[string][]rulePolicyRef) []domain.Rule { - filtered := make([]domain.Rule, 0, len(rules)) - for _, rule := range rules { - if len(refsByRuleID[rule.ID]) == 0 { - continue - } - filtered = append(filtered, rule) - } - return filtered -} - -func resolveScopedRule(ctx context.Context, client ports.NylasClient, ruleID, policyID string, all bool) (*resolvedRuleScope, error) { - scope, err := loadAgentPolicyScope(ctx, client) - if err != nil { - return nil, err - } - - refsByRuleID := buildRuleRefsByID(scope.AgentPolicies, scope.PolicyRefsByID) - allRefs := refsByRuleID[ruleID] - if len(allRefs) == 0 { - return nil, common.NewUserError( - "rule is not attached to a nylas agent policy", - "Use 'nylas agent rule list --all' to inspect provider=nylas rules", - ) - } - - selectedRefs := allRefs - if !all { - targetPolicy, _, err := resolveAgentPolicyFromScope(ctx, client, scope, policyID) - if err != nil { - return nil, err - } - - selectedRefs = filterRuleRefsByPolicyID(allRefs, targetPolicy.ID) - if len(selectedRefs) == 0 { - return nil, common.NewUserError( - "rule is not attached to the selected policy", - "Use 'nylas agent rule list --all' to inspect all agent-scoped rules", - ) - } - } - - rule, err := client.GetRule(ctx, ruleID) - if err != nil { - return nil, common.WrapGetError("rule", err) - } - - return &resolvedRuleScope{ - Rule: rule, - SelectedRefs: selectedRefs, - AllAgentRefs: allRefs, - AllAgentPolicies: scope.AgentPolicies, - SharedOutsideAgent: ruleReferencedOutsideAgentScope(scope.AllPolicies, scope.AgentPolicies, ruleID), - }, nil -} - -func filterRuleRefsByPolicyID(refs []rulePolicyRef, policyID string) []rulePolicyRef { - filtered := make([]rulePolicyRef, 0, len(refs)) - for _, ref := range refs { - if ref.PolicyID == policyID { - filtered = append(filtered, ref) - } - } - return filtered -} - -func ruleReferencedOutsideAgentScope(allPolicies, agentPolicies []domain.Policy, ruleID string) bool { - agentPolicyIDs := make(map[string]struct{}, len(agentPolicies)) - for _, policy := range agentPolicies { - agentPolicyIDs[policy.ID] = struct{}{} - } - - for _, policy := range allPolicies { - if !policyContainsRule(policy, ruleID) { - continue - } - if _, ok := agentPolicyIDs[policy.ID]; !ok { - return true - } - } - - return false -} - -func policyContainsRule(policy domain.Policy, ruleID string) bool { - for _, candidate := range policy.Rules { - if strings.TrimSpace(candidate) == ruleID { - return true - } - } - return false -} - func appendUniqueString(items []string, value string) []string { value = strings.TrimSpace(value) if value == "" { @@ -291,99 +94,128 @@ func removeString(items []string, value string) []string { return filtered } -func refreshPolicies(ctx context.Context, client ports.NylasClient, policies []domain.Policy) ([]domain.Policy, error) { - refreshed := make([]domain.Policy, 0, len(policies)) - for _, policy := range policies { - latest, err := client.GetPolicy(ctx, policy.ID) - if err != nil { - return nil, err +func attachRuleToAgentWorkspaces(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) +}, accounts []policyAgentAccountRef, ruleID string) error { + seenWorkspaceIDs := make(map[string]struct{}, len(accounts)) + for _, account := range accounts { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + continue } - refreshed = append(refreshed, *latest) - } - return refreshed, nil -} - -func policiesLeftEmptyByRuleRemoval(ctx context.Context, client interface { - GetRule(context.Context, string) (*domain.Rule, error) -}, policies []domain.Policy, ruleID string) ([]domain.Policy, error) { - blocking := make([]domain.Policy, 0) - for _, policy := range policies { - if !policyContainsRule(policy, ruleID) { + if _, seen := seenWorkspaceIDs[workspaceID]; seen { continue } + seenWorkspaceIDs[workspaceID] = struct{}{} - liveRemaining := false - for _, candidate := range removeString(policy.Rules, ruleID) { - candidate = strings.TrimSpace(candidate) - if candidate == "" { - continue - } - - _, err := client.GetRule(ctx, candidate) - switch { - case err == nil: - liveRemaining = true - case errors.Is(err, domain.ErrRuleNotFound): - continue - default: - return nil, err - } - if liveRemaining { - break - } + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil { + return err + } + if workspace == nil { + return common.NewUserError("workspace not found", "The API returned an empty workspace response") + } + updatedRules := appendUniqueString(workspace.RulesIDs, ruleID) + if slices.Equal(updatedRules, workspace.RulesIDs) { + continue } - if !liveRemaining { - blocking = append(blocking, policy) + if _, err := client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{RulesIDs: &updatedRules}); err != nil { + return err } } - return blocking, nil + if len(seenWorkspaceIDs) == 0 { + return common.NewUserError( + "agent account has no workspace", + "The selected provider=nylas account is missing a workspace to attach the rule to; reconnect the account and try again", + ) + } + return nil } -func attachRuleToPolicy(ctx context.Context, client ports.NylasClient, policy domain.Policy, ruleID string) error { - updatedRules := appendUniqueString(policy.Rules, ruleID) - if slices.Equal(updatedRules, policy.Rules) { - return nil +func detachRuleFromAgentWorkspaces(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) +}, accounts []policyAgentAccountRef, ruleID string) (func(context.Context) error, error) { + workspaces, err := loadReferencedWorkspaces(ctx, client, accounts) + if err != nil { + return nil, err } - _, err := client.UpdatePolicy(ctx, policy.ID, map[string]any{"rules": updatedRules}) - return err -} - -func detachRuleFromPolicies(ctx context.Context, client ports.NylasClient, policies []domain.Policy, ruleID string) (func(context.Context) error, error) { - originalRulesByPolicyID := make(map[string][]string) - updatedPolicyIDs := make([]string, 0) + originalRulesByWorkspaceID := make(map[string][]string) + updatedWorkspaceIDs := make([]string, 0) - for _, policy := range policies { - if !policyContainsRule(policy, ruleID) { + for _, workspace := range workspaces { + if !stringSliceContains(workspace.RulesIDs, ruleID) { continue } - originalRulesByPolicyID[policy.ID] = append([]string(nil), policy.Rules...) - updatedRules := removeString(policy.Rules, ruleID) - if _, err := client.UpdatePolicy(ctx, policy.ID, map[string]any{"rules": updatedRules}); err != nil { - if rollbackErr := rollbackPolicyRuleUpdates(ctx, client, originalRulesByPolicyID, updatedPolicyIDs); rollbackErr != nil { - return nil, fmt.Errorf("failed to detach rule from policy %s: %w (rollback failed: %v)", policy.ID, err, rollbackErr) + originalRulesByWorkspaceID[workspace.ID] = append([]string(nil), workspace.RulesIDs...) + updatedRules := removeString(workspace.RulesIDs, ruleID) + if _, err := client.UpdateWorkspace(ctx, workspace.ID, &domain.UpdateWorkspaceRequest{RulesIDs: &updatedRules}); err != nil { + if rollbackErr := rollbackWorkspaceRuleUpdates(ctx, client, originalRulesByWorkspaceID, updatedWorkspaceIDs); rollbackErr != nil { + return nil, fmt.Errorf("failed to detach rule from workspace %s: %w (rollback failed: %v)", workspace.ID, err, rollbackErr) } return nil, err } - updatedPolicyIDs = append(updatedPolicyIDs, policy.ID) + updatedWorkspaceIDs = append(updatedWorkspaceIDs, workspace.ID) } return func(ctx context.Context) error { - return rollbackPolicyRuleUpdates(ctx, client, originalRulesByPolicyID, updatedPolicyIDs) + return rollbackWorkspaceRuleUpdates(ctx, client, originalRulesByWorkspaceID, updatedWorkspaceIDs) }, nil } -func rollbackPolicyRuleUpdates(ctx context.Context, client ports.NylasClient, originalRulesByPolicyID map[string][]string, updatedPolicyIDs []string) error { +func loadReferencedWorkspaces(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, accounts []policyAgentAccountRef) ([]domain.Workspace, error) { + seenWorkspaceIDs := make(map[string]struct{}) + workspaces := make([]domain.Workspace, 0) + for _, account := range accounts { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + continue + } + if _, seen := seenWorkspaceIDs[workspaceID]; seen { + continue + } + seenWorkspaceIDs[workspaceID] = struct{}{} + + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + if workspace == nil { + return nil, common.NewUserError("workspace not found", "The API returned an empty workspace response") + } + workspaces = append(workspaces, *workspace) + } + return workspaces, nil +} + +func rollbackWorkspaceRuleUpdates(ctx context.Context, client interface { + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) +}, originalRulesByWorkspaceID map[string][]string, updatedWorkspaceIDs []string) error { var failures []string - for _, policyID := range updatedPolicyIDs { - if _, err := client.UpdatePolicy(ctx, policyID, map[string]any{"rules": originalRulesByPolicyID[policyID]}); err != nil { - failures = append(failures, fmt.Sprintf("%s: %v", policyID, err)) + for _, workspaceID := range updatedWorkspaceIDs { + rules := append([]string(nil), originalRulesByWorkspaceID[workspaceID]...) + if _, err := client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{RulesIDs: &rules}); err != nil { + failures = append(failures, fmt.Sprintf("%s: %v", workspaceID, err)) } } if len(failures) > 0 { - return fmt.Errorf("failed to rollback policy updates: %s", strings.Join(failures, "; ")) + return fmt.Errorf("failed to rollback workspace updates: %s", strings.Join(failures, "; ")) } return nil } + +func stringSliceContains(items []string, value string) bool { + value = strings.TrimSpace(value) + for _, item := range items { + if strings.TrimSpace(item) == value { + return true + } + } + return false +} diff --git a/internal/cli/agent/rule_create_update_delete.go b/internal/cli/agent/rule_create_update_delete.go index c2b0a23..c565bb2 100644 --- a/internal/cli/agent/rule_create_update_delete.go +++ b/internal/cli/agent/rule_create_update_delete.go @@ -15,7 +15,6 @@ func newRuleCreateCmd() *cobra.Command { var ( data string dataFile string - policyID string opts rulePayloadOptions enableRule bool disableRule bool @@ -24,17 +23,15 @@ func newRuleCreateCmd() *cobra.Command { cmd := &cobra.Command{ Use: "create", Short: "Create a rule", - Long: `Create a new rule and attach it to an agent policy. + Long: `Create a new rule and attach it to the default agent workspace. -Rules are created through /v3/rules, then attached to the selected policy. If ---policy-id is omitted, the CLI uses the policy attached to the current -default provider=nylas grant. +Rules are created through /v3/rules, then attached to the workspace via +rules_ids. The workspace is resolved from the current default grant. Examples: nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action block nylas agent rule create --name "Archive example.com" --condition from.domain,is,example.com --action archive --action mark_as_read - nylas agent rule create --data-file rule.json - nylas agent rule create --data-file rule.json --policy-id `, + nylas agent rule create --data-file rule.json`, RunE: func(cmd *cobra.Command, args []string) error { opts.PrioritySet = cmd.Flags().Changed("priority") if err := assignRuleStateFlags(cmd, enableRule, disableRule, &opts); err != nil { @@ -45,7 +42,7 @@ Examples: if err != nil { return err } - return runRuleCreate(loaded.Payload, policyID, common.IsJSON(cmd)) + return runRuleCreate(loaded.Payload, common.IsJSON(cmd)) }, } @@ -60,14 +57,13 @@ Examples: cmd.Flags().StringArrayVar(&opts.Actions, "action", nil, "Rule action as type or type=value (repeatable)") cmd.Flags().StringVar(&data, "data", "", "Inline JSON request body") cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON request body file") - cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to attach the created rule to") return cmd } -func runRuleCreate(payload map[string]any, policyID string, jsonOutput bool) error { +func runRuleCreate(payload map[string]any, jsonOutput bool) error { _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - policy, accounts, err := resolveAgentPolicy(ctx, client, policyID) + account, err := resolveDefaultAgentAccount(ctx, client) if err != nil { return struct{}{}, err } @@ -77,12 +73,17 @@ func runRuleCreate(payload map[string]any, policyID string, jsonOutput bool) err return struct{}{}, common.WrapCreateError("rule", err) } - if err := attachRuleToPolicy(ctx, client, *policy, rule.ID); err != nil { + ref := policyAgentAccountRef{ + GrantID: account.ID, + Email: account.Email, + WorkspaceID: strings.TrimSpace(account.WorkspaceID), + } + if err := attachRuleToAgentWorkspaces(ctx, client, []policyAgentAccountRef{ref}, rule.ID); err != nil { cleanupErr := client.DeleteRule(ctx, rule.ID) if cleanupErr != nil { - return struct{}{}, fmt.Errorf("failed to attach rule to policy: %w (cleanup failed: %v)", err, cleanupErr) + return struct{}{}, fmt.Errorf("failed to attach rule to workspace: %w (cleanup failed: %v)", err, cleanupErr) } - return struct{}{}, fmt.Errorf("failed to attach rule to policy: %w", err) + return struct{}{}, fmt.Errorf("failed to attach rule to workspace: %w", err) } if jsonOutput { @@ -91,11 +92,8 @@ func runRuleCreate(payload map[string]any, policyID string, jsonOutput bool) err common.PrintSuccess("Rule created successfully!") fmt.Println() - printRuleDetails(*rule, []rulePolicyRef{{ - PolicyID: policy.ID, - PolicyName: policy.Name, - Accounts: accounts, - }}) + workspaceRefs := buildWorkspaceRuleRefs(ctx, client) + printRuleDetails(*rule, workspaceRefs[rule.ID]) return struct{}{}, nil }) @@ -106,8 +104,6 @@ func newRuleUpdateCmd() *cobra.Command { var ( data string dataFile string - policyID string - allRules bool opts rulePayloadOptions enableRule bool disableRule bool @@ -118,22 +114,14 @@ func newRuleUpdateCmd() *cobra.Command { Short: "Update a rule", Long: `Update an existing rule. -By default, this validates that the rule belongs to the current default -provider=nylas policy. Use --policy-id to scope the validation to another -agent policy, or --all to search any agent policy. - Examples: nylas agent rule update --name "Updated Rule" nylas agent rule update --description "Archive vendor mail" --priority 20 nylas agent rule update --condition from.domain,is,example.org --action mark_as_starred nylas agent rule update --data-file update.json - nylas agent rule update --all --json`, + nylas agent rule update --json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if allRules && policyID != "" { - return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") - } - opts.PrioritySet = cmd.Flags().Changed("priority") if err := assignRuleStateFlags(cmd, enableRule, disableRule, &opts); err != nil { return err @@ -150,7 +138,7 @@ Examples: "Use flags like --name/--condition/--action, or provide JSON with --data/--data-file", ) } - return runRuleUpdate(args[0], payload, loaded.PureJSON, policyID, allRules, common.IsJSON(cmd)) + return runRuleUpdate(args[0], payload, loaded.PureJSON, common.IsJSON(cmd)) }, } @@ -165,26 +153,18 @@ Examples: cmd.Flags().StringArrayVar(&opts.Actions, "action", nil, "Replace actions with type or type=value entries (repeatable)") cmd.Flags().StringVar(&data, "data", "", "Inline JSON request body") cmd.Flags().StringVar(&dataFile, "data-file", "", "Path to a JSON request body file") - cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the update to") - cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") return cmd } -func runRuleUpdate(ruleID string, payload map[string]any, pureJSON bool, policyID string, allRules, jsonOutput bool) error { +func runRuleUpdate(ruleID string, payload map[string]any, pureJSON, jsonOutput bool) error { _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - scope, err := resolveScopedRule(ctx, client, ruleID, policyID, allRules) + existingRule, err := client.GetRule(ctx, ruleID) if err != nil { - return struct{}{}, err - } - if scope.SharedOutsideAgent { - return struct{}{}, common.NewUserError( - "rule is shared with a non-agent policy", - "Use the generic policy/rule surface to modify shared rules safely", - ) + return struct{}{}, common.WrapGetError("rule", err) } - if err := finalizeRuleUpdatePayload(payload, scope.Rule, pureJSON); err != nil { + if err := finalizeRuleUpdatePayload(payload, existingRule, pureJSON); err != nil { return struct{}{}, err } @@ -199,7 +179,8 @@ func runRuleUpdate(ruleID string, payload map[string]any, pureJSON bool, policyI common.PrintUpdateSuccess("rule", rule.Name) fmt.Println() - printRuleDetails(*rule, scope.SelectedRefs) + workspaceRefs := buildWorkspaceRuleRefs(ctx, client) + printRuleDetails(*rule, workspaceRefs[rule.ID]) return struct{}{}, nil }) @@ -216,80 +197,58 @@ func finalizeRuleUpdatePayload(payload map[string]any, existingRule *domain.Rule } func newRuleDeleteCmd() *cobra.Command { - var ( - yes bool - policyID string - allRules bool - ) + var yes bool cmd := &cobra.Command{ Use: "delete ", Short: "Delete a rule", - Long: `Delete a rule and detach it from agent policies. + Long: `Delete a rule and detach it from agent workspaces. Examples: - nylas agent rule delete --yes - nylas agent rule delete --all --yes`, + nylas agent rule delete --yes`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if !yes { return common.NewUserError("deletion requires confirmation", "Re-run with --yes to delete the rule") } - if allRules && policyID != "" { - return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") - } - return runRuleDelete(args[0], policyID, allRules) + return runRuleDelete(args[0]) }, } common.AddYesFlag(cmd, &yes) - cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the delete to") - cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") return cmd } -func runRuleDelete(ruleID, policyID string, allRules bool) error { +func runRuleDelete(ruleID string) error { _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - scope, err := resolveScopedRule(ctx, client, ruleID, policyID, allRules) + workspaceRefs, err := buildWorkspaceRuleRefsStrict(ctx, client) if err != nil { return struct{}{}, err } - if scope.SharedOutsideAgent { - return struct{}{}, common.NewUserError( - "rule is shared with a non-agent policy", - "Use the generic policy/rule surface to delete shared rules safely", - ) - } - - latestPolicies, err := refreshPolicies(ctx, client, scope.AllAgentPolicies) - if err != nil { - return struct{}{}, common.WrapGetError("policy", err) - } - - blockingPolicies, err := policiesLeftEmptyByRuleRemoval(ctx, client, latestPolicies, ruleID) - if err != nil { - return struct{}{}, common.WrapGetError("rule", err) - } - if len(blockingPolicies) > 0 { - policyNames := make([]string, 0, len(blockingPolicies)) - for _, policy := range blockingPolicies { - policyNames = append(policyNames, policy.Name) + refs := workspaceRefs[ruleID] + + var rollback func(context.Context) error + if len(refs) > 0 { + accountRefs := make([]policyAgentAccountRef, 0, len(refs)) + for _, ref := range refs { + accountRefs = append(accountRefs, policyAgentAccountRef{ + GrantID: ref.GrantID, + Email: ref.Email, + WorkspaceID: ref.WorkspaceID, + }) + } + rollback, err = detachRuleFromAgentWorkspaces(ctx, client, accountRefs, ruleID) + if err != nil { + return struct{}{}, fmt.Errorf("failed to detach rule from workspaces: %w", err) } - return struct{}{}, common.NewUserError( - "cannot delete the last rule from an agent policy", - fmt.Sprintf("Attach another rule to %s before deleting %q", strings.Join(policyNames, ", "), scope.Rule.Name), - ) - } - - rollback, err := detachRuleFromPolicies(ctx, client, latestPolicies, ruleID) - if err != nil { - return struct{}{}, fmt.Errorf("failed to detach rule from agent policies: %w", err) } if err := client.DeleteRule(ctx, ruleID); err != nil { - if rollbackErr := rollback(ctx); rollbackErr != nil { - return struct{}{}, fmt.Errorf("failed to delete rule: %w (rollback failed: %v)", err, rollbackErr) + if rollback != nil { + if rollbackErr := rollback(ctx); rollbackErr != nil { + return struct{}{}, fmt.Errorf("failed to delete rule: %w (rollback failed: %v)", err, rollbackErr) + } } return struct{}{}, common.WrapDeleteError("rule", err) } diff --git a/internal/cli/agent/rule_list_get.go b/internal/cli/agent/rule_list_get.go index 532a89e..757dd4a 100644 --- a/internal/cli/agent/rule_list_get.go +++ b/internal/cli/agent/rule_list_get.go @@ -12,125 +12,45 @@ import ( ) func newRuleListCmd() *cobra.Command { - var ( - allRules bool - policyID string - ) - cmd := &cobra.Command{ Use: "list", - Short: "List rules for the default agent policy", - Long: `List rules for the current default agent policy. + Short: "List rules", + Long: `List all rules from /v3/rules. -By default, this command resolves the current default grant and lists the rules -attached to that provider=nylas account's policy. Use --policy-id to inspect a -specific agent policy, or --all to list every rule reachable from any -provider=nylas account policy. +Shows which agent workspace has each rule attached. Examples: nylas agent rule list - nylas agent rule list --policy-id - nylas agent rule list --all nylas agent rule list --json`, RunE: func(cmd *cobra.Command, args []string) error { - if allRules && policyID != "" { - return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") - } - return runRuleList(common.IsJSON(cmd), allRules, policyID) + return runRuleList(common.IsJSON(cmd)) }, } - cmd.Flags().BoolVar(&allRules, "all", false, "List all rules reachable from provider=nylas policies") - cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the rule list to") - return cmd } -func runRuleList(jsonOutput, allRules bool, policyID string) error { +func runRuleList(jsonOutput bool) error { _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - if allRules { - scope, err := loadAgentPolicyScope(ctx, client) - if err != nil { - return struct{}{}, err - } - - refsByRuleID := buildRuleRefsByID(scope.AgentPolicies, scope.PolicyRefsByID) - if len(refsByRuleID) == 0 { - if jsonOutput { - fmt.Println("[]") - return struct{}{}, nil - } - common.PrintEmptyStateWithHint("rules attached to nylas agent policies", "Create a rule and attach it to a provider=nylas policy to see it here") - return struct{}{}, nil - } - - rules, err := client.ListRules(ctx) - if err != nil { - return struct{}{}, common.WrapListError("rules", err) - } - rules = filterRulesWithAgentPolicies(rules, refsByRuleID) - - if jsonOutput { - return struct{}{}, common.PrintJSON(rules) - } - - _, _ = common.BoldWhite.Printf("Rules (%d)\n\n", len(rules)) - for i, rule := range rules { - printRuleSummary(rule, i, refsByRuleID[rule.ID]) - } - fmt.Println() - return struct{}{}, nil - } - - scope, err := loadAgentPolicyScope(ctx, client) - if err != nil { - return struct{}{}, err - } - - policy, accounts, err := resolveAgentPolicyFromScope(ctx, client, scope, policyID) + rules, err := client.ListRules(ctx) if err != nil { - return struct{}{}, err - } - - ruleIDs := make([]string, 0, len(policy.Rules)) - for _, ruleID := range policy.Rules { - ruleID = strings.TrimSpace(ruleID) - if ruleID == "" { - continue - } - ruleIDs = append(ruleIDs, ruleID) + return struct{}{}, common.WrapListError("rules", err) } - if len(ruleIDs) == 0 { - if jsonOutput { - fmt.Println("[]") - return struct{}{}, nil - } - common.PrintEmptyStateWithHint("rules on the selected agent policy", "Use 'nylas agent rule create --data-file rule.json' to add one") - return struct{}{}, nil + if jsonOutput { + return struct{}{}, common.PrintJSON(rules) } - allRulesList, err := client.ListRules(ctx) - if err != nil { - return struct{}{}, common.WrapListError("rules", err) - } - rules, ruleRefs := collectPolicyScopedRules(policy, accounts, allRulesList) if len(rules) == 0 { - if jsonOutput { - fmt.Println("[]") - return struct{}{}, nil - } - common.PrintEmptyStateWithHint("rules on the selected agent policy", "Use 'nylas agent rule create --data-file rule.json' to add one") + common.PrintEmptyStateWithHint("rules", "Create one with: nylas agent rule create --name \"Rule Name\" --condition ... --action ...") return struct{}{}, nil } - if jsonOutput { - return struct{}{}, common.PrintJSON(rules) - } + workspaceRefs := buildWorkspaceRuleRefs(ctx, client) _, _ = common.BoldWhite.Printf("Rules (%d)\n\n", len(rules)) for i, rule := range rules { - printRuleSummary(rule, i, ruleRefs[rule.ID]) + printRuleSummary(rule, i, workspaceRefs[rule.ID]) } fmt.Println() return struct{}{}, nil @@ -139,82 +59,70 @@ func runRuleList(jsonOutput, allRules bool, policyID string) error { return err } -func collectPolicyScopedRules(policy *domain.Policy, accounts []policyAgentAccountRef, allRules []domain.Rule) ([]domain.Rule, map[string][]rulePolicyRef) { - rulesByID := make(map[string]domain.Rule, len(allRules)) - for _, rule := range allRules { - rulesByID[rule.ID] = rule - } +func buildWorkspaceRuleRefs(ctx context.Context, client ports.NylasClient) map[string][]ruleWorkspaceRef { + refs, _ := buildWorkspaceRuleRefsStrict(ctx, client) + return refs +} - accountRefs := append([]policyAgentAccountRef(nil), accounts...) - rules := make([]domain.Rule, 0, len(policy.Rules)) - ruleRefs := make(map[string][]rulePolicyRef, len(policy.Rules)) +func buildWorkspaceRuleRefsStrict(ctx context.Context, client ports.NylasClient) (map[string][]ruleWorkspaceRef, error) { + accounts, err := client.ListAgentAccounts(ctx) + if err != nil { + return nil, common.WrapListError("agent accounts", err) + } - for _, ruleID := range policy.Rules { - ruleID = strings.TrimSpace(ruleID) - if ruleID == "" { + refs := make(map[string][]ruleWorkspaceRef) + seenWorkspaces := make(map[string]*domain.Workspace) + for _, account := range accounts { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { continue } - - rule, ok := rulesByID[ruleID] + workspace, ok := seenWorkspaces[workspaceID] if !ok { - continue + workspace, err = client.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, common.WrapGetError("workspace", err) + } + seenWorkspaces[workspaceID] = workspace } - - rules = append(rules, rule) - if _, ok := ruleRefs[rule.ID]; ok { + if workspace == nil { continue } - ruleRefs[rule.ID] = []rulePolicyRef{{ - PolicyID: policy.ID, - PolicyName: policy.Name, - Accounts: accountRefs, - }} + for _, ruleID := range workspace.RulesIDs { + ruleID = strings.TrimSpace(ruleID) + if ruleID == "" { + continue + } + refs[ruleID] = append(refs[ruleID], ruleWorkspaceRef{ + WorkspaceID: workspaceID, + WorkspaceName: workspace.Name, + GrantID: account.ID, + Email: account.Email, + }) + } } - - return rules, ruleRefs + return refs, nil } func newRuleGetCmd() *cobra.Command { - var ( - allRules bool - policyID string - ) - cmd := &cobra.Command{ Use: "get ", Short: "Show a rule", Long: `Show details for a single rule. -By default, this validates that the rule is attached to the current default -agent policy. Use --policy-id to scope the lookup to another provider=nylas -policy, or --all to search any provider=nylas policy. - Examples: nylas agent rule get - nylas agent rule get --policy-id - nylas agent rule get --all nylas agent rule get --json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if allRules && policyID != "" { - return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") - } - return runRuleGet(args[0], common.IsJSON(cmd), allRules, policyID) + return runRuleGet(args[0], common.IsJSON(cmd)) }, } - cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") - cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the rule lookup to") - return cmd } func newRuleReadCmd() *cobra.Command { - var ( - allRules bool - policyID string - ) - cmd := &cobra.Command{ Use: "read ", Short: "Read a rule", @@ -222,36 +130,29 @@ func newRuleReadCmd() *cobra.Command { Examples: nylas agent rule read - nylas agent rule read --policy-id - nylas agent rule read --all nylas agent rule read --json`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - if allRules && policyID != "" { - return common.NewUserError("cannot combine --all with --policy-id", "Use either --all or --policy-id") - } - return runRuleGet(args[0], common.IsJSON(cmd), allRules, policyID) + return runRuleGet(args[0], common.IsJSON(cmd)) }, } - cmd.Flags().BoolVar(&allRules, "all", false, "Search across all provider=nylas policies") - cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to scope the rule lookup to") - return cmd } -func runRuleGet(ruleID string, jsonOutput, allRules bool, policyID string) error { +func runRuleGet(ruleID string, jsonOutput bool) error { _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { - scope, err := resolveScopedRule(ctx, client, ruleID, policyID, allRules) + rule, err := client.GetRule(ctx, ruleID) if err != nil { - return struct{}{}, err + return struct{}{}, common.WrapGetError("rule", err) } if jsonOutput { - return struct{}{}, common.PrintJSON(scope.Rule) + return struct{}{}, common.PrintJSON(rule) } - printRuleDetails(*scope.Rule, scope.SelectedRefs) + workspaceRefs := buildWorkspaceRuleRefs(ctx, client) + printRuleDetails(*rule, workspaceRefs[rule.ID]) return struct{}{}, nil }) diff --git a/internal/cli/agent/rule_list_get_test.go b/internal/cli/agent/rule_list_get_test.go index 4ee52ff..c51d887 100644 --- a/internal/cli/agent/rule_list_get_test.go +++ b/internal/cli/agent/rule_list_get_test.go @@ -1,6 +1,7 @@ package agent import ( + "context" "testing" "github.com/nylas/cli/internal/domain" @@ -8,44 +9,62 @@ import ( "github.com/stretchr/testify/require" ) -func TestCollectPolicyScopedRules_SkipsDanglingReferences(t *testing.T) { - enabled := true - accounts := []policyAgentAccountRef{{ - GrantID: "grant-1", - Email: "agent@example.com", - }} - policy := &domain.Policy{ - ID: "policy-1", - Name: "Primary Policy", - Rules: []string{"missing-rule", " rule-2 ", "", "rule-1"}, +type workspaceRuleTestClient struct { + workspaces map[string]*domain.Workspace + rules map[string]*domain.Rule + updates map[string][]string +} + +func (c *workspaceRuleTestClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { + workspace := c.workspaces[workspaceID] + if workspace == nil { + return nil, domain.ErrWorkspaceNotFound + } + copy := *workspace + copy.RulesIDs = append([]string(nil), workspace.RulesIDs...) + return ©, nil +} + +func (c *workspaceRuleTestClient) UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) { + workspace := c.workspaces[workspaceID] + if workspace == nil { + return nil, domain.ErrWorkspaceNotFound } - allRules := []domain.Rule{ - {ID: "rule-1", Name: "First Rule", Enabled: &enabled}, - {ID: "rule-2", Name: "Second Rule", Enabled: &enabled}, + if req.RulesIDs != nil { + workspace.RulesIDs = append([]string(nil), (*req.RulesIDs)...) + if c.updates != nil { + c.updates[workspaceID] = append([]string(nil), (*req.RulesIDs)...) + } } + return workspace, nil +} - rules, refs := collectPolicyScopedRules(policy, accounts, allRules) - - require.Len(t, rules, 2) - assert.Equal(t, "rule-2", rules[0].ID) - assert.Equal(t, "rule-1", rules[1].ID) - assert.NotContains(t, refs, "missing-rule") - assert.Equal(t, []rulePolicyRef{{ - PolicyID: "policy-1", - PolicyName: "Primary Policy", - Accounts: accounts, - }}, refs["rule-1"]) +func (c *workspaceRuleTestClient) GetRule(ctx context.Context, ruleID string) (*domain.Rule, error) { + rule := c.rules[ruleID] + if rule == nil { + return nil, domain.ErrRuleNotFound + } + return rule, nil } -func TestCollectPolicyScopedRules_ReturnsEmptyWhenPolicyOnlyHasDanglingReferences(t *testing.T) { - policy := &domain.Policy{ - ID: "policy-1", - Name: "Primary Policy", - Rules: []string{"missing-rule"}, +func TestDetachRuleFromAgentWorkspacesRemovesAndRollsBackWorkspaceRule(t *testing.T) { + client := &workspaceRuleTestClient{ + workspaces: map[string]*domain.Workspace{ + "workspace-1": {ID: "workspace-1", RulesIDs: []string{"rule-1", "rule-2"}}, + }, + updates: make(map[string][]string), } + accounts := []policyAgentAccountRef{{ + GrantID: "grant-1", + WorkspaceID: "workspace-1", + }} + + rollback, err := detachRuleFromAgentWorkspaces(context.Background(), client, accounts, "rule-1") - rules, refs := collectPolicyScopedRules(policy, nil, []domain.Rule{{ID: "rule-1"}}) + require.NoError(t, err) + assert.Equal(t, []string{"rule-2"}, client.workspaces["workspace-1"].RulesIDs) + assert.Equal(t, []string{"rule-2"}, client.updates["workspace-1"]) - assert.Empty(t, rules) - assert.Empty(t, refs) + require.NoError(t, rollback(context.Background())) + assert.Equal(t, []string{"rule-1", "rule-2"}, client.workspaces["workspace-1"].RulesIDs) } diff --git a/internal/cli/agent/rule_print.go b/internal/cli/agent/rule_print.go index 0ab49b7..94edf47 100644 --- a/internal/cli/agent/rule_print.go +++ b/internal/cli/agent/rule_print.go @@ -2,26 +2,33 @@ package agent import ( "fmt" - "strings" "github.com/nylas/cli/internal/cli/common" "github.com/nylas/cli/internal/domain" ) -func printRuleSummary(rule domain.Rule, index int, refs []rulePolicyRef) { +type ruleWorkspaceRef struct { + WorkspaceID string `json:"workspace_id"` + WorkspaceName string `json:"workspace_name,omitempty"` + GrantID string `json:"grant_id,omitempty"` + Email string `json:"email,omitempty"` +} + +func printRuleSummary(rule domain.Rule, index int, refs []ruleWorkspaceRef) { fmt.Printf("%d. %-32s %s\n", index+1, common.Cyan.Sprint(rule.Name), common.Dim.Sprint(rule.ID)) if !rule.UpdatedAt.IsZero() { _, _ = common.Dim.Printf(" Updated: %s\n", common.FormatTimeAgo(rule.UpdatedAt.Time)) } for _, ref := range refs { - _, _ = common.Dim.Printf(" Policy: %s (%s)\n", ref.PolicyName, ref.PolicyID) - for _, account := range ref.Accounts { - _, _ = common.Dim.Printf(" Agent: %s (%s)\n", account.Email, account.GrantID) + if ref.Email != "" { + _, _ = common.Dim.Printf(" Workspace: %s Agent: %s\n", ref.WorkspaceID, ref.Email) + } else { + _, _ = common.Dim.Printf(" Workspace: %s\n", ref.WorkspaceID) } } } -func printRuleDetails(rule domain.Rule, refs []rulePolicyRef) { +func printRuleDetails(rule domain.Rule, refs []ruleWorkspaceRef) { fmt.Printf("Rule: %s\n", rule.Name) fmt.Printf("ID: %s\n", rule.ID) if rule.Description != "" { @@ -49,26 +56,23 @@ func printRuleDetails(rule domain.Rule, refs []rulePolicyRef) { fmt.Printf("Updated: %s (%s)\n", rule.UpdatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(rule.UpdatedAt.Time)) } - printRuleRefsSection(refs) + printRuleWorkspacesSection(refs) printRuleMatchSection(rule.Match) printRuleActionsSection(rule.Actions) fmt.Println() } -func printRuleRefsSection(refs []rulePolicyRef) { - printPolicySectionHeader("Policies") +func printRuleWorkspacesSection(refs []ruleWorkspaceRef) { + printPolicySectionHeader("Workspaces") if len(refs) == 0 { fmt.Println(" none") return } for _, ref := range refs { - printPolicyField("Policy", fmt.Sprintf("%s (%s)", ref.PolicyName, ref.PolicyID)) - if len(ref.Accounts) == 0 { - continue - } - for _, account := range ref.Accounts { - printPolicyField("Agent", fmt.Sprintf("%s (%s)", account.Email, account.GrantID)) + printPolicyField("Workspace", ref.WorkspaceID) + if ref.Email != "" { + printPolicyField("Agent", fmt.Sprintf("%s (%s)", ref.Email, ref.GrantID)) } } } @@ -84,13 +88,13 @@ func printRuleMatchSection(match *domain.RuleMatch) { printPolicyField("Operator", match.Operator) } if len(match.Conditions) == 0 { - fmt.Println(" Conditions: none") + printPolicyField("Conditions", "none") return } - fmt.Println(" Conditions:") - for i, condition := range match.Conditions { - fmt.Printf(" %d. %s %s %s\n", i+1, condition.Field, condition.Operator, formatRuleValue(condition.Value)) + printPolicyField("Conditions", fmt.Sprintf("%d", len(match.Conditions))) + for _, cond := range match.Conditions { + fmt.Printf(" - %s %s %v\n", cond.Field, cond.Operator, cond.Value) } } @@ -101,30 +105,11 @@ func printRuleActionsSection(actions []domain.RuleAction) { return } - for i, action := range actions { - if action.Value == nil { - fmt.Printf(" %d. %s\n", i+1, action.Type) - continue - } - fmt.Printf(" %d. %s => %s\n", i+1, action.Type, formatRuleValue(action.Value)) - } -} - -func formatRuleValue(value any) string { - switch v := value.(type) { - case nil: - return "none" - case string: - return v - case []string: - return strings.Join(v, ", ") - case []any: - parts := make([]string, 0, len(v)) - for _, item := range v { - parts = append(parts, formatRuleValue(item)) + for _, action := range actions { + if action.Value != nil { + fmt.Printf(" - %s = %v\n", action.Type, action.Value) + } else { + fmt.Printf(" - %s\n", action.Type) } - return strings.Join(parts, ", ") - default: - return fmt.Sprintf("%v", v) } } diff --git a/internal/cli/agent/rule_print_test.go b/internal/cli/agent/rule_print_test.go index 5695690..99d54e2 100644 --- a/internal/cli/agent/rule_print_test.go +++ b/internal/cli/agent/rule_print_test.go @@ -36,19 +36,16 @@ func TestPrintRuleDetails(t *testing.T) { } output := captureStdout(t, func() { - printRuleDetails(rule, []rulePolicyRef{{ - PolicyID: "policy-123", - PolicyName: "Default Policy", - Accounts: []policyAgentAccountRef{{ - GrantID: "grant-123", - Email: "agent@example.com", - }}, + printRuleDetails(rule, []ruleWorkspaceRef{{ + WorkspaceID: "workspace-123", + GrantID: "grant-123", + Email: "agent@example.com", }}) }) assert.Contains(t, output, "Rule: Block Example") - assert.Contains(t, output, "Policies:") - assert.Contains(t, output, "Default Policy") + assert.Contains(t, output, "Workspaces:") + assert.Contains(t, output, "workspace-123") assert.Contains(t, output, "agent@example.com") assert.Contains(t, output, "Match:") assert.Contains(t, output, "from.domain is example.com") diff --git a/internal/cli/agent/status.go b/internal/cli/agent/status.go index 495dc02..0dc7af4 100644 --- a/internal/cli/agent/status.go +++ b/internal/cli/agent/status.go @@ -76,7 +76,8 @@ func runStatus(jsonOutput bool) error { if len(accounts) > 0 { fmt.Println() for i, account := range accounts { - printAgentSummary(account, i) + info := resolveWorkspacePolicy(ctx, client, account) + printAgentSummary(account, i, info.ID, info.Label) } } diff --git a/internal/cli/integration/agent_policy_test.go b/internal/cli/integration/agent_policy_test.go index e448474..68ce8e7 100644 --- a/internal/cli/integration/agent_policy_test.go +++ b/internal/cli/integration/agent_policy_test.go @@ -90,7 +90,7 @@ func TestCLI_AgentPolicyLifecycle_CreateGetListUpdateDelete(t *testing.T) { t.Fatalf("policy read text output should include spam detection section\noutput: %s", readTextStdout) } - listStdout, listStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "list", "--all", "--json") + listStdout, listStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "list", "--json") if err != nil { t.Fatalf("policy list failed: %v\nstdout: %s\nstderr: %s", err, listStdout, listStderr) } @@ -100,11 +100,16 @@ func TestCLI_AgentPolicyLifecycle_CreateGetListUpdateDelete(t *testing.T) { t.Fatalf("failed to parse policy list JSON: %v\noutput: %s", err, listStdout) } + found := false for _, listed := range policies { if listed.ID == policy.ID { - t.Fatalf("unattached policy %q should not appear in agent policy list --all\noutput: %s", policy.ID, listStdout) + found = true + break } } + if !found { + t.Fatalf("policy %q should appear in policy list\noutput: %s", policy.ID, listStdout) + } updateStdout, updateStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "update", policy.ID, "--name", updatedName, "--json") if err != nil { @@ -222,24 +227,8 @@ func TestCLI_AgentPolicyList_ShowsAttachedAgentAccount(t *testing.T) { t.Fatalf("policy list output missing agent grant ID %q\noutput: %s", createdAccount.ID, stdout) } - allStdout, allStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "policy", "list", "--all") - if err != nil { - t.Fatalf("policy list --all failed: %v\nstdout: %s\nstderr: %s", err, allStdout, allStderr) - } - if !strings.Contains(allStdout, createdPolicy.Name) { - t.Fatalf("policy list --all output missing policy name %q\noutput: %s", createdPolicy.Name, allStdout) - } - if !strings.Contains(allStdout, createdAccount.Email) { - t.Fatalf("policy list --all output missing agent email %q\noutput: %s", createdAccount.Email, allStdout) - } - if !strings.Contains(allStdout, "Agent:") { - t.Fatalf("policy list --all should include agent annotations\noutput: %s", allStdout) - } - if !strings.Contains(allStdout, fmt.Sprintf("(%s)", createdAccount.ID)) { - t.Fatalf("policy list --all output missing agent grant ID %q\noutput: %s", createdAccount.ID, allStdout) - } - if strings.Contains(allStdout, "Agent: none") { - t.Fatalf("policy list --all should not show policies without a provider=nylas account\noutput: %s", allStdout) + if !strings.Contains(stdout, "Agent:") { + t.Fatalf("policy list should include agent annotations when workspace references exist\noutput: %s", stdout) } } @@ -286,7 +275,7 @@ func TestCLI_AgentPolicyDelete_RejectsAttachedPolicy(t *testing.T) { if err == nil { t.Fatalf("expected policy delete to fail while attached\nstdout: %s\nstderr: %s", stdout, stderr) } - if !strings.Contains(strings.ToLower(stderr), "policy is attached to agent accounts") { + if !strings.Contains(strings.ToLower(stderr), "policy is attached to agent workspaces") { t.Fatalf("expected attached policy error, got stderr: %s", stderr) } if !strings.Contains(stderr, createdAccount.Email) { @@ -308,18 +297,80 @@ func TestCLI_AgentPolicyDelete_RejectsAttachedPolicy(t *testing.T) { func createAgentWithPolicyForTest(t *testing.T, email, policyID string) *domain.AgentAccount { t.Helper() + client := getTestClient() + acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), domain.TimeoutAPI) - defer cancel() + account, err := client.CreateAgentAccount(ctx, email, "", "") + cancel() + if err != nil { + t.Fatalf("failed to create agent for policy attach: %v", err) + } - client := getTestClient() - account, err := client.CreateAgentAccount(ctx, email, "", policyID) + // In the workspace model the adapter no longer attaches policies on create; + // policy attachment is a workspace PATCH (mirrors the CLI create path). + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + deleteAgentAccountQuietly(t, client, account.ID) + t.Fatalf("created agent account %q has no workspace_id to attach policy %q", email, policyID) + } + + acquireRateLimit(t) + ctx, cancel = context.WithTimeout(context.Background(), domain.TimeoutAPI) + _, err = client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{PolicyID: &policyID}) + cancel() if err != nil { - t.Fatalf("failed to create agent with policy: %v", err) + deleteAgentAccountQuietly(t, client, account.ID) + t.Fatalf("failed to attach policy %q to workspace %q: %v", policyID, workspaceID, err) } + return account } +// deleteAgentAccountQuietly best-effort deletes an agent account so a helper +// that fails mid-setup does not orphan the created grant (the caller cannot +// register cleanup until the helper returns). +func deleteAgentAccountQuietly(t *testing.T, client interface { + DeleteAgentAccount(context.Context, string) error +}, grantID string) { + t.Helper() + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := client.DeleteAgentAccount(ctx, grantID); err != nil { + t.Logf("cleanup: delete agent account %s: %v", grantID, err) + } +} + +func assertWorkspacePolicyForTest(t *testing.T, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, workspaceID, wantPolicyID string) { + t.Helper() + + if strings.TrimSpace(workspaceID) == "" { + t.Fatalf("agent account has no workspace_id; cannot verify policy %q", wantPolicyID) + } + + deadline := time.Now().Add(60 * time.Second) + var lastSeen string + for time.Now().Before(deadline) { + acquireRateLimit(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + workspace, err := client.GetWorkspace(ctx, workspaceID) + cancel() + if err == nil && workspace != nil { + lastSeen = strings.TrimSpace(workspace.PolicyID) + if lastSeen == wantPolicyID { + return + } + } + + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("workspace %q policy_id = %q, want %q", workspaceID, lastSeen, wantPolicyID) +} + func newPolicyTestName(prefix string) string { return fmt.Sprintf("it-policy-%s-%d", prefix, time.Now().UnixNano()) } diff --git a/internal/cli/integration/agent_rule_matrix_test.go b/internal/cli/integration/agent_rule_matrix_test.go index e7fde65..99fa965 100644 --- a/internal/cli/integration/agent_rule_matrix_test.go +++ b/internal/cli/integration/agent_rule_matrix_test.go @@ -23,9 +23,10 @@ type ruleMatrixScope struct { DeleteAgentAccount(context.Context, string) error DeleteRule(context.Context, string) error } - policyID string - accountID string - createdIDs []string + policyID string + accountID string + workspaceID string + createdIDs []string } type ruleConditionMatrixCase struct { @@ -52,11 +53,11 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T scope := setupRuleMatrixScope(t, "rule-matrix-create") placeholder := createRuleForTest(t, getTestClient(), "it-rule-matrix-create-placeholder") scope.trackRule(placeholder.ID) - attachRuleToPolicyForTest(t, getTestClient(), scope.policyID, placeholder.ID) + attachRuleToWorkspaceForTest(t, getTestClient(), scope.workspaceID, placeholder.ID) for _, tc := range buildRuleConditionMatrixCases() { t.Run("create-"+tc.name, func(t *testing.T) { - rule := runAgentRuleCreateJSON(t, scope.env, scope.policyID, + rule := runAgentRuleCreateJSON(t, scope.env, "--name", fmt.Sprintf("it-%s-%d", tc.name, time.Now().UnixNano()), "--trigger", tc.trigger, "--match-operator", "all", @@ -73,7 +74,7 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T for _, tc := range buildRuleActionMatrixCases() { t.Run("create-action-"+tc.name, func(t *testing.T) { - rule := runAgentRuleCreateJSON(t, scope.env, scope.policyID, + rule := runAgentRuleCreateJSON(t, scope.env, "--name", fmt.Sprintf("it-action-%s-%d", tc.name, time.Now().UnixNano()), "--trigger", tc.trigger, "--condition", representativeCondition(tc.trigger), @@ -85,7 +86,7 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T }) } - inboundStateRule := runAgentRuleCreateJSON(t, scope.env, scope.policyID, + inboundStateRule := runAgentRuleCreateJSON(t, scope.env, "--name", fmt.Sprintf("it-state-inbound-%d", time.Now().UnixNano()), "--priority", "3", "--disabled", @@ -101,7 +102,7 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T assertRuleMatchOperator(t, inboundStateRule, "any") assertRuleConditionCount(t, inboundStateRule, 2) - outboundStateRule := runAgentRuleCreateJSON(t, scope.env, scope.policyID, + outboundStateRule := runAgentRuleCreateJSON(t, scope.env, "--name", fmt.Sprintf("it-state-outbound-%d", time.Now().UnixNano()), "--trigger", "outbound", "--priority", "4", @@ -128,15 +129,15 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T placeholder := createRuleForTest(t, client, "it-rule-matrix-update-placeholder") scope.trackRule(placeholder.ID) - attachRuleToPolicyForTest(t, client, scope.policyID, placeholder.ID) + attachRuleToWorkspaceForTest(t, client, scope.workspaceID, placeholder.ID) inboundBase := createMatrixRuleForTest(t, client, "inbound", "it-rule-matrix-update-inbound") scope.trackRule(inboundBase.ID) - attachRuleToPolicyForTest(t, client, scope.policyID, inboundBase.ID) + attachRuleToWorkspaceForTest(t, client, scope.workspaceID, inboundBase.ID) outboundBase := createMatrixRuleForTest(t, client, "outbound", "it-rule-matrix-update-outbound") scope.trackRule(outboundBase.ID) - attachRuleToPolicyForTest(t, client, scope.policyID, outboundBase.ID) + attachRuleToWorkspaceForTest(t, client, scope.workspaceID, outboundBase.ID) for _, tc := range buildRuleConditionMatrixCases() { t.Run("update-condition-"+tc.name, func(t *testing.T) { @@ -145,7 +146,7 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T ruleID = outboundBase.ID } - rule := runAgentRuleUpdateJSON(t, scope.env, scope.policyID, ruleID, + rule := runAgentRuleUpdateJSON(t, scope.env, ruleID, "--trigger", tc.trigger, "--match-operator", "all", "--condition", buildConditionArg(tc.field, tc.operator, tc.rawValue), @@ -165,7 +166,7 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T ruleID = outboundBase.ID } - rule := runAgentRuleUpdateJSON(t, scope.env, scope.policyID, ruleID, + rule := runAgentRuleUpdateJSON(t, scope.env, ruleID, "--trigger", tc.trigger, "--match-operator", "all", "--condition", representativeCondition(tc.trigger), @@ -176,7 +177,7 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T }) } - inboundAny := runAgentRuleUpdateJSON(t, scope.env, scope.policyID, inboundBase.ID, + inboundAny := runAgentRuleUpdateJSON(t, scope.env, inboundBase.ID, "--trigger", "inbound", "--priority", "7", "--disabled", @@ -192,7 +193,7 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T assertRuleConditionCount(t, inboundAny, 2) assertRuleAction(t, inboundAny, "mark_as_read", nil) - outboundAny := runAgentRuleUpdateJSON(t, scope.env, scope.policyID, outboundBase.ID, + outboundAny := runAgentRuleUpdateJSON(t, scope.env, outboundBase.ID, "--trigger", "outbound", "--priority", "8", "--disabled", @@ -208,7 +209,7 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T assertRuleConditionCount(t, outboundAny, 2) assertRuleAction(t, outboundAny, "mark_as_starred", nil) - flippedOutbound := runAgentRuleUpdateJSON(t, scope.env, scope.policyID, inboundBase.ID, + flippedOutbound := runAgentRuleUpdateJSON(t, scope.env, inboundBase.ID, "--trigger", "outbound", "--condition", "recipient.domain,is,example.org", "--condition", "outbound.type,is,reply", @@ -217,7 +218,7 @@ func TestCLI_AgentRuleMatrix_UpdateAllSupportedConditionsAndActions(t *testing.T assertRuleTrigger(t, flippedOutbound, "outbound") assertRuleCondition(t, flippedOutbound, "recipient.domain", "is", "example.org") - flippedInbound := runAgentRuleUpdateJSON(t, scope.env, scope.policyID, outboundBase.ID, + flippedInbound := runAgentRuleUpdateJSON(t, scope.env, outboundBase.ID, "--trigger", "inbound", "--condition", "from.domain,is,example.org", "--action", "archive", @@ -245,10 +246,11 @@ func setupRuleMatrixScope(t *testing.T, prefix string) *ruleMatrixScope { env["NYLAS_GRANT_ID"] = account.ID scope := &ruleMatrixScope{ - env: env, - client: client, - policyID: policy.ID, - accountID: account.ID, + env: env, + client: client, + policyID: policy.ID, + accountID: account.ID, + workspaceID: account.WorkspaceID, } t.Cleanup(func() { @@ -259,7 +261,7 @@ func setupRuleMatrixScope(t *testing.T, prefix string) *ruleMatrixScope { } seen[ruleID] = struct{}{} - removeRuleFromPolicyForTest(t, client, scope.policyID, ruleID) + removeRuleFromWorkspaceForTest(t, client, scope.workspaceID, ruleID) acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) if err := client.DeleteRule(ctx, ruleID); err != nil { @@ -290,11 +292,11 @@ func (s *ruleMatrixScope) trackRule(ruleID string) { s.createdIDs = append(s.createdIDs, ruleID) } -func runAgentRuleCreateJSON(t *testing.T, env map[string]string, policyID string, args ...string) domain.Rule { +func runAgentRuleCreateJSON(t *testing.T, env map[string]string, args ...string) domain.Rule { t.Helper() cmdArgs := append([]string{"agent", "rule", "create"}, args...) - cmdArgs = append(cmdArgs, "--policy-id", policyID, "--json") + cmdArgs = append(cmdArgs, "--json") stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, cmdArgs...) if err != nil { t.Fatalf("agent rule create failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) @@ -311,11 +313,11 @@ func runAgentRuleCreateJSON(t *testing.T, env map[string]string, policyID string return rule } -func runAgentRuleUpdateJSON(t *testing.T, env map[string]string, policyID, ruleID string, args ...string) domain.Rule { +func runAgentRuleUpdateJSON(t *testing.T, env map[string]string, ruleID string, args ...string) domain.Rule { t.Helper() cmdArgs := append([]string{"agent", "rule", "update", ruleID}, args...) - cmdArgs = append(cmdArgs, "--policy-id", policyID, "--json") + cmdArgs = append(cmdArgs, "--json") stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, cmdArgs...) if err != nil { t.Fatalf("agent rule update failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) diff --git a/internal/cli/integration/agent_rule_outbound_test.go b/internal/cli/integration/agent_rule_outbound_test.go index 3a71c45..3a4535c 100644 --- a/internal/cli/integration/agent_rule_outbound_test.go +++ b/internal/cli/integration/agent_rule_outbound_test.go @@ -32,7 +32,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadUpdateDeleteOutbound(t *testing.T) { t.Cleanup(func() { if createdPolicy != nil && createdRule != nil && createdRule.ID != "" { - removeRuleFromPolicyForTest(t, client, createdPolicy.ID, createdRule.ID) + removeRuleFromWorkspaceForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) } if createdRule != nil && createdRule.ID != "" { acquireRateLimit(t) @@ -41,7 +41,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadUpdateDeleteOutbound(t *testing.T) { _ = client.DeleteRule(ctx, createdRule.ID) } if createdPolicy != nil && placeholderRule != nil && placeholderRule.ID != "" { - removeRuleFromPolicyForTest(t, client, createdPolicy.ID, placeholderRule.ID) + removeRuleFromWorkspaceForTest(t, client, createdAccount.WorkspaceID, placeholderRule.ID) } if placeholderRule != nil && placeholderRule.ID != "" { acquireRateLimit(t) @@ -76,8 +76,8 @@ func TestCLI_AgentRuleLifecycle_CreateReadUpdateDeleteOutbound(t *testing.T) { env["NYLAS_GRANT_ID"] = createdAccount.ID placeholderRule = createRuleForTest(t, client, "it-rule-outbound-placeholder") - attachRuleToPolicyForTest(t, client, createdPolicy.ID, placeholderRule.ID) - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, placeholderRule.ID) + attachRuleToWorkspaceForTest(t, client, createdAccount.WorkspaceID, placeholderRule.ID) + assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, placeholderRule.ID) createStdout, createStderr, err := runCLIWithOverridesAndRateLimit( t, @@ -114,7 +114,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadUpdateDeleteOutbound(t *testing.T) { } createdRule = &rule - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) readStdout, readStderr, err := runCLIWithOverridesAndRateLimit( t, @@ -181,6 +181,6 @@ func TestCLI_AgentRuleLifecycle_CreateReadUpdateDeleteOutbound(t *testing.T) { t.Fatalf("expected delete confirmation in stdout, got: %s", deleteStdout) } - assertPolicyMissingRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + assertWorkspaceMissingRuleForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) createdRule = nil } diff --git a/internal/cli/integration/agent_rule_test.go b/internal/cli/integration/agent_rule_test.go index 040a644..0735eee 100644 --- a/internal/cli/integration/agent_rule_test.go +++ b/internal/cli/integration/agent_rule_test.go @@ -32,7 +32,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { t.Cleanup(func() { if createdPolicy != nil && createdRule != nil && createdRule.ID != "" { - removeRuleFromPolicyForTest(t, client, createdPolicy.ID, createdRule.ID) + removeRuleFromWorkspaceForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) } if createdRule != nil && createdRule.ID != "" { acquireRateLimit(t) @@ -41,7 +41,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { _ = client.DeleteRule(ctx, createdRule.ID) } if createdPolicy != nil && placeholderRule != nil && placeholderRule.ID != "" { - removeRuleFromPolicyForTest(t, client, createdPolicy.ID, placeholderRule.ID) + removeRuleFromWorkspaceForTest(t, client, createdAccount.WorkspaceID, placeholderRule.ID) } if placeholderRule != nil && placeholderRule.ID != "" { acquireRateLimit(t) @@ -81,8 +81,8 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { env["NYLAS_GRANT_ID"] = createdAccount.ID placeholderRule = createRuleForTest(t, client, "it-rule-placeholder") - attachRuleToPolicyForTest(t, client, createdPolicy.ID, placeholderRule.ID) - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, placeholderRule.ID) + attachRuleToWorkspaceForTest(t, client, createdAccount.WorkspaceID, placeholderRule.ID) + assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, placeholderRule.ID) createStdout, createStderr, err := runCLIWithOverridesAndRateLimit( t, @@ -132,7 +132,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { } createdRule = &rule - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) readStdout, readStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "rule", "read", createdRule.ID, "--json") if err != nil { @@ -153,7 +153,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { } if !strings.Contains(readTextStdout, "Match:") || !strings.Contains(readTextStdout, "Actions:") || - !strings.Contains(readTextStdout, createdPolicy.Name) || + !strings.Contains(readTextStdout, "Workspaces:") || !strings.Contains(readTextStdout, "from.domain is example.com") || !strings.Contains(readTextStdout, "from.tld is com") || !strings.Contains(readTextStdout, "mark_as_spam") { @@ -180,14 +180,6 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { t.Fatalf("rule list did not return the created rule\noutput: %s", listStdout) } - allStdout, allStderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "rule", "list", "--all") - if err != nil { - t.Fatalf("rule list --all failed: %v\nstdout: %s\nstderr: %s", err, allStdout, allStderr) - } - if !strings.Contains(allStdout, createdRule.Name) || !strings.Contains(allStdout, createdPolicy.Name) || !strings.Contains(allStdout, createdAccount.Email) { - t.Fatalf("rule list --all output missing expected references\noutput: %s", allStdout) - } - updateStdout, updateStderr, err := runCLIWithOverridesAndRateLimit( t, 2*time.Minute, @@ -239,305 +231,108 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { t.Fatalf("expected delete confirmation in stdout, got: %s", deleteStdout) } - assertPolicyMissingRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + assertWorkspaceMissingRuleForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) createdRule = nil } -func TestCLI_AgentRuleDelete_RejectsLastRuleOnPolicy(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingAgentDomain(t) - - env := newAgentSandboxEnv(t) - client := getTestClient() - email := newAgentTestEmail(t, "rule-delete-last") - policyName := newPolicyTestName("rule-delete-last") - ruleName := fmt.Sprintf("it-rule-last-%d", time.Now().UnixNano()) - - var createdPolicy *domain.Policy - var createdAccount *domain.AgentAccount - var createdRule *domain.Rule - - t.Cleanup(func() { - if createdPolicy != nil && createdRule != nil && createdRule.ID != "" { - removeRuleFromPolicyForTest(t, client, createdPolicy.ID, createdRule.ID) - } - if createdRule != nil && createdRule.ID != "" { - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeleteRule(ctx, createdRule.ID) - } - if createdAccount != nil && createdAccount.ID != "" { - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeleteAgentAccount(ctx, createdAccount.ID) - } - if createdPolicy != nil && createdPolicy.ID != "" { - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeletePolicy(ctx, createdPolicy.ID) - } - }) - - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - policy, err := client.CreatePolicy(ctx, map[string]any{"name": policyName}) - cancel() - if err != nil { - t.Fatalf("failed to create policy for delete-last test: %v", err) - } - createdPolicy = policy - - createdAccount = createAgentWithPolicyForTest(t, email, createdPolicy.ID) - env["NYLAS_GRANT_ID"] = createdAccount.ID - - createStdout, createStderr, err := runCLIWithOverridesAndRateLimit( - t, - 2*time.Minute, - env, - "agent", - "rule", - "create", - "--name", ruleName, - "--condition", "from.domain,is,example.com", - "--action", "mark_as_spam", - "--json", - ) - if err != nil { - t.Fatalf("rule create failed: %v\nstdout: %s\nstderr: %s", err, createStdout, createStderr) - } - - var rule domain.Rule - if err := json.Unmarshal([]byte(createStdout), &rule); err != nil { - t.Fatalf("failed to parse rule create JSON: %v\noutput: %s", err, createStdout) - } - createdRule = &rule - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) - - deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit( - t, - 2*time.Minute, - env, - "agent", - "rule", - "delete", - createdRule.ID, - "--yes", - ) - if err == nil { - t.Fatalf("expected deleting the last rule on a policy to fail\nstdout: %s\nstderr: %s", deleteStdout, deleteStderr) - } - if !strings.Contains(strings.ToLower(deleteStderr), "cannot delete the last rule") { - t.Fatalf("expected last-rule delete error, got stderr: %s", deleteStderr) - } - - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) -} - -func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { - skipIfMissingCreds(t) - skipIfMissingAgentDomain(t) - - env := newAgentSandboxEnv(t) - client := getTestClient() - email := newAgentTestEmail(t, "rule-mixed") - agentPolicyName := newPolicyTestName("rule-mixed-agent") - detachedPolicyName := newPolicyTestName("rule-mixed-detached") - - var agentPolicy *domain.Policy - var detachedPolicy *domain.Policy - var createdAccount *domain.AgentAccount - var createdRule *domain.Rule - - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - policy, err := client.CreatePolicy(ctx, map[string]any{"name": agentPolicyName}) - cancel() - if err != nil { - t.Fatalf("failed to create agent policy for mixed-scope rule test: %v", err) - } - agentPolicy = policy - - acquireRateLimit(t) - ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) - policy, err = client.CreatePolicy(ctx, map[string]any{"name": detachedPolicyName}) - cancel() - if err != nil { - t.Fatalf("failed to create detached policy for mixed-scope rule test: %v", err) - } - detachedPolicy = policy - - createdAccount = createAgentWithPolicyForTest(t, email, agentPolicy.ID) - createdRule = createRuleForTest(t, client, fmt.Sprintf("it-rule-mixed-%d", time.Now().UnixNano())) - attachRuleToPolicyForTest(t, client, agentPolicy.ID, createdRule.ID) - attachRuleToPolicyForTest(t, client, detachedPolicy.ID, createdRule.ID) - assertPolicyContainsRuleForTest(t, client, agentPolicy.ID, createdRule.ID) - assertPolicyContainsRuleForTest(t, client, detachedPolicy.ID, createdRule.ID) - env["NYLAS_GRANT_ID"] = createdAccount.ID - - t.Cleanup(func() { - if createdRule != nil && createdRule.ID != "" { - if agentPolicy != nil && agentPolicy.ID != "" { - removeRuleFromPolicyForTest(t, client, agentPolicy.ID, createdRule.ID) - } - if detachedPolicy != nil && detachedPolicy.ID != "" { - removeRuleFromPolicyForTest(t, client, detachedPolicy.ID, createdRule.ID) - } - } - if createdRule != nil && createdRule.ID != "" { - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeleteRule(ctx, createdRule.ID) - } - if createdAccount != nil && createdAccount.ID != "" { - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeleteAgentAccount(ctx, createdAccount.ID) - } - if detachedPolicy != nil && detachedPolicy.ID != "" { - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeletePolicy(ctx, detachedPolicy.ID) - } - if agentPolicy != nil && agentPolicy.ID != "" { - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - _ = client.DeletePolicy(ctx, agentPolicy.ID) - } - }) - updateStdout, updateStderr, err := runCLIWithOverridesAndRateLimit( - t, - 2*time.Minute, - env, - "agent", - "rule", - "update", - createdRule.ID, - "--policy-id", agentPolicy.ID, - "--name", fmt.Sprintf("reject-mixed-%d", time.Now().UnixNano()), - "--json", - ) - if err == nil { - t.Fatalf("expected rule update to fail for mixed-scope rule\nstdout: %s\nstderr: %s", updateStdout, updateStderr) - } - if !strings.Contains(strings.ToLower(updateStderr), "shared with a non-agent policy") { - t.Fatalf("expected mixed-scope rejection, got stderr: %s", updateStderr) - } - - deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit( - t, - 2*time.Minute, - env, - "agent", - "rule", - "delete", - createdRule.ID, - "--policy-id", agentPolicy.ID, - "--yes", - ) - if err == nil { - t.Fatalf("expected rule delete to fail for mixed-scope rule\nstdout: %s\nstderr: %s", deleteStdout, deleteStderr) - } - if !strings.Contains(strings.ToLower(deleteStderr), "shared with a non-agent policy") { - t.Fatalf("expected mixed-scope rejection, got stderr: %s", deleteStderr) - } -} - -func assertPolicyContainsRuleForTest(t *testing.T, client interface { - GetPolicy(context.Context, string) (*domain.Policy, error) -}, policyID, ruleID string) { +func assertWorkspaceContainsRuleForTest(t *testing.T, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, workspaceID, ruleID string) { t.Helper() deadline := time.Now().Add(60 * time.Second) for time.Now().Before(deadline) { acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - policy, err := client.GetPolicy(ctx, policyID) + workspace, err := client.GetWorkspace(ctx, workspaceID) cancel() - if err == nil && containsString(policy.Rules, ruleID) { + if err == nil && workspace != nil && containsString(workspace.RulesIDs, ruleID) { return } time.Sleep(500 * time.Millisecond) } - t.Fatalf("policy %q does not include rule %q", policyID, ruleID) + t.Fatalf("workspace %q does not include rule %q", workspaceID, ruleID) } -func assertPolicyMissingRuleForTest(t *testing.T, client interface { - GetPolicy(context.Context, string) (*domain.Policy, error) -}, policyID, ruleID string) { +func assertWorkspaceMissingRuleForTest(t *testing.T, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, workspaceID, ruleID string) { t.Helper() deadline := time.Now().Add(60 * time.Second) for time.Now().Before(deadline) { acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - policy, err := client.GetPolicy(ctx, policyID) + workspace, err := client.GetWorkspace(ctx, workspaceID) cancel() - if err == nil && !containsString(policy.Rules, ruleID) { + if err == nil && workspace != nil && !containsString(workspace.RulesIDs, ruleID) { return } time.Sleep(500 * time.Millisecond) } - t.Fatalf("policy %q still includes deleted rule %q", policyID, ruleID) + t.Fatalf("workspace %q still includes deleted rule %q", workspaceID, ruleID) } -func removeRuleFromPolicyForTest(t *testing.T, client interface { - GetPolicy(context.Context, string) (*domain.Policy, error) - UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) -}, policyID, ruleID string) { +func removeRuleFromWorkspaceForTest(t *testing.T, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) +}, workspaceID, ruleID string) { t.Helper() acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - policy, err := client.GetPolicy(ctx, policyID) - if err != nil || !containsString(policy.Rules, ruleID) { + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil { + t.Logf("cleanup: get workspace %q: %v", workspaceID, err) + return + } + if workspace == nil || !containsString(workspace.RulesIDs, ruleID) { return } - updatedRules := make([]string, 0, len(policy.Rules)) - for _, existingRuleID := range policy.Rules { + updatedRules := make([]string, 0, len(workspace.RulesIDs)) + for _, existingRuleID := range workspace.RulesIDs { if existingRuleID == ruleID { continue } updatedRules = append(updatedRules, existingRuleID) } - _, _ = client.UpdatePolicy(ctx, policyID, map[string]any{"rules": updatedRules}) + if _, err := client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{RulesIDs: &updatedRules}); err != nil { + t.Logf("cleanup: remove rule %q from workspace %q: %v", ruleID, workspaceID, err) + } } -func attachRuleToPolicyForTest(t *testing.T, client interface { - GetPolicy(context.Context, string) (*domain.Policy, error) - UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) -}, policyID, ruleID string) { +func attachRuleToWorkspaceForTest(t *testing.T, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) +}, workspaceID, ruleID string) { t.Helper() acquireRateLimit(t) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - policy, err := client.GetPolicy(ctx, policyID) + workspace, err := client.GetWorkspace(ctx, workspaceID) if err != nil { - t.Fatalf("failed to get policy %q: %v", policyID, err) + t.Fatalf("failed to get workspace %q: %v", workspaceID, err) + } + if workspace == nil { + t.Fatalf("workspace %q not found", workspaceID) } - if containsString(policy.Rules, ruleID) { + if containsString(workspace.RulesIDs, ruleID) { return } - updatedRules := append(append([]string(nil), policy.Rules...), ruleID) - if _, err := client.UpdatePolicy(ctx, policyID, map[string]any{"rules": updatedRules}); err != nil { - t.Fatalf("failed to attach rule %q to policy %q: %v", ruleID, policyID, err) + updatedRules := append(append([]string(nil), workspace.RulesIDs...), ruleID) + if _, err := client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{RulesIDs: &updatedRules}); err != nil { + t.Fatalf("failed to attach rule %q to workspace %q: %v", ruleID, workspaceID, err) } } diff --git a/internal/cli/integration/agent_test.go b/internal/cli/integration/agent_test.go index 6ae394e..ec763c5 100644 --- a/internal/cli/integration/agent_test.go +++ b/internal/cli/integration/agent_test.go @@ -79,6 +79,11 @@ func TestCLI_AgentLifecycle_CreateListDeleteByEmail(t *testing.T) { } if listed.Email == email { found = true + // The list endpoint must surface workspace_id so the CLI can + // display it (and so policy/rule resolution can use it). + if strings.TrimSpace(listed.WorkspaceID) == "" { + t.Fatalf("agent list did not return workspace_id for %q\noutput: %s", email, listStdout) + } } } if !found { @@ -223,12 +228,12 @@ func agentAccountIDs(accounts []domain.AgentAccount) []string { return ids } -func TestCLI_AgentCreate_WithPolicyID(t *testing.T) { +func TestCLI_AgentCreate_WithWorkspacePolicy(t *testing.T) { skipIfMissingCreds(t) skipIfMissingAgentDomain(t) - env := newAgentSandboxEnv(t) - email := newAgentTestEmail(t, "policy-create") + _ = newAgentSandboxEnv(t) + email := newAgentTestEmail(t, "ws-policy") policyName := newPolicyTestName("account-create") client := getTestClient() @@ -254,32 +259,18 @@ func TestCLI_AgentCreate_WithPolicyID(t *testing.T) { policy, err := client.CreatePolicy(ctx, map[string]any{"name": policyName}) cancel() if err != nil { - t.Fatalf("failed to create policy for agent account test: %v", err) + t.Fatalf("failed to create policy: %v", err) } createdPolicy = policy - stdout, stderr, err := runCLIWithOverridesAndRateLimit(t, 2*time.Minute, env, "agent", "account", "create", email, "--policy-id", policy.ID, "--json") - if err != nil { - t.Fatalf("agent create with policy failed: %v\nstdout: %s\nstderr: %s", err, stdout, stderr) - } - - var account domain.AgentAccount - if err := json.Unmarshal([]byte(stdout), &account); err != nil { - t.Fatalf("failed to parse agent create with policy JSON: %v\noutput: %s", err, stdout) - } - if account.Email != email { - t.Fatalf("created email = %q, want %q", account.Email, email) - } - if account.Settings.PolicyID != "" && account.Settings.PolicyID != policy.ID { - t.Fatalf("created policy_id = %q, want %q or empty response field", account.Settings.PolicyID, policy.ID) - } - created = &account + account := createAgentWithPolicyForTest(t, email, policy.ID) + created = account - if exists, fetched := waitForAgentByID(t, client, account.ID, true); !exists { + exists, fetched := waitForAgentByID(t, client, account.ID, true) + if !exists { t.Fatalf("created agent account %q did not appear via GET-by-id", email) - } else if fetched.Settings.PolicyID != policy.ID { - t.Fatalf("fetched policy_id = %q, want %q", fetched.Settings.PolicyID, policy.ID) } + assertWorkspacePolicyForTest(t, client, fetched.WorkspaceID, policy.ID) } func TestCLI_AgentUpdate_ByEmail(t *testing.T) { @@ -346,7 +337,7 @@ func TestCLI_AgentUpdate_ByEmail(t *testing.T) { } } -func TestCLI_AgentUpdate_PreservesPolicyID(t *testing.T) { +func TestCLI_AgentUpdate_PreservesWorkspacePolicy(t *testing.T) { skipIfMissingCreds(t) skipIfMissingAgentDomain(t) @@ -382,20 +373,13 @@ func TestCLI_AgentUpdate_PreservesPolicyID(t *testing.T) { } createdPolicy = policy - acquireRateLimit(t) - ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) - account, err := client.CreateAgentAccount(ctx, email, "", policy.ID) - cancel() - if err != nil { - t.Fatalf("failed to create agent account %q for update test: %v", email, err) - } + account := createAgentWithPolicyForTest(t, email, policy.ID) created = account env["NYLAS_GRANT_ID"] = account.ID - if exists, fetched := waitForAgentByID(t, client, account.ID, true); !exists { + if exists, _ := waitForAgentByID(t, client, account.ID, true); !exists { t.Fatalf("created agent account %q not retrievable by id", email) - } else if fetched.Settings.PolicyID != policy.ID { - t.Fatalf("fetched policy_id before update = %q, want %q", fetched.Settings.PolicyID, policy.ID) } + assertWorkspacePolicyForTest(t, client, account.WorkspaceID, policy.ID) stdout, stderr, err := runCLIWithOverridesAndRateLimit( t, @@ -427,9 +411,8 @@ func TestCLI_AgentUpdate_PreservesPolicyID(t *testing.T) { if err != nil { t.Fatalf("failed to refetch agent account after update: %v", err) } - if refetched.Settings.PolicyID != policy.ID { - t.Fatalf("refetched policy_id after update = %q, want %q", refetched.Settings.PolicyID, policy.ID) - } + // app-password update must not disturb the workspace policy attachment. + assertWorkspacePolicyForTest(t, client, refetched.WorkspaceID, policy.ID) } func TestCLI_AgentDelete_ByID(t *testing.T) { diff --git a/internal/cli/integration/local_regressions_test.go b/internal/cli/integration/local_regressions_test.go index 1f40885..f91b9ea 100644 --- a/internal/cli/integration/local_regressions_test.go +++ b/internal/cli/integration/local_regressions_test.go @@ -270,7 +270,7 @@ func TestCLI_AuthList_DoesNotRequireFileStorePassphrase(t *testing.T) { if strings.Contains(stderr, "NYLAS_FILE_STORE_PASSPHRASE") { t.Fatalf("auth list should not require file-store passphrase, stderr: %q", stderr) } - if !strings.Contains(stderr, "access credential") && !strings.Contains(stderr, "API key") { + if !strings.Contains(stderr, "access credential") && !strings.Contains(stderr, "API key") && !strings.Contains(stderr, "Authentication failed") { t.Fatalf("stderr %q does not mention API credential failure", stderr) } } diff --git a/internal/cli/workspace/commands.go b/internal/cli/workspace/commands.go new file mode 100644 index 0000000..0fa1ff1 --- /dev/null +++ b/internal/cli/workspace/commands.go @@ -0,0 +1,274 @@ +package workspace + +import ( + "context" + "fmt" + "strings" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newListCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List workspaces", + Long: `List all workspaces. + +Examples: + nylas workspace list + nylas workspace list --json`, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + workspaces, err := client.ListWorkspaces(ctx) + if err != nil { + return struct{}{}, common.WrapListError("workspaces", err) + } + + if common.IsJSON(cmd) { + return struct{}{}, common.PrintJSON(workspaces) + } + + if len(workspaces) == 0 { + common.PrintEmptyStateWithHint("workspaces", "Create one with: nylas workspace create --name \"Workspace Name\"") + return struct{}{}, nil + } + + _, _ = common.BoldWhite.Printf("Workspaces (%d)\n\n", len(workspaces)) + for i, ws := range workspaces { + printWorkspaceSummary(ws, i) + } + fmt.Println() + return struct{}{}, nil + }) + return err + }, + } + + return cmd +} + +func newGetCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "get ", + Short: "Show workspace details", + Long: `Show details for a single workspace. + +Examples: + nylas workspace get + nylas workspace get --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + workspace, err := client.GetWorkspace(ctx, args[0]) + if err != nil { + return struct{}{}, common.WrapGetError("workspace", err) + } + + if common.IsJSON(cmd) { + return struct{}{}, common.PrintJSON(workspace) + } + + printWorkspaceDetails(*workspace) + return struct{}{}, nil + }) + return err + }, + } + + return cmd +} + +func newCreateCmd() *cobra.Command { + var ( + name string + wsDomain string + autoGroup bool + policyID string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a workspace", + Long: `Create a new workspace. + +Examples: + nylas workspace create --name "My Workspace" + nylas workspace create --name "My Workspace" --policy-id + nylas workspace create --name "My Workspace" --domain example.com --auto-group`, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + req := &domain.CreateWorkspaceRequest{ + Name: name, + Domain: wsDomain, + PolicyID: policyID, + } + if cmd.Flags().Changed("auto-group") { + req.AutoGroup = &autoGroup + } + + workspace, err := client.CreateWorkspace(ctx, req) + if err != nil { + return struct{}{}, common.WrapCreateError("workspace", err) + } + + if common.IsJSON(cmd) { + return struct{}{}, common.PrintJSON(workspace) + } + + common.PrintSuccess("Created workspace: %s (%s)", workspace.Name, workspace.ID) + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVar(&name, "name", "", "Workspace name (required)") + cmd.Flags().StringVar(&wsDomain, "domain", "", "Workspace domain") + cmd.Flags().BoolVar(&autoGroup, "auto-group", false, "Enable auto-grouping") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to attach") + + _ = cmd.MarkFlagRequired("name") + + return cmd +} + +func newUpdateCmd() *cobra.Command { + var ( + policyID string + rulesIDs []string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a workspace", + Long: `Update a workspace's policy or rules. + +Examples: + nylas workspace update --policy-id + nylas workspace update --rules-ids rule1,rule2`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("policy-id") && !cmd.Flags().Changed("rules-ids") { + return common.NewUserError( + "workspace update requires at least one field", + "Use --policy-id or --rules-ids to specify what to update", + ) + } + + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + req := &domain.UpdateWorkspaceRequest{} + + if cmd.Flags().Changed("policy-id") { + req.PolicyID = &policyID + } + if cmd.Flags().Changed("rules-ids") { + req.RulesIDs = &rulesIDs + } + + workspace, err := client.UpdateWorkspace(ctx, args[0], req) + if err != nil { + return struct{}{}, common.WrapUpdateError("workspace", err) + } + + if common.IsJSON(cmd) { + return struct{}{}, common.PrintJSON(workspace) + } + + common.PrintSuccess("Updated workspace: %s (%s)", workspace.Name, workspace.ID) + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID to attach") + cmd.Flags().StringSliceVar(&rulesIDs, "rules-ids", nil, "Rule IDs to attach (comma-separated)") + + return cmd +} + +func newDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a workspace", + Long: `Delete a workspace permanently. + +Examples: + nylas workspace delete + nylas workspace delete --yes`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + fmt.Printf("Are you sure you want to delete workspace %s? (y/N): ", args[0]) + var confirm string + _, _ = fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Cancelled.") + return nil + } + } + + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + if err := client.DeleteWorkspace(ctx, args[0]); err != nil { + return struct{}{}, common.WrapDeleteError("workspace", err) + } + + common.PrintSuccess("Deleted workspace: %s", args[0]) + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} + +func printWorkspaceSummary(ws domain.Workspace, index int) { + name := ws.Name + if name == "" { + name = ws.ID + } + fmt.Printf("%d. %s %s\n", index+1, common.Cyan.Sprint(name), common.Dim.Sprint(ws.ID)) + if ws.PolicyID != "" { + _, _ = common.Dim.Printf(" Policy ID: %s\n", ws.PolicyID) + } + if len(ws.RulesIDs) > 0 { + _, _ = common.Dim.Printf(" Rules: %s\n", strings.Join(ws.RulesIDs, ", ")) + } +} + +func printWorkspaceDetails(ws domain.Workspace) { + fmt.Println(strings.Repeat("─", 60)) + _, _ = common.BoldWhite.Printf("Workspace: %s\n", ws.Name) + fmt.Println(strings.Repeat("─", 60)) + fmt.Printf("ID: %s\n", ws.ID) + fmt.Printf("Application: %s\n", ws.ApplicationID) + if ws.Name != "" { + fmt.Printf("Name: %s\n", ws.Name) + } + if ws.Domain != nil && *ws.Domain != "" { + fmt.Printf("Domain: %s\n", *ws.Domain) + } + fmt.Printf("Auto Group: %t\n", ws.AutoGroup) + if ws.PolicyID != "" { + fmt.Printf("Policy ID: %s\n", ws.PolicyID) + } + if len(ws.RulesIDs) > 0 { + fmt.Printf("Rules: %s\n", strings.Join(ws.RulesIDs, ", ")) + } + if !ws.CreatedAt.IsZero() { + fmt.Printf("Created: %s (%s)\n", ws.CreatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(ws.CreatedAt.Time)) + } + if !ws.UpdatedAt.IsZero() { + fmt.Printf("Updated: %s (%s)\n", ws.UpdatedAt.Format(common.DisplayDateTime), common.FormatTimeAgo(ws.UpdatedAt.Time)) + } + fmt.Println() +} diff --git a/internal/cli/workspace/workspace.go b/internal/cli/workspace/workspace.go new file mode 100644 index 0000000..5e75b68 --- /dev/null +++ b/internal/cli/workspace/workspace.go @@ -0,0 +1,29 @@ +package workspace + +import "github.com/spf13/cobra" + +func NewWorkspaceCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "workspace", + Aliases: []string{"workspaces", "ws"}, + Short: "Manage workspaces", + Long: `Manage Nylas workspaces. + +Workspaces group agent accounts and attach policies and rules. + +Examples: + nylas workspace list + nylas workspace get + nylas workspace create --name "My Workspace" + nylas workspace update --policy-id + nylas workspace delete --yes`, + } + + cmd.AddCommand(newListCmd()) + cmd.AddCommand(newGetCmd()) + cmd.AddCommand(newCreateCmd()) + cmd.AddCommand(newUpdateCmd()) + cmd.AddCommand(newDeleteCmd()) + + return cmd +} diff --git a/internal/cli/workspace/workspace_test.go b/internal/cli/workspace/workspace_test.go new file mode 100644 index 0000000..203d330 --- /dev/null +++ b/internal/cli/workspace/workspace_test.go @@ -0,0 +1,63 @@ +package workspace + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewWorkspaceCmd(t *testing.T) { + cmd := NewWorkspaceCmd() + + assert.Equal(t, "workspace", cmd.Use) + assert.Contains(t, cmd.Aliases, "workspaces") + assert.Contains(t, cmd.Aliases, "ws") + + subcommands := make(map[string]bool) + for _, sub := range cmd.Commands() { + subcommands[sub.Name()] = true + } + assert.True(t, subcommands["list"]) + assert.True(t, subcommands["get"]) + assert.True(t, subcommands["create"]) + assert.True(t, subcommands["update"]) + assert.True(t, subcommands["delete"]) +} + +func TestWorkspaceListCmd(t *testing.T) { + cmd := newListCmd() + + assert.Equal(t, "list", cmd.Use) + assert.Contains(t, cmd.Aliases, "ls") +} + +func TestWorkspaceCreateCmd(t *testing.T) { + cmd := newCreateCmd() + + assert.Equal(t, "create", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("name")) + assert.NotNil(t, cmd.Flags().Lookup("domain")) + assert.NotNil(t, cmd.Flags().Lookup("auto-group")) + assert.NotNil(t, cmd.Flags().Lookup("policy-id")) +} + +func TestWorkspaceUpdateCmd(t *testing.T) { + cmd := newUpdateCmd() + + assert.Equal(t, "update ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("policy-id")) + assert.NotNil(t, cmd.Flags().Lookup("rules-ids")) +} + +func TestWorkspaceDeleteCmd(t *testing.T) { + cmd := newDeleteCmd() + + assert.Equal(t, "delete ", cmd.Use) + assert.NotNil(t, cmd.Flags().Lookup("yes")) +} + +func TestWorkspaceGetCmd(t *testing.T) { + cmd := newGetCmd() + + assert.Equal(t, "get ", cmd.Use) +} diff --git a/internal/domain/admin.go b/internal/domain/admin.go index 73246ca..bdbe985 100644 --- a/internal/domain/admin.go +++ b/internal/domain/admin.go @@ -62,13 +62,14 @@ type UpdateApplicationRequest struct { // Connector represents a provider connector (Google, Microsoft, iCloud, Yahoo, IMAP) type Connector struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Provider string `json:"provider"` // "google", "microsoft", "icloud", "yahoo", "imap" - Settings *ConnectorSettings `json:"settings,omitempty"` - Scopes []string `json:"scopes,omitempty"` - CreatedAt *UnixTime `json:"created_at,omitempty"` - UpdatedAt *UnixTime `json:"updated_at,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name"` + Provider string `json:"provider"` // "google", "microsoft", "icloud", "yahoo", "imap" + DefaultWorkspaceID string `json:"default_workspace_id,omitempty"` + Settings *ConnectorSettings `json:"settings,omitempty"` + Scopes []string `json:"scopes,omitempty"` + CreatedAt *UnixTime `json:"created_at,omitempty"` + UpdatedAt *UnixTime `json:"updated_at,omitempty"` } // ConnectorSettings holds provider-specific configuration diff --git a/internal/domain/agent.go b/internal/domain/agent.go index 6d3d320..4f07a70 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -6,6 +6,7 @@ type AgentAccount struct { Provider Provider `json:"provider"` Email string `json:"email"` GrantStatus string `json:"grant_status"` + WorkspaceID string `json:"workspace_id,omitempty"` Settings AgentAccountSettings `json:"settings,omitempty"` CreatedAt UnixTime `json:"created_at,omitempty"` UpdatedAt UnixTime `json:"updated_at,omitempty"` diff --git a/internal/domain/errors.go b/internal/domain/errors.go index d80950d..7d7fab1 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -65,6 +65,7 @@ var ( ErrCallbackURINotFound = errors.New("callback URI not found") ErrConnectorNotFound = errors.New("connector not found") ErrCredentialNotFound = errors.New("credential not found") + ErrWorkspaceNotFound = errors.New("workspace not found") // Dashboard auth errors ErrDashboardNotLoggedIn = errors.New("not logged in to Nylas Dashboard") diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go new file mode 100644 index 0000000..84817f3 --- /dev/null +++ b/internal/domain/workspace.go @@ -0,0 +1,30 @@ +package domain + +// Workspace represents a grant workspace. For provider=nylas accounts, this is +// the attachment point for policy and rule relationships. +type Workspace struct { + ID string `json:"workspace_id,omitempty"` + ApplicationID string `json:"application_id,omitempty"` + Name string `json:"name,omitempty"` + Domain *string `json:"domain,omitempty"` + AutoGroup bool `json:"auto_group,omitempty"` + PolicyID string `json:"policy_id,omitempty"` + RulesIDs []string `json:"rules_ids,omitempty"` + CreatedAt UnixTime `json:"created_at,omitempty"` + UpdatedAt UnixTime `json:"updated_at,omitempty"` +} + +// CreateWorkspaceRequest creates a new workspace. +type CreateWorkspaceRequest struct { + Name string `json:"name"` + Domain string `json:"domain,omitempty"` + AutoGroup *bool `json:"auto_group,omitempty"` + PolicyID string `json:"policy_id,omitempty"` + RulesIDs []string `json:"rules_ids,omitempty"` +} + +// UpdateWorkspaceRequest updates workspace policy/rule attachments. +type UpdateWorkspaceRequest struct { + PolicyID *string `json:"policy_id,omitempty"` + RulesIDs *[]string `json:"rules_ids,omitempty"` +} diff --git a/internal/ports/admin.go b/internal/ports/admin.go index c5b2a9a..7187853 100644 --- a/internal/ports/admin.go +++ b/internal/ports/admin.go @@ -61,6 +61,21 @@ type AdminClient interface { // DeleteConnector deletes a connector. DeleteConnector(ctx context.Context, connectorID string) error + // ListWorkspaces retrieves all workspaces. + ListWorkspaces(ctx context.Context) ([]domain.Workspace, error) + + // GetWorkspace retrieves a grant workspace. + GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) + + // CreateWorkspace creates a new workspace. + CreateWorkspace(ctx context.Context, req *domain.CreateWorkspaceRequest) (*domain.Workspace, error) + + // UpdateWorkspace updates workspace policy/rule attachments. + UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) + + // DeleteWorkspace deletes a workspace. + DeleteWorkspace(ctx context.Context, workspaceID string) error + // ================================ // CREDENTIAL OPERATIONS // ================================ diff --git a/internal/ports/agent.go b/internal/ports/agent.go index 6d51409..b83cfb1 100644 --- a/internal/ports/agent.go +++ b/internal/ports/agent.go @@ -16,8 +16,8 @@ type AgentClient interface { // CreateAgentAccount creates a new agent account with the given email address. // appPassword is optional and enables IMAP/SMTP client access when set. - // policyID is optional and attaches the created account to an existing policy. - CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) + // workspaceID assigns the account to an existing workspace when set. + CreateAgentAccount(ctx context.Context, email, appPassword, workspaceID string) (*domain.AgentAccount, error) // UpdateAgentAccount updates mutable settings on an existing agent account. // email is required by the current grant update API for provider=nylas grants.