From 254f828ebc5e59e9ee98ac4222e8a4042b00e04d Mon Sep 17 00:00:00 2001 From: Pouya Sanooei Date: Thu, 21 May 2026 16:26:40 -0400 Subject: [PATCH 01/10] Update agent policy and rule workspace handling --- docs/COMMANDS.md | 10 +- docs/commands/agent-policy.md | 22 +- docs/commands/agent-rule.md | 38 +-- docs/commands/agent.md | 12 +- internal/adapters/nylas/admin.go | 42 ++++ internal/adapters/nylas/agent.go | 6 - internal/adapters/nylas/agent_test.go | 11 +- internal/adapters/nylas/demo_admin.go | 20 ++ internal/adapters/nylas/demo_agent.go | 14 +- internal/adapters/nylas/managed_grants.go | 2 + internal/adapters/nylas/mock_admin.go | 20 ++ internal/adapters/nylas/mock_agent.go | 14 +- internal/cli/agent/agent_scope.go | 90 ++++++- internal/cli/agent/agent_scope_test.go | 124 +++++++++ internal/cli/agent/agent_test.go | 2 +- internal/cli/agent/create.go | 39 +-- internal/cli/agent/create_test.go | 22 +- internal/cli/agent/helpers.go | 24 ++ internal/cli/agent/helpers_test.go | 33 +++ internal/cli/agent/policy.go | 22 +- .../cli/agent/policy_create_update_delete.go | 2 +- internal/cli/agent/policy_list_get.go | 64 +++-- internal/cli/agent/rule.go | 237 +++++++++++++++++- .../cli/agent/rule_create_update_delete.go | 81 +++--- internal/cli/agent/rule_list_get.go | 43 ++-- internal/cli/agent/rule_list_get_test.go | 119 +++++++++ internal/domain/admin.go | 15 +- internal/domain/agent.go | 1 + internal/domain/errors.go | 1 + internal/domain/workspace.go | 21 ++ internal/ports/admin.go | 6 + internal/ports/agent.go | 2 +- 32 files changed, 932 insertions(+), 227 deletions(-) create mode 100644 internal/domain/workspace.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index f4ad18f..b0fa085 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -458,20 +458,20 @@ 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 create --policy-id # Create account and attach policy to its workspace 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 policy for default agent workspace +nylas agent policy list --all # List all policies attached to agent workspaces 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 rules for default agent workspace policy +nylas agent rule list --all # List all rules attached to agent workspaces 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..aa69788 100644 --- a/docs/commands/agent-policy.md +++ b/docs/commands/agent-policy.md @@ -2,7 +2,7 @@ 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. +Agent policies are filtered through `provider=nylas` agent account workspaces in the CLI, even though the underlying policy objects are application-level resources. ## Commands @@ -22,14 +22,14 @@ nylas agent policy delete --yes 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 +- `nylas agent policy list` shows only the policy attached to the current default `provider=nylas` grant workspace +- `nylas agent policy list --all` shows only policies referenced by at least one `provider=nylas` agent workspace +- text output includes the attached agent email, grant ID, and workspace context 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 +- a policy with no attached `provider=nylas` workspace is hidden from the agent policy list ## Listing Policies @@ -44,7 +44,7 @@ Behavior: - resolves the current default local grant - requires that default grant to be `provider=nylas` -- returns the single attached policy for that grant +- returns the single attached policy for that grant's workspace ### All Agent Policies @@ -55,7 +55,7 @@ nylas agent policy list --all --json Behavior: -- lists all policies referenced by at least one `provider=nylas` agent account +- lists all policies referenced by at least one `provider=nylas` agent workspace - text output includes one `Agent:` line per attached agent account ## Reading Policies @@ -156,7 +156,7 @@ nylas agent policy delete --yes Safety rule: -- delete is rejected if any `provider=nylas` agent account still references the policy +- delete is rejected if any `provider=nylas` agent workspace still references the policy To remove a policy from active use: @@ -167,20 +167,20 @@ To remove a policy from active use: ## Relationship to Agent Accounts -Policies are primarily attached at agent account creation time: +Policies are attached through the agent account workspace. Account creation can patch that workspace immediately: ```bash nylas agent account create me@yourapp.nylas.email --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 CLI now has `nylas agent account update`, but it currently manages mutable grant settings such as `--app-password`, not workspace policy assignment. In practice, policy attachment remains a create-time workflow on the agent account surface. ## 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` +- verify the agent account has a workspace with `policy_id` - try `nylas auth list` to confirm which grant is marked default If `nylas agent policy delete` fails: diff --git a/docs/commands/agent-rule.md b/docs/commands/agent-rule.md index 3fafaab..daf6153 100644 --- a/docs/commands/agent-rule.md +++ b/docs/commands/agent-rule.md @@ -2,7 +2,7 @@ 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. +Agent rules are filtered through `provider=nylas` agent account workspaces. The CLI hides rules that are outside that agent scope. ## Commands @@ -21,11 +21,11 @@ nylas agent rule delete --yes ## Scope Model -The CLI resolves rules through agent policy attachment: +The CLI resolves rules through agent workspace attachments: -- `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 +- `nylas agent rule list` uses the policy and rules attached to the current default `provider=nylas` grant workspace +- `nylas agent rule list --policy-id ` uses that specific policy within the agent workspace scope +- `nylas agent rule list --all` shows rules reachable from any `provider=nylas` agent workspace - `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. @@ -42,9 +42,9 @@ 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` +- finds the policy attached to that grant's workspace +- returns the rules attached to that workspace +- skips stale workspace rule references that no longer exist in `/v3/rules` ### Rules for a Specific Agent Policy @@ -63,7 +63,7 @@ nylas agent rule list --all --json Behavior: -- shows only rules referenced by policies attached to `provider=nylas` accounts +- shows only rules referenced by `provider=nylas` account workspaces - text output includes policy and agent account references ## Reading Rules @@ -271,45 +271,45 @@ 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 +- delete is rejected if removing the rule would leave an attached agent workspace with zero live rules These checks are there to prevent accidental breakage of active agent policy configuration. ## Relationship to Policies -Rules are attached to policies, and policies are attached to agent accounts. +Policies and rules are attached to agent account workspaces. Practical flow: 1. create or choose a policy -2. create a rule and attach it to that policy in the same command +2. create a rule and attach it to the selected agent workspace in the same command 3. create an agent account with that policy using `--policy-id` The CLI scope always follows that chain: - agent account -- policy -- rules reachable from that policy +- workspace +- policy and rules reachable from that workspace ## 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 +- confirm that default agent account has a workspace with a policy attached +- confirm the workspace actually has rules attached +- if the workspace 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 +- or the workspace may still reference a deleted rule ID - try `nylas agent rule list --all` to see what is reachable from agent accounts If `nylas agent rule delete` is rejected: - the rule is shared outside the current agent scope, or -- deleting it would leave an attached policy with no remaining rules +- deleting it would leave an attached workspace with no remaining rules ## See Also diff --git a/docs/commands/agent.md b/docs/commands/agent.md index 4d51806..70da07a 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -61,7 +61,7 @@ Behavior: - automatically creates the `nylas` connector first if it does not exist - 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 +- optionally patches the account workspace with `policy_id` so the new account starts with an attached policy **Example output:** ```bash @@ -162,8 +162,8 @@ 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` resolves the default `provider=nylas` grant and shows the policy attached to its workspace +- `list --all` shows only policies that are actually referenced by `provider=nylas` agent workspaces - `get` and `read` are aliases - `delete` refuses to remove a policy that is still attached to any `provider=nylas` agent account @@ -187,9 +187,9 @@ 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 +- `list` uses the policy and rules attached to the current default `provider=nylas` grant workspace unless `--policy-id` is passed +- `list --all` shows only rules reachable from `provider=nylas` account workspaces +- `list` skips stale workspace 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 - `get` and `read` are aliases diff --git a/internal/adapters/nylas/admin.go b/internal/adapters/nylas/admin.go index bc5de13..c0912d3 100644 --- a/internal/adapters/nylas/admin.go +++ b/internal/adapters/nylas/admin.go @@ -305,6 +305,48 @@ func (c *HTTPClient) DeleteConnector(ctx context.Context, connectorID string) er return c.doDelete(ctx, queryURL) } +// 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 +} + +// 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 +} + // 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..39f7bb8 100644 --- a/internal/adapters/nylas/agent.go +++ b/internal/adapters/nylas/agent.go @@ -51,9 +51,6 @@ 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), @@ -97,9 +94,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..b4b0b25 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.NotContains(t, settings, "policy_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", - }, }, } @@ -282,7 +280,8 @@ func TestCreateAgentAccount(t *testing.T) { 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{ diff --git a/internal/adapters/nylas/demo_admin.go b/internal/adapters/nylas/demo_admin.go index 7962b34..1d78b72 100644 --- a/internal/adapters/nylas/demo_admin.go +++ b/internal/adapters/nylas/demo_admin.go @@ -207,6 +207,26 @@ func (d *DemoClient) DeleteConnector(ctx context.Context, connectorID string) er return 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) 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) 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..8debb46 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,9 +24,7 @@ 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 } @@ -38,9 +34,7 @@ func (d *DemoClient) CreateAgentAccount(ctx context.Context, email, appPassword, 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..89ed564 100644 --- a/internal/adapters/nylas/mock_admin.go +++ b/internal/adapters/nylas/mock_admin.go @@ -120,6 +120,26 @@ func (m *MockClient) DeleteConnector(ctx context.Context, connectorID string) er return 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) 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) 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..762006c 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,9 +24,7 @@ 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 } @@ -38,9 +34,7 @@ func (m *MockClient) CreateAgentAccount(ctx context.Context, email, appPassword, 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/cli/agent/agent_scope.go b/internal/cli/agent/agent_scope.go index cb0c21f..889d27d 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 } @@ -147,23 +209,27 @@ func resolveAgentPolicyFromScope(ctx context.Context, client ports.NylasClient, } defaultPolicyID := strings.TrimSpace(account.Settings.PolicyID) + if workspace := scope.WorkspacesByID[strings.TrimSpace(account.WorkspaceID)]; workspace != nil { + defaultPolicyID = strings.TrimSpace(workspace.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", + "Pass --policy-id or attach a policy to the active provider=nylas workspace 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", + "default agent workspace policy is not attached to a nylas agent workspace", "Use 'nylas agent policy list --all' to inspect provider=nylas policies", ) } return policy, []policyAgentAccountRef{{ - GrantID: account.ID, - Email: account.Email, + GrantID: account.ID, + Email: account.Email, + WorkspaceID: strings.TrimSpace(account.WorkspaceID), }}, nil } diff --git a/internal/cli/agent/agent_scope_test.go b/internal/cli/agent/agent_scope_test.go index f2bae34..6373abb 100644 --- a/internal/cli/agent/agent_scope_test.go +++ b/internal/cli/agent/agent_scope_test.go @@ -20,6 +20,41 @@ 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 +} + +type workspacePolicyClient struct { + workspaces map[string]*domain.Workspace + policies map[string]domain.Policy + gotPolicyID string +} + +func (c *workspacePolicyClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { + workspace := c.workspaces[workspaceID] + if workspace == nil { + return nil, domain.ErrWorkspaceNotFound + } + return workspace, nil +} + +func (c *workspacePolicyClient) GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) { + c.gotPolicyID = policyID + policy, ok := c.policies[policyID] + if !ok { + return nil, domain.ErrPolicyNotFound + } + return &policy, nil +} + func TestUpsertAgentAccount(t *testing.T) { accounts := []domain.AgentAccount{ { @@ -99,3 +134,92 @@ 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) + } +} + +func TestResolveAgentAccountWorkspacePolicyUsesWorkspacePolicy(t *testing.T) { + client := &workspacePolicyClient{ + workspaces: map[string]*domain.Workspace{ + "workspace-1": {ID: "workspace-1", PolicyID: "workspace-policy"}, + }, + policies: map[string]domain.Policy{ + "workspace-policy": {ID: "workspace-policy", Name: "Workspace Policy"}, + "legacy-policy": {ID: "legacy-policy", Name: "Legacy Policy"}, + }, + } + account := domain.AgentAccount{ + ID: "grant-1", + Email: "agent@example.com", + WorkspaceID: "workspace-1", + Settings: domain.AgentAccountSettings{ + PolicyID: "legacy-policy", + }, + } + + policy, ref, err := resolveAgentAccountWorkspacePolicy(context.Background(), client, account) + + assert.NoError(t, err) + assert.Equal(t, "workspace-policy", policy.ID) + assert.Equal(t, "workspace-policy", client.gotPolicyID) + assert.Equal(t, policyAgentAccountRef{ + GrantID: "grant-1", + Email: "agent@example.com", + WorkspaceID: "workspace-1", + }, ref) +} diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index 04f3496..2d64ee3 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -136,7 +136,7 @@ func TestPolicyListCmd(t *testing.T) { 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.Flags().Lookup("all").Usage, "provider=nylas workspaces") } func TestPolicyReadCmd(t *testing.T) { diff --git a/internal/cli/agent/create.go b/internal/cli/agent/create.go index a9057be..368c40f 100644 --- a/internal/cli/agent/create.go +++ b/internal/cli/agent/create.go @@ -37,7 +37,7 @@ Examples: } 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") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Optional policy ID to attach to the account workspace") return cmd } @@ -68,6 +68,12 @@ func runCreate(email, appPassword, policyID string, jsonOutput bool) error { if err != nil { return struct{}{}, common.WrapCreateError("agent account", err) } + if policyID != "" { + account, err = attachPolicyToAgentWorkspace(ctx, client, account, policyID) + if err != nil { + return struct{}{}, err + } + } saveGrantLocally(account.ID, account.Email) @@ -103,10 +109,6 @@ func createAgentAccountWithFallback(ctx context.Context, client ports.AgentClien 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 @@ -161,33 +163,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..52db299 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" @@ -196,7 +195,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", @@ -230,17 +229,13 @@ func TestCreateAgentAccountWithFallback_RejectsExistingGrantWithoutRequestedPoli "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", @@ -275,15 +270,10 @@ func TestCreateAgentAccountWithFallback_RejectsExistingGrantOnDifferentPolicy(t "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) { diff --git a/internal/cli/agent/helpers.go b/internal/cli/agent/helpers.go index 040209d..f9c676a 100644 --- a/internal/cli/agent/helpers.go +++ b/internal/cli/agent/helpers.go @@ -34,6 +34,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) } @@ -88,6 +91,27 @@ func ensureNylasConnector(ctx context.Context, client ports.NylasClient) (*domai return nil, err } +func attachPolicyToAgentWorkspace(ctx context.Context, client interface { + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) +}, account *domain.AgentAccount, policyID string) (*domain.AgentAccount, error) { + if account == nil { + return nil, common.NewUserError("agent account missing", "The API did not return an agent account") + } + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + return nil, common.NewUserError( + "agent account has no workspace", + "Create completed, but the API did not return a workspace_id to attach the policy to", + ) + } + + if _, err := client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{PolicyID: &policyID}); err != nil { + return nil, common.WrapUpdateError("agent workspace", err) + } + + return account, nil +} + func resolveAgentID(ctx context.Context, client ports.NylasClient, identifier string) (string, error) { if !strings.Contains(identifier, "@") { return identifier, nil diff --git a/internal/cli/agent/helpers_test.go b/internal/cli/agent/helpers_test.go index 044ba2f..cb639d1 100644 --- a/internal/cli/agent/helpers_test.go +++ b/internal/cli/agent/helpers_test.go @@ -1,6 +1,7 @@ package agent import ( + "context" "path/filepath" "testing" @@ -10,6 +11,38 @@ import ( "github.com/stretchr/testify/require" ) +type workspaceUpdateTestClient struct { + workspaceID string + req *domain.UpdateWorkspaceRequest +} + +func (c *workspaceUpdateTestClient) UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) { + c.workspaceID = workspaceID + c.req = req + return &domain.Workspace{ID: workspaceID}, nil +} + +func TestAttachPolicyToAgentWorkspacePatchesWorkspaceWithoutMutatingGrantSettings(t *testing.T) { + client := &workspaceUpdateTestClient{} + account := &domain.AgentAccount{ + ID: "grant-1", + WorkspaceID: "workspace-1", + Settings: domain.AgentAccountSettings{ + PolicyID: "legacy-policy", + }, + } + + updated, err := attachPolicyToAgentWorkspace(context.Background(), client, account, "workspace-policy") + + require.NoError(t, err) + require.Same(t, account, updated) + assert.Equal(t, "workspace-1", client.workspaceID) + require.NotNil(t, client.req) + require.NotNil(t, client.req.PolicyID) + assert.Equal(t, "workspace-policy", *client.req.PolicyID) + assert.Equal(t, "legacy-policy", updated.Settings.PolicyID) +} + func TestGetAgentIdentifier(t *testing.T) { t.Run("uses explicit argument", func(t *testing.T) { setupAgentIdentifierTestEnv(t) diff --git a/internal/cli/agent/policy.go b/internal/cli/agent/policy.go index 42fb0e4..b984333 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, }) } 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..8c161d4 100644 --- a/internal/cli/agent/policy_list_get.go +++ b/internal/cli/agent/policy_list_get.go @@ -17,12 +17,12 @@ func newPolicyListCmd() *cobra.Command { 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 for the default agent workspace", + Long: `List policies for the current default agent workspace. 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. +policy attached to that provider=nylas account's workspace. Use --all to list +every policy referenced by a provider=nylas workspace. Examples: nylas agent policy list @@ -33,7 +33,7 @@ Examples: }, } - cmd.Flags().BoolVar(&allPolicies, "all", false, "List all policies referenced by provider=nylas accounts") + cmd.Flags().BoolVar(&allPolicies, "all", false, "List all policies referenced by provider=nylas workspaces") return cmd } @@ -52,7 +52,7 @@ func runPolicyList(jsonOutput, allPolicies bool) error { } 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") + common.PrintEmptyStateWithHint("policies attached to nylas agent workspaces", "Create a provider=nylas account with --policy-id to see it here") return struct{}{}, nil } @@ -80,23 +80,22 @@ func runPolicyList(jsonOutput, allPolicies bool) error { return struct{}{}, common.WrapGetError("default agent account", err) } - policyID := strings.TrimSpace(account.Settings.PolicyID) - if policyID == "" { + policy, accountRef, err := resolveAgentAccountWorkspacePolicy(ctx, client, *account) + if err != nil { + return struct{}{}, err + } + if policy == nil { 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", + "policy on the default agent workspace", + "Use 'nylas agent policy list --all' to inspect all workspace-attached policies", ) return struct{}{}, nil } - policy, err := client.GetPolicy(ctx, policyID) - if err != nil { - return struct{}{}, common.WrapGetError("policy", err) - } policies := []domain.Policy{*policy} if jsonOutput { @@ -104,10 +103,7 @@ func runPolicyList(jsonOutput, allPolicies bool) error { } _, _ = common.BoldWhite.Printf("Policies (%d)\n\n", len(policies)) - printPolicySummary(*policy, 0, []policyAgentAccountRef{{ - GrantID: account.ID, - Email: account.Email, - }}) + printPolicySummary(*policy, 0, []policyAgentAccountRef{accountRef}) fmt.Println() return struct{}{}, nil }) @@ -115,6 +111,38 @@ func runPolicyList(jsonOutput, allPolicies bool) error { return err } +func resolveAgentAccountWorkspacePolicy(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + GetPolicy(context.Context, string) (*domain.Policy, error) +}, account domain.AgentAccount) (*domain.Policy, policyAgentAccountRef, error) { + accountRef := policyAgentAccountRef{ + GrantID: account.ID, + Email: account.Email, + WorkspaceID: strings.TrimSpace(account.WorkspaceID), + } + + policyID := strings.TrimSpace(account.Settings.PolicyID) + if accountRef.WorkspaceID != "" { + workspace, err := client.GetWorkspace(ctx, accountRef.WorkspaceID) + if err != nil { + return nil, accountRef, common.WrapGetError("workspace", err) + } + if workspace == nil { + return nil, accountRef, common.NewUserError("workspace not found", "The API returned an empty workspace response") + } + policyID = strings.TrimSpace(workspace.PolicyID) + } + if policyID == "" { + return nil, accountRef, nil + } + + policy, err := client.GetPolicy(ctx, policyID) + if err != nil { + return nil, accountRef, common.WrapGetError("policy", err) + } + return policy, accountRef, nil +} + 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..f8e1ee3 100644 --- a/internal/cli/agent/rule.go +++ b/internal/cli/agent/rule.go @@ -32,11 +32,11 @@ 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. +provider=nylas account workspaces. This surface manages both inbound and +outbound rules attached to those workspaces. Examples: nylas agent rule list @@ -103,10 +103,21 @@ func resolveAgentPolicy(ctx context.Context, client ports.NylasClient, policyID } defaultPolicyID := strings.TrimSpace(account.Settings.PolicyID) + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID != "" { + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, nil, common.WrapGetError("workspace", err) + } + if workspace == nil { + return nil, nil, common.NewUserError("workspace not found", "The API returned an empty workspace response") + } + defaultPolicyID = strings.TrimSpace(workspace.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", + "Pass --policy-id or attach a policy to the active provider=nylas workspace first", ) } @@ -116,8 +127,9 @@ func resolveAgentPolicy(ctx context.Context, client ports.NylasClient, policyID } return policy, []policyAgentAccountRef{{ - GrantID: account.ID, - Email: account.Email, + GrantID: account.ID, + Email: account.Email, + WorkspaceID: workspaceID, }}, nil } @@ -131,6 +143,10 @@ func findPolicyByID(policies []domain.Policy, policyID string) *domain.Policy { } func buildRuleRefsByID(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef) map[string][]rulePolicyRef { + return buildRuleRefsByIDWithRuleIDs(policies, refsByPolicyID, nil) +} + +func buildRuleRefsByIDWithRuleIDs(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef, ruleIDsByPolicy map[string][]string) map[string][]rulePolicyRef { refsByRuleID := make(map[string][]rulePolicyRef) for _, policy := range policies { accounts := refsByPolicyID[policy.ID] @@ -138,8 +154,12 @@ func buildRuleRefsByID(policies []domain.Policy, refsByPolicyID map[string][]pol continue } - seen := make(map[string]struct{}, len(policy.Rules)) - for _, ruleID := range policy.Rules { + ruleIDs := policy.Rules + if workspaceRuleIDs, ok := ruleIDsByPolicy[policy.ID]; ok { + ruleIDs = workspaceRuleIDs + } + seen := make(map[string]struct{}, len(ruleIDs)) + for _, ruleID := range ruleIDs { ruleID = strings.TrimSpace(ruleID) if ruleID == "" { continue @@ -190,7 +210,7 @@ func resolveScopedRule(ctx context.Context, client ports.NylasClient, ruleID, po return nil, err } - refsByRuleID := buildRuleRefsByID(scope.AgentPolicies, scope.PolicyRefsByID) + refsByRuleID := buildRuleRefsByIDWithRuleIDs(scope.AgentPolicies, scope.PolicyRefsByID, scope.RuleIDsByPolicy) allRefs := refsByRuleID[ruleID] if len(allRefs) == 0 { return nil, common.NewUserError( @@ -339,7 +359,9 @@ func policiesLeftEmptyByRuleRemoval(ctx context.Context, client interface { return blocking, nil } -func attachRuleToPolicy(ctx context.Context, client ports.NylasClient, policy domain.Policy, ruleID string) error { +func attachRuleToPolicy(ctx context.Context, client interface { + UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) +}, policy domain.Policy, ruleID string) error { updatedRules := appendUniqueString(policy.Rules, ruleID) if slices.Equal(updatedRules, policy.Rules) { return nil @@ -349,7 +371,196 @@ func attachRuleToPolicy(ctx context.Context, client ports.NylasClient, policy do return err } -func detachRuleFromPolicies(ctx context.Context, client ports.NylasClient, policies []domain.Policy, ruleID string) (func(context.Context) error, error) { +func attachRuleToAgentWorkspaces(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) + UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) +}, policy domain.Policy, accounts []policyAgentAccountRef, ruleID string) error { + seenWorkspaceIDs := make(map[string]struct{}, len(accounts)) + 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 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 _, err := client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{RulesIDs: &updatedRules}); err != nil { + return err + } + } + if len(seenWorkspaceIDs) == 0 { + return attachRuleToPolicy(ctx, client, policy, ruleID) + } + return nil +} + +func hasWorkspaceRefs(refs []rulePolicyRef) bool { + for _, ref := range refs { + for _, account := range ref.Accounts { + if strings.TrimSpace(account.WorkspaceID) != "" { + return true + } + } + } + return false +} + +func workspacesLeftEmptyByRuleRemoval(ctx context.Context, client interface { + GetRule(context.Context, string) (*domain.Rule, error) + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, refs []rulePolicyRef, ruleID string) ([]string, error) { + workspaces, err := loadReferencedWorkspaces(ctx, client, refs) + if err != nil { + return nil, err + } + + blocking := make([]string, 0) + for _, workspace := range workspaces { + if !stringSliceContains(workspace.RulesIDs, ruleID) { + continue + } + + liveRemaining := false + for _, candidate := range removeString(workspace.RulesIDs, 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 + } + } + if !liveRemaining { + blocking = append(blocking, formatWorkspaceRef(workspace)) + } + } + return blocking, nil +} + +func detachRuleFromAgentWorkspaces(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) + UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) +}, refs []rulePolicyRef, ruleID string) (func(context.Context) error, error) { + workspaces, err := loadReferencedWorkspaces(ctx, client, refs) + if err != nil { + return nil, err + } + + originalRulesByWorkspaceID := make(map[string][]string) + updatedWorkspaceIDs := make([]string, 0) + + for _, workspace := range workspaces { + if !stringSliceContains(workspace.RulesIDs, ruleID) { + continue + } + + 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 + } + updatedWorkspaceIDs = append(updatedWorkspaceIDs, workspace.ID) + } + + return func(ctx context.Context) error { + return rollbackWorkspaceRuleUpdates(ctx, client, originalRulesByWorkspaceID, updatedWorkspaceIDs) + }, nil +} + +func loadReferencedWorkspaces(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, refs []rulePolicyRef) ([]domain.Workspace, error) { + seenWorkspaceIDs := make(map[string]struct{}) + workspaces := make([]domain.Workspace, 0) + for _, ref := range refs { + for _, account := range ref.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 _, 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 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 +} + +func formatWorkspaceRef(workspace domain.Workspace) string { + name := strings.TrimSpace(workspace.Name) + if name == "" { + return workspace.ID + } + return fmt.Sprintf("%s (%s)", name, workspace.ID) +} + +func detachRuleFromPolicies(ctx context.Context, client interface { + UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) +}, policies []domain.Policy, ruleID string) (func(context.Context) error, error) { originalRulesByPolicyID := make(map[string][]string) updatedPolicyIDs := make([]string, 0) @@ -374,7 +585,9 @@ func detachRuleFromPolicies(ctx context.Context, client ports.NylasClient, polic }, nil } -func rollbackPolicyRuleUpdates(ctx context.Context, client ports.NylasClient, originalRulesByPolicyID map[string][]string, updatedPolicyIDs []string) error { +func rollbackPolicyRuleUpdates(ctx context.Context, client interface { + UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) +}, originalRulesByPolicyID map[string][]string, updatedPolicyIDs []string) error { var failures []string for _, policyID := range updatedPolicyIDs { if _, err := client.UpdatePolicy(ctx, policyID, map[string]any{"rules": originalRulesByPolicyID[policyID]}); err != nil { diff --git a/internal/cli/agent/rule_create_update_delete.go b/internal/cli/agent/rule_create_update_delete.go index c2b0a23..f4e0f7e 100644 --- a/internal/cli/agent/rule_create_update_delete.go +++ b/internal/cli/agent/rule_create_update_delete.go @@ -24,11 +24,11 @@ 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 agent workspaces. -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 workspaces using the +selected policy. If --policy-id is omitted, the CLI uses the policy attached to +the current default provider=nylas grant workspace. Examples: nylas agent rule create --name "Block Example" --condition from.domain,is,example.com --action block @@ -60,7 +60,7 @@ 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") + cmd.Flags().StringVar(&policyID, "policy-id", "", "Policy ID whose agent workspaces receive the created rule") return cmd } @@ -77,12 +77,12 @@ 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 { + if err := attachRuleToAgentWorkspaces(ctx, client, *policy, accounts, 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 { @@ -119,8 +119,8 @@ func newRuleUpdateCmd() *cobra.Command { 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. +provider=nylas workspace. Use --policy-id to scope the validation to another +agent workspace policy, or --all to search any agent workspace policy. Examples: nylas agent rule update --name "Updated Rule" @@ -225,7 +225,7 @@ func newRuleDeleteCmd() *cobra.Command { 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 @@ -262,29 +262,48 @@ func runRuleDelete(ruleID, policyID string, allRules bool) error { ) } - latestPolicies, err := refreshPolicies(ctx, client, scope.AllAgentPolicies) - if err != nil { - return struct{}{}, common.WrapGetError("policy", err) - } + var rollback func(context.Context) error + if hasWorkspaceRefs(scope.AllAgentRefs) { + blockingWorkspaces, err := workspacesLeftEmptyByRuleRemoval(ctx, client, scope.AllAgentRefs, ruleID) + if err != nil { + return struct{}{}, common.WrapGetError("rule", err) + } + if len(blockingWorkspaces) > 0 { + return struct{}{}, common.NewUserError( + "cannot delete the last rule from an agent workspace", + fmt.Sprintf("Attach another rule to %s before deleting %q", strings.Join(blockingWorkspaces, ", "), scope.Rule.Name), + ) + } - 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) + rollback, err = detachRuleFromAgentWorkspaces(ctx, client, scope.AllAgentRefs, ruleID) + if err != nil { + return struct{}{}, fmt.Errorf("failed to detach rule from agent workspaces: %w", err) + } + } else { + latestPolicies, err := refreshPolicies(ctx, client, scope.AllAgentPolicies) + if err != nil { + return struct{}{}, common.WrapGetError("policy", 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) + 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) + } + 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 { diff --git a/internal/cli/agent/rule_list_get.go b/internal/cli/agent/rule_list_get.go index 532a89e..52e5fea 100644 --- a/internal/cli/agent/rule_list_get.go +++ b/internal/cli/agent/rule_list_get.go @@ -19,13 +19,13 @@ func newRuleListCmd() *cobra.Command { 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 for the default agent workspace", + Long: `List rules for the current default agent workspace. 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. +attached to that provider=nylas account's workspace. Use --policy-id to inspect +agent workspaces using a specific policy, or --all to list every rule reachable +from any provider=nylas account workspace. Examples: nylas agent rule list @@ -54,13 +54,13 @@ func runRuleList(jsonOutput, allRules bool, policyID string) error { return struct{}{}, err } - refsByRuleID := buildRuleRefsByID(scope.AgentPolicies, scope.PolicyRefsByID) + refsByRuleID := buildRuleRefsByIDWithRuleIDs(scope.AgentPolicies, scope.PolicyRefsByID, scope.RuleIDsByPolicy) 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") + common.PrintEmptyStateWithHint("rules attached to nylas agent workspaces", "Create a rule and attach it to a provider=nylas workspace to see it here") return struct{}{}, nil } @@ -92,8 +92,12 @@ func runRuleList(jsonOutput, allRules bool, policyID string) error { return struct{}{}, err } - ruleIDs := make([]string, 0, len(policy.Rules)) - for _, ruleID := range policy.Rules { + sourceRuleIDs, ok := scope.RuleIDsByPolicy[policy.ID] + if !ok { + sourceRuleIDs = policy.Rules + } + ruleIDs := make([]string, 0, len(sourceRuleIDs)) + for _, ruleID := range sourceRuleIDs { ruleID = strings.TrimSpace(ruleID) if ruleID == "" { continue @@ -106,7 +110,7 @@ func runRuleList(jsonOutput, allRules bool, policyID string) error { 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 on the selected agent workspaces", "Use 'nylas agent rule create --data-file rule.json' to add one") return struct{}{}, nil } @@ -114,13 +118,13 @@ func runRuleList(jsonOutput, allRules bool, policyID string) error { if err != nil { return struct{}{}, common.WrapListError("rules", err) } - rules, ruleRefs := collectPolicyScopedRules(policy, accounts, allRulesList) + rules, ruleRefs := collectPolicyScopedWorkspaceRules(policy, accounts, ruleIDs, 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 on the selected agent workspaces", "Use 'nylas agent rule create --data-file rule.json' to add one") return struct{}{}, nil } @@ -140,16 +144,20 @@ func runRuleList(jsonOutput, allRules bool, policyID string) error { } func collectPolicyScopedRules(policy *domain.Policy, accounts []policyAgentAccountRef, allRules []domain.Rule) ([]domain.Rule, map[string][]rulePolicyRef) { + return collectPolicyScopedWorkspaceRules(policy, accounts, policy.Rules, allRules) +} + +func collectPolicyScopedWorkspaceRules(policy *domain.Policy, accounts []policyAgentAccountRef, ruleIDs []string, allRules []domain.Rule) ([]domain.Rule, map[string][]rulePolicyRef) { rulesByID := make(map[string]domain.Rule, len(allRules)) for _, rule := range allRules { rulesByID[rule.ID] = rule } accountRefs := append([]policyAgentAccountRef(nil), accounts...) - rules := make([]domain.Rule, 0, len(policy.Rules)) - ruleRefs := make(map[string][]rulePolicyRef, len(policy.Rules)) + rules := make([]domain.Rule, 0, len(ruleIDs)) + ruleRefs := make(map[string][]rulePolicyRef, len(ruleIDs)) - for _, ruleID := range policy.Rules { + for _, ruleID := range ruleIDs { ruleID = strings.TrimSpace(ruleID) if ruleID == "" { continue @@ -186,8 +194,9 @@ func newRuleGetCmd() *cobra.Command { 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. +agent workspace. Use --policy-id to scope the lookup to provider=nylas +workspaces using another policy, or --all to search any provider=nylas +workspace policy. Examples: nylas agent rule get diff --git a/internal/cli/agent/rule_list_get_test.go b/internal/cli/agent/rule_list_get_test.go index 4ee52ff..ecd31db 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,6 +9,44 @@ import ( "github.com/stretchr/testify/require" ) +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 + } + 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 +} + +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_SkipsDanglingReferences(t *testing.T) { enabled := true accounts := []policyAgentAccountRef{{ @@ -49,3 +88,83 @@ func TestCollectPolicyScopedRules_ReturnsEmptyWhenPolicyOnlyHasDanglingReference assert.Empty(t, rules) assert.Empty(t, refs) } + +func TestBuildRuleRefsByIDWithRuleIDsFallsBackToPolicyRulesWhenWorkspaceRulesAbsent(t *testing.T) { + accounts := []policyAgentAccountRef{{ + GrantID: "grant-1", + Email: "agent@example.com", + }} + policies := []domain.Policy{{ + ID: "policy-1", + Name: "Primary Policy", + Rules: []string{"legacy-rule"}, + }} + + refs := buildRuleRefsByIDWithRuleIDs(policies, map[string][]policyAgentAccountRef{"policy-1": accounts}, map[string][]string{}) + + require.Contains(t, refs, "legacy-rule") + assert.Equal(t, accounts, refs["legacy-rule"][0].Accounts) +} + +func TestBuildRuleRefsByIDWithRuleIDsUsesEmptyWorkspaceRulesWhenPresent(t *testing.T) { + policies := []domain.Policy{{ + ID: "policy-1", + Name: "Primary Policy", + Rules: []string{"legacy-rule"}, + }} + refsByPolicyID := map[string][]policyAgentAccountRef{ + "policy-1": {{GrantID: "grant-1", WorkspaceID: "workspace-1"}}, + } + + refs := buildRuleRefsByIDWithRuleIDs(policies, refsByPolicyID, map[string][]string{"policy-1": {}}) + + assert.Empty(t, refs) +} + +func TestWorkspacesLeftEmptyByRuleRemovalBlocksLastLiveRule(t *testing.T) { + client := &workspaceRuleTestClient{ + workspaces: map[string]*domain.Workspace{ + "workspace-1": {ID: "workspace-1", Name: "Agent Workspace", RulesIDs: []string{"rule-1", "missing-rule"}}, + }, + rules: map[string]*domain.Rule{ + "rule-1": {ID: "rule-1"}, + }, + } + refs := []rulePolicyRef{{ + PolicyID: "policy-1", + Accounts: []policyAgentAccountRef{{ + GrantID: "grant-1", + WorkspaceID: "workspace-1", + }}, + }} + + blocking, err := workspacesLeftEmptyByRuleRemoval(context.Background(), client, refs, "rule-1") + + require.NoError(t, err) + assert.Equal(t, []string{"Agent Workspace (workspace-1)"}, blocking) +} + +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), + } + refs := []rulePolicyRef{{ + PolicyID: "policy-1", + Accounts: []policyAgentAccountRef{{ + GrantID: "grant-1", + WorkspaceID: "workspace-1", + }}, + }} + + rollback, err := detachRuleFromAgentWorkspaces(context.Background(), client, refs, "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"]) + + require.NoError(t, rollback(context.Background())) + assert.Equal(t, []string{"rule-1", "rule-2"}, client.workspaces["workspace-1"].RulesIDs) +} 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..7da763a --- /dev/null +++ b/internal/domain/workspace.go @@ -0,0 +1,21 @@ +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"` +} + +// 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..39ce26f 100644 --- a/internal/ports/admin.go +++ b/internal/ports/admin.go @@ -61,6 +61,12 @@ type AdminClient interface { // DeleteConnector deletes a connector. DeleteConnector(ctx context.Context, connectorID string) error + // GetWorkspace retrieves a grant workspace. + GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) + + // UpdateWorkspace updates workspace policy/rule attachments. + UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) + // ================================ // CREDENTIAL OPERATIONS // ================================ diff --git a/internal/ports/agent.go b/internal/ports/agent.go index 6d51409..467b749 100644 --- a/internal/ports/agent.go +++ b/internal/ports/agent.go @@ -16,7 +16,7 @@ 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. + // policyID is deprecated; policy attachment happens through workspaces. CreateAgentAccount(ctx context.Context, email, appPassword, policyID string) (*domain.AgentAccount, error) // UpdateAgentAccount updates mutable settings on an existing agent account. From 7250b2078428980bb6031963e6ec9085418c1571 Mon Sep 17 00:00:00 2001 From: Qasim Date: Mon, 25 May 2026 12:33:52 -0400 Subject: [PATCH 02/10] fix ws/policy --- internal/air/handlers_rules_policy.go | 59 ++++++++++++++++----------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/internal/air/handlers_rules_policy.go b/internal/air/handlers_rules_policy.go index c592f29..035a82a 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,7 @@ func (s *Server) handleListPolicies(w http.ResponseWriter, r *http.Request) { return } - policyID := strings.TrimSpace(account.Settings.PolicyID) + policyID := s.resolveAccountPolicyID(ctx, account) if policyID == "" { writeJSON(w, http.StatusOK, PoliciesResponse{Policies: []domain.Policy{}}) return @@ -88,28 +89,7 @@ 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) - if err != nil { - writeJSON(w, http.StatusInternalServerError, map[string]string{ - "error": "Failed to fetch policy for 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{}{} - } + ruleIDs := s.resolveAccountRuleIDs(ctx, account) if len(ruleIDs) == 0 { writeJSON(w, http.StatusOK, RulesResponse{Rules: []domain.Rule{}}) return @@ -123,9 +103,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 +119,32 @@ 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 { + if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { + if ws, err := s.nylasClient.GetWorkspace(ctx, wsID); err == nil && ws != nil { + if pid := strings.TrimSpace(ws.PolicyID); pid != "" { + return pid + } + } + } + return strings.TrimSpace(account.Settings.PolicyID) +} + +func (s *Server) resolveAccountRuleIDs(ctx context.Context, account *domain.AgentAccount) []string { + if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { + if ws, err := s.nylasClient.GetWorkspace(ctx, wsID); err == nil && ws != nil { + var ids []string + for _, id := range ws.RulesIDs { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + return ids + } + } + return nil +} + func demoPolicies() []domain.Policy { return []domain.Policy{ { From 90aeb1306130310d510315abb4e4fae5d2e8ebc5 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 18:52:34 -0400 Subject: [PATCH 03/10] Align agent CLI and integration tests with workspace policy/rule model - Migrate agent integration tests to the workspace model: attach policies and rules via UpdateWorkspace and assert on workspace.PolicyID / workspace.RulesIDs instead of grant settings.policy_id / policy.rules. The policy.rules helpers are kept only for the non-agent (detached) policy exercised by the mixed-scope safety test. - Show "Workspace ID" in `nylas agent account list` (parity with create/get/update); update docs/commands/agent.md example. - Remove the legacy no-workspace fallback paths (attachRuleToPolicy, detachRuleFromPolicies, rollbackPolicyRuleUpdates, policiesLeftEmptyByRuleRemoval, refreshPolicies, buildRuleRefsByID shim, hasWorkspaceRefs branch, AllAgentPolicies scope field) now that the Nylas API backend performs that fallback server-side. Fail loud when an agent account has no workspace instead of silently no-oping rule create/delete. The cross-scope "shared with a non-agent policy" safety check is kept. --- docs/commands/agent.md | 2 + internal/cli/agent/agent_test.go | 72 +---------- internal/cli/agent/helpers.go | 3 + internal/cli/agent/rule.go | 117 +---------------- .../cli/agent/rule_create_update_delete.go | 62 +++------ internal/cli/integration/agent_policy_test.go | 72 ++++++++++- .../cli/integration/agent_rule_matrix_test.go | 26 ++-- .../integration/agent_rule_outbound_test.go | 12 +- internal/cli/integration/agent_rule_test.go | 121 +++++++++++++++--- internal/cli/integration/agent_test.go | 30 ++--- 10 files changed, 236 insertions(+), 281 deletions(-) diff --git a/docs/commands/agent.md b/docs/commands/agent.md index 70da07a..99b3872 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 diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index 2d64ee3..fa6a136 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() @@ -411,7 +402,7 @@ func TestResolvePolicyForAgentOps(t *testing.T) { } func TestBuildRuleRefsByID(t *testing.T) { - refsByRuleID := buildRuleRefsByID( + refsByRuleID := buildRuleRefsByIDWithRuleIDs( []domain.Policy{ {ID: "policy-b", Name: "Beta", Rules: []string{"rule-1"}}, {ID: "policy-a", Name: "Alpha", Rules: []string{"rule-1", "rule-2", "rule-1"}}, @@ -426,6 +417,7 @@ func TestBuildRuleRefsByID(t *testing.T) { Email: "beta@example.com", }}, }, + nil, ) if assert.Len(t, refsByRuleID["rule-1"], 2) { @@ -450,66 +442,6 @@ func TestRuleReferencedOutsideAgentScope(t *testing.T) { 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/helpers.go b/internal/cli/agent/helpers.go index f9c676a..59d53bc 100644 --- a/internal/cli/agent/helpers.go +++ b/internal/cli/agent/helpers.go @@ -21,6 +21,9 @@ 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) + } } func printAgentDetails(account domain.AgentAccount) { diff --git a/internal/cli/agent/rule.go b/internal/cli/agent/rule.go index f8e1ee3..a00709a 100644 --- a/internal/cli/agent/rule.go +++ b/internal/cli/agent/rule.go @@ -24,7 +24,6 @@ type resolvedRuleScope struct { Rule *domain.Rule SelectedRefs []rulePolicyRef AllAgentRefs []rulePolicyRef - AllAgentPolicies []domain.Policy SharedOutsideAgent bool } @@ -142,10 +141,6 @@ func findPolicyByID(policies []domain.Policy, policyID string) *domain.Policy { return nil } -func buildRuleRefsByID(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef) map[string][]rulePolicyRef { - return buildRuleRefsByIDWithRuleIDs(policies, refsByPolicyID, nil) -} - func buildRuleRefsByIDWithRuleIDs(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef, ruleIDsByPolicy map[string][]string) map[string][]rulePolicyRef { refsByRuleID := make(map[string][]rulePolicyRef) for _, policy := range policies { @@ -244,7 +239,6 @@ func resolveScopedRule(ctx context.Context, client ports.NylasClient, ruleID, po Rule: rule, SelectedRefs: selectedRefs, AllAgentRefs: allRefs, - AllAgentPolicies: scope.AgentPolicies, SharedOutsideAgent: ruleReferencedOutsideAgentScope(scope.AllPolicies, scope.AgentPolicies, ruleID), }, nil } @@ -311,71 +305,10 @@ 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 - } - 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) { - continue - } - - 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 - } - } - if !liveRemaining { - blocking = append(blocking, policy) - } - } - return blocking, nil -} - -func attachRuleToPolicy(ctx context.Context, client interface { - UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) -}, policy domain.Policy, ruleID string) error { - updatedRules := appendUniqueString(policy.Rules, ruleID) - if slices.Equal(updatedRules, policy.Rules) { - return nil - } - - _, err := client.UpdatePolicy(ctx, policy.ID, map[string]any{"rules": updatedRules}) - return err -} - func attachRuleToAgentWorkspaces(ctx context.Context, client interface { GetWorkspace(context.Context, string) (*domain.Workspace, error) UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) - UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) -}, policy domain.Policy, accounts []policyAgentAccountRef, ruleID string) error { +}, accounts []policyAgentAccountRef, ruleID string) error { seenWorkspaceIDs := make(map[string]struct{}, len(accounts)) for _, account := range accounts { workspaceID := strings.TrimSpace(account.WorkspaceID) @@ -403,7 +336,10 @@ func attachRuleToAgentWorkspaces(ctx context.Context, client interface { } } if len(seenWorkspaceIDs) == 0 { - return attachRuleToPolicy(ctx, client, policy, ruleID) + 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 } @@ -557,46 +493,3 @@ func formatWorkspaceRef(workspace domain.Workspace) string { } return fmt.Sprintf("%s (%s)", name, workspace.ID) } - -func detachRuleFromPolicies(ctx context.Context, client interface { - UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) -}, policies []domain.Policy, ruleID string) (func(context.Context) error, error) { - originalRulesByPolicyID := make(map[string][]string) - updatedPolicyIDs := make([]string, 0) - - for _, policy := range policies { - if !policyContainsRule(policy, 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) - } - return nil, err - } - updatedPolicyIDs = append(updatedPolicyIDs, policy.ID) - } - - return func(ctx context.Context) error { - return rollbackPolicyRuleUpdates(ctx, client, originalRulesByPolicyID, updatedPolicyIDs) - }, nil -} - -func rollbackPolicyRuleUpdates(ctx context.Context, client interface { - UpdatePolicy(context.Context, string, map[string]any) (*domain.Policy, error) -}, originalRulesByPolicyID map[string][]string, updatedPolicyIDs []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)) - } - } - - if len(failures) > 0 { - return fmt.Errorf("failed to rollback policy updates: %s", strings.Join(failures, "; ")) - } - return nil -} diff --git a/internal/cli/agent/rule_create_update_delete.go b/internal/cli/agent/rule_create_update_delete.go index f4e0f7e..17e81db 100644 --- a/internal/cli/agent/rule_create_update_delete.go +++ b/internal/cli/agent/rule_create_update_delete.go @@ -77,7 +77,7 @@ func runRuleCreate(payload map[string]any, policyID string, jsonOutput bool) err return struct{}{}, common.WrapCreateError("rule", err) } - if err := attachRuleToAgentWorkspaces(ctx, client, *policy, accounts, rule.ID); err != nil { + if err := attachRuleToAgentWorkspaces(ctx, client, accounts, rule.ID); err != nil { cleanupErr := client.DeleteRule(ctx, rule.ID) if cleanupErr != nil { return struct{}{}, fmt.Errorf("failed to attach rule to workspace: %w (cleanup failed: %v)", err, cleanupErr) @@ -261,49 +261,27 @@ func runRuleDelete(ruleID, policyID string, allRules bool) error { "Use the generic policy/rule surface to delete shared rules safely", ) } + if !hasWorkspaceRefs(scope.AllAgentRefs) { + return struct{}{}, common.NewUserError( + "agent account has no workspace", + "The rule's provider=nylas account is missing a workspace; reconnect the account and try again", + ) + } - var rollback func(context.Context) error - if hasWorkspaceRefs(scope.AllAgentRefs) { - blockingWorkspaces, err := workspacesLeftEmptyByRuleRemoval(ctx, client, scope.AllAgentRefs, ruleID) - if err != nil { - return struct{}{}, common.WrapGetError("rule", err) - } - if len(blockingWorkspaces) > 0 { - return struct{}{}, common.NewUserError( - "cannot delete the last rule from an agent workspace", - fmt.Sprintf("Attach another rule to %s before deleting %q", strings.Join(blockingWorkspaces, ", "), scope.Rule.Name), - ) - } - - rollback, err = detachRuleFromAgentWorkspaces(ctx, client, scope.AllAgentRefs, ruleID) - if err != nil { - return struct{}{}, fmt.Errorf("failed to detach rule from agent workspaces: %w", err) - } - } else { - 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) - } - 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), - ) - } + blockingWorkspaces, err := workspacesLeftEmptyByRuleRemoval(ctx, client, scope.AllAgentRefs, ruleID) + if err != nil { + return struct{}{}, common.WrapGetError("rule", err) + } + if len(blockingWorkspaces) > 0 { + return struct{}{}, common.NewUserError( + "cannot delete the last rule from an agent workspace", + fmt.Sprintf("Attach another rule to %s before deleting %q", strings.Join(blockingWorkspaces, ", "), 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) - } + rollback, err := detachRuleFromAgentWorkspaces(ctx, client, scope.AllAgentRefs, ruleID) + if err != nil { + return struct{}{}, fmt.Errorf("failed to detach rule from agent workspaces: %w", err) } if err := client.DeleteRule(ctx, ruleID); err != nil { diff --git a/internal/cli/integration/agent_policy_test.go b/internal/cli/integration/agent_policy_test.go index e448474..64c4a9a 100644 --- a/internal/cli/integration/agent_policy_test.go +++ b/internal/cli/integration/agent_policy_test.go @@ -286,7 +286,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 +308,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..cd47b51 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,7 +53,7 @@ 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) { @@ -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) { @@ -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 { 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..45dcfec 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 { @@ -239,7 +239,7 @@ 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 } @@ -259,7 +259,7 @@ func TestCLI_AgentRuleDelete_RejectsLastRuleOnPolicy(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) @@ -314,7 +314,7 @@ func TestCLI_AgentRuleDelete_RejectsLastRuleOnPolicy(t *testing.T) { t.Fatalf("failed to parse rule create JSON: %v\noutput: %s", err, createStdout) } createdRule = &rule - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) deleteStdout, deleteStderr, err := runCLIWithOverridesAndRateLimit( t, @@ -333,7 +333,7 @@ func TestCLI_AgentRuleDelete_RejectsLastRuleOnPolicy(t *testing.T) { t.Fatalf("expected last-rule delete error, got stderr: %s", deleteStderr) } - assertPolicyContainsRuleForTest(t, client, createdPolicy.ID, createdRule.ID) + assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) } func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { @@ -371,16 +371,16 @@ func TestCLI_AgentRuleCommands_RejectMixedScopeRule(t *testing.T) { 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) + attachRuleToWorkspaceForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) attachRuleToPolicyForTest(t, client, detachedPolicy.ID, createdRule.ID) - assertPolicyContainsRuleForTest(t, client, agentPolicy.ID, createdRule.ID) + assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, 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) + removeRuleFromWorkspaceForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) } if detachedPolicy != nil && detachedPolicy.ID != "" { removeRuleFromPolicyForTest(t, client, detachedPolicy.ID, createdRule.ID) @@ -470,25 +470,110 @@ func assertPolicyContainsRuleForTest(t *testing.T, client interface { t.Fatalf("policy %q does not include rule %q", policyID, ruleID) } -func assertPolicyMissingRuleForTest(t *testing.T, client interface { - GetPolicy(context.Context, string) (*domain.Policy, error) -}, policyID, ruleID string) { +// Workspace-scoped rule helpers mirror the policy-scoped ones above, but operate +// on workspace.rules_ids — the attachment point the CLI now reads for agent +// accounts. The policy-scoped helpers remain for non-agent policies (e.g. the +// mixed-scope test's detached policy). + +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 && workspace != nil && containsString(workspace.RulesIDs, ruleID) { + return + } + + time.Sleep(500 * time.Millisecond) + } + + t.Fatalf("workspace %q does not include rule %q", workspaceID, ruleID) +} + +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) + 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 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() + + 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(workspace.RulesIDs)) + for _, existingRuleID := range workspace.RulesIDs { + if existingRuleID == ruleID { + continue + } + updatedRules = append(updatedRules, existingRuleID) + } + + 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 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() + + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil { + t.Fatalf("failed to get workspace %q: %v", workspaceID, err) + } + if workspace == nil { + t.Fatalf("workspace %q not found", workspaceID) + } + if containsString(workspace.RulesIDs, ruleID) { + return + } + + 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) + } } func removeRuleFromPolicyForTest(t *testing.T, client interface { diff --git a/internal/cli/integration/agent_test.go b/internal/cli/integration/agent_test.go index 6ae394e..47b6fbf 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 { @@ -275,11 +280,12 @@ func TestCLI_AgentCreate_WithPolicyID(t *testing.T) { } 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) } + // Policy now lives on the account's workspace, not grant settings. + assertWorkspacePolicyForTest(t, client, fetched.WorkspaceID, policy.ID) } func TestCLI_AgentUpdate_ByEmail(t *testing.T) { @@ -382,20 +388,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 +426,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) { From c01e4959e1696caccfc360845c13677bb1168cea Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 19:55:46 -0400 Subject: [PATCH 04/10] Show workspace policy ID in account list, simplify policy list to use /v3/policies - account list/status: resolve and display policy_id from workspace - policy list: list all policies from /v3/policies with workspace refs - Remove --all flag and resolveAgentAccountWorkspacePolicy (no longer needed) --- internal/cli/agent/agent_scope_test.go | 52 ---------- internal/cli/agent/agent_test.go | 4 +- internal/cli/agent/helpers.go | 19 +++- internal/cli/agent/list.go | 2 +- internal/cli/agent/policy_list_get.go | 130 +++++++------------------ internal/cli/agent/status.go | 2 +- 6 files changed, 58 insertions(+), 151 deletions(-) diff --git a/internal/cli/agent/agent_scope_test.go b/internal/cli/agent/agent_scope_test.go index 6373abb..93802ea 100644 --- a/internal/cli/agent/agent_scope_test.go +++ b/internal/cli/agent/agent_scope_test.go @@ -32,28 +32,6 @@ func (c workspaceLookupClient) GetWorkspace(ctx context.Context, workspaceID str return c.workspaces[workspaceID], nil } -type workspacePolicyClient struct { - workspaces map[string]*domain.Workspace - policies map[string]domain.Policy - gotPolicyID string -} - -func (c *workspacePolicyClient) GetWorkspace(ctx context.Context, workspaceID string) (*domain.Workspace, error) { - workspace := c.workspaces[workspaceID] - if workspace == nil { - return nil, domain.ErrWorkspaceNotFound - } - return workspace, nil -} - -func (c *workspacePolicyClient) GetPolicy(ctx context.Context, policyID string) (*domain.Policy, error) { - c.gotPolicyID = policyID - policy, ok := c.policies[policyID] - if !ok { - return nil, domain.ErrPolicyNotFound - } - return &policy, nil -} func TestUpsertAgentAccount(t *testing.T) { accounts := []domain.AgentAccount{ @@ -193,33 +171,3 @@ func TestUpsertPoliciesForAgentAccountsUsesWorkspacePolicy(t *testing.T) { } } -func TestResolveAgentAccountWorkspacePolicyUsesWorkspacePolicy(t *testing.T) { - client := &workspacePolicyClient{ - workspaces: map[string]*domain.Workspace{ - "workspace-1": {ID: "workspace-1", PolicyID: "workspace-policy"}, - }, - policies: map[string]domain.Policy{ - "workspace-policy": {ID: "workspace-policy", Name: "Workspace Policy"}, - "legacy-policy": {ID: "legacy-policy", Name: "Legacy Policy"}, - }, - } - account := domain.AgentAccount{ - ID: "grant-1", - Email: "agent@example.com", - WorkspaceID: "workspace-1", - Settings: domain.AgentAccountSettings{ - PolicyID: "legacy-policy", - }, - } - - policy, ref, err := resolveAgentAccountWorkspacePolicy(context.Background(), client, account) - - assert.NoError(t, err) - assert.Equal(t, "workspace-policy", policy.ID) - assert.Equal(t, "workspace-policy", client.gotPolicyID) - assert.Equal(t, policyAgentAccountRef{ - GrantID: "grant-1", - Email: "agent@example.com", - WorkspaceID: "workspace-1", - }, ref) -} diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index fa6a136..3b0a49b 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -125,9 +125,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 workspaces") + assert.Contains(t, cmd.Long, "/v3/policies") } func TestPolicyReadCmd(t *testing.T) { diff --git a/internal/cli/agent/helpers.go b/internal/cli/agent/helpers.go index 59d53bc..42cb56e 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 string) { createdStr := common.FormatTimeAgo(account.CreatedAt.Time) fmt.Printf("%d. %-40s %s %s\n", index+1, @@ -24,6 +24,23 @@ func printAgentSummary(account domain.AgentAccount, index int) { if account.WorkspaceID != "" { _, _ = common.Dim.Printf(" Workspace ID: %s\n", account.WorkspaceID) } + if policyID != "" { + _, _ = common.Dim.Printf(" Policy ID: %s\n", policyID) + } +} + +func resolveWorkspacePolicyID(ctx context.Context, client interface { + GetWorkspace(context.Context, string) (*domain.Workspace, error) +}, account domain.AgentAccount) string { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + return "" + } + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil || workspace == nil { + return "" + } + return strings.TrimSpace(workspace.PolicyID) } func printAgentDetails(account domain.AgentAccount) { diff --git a/internal/cli/agent/list.go b/internal/cli/agent/list.go index c30dbe2..807a702 100644 --- a/internal/cli/agent/list.go +++ b/internal/cli/agent/list.go @@ -50,7 +50,7 @@ func runList(jsonOutput bool) error { _, _ = common.BoldWhite.Printf("Agent Accounts (%d)\n\n", len(accounts)) for i, account := range accounts { - printAgentSummary(account, i) + printAgentSummary(account, i, resolveWorkspacePolicyID(ctx, client, account)) } fmt.Println() diff --git a/internal/cli/agent/policy_list_get.go b/internal/cli/agent/policy_list_get.go index 8c161d4..ee8c7b8 100644 --- a/internal/cli/agent/policy_list_get.go +++ b/internal/cli/agent/policy_list_get.go @@ -2,108 +2,55 @@ package agent import ( "context" - "errors" "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 newPolicyListCmd() *cobra.Command { - var allPolicies bool - cmd := &cobra.Command{ Use: "list", - Short: "List policies for the default agent workspace", - Long: `List policies for the current default agent workspace. + Short: "List policies", + Long: `List all policies from /v3/policies. -By default, this command resolves the current default grant and shows the -policy attached to that provider=nylas account's workspace. Use --all to list -every policy referenced by a provider=nylas workspace. +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 workspaces") - 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 workspaces", "Create a provider=nylas account with --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) } - policy, accountRef, err := resolveAgentAccountWorkspacePolicy(ctx, client, *account) - if err != nil { - return struct{}{}, err - } - if policy == nil { - if jsonOutput { - fmt.Println("[]") - return struct{}{}, nil - } - common.PrintEmptyStateWithHint( - "policy on the default agent workspace", - "Use 'nylas agent policy list --all' to inspect all workspace-attached policies", - ) + if len(policies) == 0 { + common.PrintEmptyStateWithHint("policies", "Create one with: nylas agent policy create --name \"Policy Name\"") return struct{}{}, nil } - 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{accountRef}) + for i, policy := range policies { + printPolicySummary(policy, i, workspaceRefs[policy.ID]) + } fmt.Println() return struct{}{}, nil }) @@ -111,36 +58,33 @@ func runPolicyList(jsonOutput, allPolicies bool) error { return err } -func resolveAgentAccountWorkspacePolicy(ctx context.Context, client interface { - GetWorkspace(context.Context, string) (*domain.Workspace, error) - GetPolicy(context.Context, string) (*domain.Policy, error) -}, account domain.AgentAccount) (*domain.Policy, policyAgentAccountRef, error) { - accountRef := policyAgentAccountRef{ - GrantID: account.ID, - Email: account.Email, - WorkspaceID: strings.TrimSpace(account.WorkspaceID), +func buildWorkspacePolicyRefs(ctx context.Context, client ports.NylasClient) map[string][]policyAgentAccountRef { + accounts, err := client.ListAgentAccounts(ctx) + if err != nil { + return nil } - policyID := strings.TrimSpace(account.Settings.PolicyID) - if accountRef.WorkspaceID != "" { - workspace, err := client.GetWorkspace(ctx, accountRef.WorkspaceID) - if err != nil { - return nil, accountRef, common.WrapGetError("workspace", err) + refs := make(map[string][]policyAgentAccountRef) + for _, account := range accounts { + workspaceID := strings.TrimSpace(account.WorkspaceID) + if workspaceID == "" { + continue } - if workspace == nil { - return nil, accountRef, common.NewUserError("workspace not found", "The API returned an empty workspace response") + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil || workspace == nil { + continue } - policyID = strings.TrimSpace(workspace.PolicyID) - } - if policyID == "" { - return nil, accountRef, nil - } - - policy, err := client.GetPolicy(ctx, policyID) - if err != nil { - return nil, accountRef, common.WrapGetError("policy", err) + policyID := strings.TrimSpace(workspace.PolicyID) + if policyID == "" { + continue + } + refs[policyID] = append(refs[policyID], policyAgentAccountRef{ + GrantID: account.ID, + Email: account.Email, + WorkspaceID: workspaceID, + }) } - return policy, accountRef, nil + return refs } func newPolicyGetCmd() *cobra.Command { diff --git a/internal/cli/agent/status.go b/internal/cli/agent/status.go index 495dc02..ea8c999 100644 --- a/internal/cli/agent/status.go +++ b/internal/cli/agent/status.go @@ -76,7 +76,7 @@ func runStatus(jsonOutput bool) error { if len(accounts) > 0 { fmt.Println() for i, account := range accounts { - printAgentSummary(account, i) + printAgentSummary(account, i, resolveWorkspacePolicyID(ctx, client, account)) } } From 05368b2c164efcb78691b0d0ab907fce884b3396 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 20:34:45 -0400 Subject: [PATCH 05/10] Add workspace CRUD commands (nylas workspace list/get/create/update/delete) - Add ListWorkspaces, CreateWorkspace, DeleteWorkspace to port/adapter - Add CreateWorkspaceRequest domain type - Wire up nylas workspace CLI with aliases (workspaces, ws) --- cmd/nylas/main.go | 2 + internal/adapters/nylas/admin.go | 44 ++++ internal/adapters/nylas/demo_admin.go | 19 ++ internal/adapters/nylas/mock_admin.go | 19 ++ internal/cli/agent/agent_scope_test.go | 2 - internal/cli/workspace/commands.go | 265 +++++++++++++++++++++++++ internal/cli/workspace/workspace.go | 29 +++ internal/domain/workspace.go | 9 + internal/ports/admin.go | 9 + 9 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 internal/cli/workspace/commands.go create mode 100644 internal/cli/workspace/workspace.go 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/internal/adapters/nylas/admin.go b/internal/adapters/nylas/admin.go index c0912d3..0d8bee6 100644 --- a/internal/adapters/nylas/admin.go +++ b/internal/adapters/nylas/admin.go @@ -305,6 +305,19 @@ 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 { @@ -322,6 +335,28 @@ func (c *HTTPClient) GetWorkspace(ctx context.Context, workspaceID string) (*dom 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 { @@ -347,6 +382,15 @@ func (c *HTTPClient) UpdateWorkspace(ctx context.Context, workspaceID string, re 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/demo_admin.go b/internal/adapters/nylas/demo_admin.go index 1d78b72..78e4ab3 100644 --- a/internal/adapters/nylas/demo_admin.go +++ b/internal/adapters/nylas/demo_admin.go @@ -207,6 +207,12 @@ 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, @@ -216,6 +222,15 @@ func (d *DemoClient) GetWorkspace(ctx context.Context, workspaceID string) (*dom }, 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 { @@ -227,6 +242,10 @@ func (d *DemoClient) UpdateWorkspace(ctx context.Context, workspaceID string, re 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/mock_admin.go b/internal/adapters/nylas/mock_admin.go index 89ed564..4fa09a6 100644 --- a/internal/adapters/nylas/mock_admin.go +++ b/internal/adapters/nylas/mock_admin.go @@ -120,6 +120,12 @@ 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, @@ -129,6 +135,15 @@ func (m *MockClient) GetWorkspace(ctx context.Context, workspaceID string) (*dom }, 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 { @@ -140,6 +155,10 @@ func (m *MockClient) UpdateWorkspace(ctx context.Context, workspaceID string, re 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/cli/agent/agent_scope_test.go b/internal/cli/agent/agent_scope_test.go index 93802ea..1f519a9 100644 --- a/internal/cli/agent/agent_scope_test.go +++ b/internal/cli/agent/agent_scope_test.go @@ -32,7 +32,6 @@ func (c workspaceLookupClient) GetWorkspace(ctx context.Context, workspaceID str return c.workspaces[workspaceID], nil } - func TestUpsertAgentAccount(t *testing.T) { accounts := []domain.AgentAccount{ { @@ -170,4 +169,3 @@ func TestUpsertPoliciesForAgentAccountsUsesWorkspacePolicy(t *testing.T) { assert.Equal(t, "policy-fresh", updated[1].ID) } } - diff --git a/internal/cli/workspace/commands.go b/internal/cli/workspace/commands.go new file mode 100644 index 0000000..4e8494a --- /dev/null +++ b/internal/cli/workspace/commands.go @@ -0,0 +1,265 @@ +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, + AutoGroup: autoGroup, + PolicyID: policyID, + } + + 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 { + _, 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/domain/workspace.go b/internal/domain/workspace.go index 7da763a..4eaacb1 100644 --- a/internal/domain/workspace.go +++ b/internal/domain/workspace.go @@ -14,6 +14,15 @@ type Workspace struct { 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"` diff --git a/internal/ports/admin.go b/internal/ports/admin.go index 39ce26f..7187853 100644 --- a/internal/ports/admin.go +++ b/internal/ports/admin.go @@ -61,12 +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 // ================================ From 966a9f8549f367942af6614fde9398600f5e9c39 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 20:40:02 -0400 Subject: [PATCH 06/10] Label workspace policy as Default Account Policy when not in /v3/policies --- internal/cli/agent/helpers.go | 27 ++++++++++++++++++++------- internal/cli/agent/list.go | 3 ++- internal/cli/agent/status.go | 3 ++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/internal/cli/agent/helpers.go b/internal/cli/agent/helpers.go index 42cb56e..6d3ab4a 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, policyID string) { +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, @@ -25,22 +25,35 @@ func printAgentSummary(account domain.AgentAccount, index int, policyID string) _, _ = common.Dim.Printf(" Workspace ID: %s\n", account.WorkspaceID) } if policyID != "" { - _, _ = common.Dim.Printf(" Policy ID: %s\n", policyID) + _, _ = common.Dim.Printf(" Policy ID: %s %s\n", policyID, policyLabel) } } -func resolveWorkspacePolicyID(ctx context.Context, client interface { +type workspacePolicyInfo struct { + ID string + Label string +} + +func resolveWorkspacePolicy(ctx context.Context, client interface { GetWorkspace(context.Context, string) (*domain.Workspace, error) -}, account domain.AgentAccount) string { + GetPolicy(context.Context, string) (*domain.Policy, error) +}, account domain.AgentAccount) workspacePolicyInfo { workspaceID := strings.TrimSpace(account.WorkspaceID) if workspaceID == "" { - return "" + return workspacePolicyInfo{} } workspace, err := client.GetWorkspace(ctx, workspaceID) if err != nil || workspace == nil { - return "" + 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 strings.TrimSpace(workspace.PolicyID) + return workspacePolicyInfo{ID: policyID} } func printAgentDetails(account domain.AgentAccount) { diff --git a/internal/cli/agent/list.go b/internal/cli/agent/list.go index 807a702..9c87cf4 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, resolveWorkspacePolicyID(ctx, client, account)) + info := resolveWorkspacePolicy(ctx, client, account) + printAgentSummary(account, i, info.ID, info.Label) } fmt.Println() diff --git a/internal/cli/agent/status.go b/internal/cli/agent/status.go index ea8c999..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, resolveWorkspacePolicyID(ctx, client, account)) + info := resolveWorkspacePolicy(ctx, client, account) + printAgentSummary(account, i, info.ID, info.Label) } } From 0c53291fe572a245921429f72733ce792e246cd1 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 23:25:21 -0400 Subject: [PATCH 07/10] Align agent CLI with workspace model: rules and policies attach to workspaces - Remove --policy-id from account create and rule commands - Remove --all from policy list and rule list - Rules route through workspace rules_ids[] directly, not via policies - Policy list and rule list call /v3/policies and /v3/rules directly - Simplify detachRuleFromAgentWorkspaces to accept []policyAgentAccountRef - Add rollback on delete failure, strict error variant for mutation paths - Fix Air handlers to surface workspace fetch errors as 500s - Fix RuleAction.Value any-type print handling - Add workspace dedup in buildWorkspacePolicyRefs/buildWorkspaceRuleRefsStrict - Validate workspace update requires at least one flag - Update docs and integration tests to match new workspace flow - Fix auth list regression test for changed error message --- docs/COMMANDS.md | 7 +- docs/commands/agent-rule.md | 227 ++----------- docs/commands/agent.md | 32 +- internal/adapters/nylas/agent.go | 5 +- internal/adapters/nylas/agent_test.go | 14 +- internal/adapters/nylas/demo_agent.go | 2 +- internal/adapters/nylas/mock_agent.go | 2 +- internal/air/handlers_rules_policy.go | 42 ++- internal/cli/agent/agent_scope.go | 45 --- internal/cli/agent/agent_test.go | 48 +-- internal/cli/agent/create.go | 31 +- internal/cli/agent/create_test.go | 32 +- internal/cli/agent/helpers.go | 21 -- internal/cli/agent/helpers_test.go | 33 -- internal/cli/agent/list.go | 2 +- internal/cli/agent/policy_list_get.go | 10 +- internal/cli/agent/rule.go | 316 ++---------------- .../cli/agent/rule_create_update_delete.go | 134 +++----- internal/cli/agent/rule_list_get.go | 212 +++--------- internal/cli/agent/rule_list_get_test.go | 108 +----- internal/cli/agent/rule_print.go | 71 ++-- internal/cli/agent/rule_print_test.go | 15 +- internal/cli/integration/agent_policy_test.go | 29 +- .../cli/integration/agent_rule_matrix_test.go | 28 +- internal/cli/integration/agent_rule_test.go | 289 +--------------- internal/cli/integration/agent_test.go | 29 +- .../cli/integration/local_regressions_test.go | 2 +- internal/cli/workspace/commands.go | 7 + internal/ports/agent.go | 4 +- 29 files changed, 318 insertions(+), 1479 deletions(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b0fa085..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 and attach policy to its workspace 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 workspace -nylas agent policy list --all # List all policies attached to agent workspaces +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 workspace policy -nylas agent rule list --all # List all rules attached to agent workspaces +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-rule.md b/docs/commands/agent-rule.md index daf6153..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 `provider=nylas` agent account workspaces. 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 workspace attachments: - -- `nylas agent rule list` uses the policy and rules attached to the current default `provider=nylas` grant workspace -- `nylas agent rule list --policy-id ` uses that specific policy within the agent workspace scope -- `nylas agent rule list --all` shows rules reachable from any `provider=nylas` agent workspace -- `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's workspace -- returns the rules attached to that workspace -- skips stale workspace 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 `provider=nylas` account workspaces -- 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 workspace with zero live 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. -Policies and rules are attached to agent account workspaces. - -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 the selected agent workspace 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 -- workspace -- policy and rules reachable from that workspace +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 workspace with a policy attached -- confirm the workspace actually has rules attached -- if the workspace 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 workspace 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 workspace 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 99b3872..aeb9966 100644 --- a/docs/commands/agent.md +++ b/docs/commands/agent.md @@ -54,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 patches the account workspace with `policy_id` 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 @@ -93,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 @@ -152,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 @@ -164,8 +159,7 @@ nylas agent policy delete 12345678-1234-1234-1234-123456789012 --yes ``` Summary: -- `list` resolves the default `provider=nylas` grant and shows the policy attached to its workspace -- `list --all` shows only policies that are actually referenced by `provider=nylas` agent workspaces +- `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 @@ -175,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 @@ -189,13 +181,11 @@ nylas agent rule delete --yes ``` Summary: -- `list` uses the policy and rules attached to the current default `provider=nylas` grant workspace unless `--policy-id` is passed -- `list --all` shows only rules reachable from `provider=nylas` account workspaces -- `list` skips stale workspace 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/agent.go b/internal/adapters/nylas/agent.go index 39f7bb8..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{ @@ -56,6 +56,9 @@ func (c *HTTPClient) CreateAgentAccount(ctx context.Context, email, appPassword, "provider": string(domain.ProviderNylas), "settings": settings, } + if workspaceID != "" { + payload["workspace_id"] = workspaceID + } resp, err := c.doJSONRequest(ctx, "POST", queryURL, payload) if err != nil { diff --git a/internal/adapters/nylas/agent_test.go b/internal/adapters/nylas/agent_test.go index b4b0b25..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.NotContains(t, settings, "policy_id") + assert.Equal(t, "workspace-123", payload["workspace_id"]) response := map[string]any{ "data": map[string]any{ @@ -276,7 +276,7 @@ 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) @@ -491,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", @@ -512,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_agent.go b/internal/adapters/nylas/demo_agent.go index 8debb46..9bc95e8 100644 --- a/internal/adapters/nylas/demo_agent.go +++ b/internal/adapters/nylas/demo_agent.go @@ -28,7 +28,7 @@ func (d *DemoClient) GetAgentAccount(ctx context.Context, grantID string) (*doma }, 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, diff --git a/internal/adapters/nylas/mock_agent.go b/internal/adapters/nylas/mock_agent.go index 762006c..8140eec 100644 --- a/internal/adapters/nylas/mock_agent.go +++ b/internal/adapters/nylas/mock_agent.go @@ -28,7 +28,7 @@ func (m *MockClient) GetAgentAccount(ctx context.Context, grantID string) (*doma }, 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, diff --git a/internal/air/handlers_rules_policy.go b/internal/air/handlers_rules_policy.go index 035a82a..bacda81 100644 --- a/internal/air/handlers_rules_policy.go +++ b/internal/air/handlers_rules_policy.go @@ -41,7 +41,13 @@ func (s *Server) handleListPolicies(w http.ResponseWriter, r *http.Request) { return } - policyID := s.resolveAccountPolicyID(ctx, account) + 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 @@ -89,7 +95,13 @@ func (s *Server) handleListRules(w http.ResponseWriter, r *http.Request) { return } - ruleIDs := s.resolveAccountRuleIDs(ctx, account) + ruleIDs, err := s.resolveAccountRuleIDs(ctx, account) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "Failed to resolve workspace rules: " + err.Error(), + }) + return + } if len(ruleIDs) == 0 { writeJSON(w, http.StatusOK, RulesResponse{Rules: []domain.Rule{}}) return @@ -119,30 +131,36 @@ 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 { +func (s *Server) resolveAccountPolicyID(ctx context.Context, account *domain.AgentAccount) (string, error) { if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { - if ws, err := s.nylasClient.GetWorkspace(ctx, wsID); err == nil && ws != nil { - if pid := strings.TrimSpace(ws.PolicyID); pid != "" { - return pid - } + 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) + return strings.TrimSpace(account.Settings.PolicyID), nil } -func (s *Server) resolveAccountRuleIDs(ctx context.Context, account *domain.AgentAccount) []string { +func (s *Server) resolveAccountRuleIDs(ctx context.Context, account *domain.AgentAccount) ([]string, error) { if wsID := strings.TrimSpace(account.WorkspaceID); wsID != "" { - if ws, err := s.nylasClient.GetWorkspace(ctx, wsID); err == nil && ws != nil { + 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 + return ids, nil } } - return nil + return nil, nil } func demoPolicies() []domain.Policy { diff --git a/internal/cli/agent/agent_scope.go b/internal/cli/agent/agent_scope.go index 889d27d..a9f9b4e 100644 --- a/internal/cli/agent/agent_scope.go +++ b/internal/cli/agent/agent_scope.go @@ -188,48 +188,3 @@ func upsertPoliciesForAgentAccountsWithWorkspaces(ctx context.Context, client in 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 workspace := scope.WorkspacesByID[strings.TrimSpace(account.WorkspaceID)]; workspace != nil { - defaultPolicyID = strings.TrimSpace(workspace.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 workspace first", - ) - } - - policy := findPolicyByID(scope.AgentPolicies, defaultPolicyID) - if policy == nil { - return nil, nil, common.NewUserError( - "default agent workspace policy is not attached to a nylas agent workspace", - "Use 'nylas agent policy list --all' to inspect provider=nylas policies", - ) - } - - return policy, []policyAgentAccountRef{{ - GrantID: account.ID, - Email: account.Email, - WorkspaceID: strings.TrimSpace(account.WorkspaceID), - }}, nil -} diff --git a/internal/cli/agent/agent_test.go b/internal/cli/agent/agent_test.go index 3b0a49b..20a5675 100644 --- a/internal/cli/agent/agent_test.go +++ b/internal/cli/agent/agent_test.go @@ -34,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") } @@ -139,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") } @@ -399,47 +394,6 @@ func TestResolvePolicyForAgentOps(t *testing.T) { } } -func TestBuildRuleRefsByID(t *testing.T) { - refsByRuleID := buildRuleRefsByIDWithRuleIDs( - []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", - }}, - }, - nil, - ) - - 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 captureStdout(t *testing.T, fn func()) string { t.Helper() diff --git a/internal/cli/agent/create.go b/internal/cli/agent/create.go index 368c40f..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 account workspace") 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,24 +57,16 @@ 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) } - if policyID != "" { - account, err = attachPolicyToAgentWorkspace(ctx, client, account, policyID) - if err != nil { - return struct{}{}, err - } - } saveGrantLocally(account.ID, account.Email) @@ -101,8 +94,8 @@ 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 } @@ -117,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) } diff --git a/internal/cli/agent/create_test.go b/internal/cli/agent/create_test.go index 52db299..835d234 100644 --- a/internal/cli/agent/create_test.go +++ b/internal/cli/agent/create_test.go @@ -13,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 } @@ -29,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) { @@ -61,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: @@ -82,7 +82,6 @@ func TestCreateAgentAccountWithFallback_ReturnsRetryError(t *testing.T) { client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.Error(t, err) @@ -111,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 @@ -134,7 +133,6 @@ func TestCreateAgentAccountWithFallback_SkipsCleanupForExistingGrant(t *testing. client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.Error(t, err) @@ -162,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 @@ -185,7 +183,6 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutRetryCreate(t client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.NoError(t, err) @@ -211,7 +208,7 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutCheckingPolic 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 }, @@ -226,7 +223,6 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantWithoutCheckingPolic client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.NoError(t, err) @@ -252,7 +248,7 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantOnDifferentPolicy(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 }, @@ -267,7 +263,6 @@ func TestCreateAgentAccountWithFallback_UpdatesExistingGrantOnDifferentPolicy(t client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.NoError(t, err) @@ -288,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 } @@ -312,7 +307,6 @@ func TestCreateAgentAccountWithFallback_PreservesNewGrantOnUpdateFailure(t *test client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.Error(t, err) @@ -323,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 } @@ -354,7 +348,6 @@ func TestCreateAgentAccountWithFallback_DoesNotInventPolicyID(t *testing.T) { client, "agent@example.com", "ValidAgentPass123ABC!", - "policy-123", ) require.NoError(t, err) @@ -370,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 }, @@ -381,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 6d3ab4a..fa698ae 100644 --- a/internal/cli/agent/helpers.go +++ b/internal/cli/agent/helpers.go @@ -124,27 +124,6 @@ func ensureNylasConnector(ctx context.Context, client ports.NylasClient) (*domai return nil, err } -func attachPolicyToAgentWorkspace(ctx context.Context, client interface { - UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) -}, account *domain.AgentAccount, policyID string) (*domain.AgentAccount, error) { - if account == nil { - return nil, common.NewUserError("agent account missing", "The API did not return an agent account") - } - workspaceID := strings.TrimSpace(account.WorkspaceID) - if workspaceID == "" { - return nil, common.NewUserError( - "agent account has no workspace", - "Create completed, but the API did not return a workspace_id to attach the policy to", - ) - } - - if _, err := client.UpdateWorkspace(ctx, workspaceID, &domain.UpdateWorkspaceRequest{PolicyID: &policyID}); err != nil { - return nil, common.WrapUpdateError("agent workspace", err) - } - - return account, nil -} - func resolveAgentID(ctx context.Context, client ports.NylasClient, identifier string) (string, error) { if !strings.Contains(identifier, "@") { return identifier, nil diff --git a/internal/cli/agent/helpers_test.go b/internal/cli/agent/helpers_test.go index cb639d1..044ba2f 100644 --- a/internal/cli/agent/helpers_test.go +++ b/internal/cli/agent/helpers_test.go @@ -1,7 +1,6 @@ package agent import ( - "context" "path/filepath" "testing" @@ -11,38 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -type workspaceUpdateTestClient struct { - workspaceID string - req *domain.UpdateWorkspaceRequest -} - -func (c *workspaceUpdateTestClient) UpdateWorkspace(ctx context.Context, workspaceID string, req *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) { - c.workspaceID = workspaceID - c.req = req - return &domain.Workspace{ID: workspaceID}, nil -} - -func TestAttachPolicyToAgentWorkspacePatchesWorkspaceWithoutMutatingGrantSettings(t *testing.T) { - client := &workspaceUpdateTestClient{} - account := &domain.AgentAccount{ - ID: "grant-1", - WorkspaceID: "workspace-1", - Settings: domain.AgentAccountSettings{ - PolicyID: "legacy-policy", - }, - } - - updated, err := attachPolicyToAgentWorkspace(context.Background(), client, account, "workspace-policy") - - require.NoError(t, err) - require.Same(t, account, updated) - assert.Equal(t, "workspace-1", client.workspaceID) - require.NotNil(t, client.req) - require.NotNil(t, client.req.PolicyID) - assert.Equal(t, "workspace-policy", *client.req.PolicyID) - assert.Equal(t, "legacy-policy", updated.Settings.PolicyID) -} - func TestGetAgentIdentifier(t *testing.T) { t.Run("uses explicit argument", func(t *testing.T) { setupAgentIdentifierTestEnv(t) diff --git a/internal/cli/agent/list.go b/internal/cli/agent/list.go index 9c87cf4..a29dea0 100644 --- a/internal/cli/agent/list.go +++ b/internal/cli/agent/list.go @@ -51,7 +51,7 @@ func runList(jsonOutput bool) error { _, _ = common.BoldWhite.Printf("Agent Accounts (%d)\n\n", len(accounts)) for i, account := range accounts { info := resolveWorkspacePolicy(ctx, client, account) - printAgentSummary(account, i, info.ID, info.Label) + printAgentSummary(account, i, info.ID, info.Label) } fmt.Println() diff --git a/internal/cli/agent/policy_list_get.go b/internal/cli/agent/policy_list_get.go index ee8c7b8..e291c96 100644 --- a/internal/cli/agent/policy_list_get.go +++ b/internal/cli/agent/policy_list_get.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" "github.com/nylas/cli/internal/ports" "github.com/spf13/cobra" ) @@ -65,13 +66,18 @@ func buildWorkspacePolicyRefs(ctx context.Context, client ports.NylasClient) map } refs := make(map[string][]policyAgentAccountRef) + seenWorkspaces := make(map[string]*domain.Workspace) for _, account := range accounts { workspaceID := strings.TrimSpace(account.WorkspaceID) if workspaceID == "" { continue } - workspace, err := client.GetWorkspace(ctx, workspaceID) - if err != nil || workspace == nil { + workspace, ok := seenWorkspaces[workspaceID] + if !ok { + workspace, _ = client.GetWorkspace(ctx, workspaceID) + seenWorkspaces[workspaceID] = workspace + } + if workspace == nil { continue } policyID := strings.TrimSpace(workspace.PolicyID) diff --git a/internal/cli/agent/rule.go b/internal/cli/agent/rule.go index a00709a..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,34 +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 - SharedOutsideAgent bool -} - func newRuleCmd() *cobra.Command { cmd := &cobra.Command{ Use: "rule", Short: "Manage agent rules", Long: `Manage rules attached to agent account workspaces. -Rules are backed by the /v3/rules API. The agent namespace scopes them through -provider=nylas account workspaces. This surface manages both inbound and -outbound rules attached to those workspaces. +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`, @@ -77,61 +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) - workspaceID := strings.TrimSpace(account.WorkspaceID) - if workspaceID != "" { - workspace, err := client.GetWorkspace(ctx, workspaceID) - if err != nil { - return nil, nil, common.WrapGetError("workspace", err) - } - if workspace == nil { - return nil, nil, common.NewUserError("workspace not found", "The API returned an empty workspace response") - } - defaultPolicyID = strings.TrimSpace(workspace.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 workspace 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, - WorkspaceID: workspaceID, - }}, nil -} - func findPolicyByID(policies []domain.Policy, policyID string) *domain.Policy { for i := range policies { if policies[i].ID == policyID { @@ -141,145 +69,6 @@ func findPolicyByID(policies []domain.Policy, policyID string) *domain.Policy { return nil } -func buildRuleRefsByIDWithRuleIDs(policies []domain.Policy, refsByPolicyID map[string][]policyAgentAccountRef, ruleIDsByPolicy map[string][]string) map[string][]rulePolicyRef { - refsByRuleID := make(map[string][]rulePolicyRef) - for _, policy := range policies { - accounts := refsByPolicyID[policy.ID] - if len(accounts) == 0 { - continue - } - - ruleIDs := policy.Rules - if workspaceRuleIDs, ok := ruleIDsByPolicy[policy.ID]; ok { - ruleIDs = workspaceRuleIDs - } - seen := make(map[string]struct{}, len(ruleIDs)) - for _, ruleID := range ruleIDs { - 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 := buildRuleRefsByIDWithRuleIDs(scope.AgentPolicies, scope.PolicyRefsByID, scope.RuleIDsByPolicy) - 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, - 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 == "" { @@ -344,64 +133,11 @@ func attachRuleToAgentWorkspaces(ctx context.Context, client interface { return nil } -func hasWorkspaceRefs(refs []rulePolicyRef) bool { - for _, ref := range refs { - for _, account := range ref.Accounts { - if strings.TrimSpace(account.WorkspaceID) != "" { - return true - } - } - } - return false -} - -func workspacesLeftEmptyByRuleRemoval(ctx context.Context, client interface { - GetRule(context.Context, string) (*domain.Rule, error) - GetWorkspace(context.Context, string) (*domain.Workspace, error) -}, refs []rulePolicyRef, ruleID string) ([]string, error) { - workspaces, err := loadReferencedWorkspaces(ctx, client, refs) - if err != nil { - return nil, err - } - - blocking := make([]string, 0) - for _, workspace := range workspaces { - if !stringSliceContains(workspace.RulesIDs, ruleID) { - continue - } - - liveRemaining := false - for _, candidate := range removeString(workspace.RulesIDs, 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 - } - } - if !liveRemaining { - blocking = append(blocking, formatWorkspaceRef(workspace)) - } - } - return blocking, nil -} - func detachRuleFromAgentWorkspaces(ctx context.Context, client interface { GetWorkspace(context.Context, string) (*domain.Workspace, error) UpdateWorkspace(context.Context, string, *domain.UpdateWorkspaceRequest) (*domain.Workspace, error) -}, refs []rulePolicyRef, ruleID string) (func(context.Context) error, error) { - workspaces, err := loadReferencedWorkspaces(ctx, client, refs) +}, accounts []policyAgentAccountRef, ruleID string) (func(context.Context) error, error) { + workspaces, err := loadReferencedWorkspaces(ctx, client, accounts) if err != nil { return nil, err } @@ -432,29 +168,27 @@ func detachRuleFromAgentWorkspaces(ctx context.Context, client interface { func loadReferencedWorkspaces(ctx context.Context, client interface { GetWorkspace(context.Context, string) (*domain.Workspace, error) -}, refs []rulePolicyRef) ([]domain.Workspace, error) { +}, accounts []policyAgentAccountRef) ([]domain.Workspace, error) { seenWorkspaceIDs := make(map[string]struct{}) workspaces := make([]domain.Workspace, 0) - for _, ref := range refs { - for _, account := range ref.Accounts { - workspaceID := strings.TrimSpace(account.WorkspaceID) - if workspaceID == "" { - continue - } - if _, seen := seenWorkspaceIDs[workspaceID]; seen { - continue - } - seenWorkspaceIDs[workspaceID] = struct{}{} + 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) + 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 } @@ -485,11 +219,3 @@ func stringSliceContains(items []string, value string) bool { } return false } - -func formatWorkspaceRef(workspace domain.Workspace) string { - name := strings.TrimSpace(workspace.Name) - if name == "" { - return workspace.ID - } - return fmt.Sprintf("%s (%s)", name, workspace.ID) -} diff --git a/internal/cli/agent/rule_create_update_delete.go b/internal/cli/agent/rule_create_update_delete.go index 17e81db..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 agent workspaces. + Long: `Create a new rule and attach it to the default agent workspace. -Rules are created through /v3/rules, then attached to the workspaces using the -selected policy. If --policy-id is omitted, the CLI uses the policy attached to -the current default provider=nylas grant workspace. +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 whose agent workspaces receive the created rule") 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,7 +73,12 @@ func runRuleCreate(payload map[string]any, policyID string, jsonOutput bool) err return struct{}{}, common.WrapCreateError("rule", err) } - if err := attachRuleToAgentWorkspaces(ctx, client, accounts, 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 workspace: %w (cleanup failed: %v)", err, cleanupErr) @@ -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 workspace. Use --policy-id to scope the validation to another -agent workspace policy, or --all to search any agent workspace 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,11 +197,7 @@ 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 ", @@ -228,65 +205,50 @@ func newRuleDeleteCmd() *cobra.Command { 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", - ) - } - if !hasWorkspaceRefs(scope.AllAgentRefs) { - return struct{}{}, common.NewUserError( - "agent account has no workspace", - "The rule's provider=nylas account is missing a workspace; reconnect the account and try again", - ) - } - - blockingWorkspaces, err := workspacesLeftEmptyByRuleRemoval(ctx, client, scope.AllAgentRefs, ruleID) - if err != nil { - return struct{}{}, common.WrapGetError("rule", err) - } - if len(blockingWorkspaces) > 0 { - return struct{}{}, common.NewUserError( - "cannot delete the last rule from an agent workspace", - fmt.Sprintf("Attach another rule to %s before deleting %q", strings.Join(blockingWorkspaces, ", "), scope.Rule.Name), - ) - } - - rollback, err := detachRuleFromAgentWorkspaces(ctx, client, scope.AllAgentRefs, ruleID) - if err != nil { - return struct{}{}, fmt.Errorf("failed to detach rule from agent workspaces: %w", err) + 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) + } } 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 52e5fea..757dd4a 100644 --- a/internal/cli/agent/rule_list_get.go +++ b/internal/cli/agent/rule_list_get.go @@ -12,129 +12,45 @@ import ( ) func newRuleListCmd() *cobra.Command { - var ( - allRules bool - policyID string - ) - cmd := &cobra.Command{ Use: "list", - Short: "List rules for the default agent workspace", - Long: `List rules for the current default agent workspace. + 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 workspace. Use --policy-id to inspect -agent workspaces using a specific policy, or --all to list every rule reachable -from any provider=nylas account workspace. +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 := buildRuleRefsByIDWithRuleIDs(scope.AgentPolicies, scope.PolicyRefsByID, scope.RuleIDsByPolicy) - if len(refsByRuleID) == 0 { - if jsonOutput { - fmt.Println("[]") - return struct{}{}, nil - } - common.PrintEmptyStateWithHint("rules attached to nylas agent workspaces", "Create a rule and attach it to a provider=nylas workspace 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 - } - - sourceRuleIDs, ok := scope.RuleIDsByPolicy[policy.ID] - if !ok { - sourceRuleIDs = policy.Rules - } - ruleIDs := make([]string, 0, len(sourceRuleIDs)) - for _, ruleID := range sourceRuleIDs { - 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 workspaces", "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 := collectPolicyScopedWorkspaceRules(policy, accounts, ruleIDs, allRulesList) if len(rules) == 0 { - if jsonOutput { - fmt.Println("[]") - return struct{}{}, nil - } - common.PrintEmptyStateWithHint("rules on the selected agent workspaces", "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 @@ -143,87 +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) { - return collectPolicyScopedWorkspaceRules(policy, accounts, policy.Rules, allRules) +func buildWorkspaceRuleRefs(ctx context.Context, client ports.NylasClient) map[string][]ruleWorkspaceRef { + refs, _ := buildWorkspaceRuleRefsStrict(ctx, client) + return refs } -func collectPolicyScopedWorkspaceRules(policy *domain.Policy, accounts []policyAgentAccountRef, ruleIDs []string, 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 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) } - accountRefs := append([]policyAgentAccountRef(nil), accounts...) - rules := make([]domain.Rule, 0, len(ruleIDs)) - ruleRefs := make(map[string][]rulePolicyRef, len(ruleIDs)) - - for _, ruleID := range ruleIDs { - 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 workspace. Use --policy-id to scope the lookup to provider=nylas -workspaces using another policy, or --all to search any provider=nylas -workspace 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", @@ -231,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 ecd31db..c51d887 100644 --- a/internal/cli/agent/rule_list_get_test.go +++ b/internal/cli/agent/rule_list_get_test.go @@ -47,103 +47,6 @@ func (c *workspaceRuleTestClient) GetRule(ctx context.Context, ruleID string) (* return rule, nil } -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"}, - } - allRules := []domain.Rule{ - {ID: "rule-1", Name: "First Rule", Enabled: &enabled}, - {ID: "rule-2", Name: "Second Rule", Enabled: &enabled}, - } - - 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 TestCollectPolicyScopedRules_ReturnsEmptyWhenPolicyOnlyHasDanglingReferences(t *testing.T) { - policy := &domain.Policy{ - ID: "policy-1", - Name: "Primary Policy", - Rules: []string{"missing-rule"}, - } - - rules, refs := collectPolicyScopedRules(policy, nil, []domain.Rule{{ID: "rule-1"}}) - - assert.Empty(t, rules) - assert.Empty(t, refs) -} - -func TestBuildRuleRefsByIDWithRuleIDsFallsBackToPolicyRulesWhenWorkspaceRulesAbsent(t *testing.T) { - accounts := []policyAgentAccountRef{{ - GrantID: "grant-1", - Email: "agent@example.com", - }} - policies := []domain.Policy{{ - ID: "policy-1", - Name: "Primary Policy", - Rules: []string{"legacy-rule"}, - }} - - refs := buildRuleRefsByIDWithRuleIDs(policies, map[string][]policyAgentAccountRef{"policy-1": accounts}, map[string][]string{}) - - require.Contains(t, refs, "legacy-rule") - assert.Equal(t, accounts, refs["legacy-rule"][0].Accounts) -} - -func TestBuildRuleRefsByIDWithRuleIDsUsesEmptyWorkspaceRulesWhenPresent(t *testing.T) { - policies := []domain.Policy{{ - ID: "policy-1", - Name: "Primary Policy", - Rules: []string{"legacy-rule"}, - }} - refsByPolicyID := map[string][]policyAgentAccountRef{ - "policy-1": {{GrantID: "grant-1", WorkspaceID: "workspace-1"}}, - } - - refs := buildRuleRefsByIDWithRuleIDs(policies, refsByPolicyID, map[string][]string{"policy-1": {}}) - - assert.Empty(t, refs) -} - -func TestWorkspacesLeftEmptyByRuleRemovalBlocksLastLiveRule(t *testing.T) { - client := &workspaceRuleTestClient{ - workspaces: map[string]*domain.Workspace{ - "workspace-1": {ID: "workspace-1", Name: "Agent Workspace", RulesIDs: []string{"rule-1", "missing-rule"}}, - }, - rules: map[string]*domain.Rule{ - "rule-1": {ID: "rule-1"}, - }, - } - refs := []rulePolicyRef{{ - PolicyID: "policy-1", - Accounts: []policyAgentAccountRef{{ - GrantID: "grant-1", - WorkspaceID: "workspace-1", - }}, - }} - - blocking, err := workspacesLeftEmptyByRuleRemoval(context.Background(), client, refs, "rule-1") - - require.NoError(t, err) - assert.Equal(t, []string{"Agent Workspace (workspace-1)"}, blocking) -} - func TestDetachRuleFromAgentWorkspacesRemovesAndRollsBackWorkspaceRule(t *testing.T) { client := &workspaceRuleTestClient{ workspaces: map[string]*domain.Workspace{ @@ -151,15 +54,12 @@ func TestDetachRuleFromAgentWorkspacesRemovesAndRollsBackWorkspaceRule(t *testin }, updates: make(map[string][]string), } - refs := []rulePolicyRef{{ - PolicyID: "policy-1", - Accounts: []policyAgentAccountRef{{ - GrantID: "grant-1", - WorkspaceID: "workspace-1", - }}, + accounts := []policyAgentAccountRef{{ + GrantID: "grant-1", + WorkspaceID: "workspace-1", }} - rollback, err := detachRuleFromAgentWorkspaces(context.Background(), client, refs, "rule-1") + rollback, err := detachRuleFromAgentWorkspaces(context.Background(), client, accounts, "rule-1") require.NoError(t, err) assert.Equal(t, []string{"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/integration/agent_policy_test.go b/internal/cli/integration/agent_policy_test.go index 64c4a9a..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) } } diff --git a/internal/cli/integration/agent_rule_matrix_test.go b/internal/cli/integration/agent_rule_matrix_test.go index cd47b51..99fa965 100644 --- a/internal/cli/integration/agent_rule_matrix_test.go +++ b/internal/cli/integration/agent_rule_matrix_test.go @@ -57,7 +57,7 @@ func TestCLI_AgentRuleMatrix_CreateAllSupportedConditionsAndActions(t *testing.T 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", @@ -74,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), @@ -86,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", @@ -102,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", @@ -146,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), @@ -166,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), @@ -177,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", @@ -193,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", @@ -209,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", @@ -218,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", @@ -292,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) @@ -313,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_test.go b/internal/cli/integration/agent_rule_test.go index 45dcfec..e560495 100644 --- a/internal/cli/integration/agent_rule_test.go +++ b/internal/cli/integration/agent_rule_test.go @@ -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, @@ -243,237 +235,7 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { 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 != "" { - removeRuleFromWorkspaceForTest(t, client, createdAccount.WorkspaceID, 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 - assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, 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) - } - - assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, 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())) - attachRuleToWorkspaceForTest(t, client, createdAccount.WorkspaceID, createdRule.ID) - attachRuleToPolicyForTest(t, client, detachedPolicy.ID, createdRule.ID) - assertWorkspaceContainsRuleForTest(t, client, createdAccount.WorkspaceID, 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 != "" { - removeRuleFromWorkspaceForTest(t, client, createdAccount.WorkspaceID, 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) { - 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) - cancel() - if err == nil && containsString(policy.Rules, ruleID) { - return - } - - time.Sleep(500 * time.Millisecond) - } - - t.Fatalf("policy %q does not include rule %q", policyID, ruleID) -} - -// Workspace-scoped rule helpers mirror the policy-scoped ones above, but operate -// on workspace.rules_ids — the attachment point the CLI now reads for agent -// accounts. The policy-scoped helpers remain for non-agent policies (e.g. the -// mixed-scope test's detached policy). func assertWorkspaceContainsRuleForTest(t *testing.T, client interface { GetWorkspace(context.Context, string) (*domain.Workspace, error) @@ -576,55 +338,6 @@ func attachRuleToWorkspaceForTest(t *testing.T, client interface { } } -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) { - 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) { - return - } - - updatedRules := make([]string, 0, len(policy.Rules)) - for _, existingRuleID := range policy.Rules { - if existingRuleID == ruleID { - continue - } - updatedRules = append(updatedRules, existingRuleID) - } - - _, _ = client.UpdatePolicy(ctx, policyID, map[string]any{"rules": updatedRules}) -} - -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) { - t.Helper() - - acquireRateLimit(t) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - policy, err := client.GetPolicy(ctx, policyID) - if err != nil { - t.Fatalf("failed to get policy %q: %v", policyID, err) - } - if containsString(policy.Rules, 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) - } -} func createRuleForTest(t *testing.T, client interface { CreateRule(context.Context, map[string]any) (*domain.Rule, error) diff --git a/internal/cli/integration/agent_test.go b/internal/cli/integration/agent_test.go index 47b6fbf..ec763c5 100644 --- a/internal/cli/integration/agent_test.go +++ b/internal/cli/integration/agent_test.go @@ -228,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() @@ -259,32 +259,17 @@ 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 exists, fetched := waitForAgentByID(t, client, account.ID, true) if !exists { t.Fatalf("created agent account %q did not appear via GET-by-id", email) } - // Policy now lives on the account's workspace, not grant settings. assertWorkspacePolicyForTest(t, client, fetched.WorkspaceID, policy.ID) } @@ -352,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) 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 index 4e8494a..0d5398c 100644 --- a/internal/cli/workspace/commands.go +++ b/internal/cli/workspace/commands.go @@ -150,6 +150,13 @@ Examples: 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{} diff --git a/internal/ports/agent.go b/internal/ports/agent.go index 467b749..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 deprecated; policy attachment happens through workspaces. - 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. From 648494797f1eb46a44c7f5d6b376e65f0ca8a295 Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 23:28:36 -0400 Subject: [PATCH 08/10] Fix gofmt formatting in agent rule integration test --- internal/cli/integration/agent_rule_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/cli/integration/agent_rule_test.go b/internal/cli/integration/agent_rule_test.go index e560495..0735eee 100644 --- a/internal/cli/integration/agent_rule_test.go +++ b/internal/cli/integration/agent_rule_test.go @@ -235,8 +235,6 @@ func TestCLI_AgentRuleLifecycle_CreateReadListUpdateDelete(t *testing.T) { createdRule = nil } - - func assertWorkspaceContainsRuleForTest(t *testing.T, client interface { GetWorkspace(context.Context, string) (*domain.Workspace, error) }, workspaceID, ruleID string) { @@ -338,7 +336,6 @@ func attachRuleToWorkspaceForTest(t *testing.T, client interface { } } - func createRuleForTest(t *testing.T, client interface { CreateRule(context.Context, map[string]any) (*domain.Rule, error) }, name string) *domain.Rule { From 1e6a7c4027b6ece79d990b1952b296ddf07c52bb Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 23:30:25 -0400 Subject: [PATCH 09/10] Fix stale docs, error messages, and add workspace unit tests - Update agent-policy.md to remove --all flag and --policy-id on create - Fix resolvePolicyForAgentOps error message referencing removed --all flag - Add workspace package unit tests for command structure and flags --- docs/commands/agent-policy.md | 60 ++++------------------ internal/cli/agent/policy.go | 2 +- internal/cli/workspace/workspace_test.go | 63 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 51 deletions(-) create mode 100644 internal/cli/workspace/workspace_test.go diff --git a/docs/commands/agent-policy.md b/docs/commands/agent-policy.md index aa69788..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 account workspaces 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 workspace -- `nylas agent policy list --all` shows only policies referenced by at least one `provider=nylas` agent workspace -- text output includes the attached agent email, grant ID, and workspace context 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` workspace 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's workspace - -### 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 workspace -- 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 @@ -158,35 +126,27 @@ Safety rule: - delete is rejected if any `provider=nylas` agent workspace 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 - -## Relationship to Agent Accounts +## Relationship to Workspaces -Policies are attached through the agent account workspace. Account creation can patch that workspace immediately: +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 grant settings such as `--app-password`, not workspace policy assignment. 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 has a workspace with `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/internal/cli/agent/policy.go b/internal/cli/agent/policy.go index b984333..863f2be 100644 --- a/internal/cli/agent/policy.go +++ b/internal/cli/agent/policy.go @@ -103,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/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) +} From 21f22a76161e83b1c476287f2fe96ab8882bc8aa Mon Sep 17 00:00:00 2001 From: Qasim Date: Thu, 28 May 2026 23:32:30 -0400 Subject: [PATCH 10/10] Fix AutoGroup in CreateWorkspaceRequest to use *bool for omitempty correctness --- internal/cli/workspace/commands.go | 10 ++++++---- internal/domain/workspace.go | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/cli/workspace/commands.go b/internal/cli/workspace/commands.go index 0d5398c..0fa1ff1 100644 --- a/internal/cli/workspace/commands.go +++ b/internal/cli/workspace/commands.go @@ -102,10 +102,12 @@ Examples: 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, - AutoGroup: autoGroup, - PolicyID: policyID, + Name: name, + Domain: wsDomain, + PolicyID: policyID, + } + if cmd.Flags().Changed("auto-group") { + req.AutoGroup = &autoGroup } workspace, err := client.CreateWorkspace(ctx, req) diff --git a/internal/domain/workspace.go b/internal/domain/workspace.go index 4eaacb1..84817f3 100644 --- a/internal/domain/workspace.go +++ b/internal/domain/workspace.go @@ -18,7 +18,7 @@ type Workspace struct { type CreateWorkspaceRequest struct { Name string `json:"name"` Domain string `json:"domain,omitempty"` - AutoGroup bool `json:"auto_group,omitempty"` + AutoGroup *bool `json:"auto_group,omitempty"` PolicyID string `json:"policy_id,omitempty"` RulesIDs []string `json:"rules_ids,omitempty"` }