From 0843a40aa9bdb2b681d8f55f7f7c9f3a6db0874f Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Tue, 21 Jul 2026 14:22:40 -0700 Subject: [PATCH] feat: add the policy command family (/v1/policy + /v1/org/policy) Adds `onecli policy` and `onecli org policy`, the staged draft-to-publish policy engine surface (10 operations per scope): rules list/get/create/update/delete/reorder, default get/set, publish, and status (a client-composed staged diff via a faithful Go port of the server's policy-diff library). Writes auto-publish only when the scope's draft has no other staged changes; otherwise the publish is withheld (`publishSkipped` + `pendingChanges` in the output) so a write never ships someone else's half-finished edits. `--publish-all` overrides the guard; `--no-publish` always stages. `--json` PATCH payloads are sent verbatim as raw JSON so explicit nulls (the only way to clear rateLimit/conditions server-side) reach the wire. The legacy `rules` / `org rules` commands keep working against self-hosted servers that predate the policy engine and now point at the policy family in their help text; cloud deployments reject legacy writes with 410 Gone. New GONE and VALIDATION_ERROR error codes map 410/422 responses to actionable output. Co-Authored-By: Claude Opus 4.8 --- README.md | 31 +- cmd/onecli/help.go | 80 ++- cmd/onecli/main.go | 38 +- cmd/onecli/org.go | 3 +- cmd/onecli/org_policy.go | 396 +++++++++++++++ cmd/onecli/policy.go | 829 ++++++++++++++++++++++++++++++++ cmd/onecli/policy_test.go | 309 ++++++++++++ internal/api/policy.go | 371 ++++++++++++++ internal/api/policy_test.go | 243 ++++++++++ internal/api/policydiff.go | 237 +++++++++ internal/api/policydiff_test.go | 155 ++++++ pkg/exitcode/exitcode.go | 5 + skills/onecli/SKILL.md | 31 ++ 13 files changed, 2714 insertions(+), 14 deletions(-) create mode 100644 cmd/onecli/org_policy.go create mode 100644 cmd/onecli/policy.go create mode 100644 cmd/onecli/policy_test.go create mode 100644 internal/api/policy.go create mode 100644 internal/api/policy_test.go create mode 100644 internal/api/policydiff.go create mode 100644 internal/api/policydiff_test.go diff --git a/README.md b/README.md index 39a2d91..bb4dda7 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,10 @@ onecli secrets update --id X --value Y Update a secret onecli secrets delete --id X Delete a secret ``` -### Rules +### Rules (legacy model) + +Cloud deployments reject these writes (410) — use the `policy` family below. +Pre-cutover self-hosted servers still accept them. ``` onecli rules list List all policy rules @@ -58,14 +61,36 @@ onecli rules update --id X [--action block] ... Update a rule onecli rules delete --id X Delete a rule ``` +### Policy (the policy engine) + +Rules stage into a DRAFT and enforce on publish. Writes auto-publish when the +draft has no other staged changes (`--no-publish` stages; `--publish-all` +publishes everything). + +``` +onecli policy rules list [--status published] List rules (draft or the enforced set) +onecli policy rules get --id X Get a DRAFT rule +onecli policy rules create --name X --action allow \ + --targets '[{"kind":"network","hostPattern":"api.example.com"}]' +onecli policy rules update --id X [--action block] Update a DRAFT rule +onecli policy rules delete --id X Delete a DRAFT rule +onecli policy rules reorder --ordered-ids '[...]' Reorder (every draft id exactly once) +onecli policy default get Show the terminal Default Rule +onecli policy default set --action allow|block Set the Default Rule's action +onecli policy publish Publish the whole staged draft +onecli policy status Staged diff + last publish +``` + ### Organization Organization-level resources are shared by every project in the org. Authenticate with an organization API key (`oc_org_...`); project selection is not required. ``` onecli org secrets list|create|update|delete Manage org-level secrets -onecli org rules list|get|create|update|delete Manage org-level rules -onecli org rules permissions get|set --provider X Layered app permissions +onecli org rules list|get|create|update|delete Manage org-level rules (legacy; cloud rejects writes) +onecli org rules permissions get|set --provider X Layered app permissions (legacy; cloud rejects writes) +onecli org policy rules list|get|create|update|delete|reorder Org policy-engine rules (draft → publish) +onecli org policy default get|set / publish / status Org Default Rule + publish + staged diff onecli org connections list [--provider X] List org connections onecli org connections rename --id X --label Y Rename an org connection onecli org connections delete --id X Delete an org connection diff --git a/cmd/onecli/help.go b/cmd/onecli/help.go index 0e1d9f3..11d3ea0 100644 --- a/cmd/onecli/help.go +++ b/cmd/onecli/help.go @@ -164,24 +164,24 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "rules list", Description: "List all policy rules.", Args: []ArgInfo{ {Name: "--project, -p", Description: "Project slug."}, }}, - {Name: "rules create", Description: "Create a new policy rule.", Args: []ArgInfo{ + {Name: "rules create", Description: "Create a legacy rule (cloud deployments reject with 410 — use 'policy rules create').", Args: []ArgInfo{ {Name: "--project, -p", Description: "Project slug."}, {Name: "--name", Required: true, Description: "Display name for the rule."}, {Name: "--host-pattern", Required: true, Description: "Host pattern to match."}, {Name: "--action", Required: true, Description: "Action: 'block', 'rate_limit', 'manual_approval', or 'allow'."}, {Name: "--conditions", Description: "Content conditions as a JSON array."}, }}, - {Name: "rules update", Description: "Update an existing policy rule.", Args: []ArgInfo{ + {Name: "rules update", Description: "Update a legacy rule (cloud deployments reject with 410 — use 'policy rules update').", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to update."}, }}, - {Name: "rules delete", Description: "Delete a policy rule.", Args: []ArgInfo{ + {Name: "rules delete", Description: "Delete a legacy rule (cloud deployments reject with 410 — use 'policy rules delete').", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to delete."}, }}, {Name: "rules permissions get", Description: "Get layered tool permissions for a provider.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, {Name: "--agent-id", Description: "Show only this agent's override layer."}, }}, - {Name: "rules permissions set", Description: "Set tool permissions for a provider (optionally per agent).", Args: []ArgInfo{ + {Name: "rules permissions set", Description: "Set tool permissions (legacy — cloud deployments reject with 410; use 'policy' rules with app targets).", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, {Name: "--tool", Description: "Tool ID (see 'apps permission-definition')."}, {Name: "--permission", Description: "Permission: 'allow', 'manual_approval', 'block', or 'inherit' (agent layer only)."}, @@ -191,6 +191,41 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "rules overlap", Description: "Count custom rules overlapping an app's hosts.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name."}, }}, + {Name: "policy rules list", Description: "List policy-engine rules (draft or the enforced published set).", Args: []ArgInfo{ + {Name: "--project, -p", Description: "Project slug."}, + {Name: "--status", Description: "'draft' (default) or 'published' (enforced)."}, + }}, + {Name: "policy rules get", Description: "Get one DRAFT policy rule by id.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "Draft rule id (published ids regenerate every publish — match by logicalId)."}, + }}, + {Name: "policy rules create", Description: "Create a policy rule (auto-publishes when the draft is otherwise clean).", Args: []ArgInfo{ + {Name: "--name", Description: "Display name (required unless --json)."}, + {Name: "--action", Description: "'allow' or 'block' (required unless --json)."}, + {Name: "--targets", Description: "JSON array of targets: app/connection/secret/network (required unless --json)."}, + {Name: "--identities", Description: "JSON array of identities; omit for all agents."}, + {Name: "--rate-limit", Description: "Max requests per window (allow rules; pair with --rate-limit-window)."}, + {Name: "--require-approval", Description: "Require manual approval (allow rules)."}, + {Name: "--json", Description: "Raw JSON payload for the full rule (do not combine with field flags)."}, + {Name: "--no-publish", Description: "Stage only."}, + {Name: "--publish-all", Description: "Publish even when the draft holds other staged changes."}, + }}, + {Name: "policy rules update", Description: "Update a DRAFT policy rule (same publish flags as create).", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "Draft rule id."}, + }}, + {Name: "policy rules delete", Description: "Delete a DRAFT policy rule (same publish flags).", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "Draft rule id."}, + }}, + {Name: "policy rules reorder", Description: "Reorder the draft — the id list must name EVERY non-default draft rule.", Args: []ArgInfo{ + {Name: "--ordered-ids", Required: true, Description: "JSON array of all draft rule ids (from 'policy rules list --quiet id')."}, + }}, + {Name: "policy default get", Description: "Show the terminal Default Rule.", Args: []ArgInfo{ + {Name: "--status", Description: "'draft' (default) or 'published'."}, + }}, + {Name: "policy default set", Description: "Set the Default Rule's action.", Args: []ArgInfo{ + {Name: "--action", Required: true, Description: "'allow' or 'block'."}, + }}, + {Name: "policy publish", Description: "Publish the WHOLE staged draft (all staged changes, yours and others')."}, + {Name: "policy status", Description: "Show staged changes (the diff) and the last publish."}, {Name: "projects list", Description: "List all projects."}, {Name: "projects get", Description: "Get a single project by ID.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the project to retrieve."}, @@ -222,27 +257,56 @@ func (cmd *HelpCmd) Run(out *output.Writer) error { {Name: "org rules get", Description: "Get a single org-scoped policy rule.", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to retrieve."}, }}, - {Name: "org rules create", Description: "Create a new org-scoped policy rule.", Args: []ArgInfo{ + {Name: "org rules create", Description: "Create a legacy org rule (cloud deployments reject with 410 — use 'org policy rules create').", Args: []ArgInfo{ {Name: "--name", Required: true, Description: "Display name for the rule."}, {Name: "--host-pattern", Required: true, Description: "Host pattern to match."}, {Name: "--action", Required: true, Description: "Action: 'block', 'rate_limit', 'manual_approval', or 'allow'."}, }}, - {Name: "org rules update", Description: "Update an org-scoped policy rule.", Args: []ArgInfo{ + {Name: "org rules update", Description: "Update a legacy org rule (cloud deployments reject with 410 — use 'org policy rules update').", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to update."}, }}, - {Name: "org rules delete", Description: "Delete an org-scoped policy rule.", Args: []ArgInfo{ + {Name: "org rules delete", Description: "Delete a legacy org rule (cloud deployments reject with 410 — use 'org policy rules delete').", Args: []ArgInfo{ {Name: "--id", Required: true, Description: "ID of the rule to delete."}, }}, {Name: "org rules permissions get", Description: "Get tool permissions for a provider.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, }}, - {Name: "org rules permissions set", Description: "Set tool permissions for a provider.", Args: []ArgInfo{ + {Name: "org rules permissions set", Description: "Set org tool permissions (legacy — cloud deployments reject with 410; use 'org policy' rules with app targets).", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name (e.g. 'github', 'gmail')."}, {Name: "--json", Required: true, Description: "JSON payload with 'changes' array."}, }}, {Name: "org rules overlap", Description: "Count custom org rules overlapping an app's hosts.", Args: []ArgInfo{ {Name: "--provider", Required: true, Description: "Provider name."}, }}, + {Name: "org policy rules list", Description: "List org policy-engine rules (draft or published).", Args: []ArgInfo{ + {Name: "--status", Description: "'draft' (default) or 'published' (enforced)."}, + }}, + {Name: "org policy rules get", Description: "Get one DRAFT org policy rule by id.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "Draft rule id (published ids regenerate every publish — match by logicalId)."}, + }}, + {Name: "org policy rules create", Description: "Create an org policy rule (group/user identities; auto-publishes when the draft is clean).", Args: []ArgInfo{ + {Name: "--name", Description: "Display name (required unless --json)."}, + {Name: "--action", Description: "'allow' or 'block' (required unless --json)."}, + {Name: "--targets", Description: "JSON array of targets (required unless --json)."}, + {Name: "--identities", Description: "JSON array — org rules take agentGroup/user/group identities."}, + {Name: "--no-publish", Description: "Stage only."}, + {Name: "--publish-all", Description: "Publish even when the draft holds other staged changes."}, + }}, + {Name: "org policy rules update", Description: "Update a DRAFT org policy rule.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "Draft rule id."}, + }}, + {Name: "org policy rules delete", Description: "Delete a DRAFT org policy rule.", Args: []ArgInfo{ + {Name: "--id", Required: true, Description: "Draft rule id."}, + }}, + {Name: "org policy rules reorder", Description: "Reorder the org draft (full id permutation).", Args: []ArgInfo{ + {Name: "--ordered-ids", Required: true, Description: "JSON array of all org draft rule ids."}, + }}, + {Name: "org policy default get", Description: "Show the org's terminal Default Rule."}, + {Name: "org policy default set", Description: "Set the org Default Rule's action.", Args: []ArgInfo{ + {Name: "--action", Required: true, Description: "'allow' or 'block'."}, + }}, + {Name: "org policy publish", Description: "Publish the org's WHOLE staged draft."}, + {Name: "org policy status", Description: "Show the org's staged changes and last publish."}, {Name: "org connections list", Description: "List all org-scoped connections.", Args: []ArgInfo{ {Name: "--provider", Description: "Filter by provider name."}, }}, diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index 36a3f67..718beff 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -27,9 +27,10 @@ type CLI struct { Agents AgentsCmd `cmd:"" help:"Manage agents."` Secrets SecretsCmd `cmd:"" help:"Manage secrets."` Apps AppsCmd `cmd:"" help:"Manage app connections."` - Rules RulesCmd `cmd:"" help:"Manage policy rules."` + Rules RulesCmd `cmd:"" help:"Manage legacy policy rules (cloud deployments reject writes — see 'onecli policy')."` + Policy PolicyCmd `cmd:"" help:"Manage policy rules on the policy engine (draft → publish)."` Projects ProjectsCmd `cmd:"" help:"Manage projects."` - Org OrgCmd `cmd:"" help:"Organization-scoped management (secrets, rules, connections, apps, settings)."` + Org OrgCmd `cmd:"" help:"Organization-scoped management (secrets, rules, policy, connections, apps, settings)."` Vaults VaultsCmd `cmd:"" help:"List external vault connections."` Counts CountsCmd `cmd:"" help:"Show the project's resource counts."` Auth AuthCmd `cmd:"" help:"Manage authentication."` @@ -83,6 +84,17 @@ func main() { func handleError(out *output.Writer, err error) { var apiErr *api.APIError if errors.As(err, &apiErr) { + // A 400/401 demanding a project header is a scoping problem, not an + // auth one — "onecli auth login" would be misleading advice. + if (apiErr.StatusCode == 400 || apiErr.StatusCode == 401) && + strings.Contains(apiErr.Message, "X-Project-Id") { + _ = out.ErrorWithAction( + exitcode.CodeError, + apiErr.Message, + "pass --project or run 'onecli config set project '", + ) + os.Exit(exitcode.Error) + } switch apiErr.StatusCode { case 401: _ = out.ErrorWithAction(exitcode.CodeAuthRequired, apiErr.Message, "onecli auth login") @@ -96,6 +108,17 @@ func handleError(out *output.Writer, err error) { case 409: _ = out.Error(exitcode.CodeConflict, apiErr.Message) os.Exit(exitcode.Conflict) + case 410: + // A retired endpoint: the server message names the replacement. + _ = out.ErrorWithAction( + exitcode.CodeGone, + apiErr.Message, + "onecli policy --help (org rules: 'onecli org policy --help')", + ) + os.Exit(exitcode.Error) + case 422: + _ = out.Error(exitcode.CodeValidation, apiErr.Message) + os.Exit(exitcode.Error) } } @@ -103,6 +126,17 @@ func handleError(out *output.Writer, err error) { os.Exit(exitcode.Error) } +// loadStoredAPIKey returns the resolved API key (env or credential file), +// or "" — used for fail-fast key-shape checks; the client loads it itself. +func loadStoredAPIKey() string { + credDir, err := config.CredentialsDir() + if err != nil { + return "" + } + key, _ := auth.NewStore(nil, credDir).Load() + return key +} + // newClient creates an API client using the resolved API key and host. // If no API key is stored, the client is created without one — the server // decides whether authentication is required (local mode doesn't need it). diff --git a/cmd/onecli/org.go b/cmd/onecli/org.go index 21b996b..e65c441 100644 --- a/cmd/onecli/org.go +++ b/cmd/onecli/org.go @@ -3,7 +3,8 @@ package main // OrgCmd is the `onecli org` command group for organization-scoped operations. type OrgCmd struct { Secrets OrgSecretsCmd `cmd:"" help:"Manage org-scoped secrets."` - Rules OrgRulesCmd `cmd:"" help:"Manage org-scoped policy rules."` + Rules OrgRulesCmd `cmd:"" help:"Manage legacy org policy rules (cloud deployments reject writes — see 'onecli org policy')."` + Policy OrgPolicyCmd `cmd:"" help:"Manage org policy rules on the policy engine (draft → publish)."` Connections OrgConnectionsCmd `cmd:"" help:"Manage org-scoped connections."` Apps OrgAppsCmd `cmd:"" help:"Manage org-scoped app configuration."` Settings OrgSettingsCmd `cmd:"" help:"Manage organization settings (policy mode)."` diff --git a/cmd/onecli/org_policy.go b/cmd/onecli/org_policy.go new file mode 100644 index 0000000..4c44f1f --- /dev/null +++ b/cmd/onecli/org_policy.go @@ -0,0 +1,396 @@ +package main + +import ( + "encoding/json" + "fmt" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// `onecli org policy` — the org-scoped mirror of the policy family. Requires +// an organization API key with the admin role; org rules take agent-group / +// user / user-group identities (never a specific agent — the server rejects +// those). Available on OneCLI Cloud and Enterprise self-hosted releases. + +// OrgPolicyCmd is `onecli org policy`. +type OrgPolicyCmd struct { + Rules OrgPolicyRulesCmd `cmd:"" help:"Manage the organization's policy rules (draft → publish)."` + Default OrgPolicyDefaultCmd `cmd:"" help:"Show or set the organization's terminal Default Rule."` + Publish OrgPolicyPublishCmd `cmd:"" help:"Publish the organization's staged draft (all staged changes)."` + Status OrgPolicyStatusCmd `cmd:"" help:"Show staged changes and the last publish."` +} + +// OrgPolicyRulesCmd groups the org rule subcommands. +type OrgPolicyRulesCmd struct { + List OrgPolicyRulesListCmd `cmd:"" help:"List org policy rules (draft by default; --status published = the enforced set)."` + Get OrgPolicyRulesGetCmd `cmd:"" help:"Get one DRAFT org rule by id."` + Create OrgPolicyRulesCreateCmd `cmd:"" help:"Create an org policy rule."` + Update OrgPolicyRulesUpdateCmd `cmd:"" help:"Update a DRAFT org policy rule."` + Delete OrgPolicyRulesDeleteCmd `cmd:"" help:"Delete a DRAFT org policy rule."` + Reorder OrgPolicyRulesReorderCmd `cmd:"" help:"Reorder the org draft (full id permutation)."` +} + +func orgScopeOps(client *api.Client) policyScopeOps { + return policyScopeOps{ + diffCount: func() (int, error) { + return stagedPolicyDiffCount(client, "", true) + }, + publish: func() (*api.PolicyPublishResult, error) { + return client.PublishOrgPolicy(newContext()) + }, + statusCmd: "onecli org policy status", + publishCmd: "onecli org policy publish", + publishFlag: "--publish-all", + } +} + +// OrgPolicyRulesListCmd is `onecli org policy rules list`. +type OrgPolicyRulesListCmd struct { + Status string `optional:"" default:"draft" help:"Rule set: 'draft' (working copy) or 'published' (enforced)."` + Fields string `optional:"" help:"Comma-separated fields to include."` + Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line (e.g. 'id')."` + Max int `optional:"" default:"0" help:"Max rules to output (0 = all — reorder needs the complete list)."` +} + +func (c *OrgPolicyRulesListCmd) Run(out *output.Writer) error { + if c.Status != "draft" && c.Status != "published" { + return fmt.Errorf("invalid --status %q: must be 'draft' or 'published'", c.Status) + } + client, err := newClient() + if err != nil { + return err + } + rules, err := client.ListOrgPolicyRules(newContext(), c.Status) + if err != nil { + return err + } + if c.Max > 0 && len(rules) > c.Max { + rules = rules[:c.Max] + } + if c.Quiet != "" { + return out.WriteQuiet(rules, c.Quiet) + } + return out.WriteFiltered(rules, c.Fields) +} + +// OrgPolicyRulesGetCmd is `onecli org policy rules get`. +type OrgPolicyRulesGetCmd struct { + ID string `required:"" help:"DRAFT rule id (published ids regenerate every publish — match by logicalId)."` + Fields string `optional:"" help:"Comma-separated fields to include."` +} + +func (c *OrgPolicyRulesGetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule id: %w", err) + } + client, err := newClient() + if err != nil { + return err + } + rule, err := client.GetOrgPolicyRule(newContext(), c.ID) + if err != nil { + return err + } + return out.WriteFiltered(rule, c.Fields) +} + +// OrgPolicyRulesCreateCmd is `onecli org policy rules create`. +type OrgPolicyRulesCreateCmd struct { + Name string `optional:"" help:"Display name (required unless --json)."` + Action string `optional:"" help:"'allow' or 'block' (required unless --json)."` + Targets string `optional:"" help:"JSON array of targets (required unless --json)."` + Identities string `optional:"" help:"JSON array of identities — org rules take agentGroup/user/group kinds, e.g. '[{\"type\":\"group\",\"id\":\"\"}]'."` + Description string `optional:"" help:"Rule description."` + Enabled *bool `optional:"" help:"Whether the rule is enabled (default true)."` + RateLimit *int `optional:"" name:"rate-limit" help:"Max requests per window (allow rules only)."` + RateLimitWindow string `optional:"" name:"rate-limit-window" help:"'minute', 'hour', or 'day'."` + RequireApproval *bool `optional:"" name:"require-approval" help:"Require manual approval (allow rules only)."` + Conditions string `optional:"" help:"JSON conditions array or session-policy object."` + Json string `optional:"" help:"Raw JSON payload. Do not combine with field flags."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgPolicyRulesCreateCmd) Run(out *output.Writer) error { + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + input, err := buildCreatePolicyInput( + c.Json, c.Name, c.Action, c.Description, c.Targets, c.Identities, + c.Conditions, c.Enabled, c.RateLimit, c.RateLimitWindow, c.RequireApproval, + ) + if err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would create org policy rule (draft; publish per --no-publish/--publish-all)", input) + } + client, err := newClient() + if err != nil { + return err + } + ops := orgScopeOps(client) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rule, err := client.CreateOrgPolicyRule(newContext(), input) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rule: rule}, plan, pending, ops) +} + +// OrgPolicyRulesUpdateCmd is `onecli org policy rules update`. +type OrgPolicyRulesUpdateCmd struct { + ID string `required:"" help:"DRAFT rule id."` + Name string `optional:"" help:"New display name."` + Action string `optional:"" help:"'allow' or 'block'."` + Targets string `optional:"" help:"JSON array replacing the rule's targets."` + Identities string `optional:"" help:"JSON array replacing the rule's identities ('[]' = everyone)."` + Description string `optional:"" help:"New description."` + Enabled *bool `optional:"" help:"Enable or disable the rule."` + RateLimit *int `optional:"" name:"rate-limit" help:"New rate limit (pair with --rate-limit-window)."` + RateLimitWindow string `optional:"" name:"rate-limit-window" help:"'minute', 'hour', or 'day'."` + RequireApproval *bool `optional:"" name:"require-approval" help:"Require manual approval."` + Conditions string `optional:"" help:"JSON conditions; 'null' clears."` + Json string `optional:"" help:"Raw JSON PATCH payload, sent verbatim (an explicit JSON null clears a nullable field, e.g. '{\"rateLimit\":null}'). Do not combine with field flags."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgPolicyRulesUpdateCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule id: %w", err) + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + proxy := PolicyRulesUpdateCmd{ + ID: c.ID, Name: c.Name, Action: c.Action, Targets: c.Targets, + Identities: c.Identities, Description: c.Description, Enabled: c.Enabled, + RateLimit: c.RateLimit, RateLimitWindow: c.RateLimitWindow, + RequireApproval: c.RequireApproval, Conditions: c.Conditions, Json: c.Json, + } + input, err := proxy.buildInput() + if err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would update org policy rule "+c.ID, input) + } + client, err := newClient() + if err != nil { + return err + } + ops := orgScopeOps(client) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rule, err := client.UpdateOrgPolicyRule(newContext(), c.ID, input) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rule: rule}, plan, pending, ops) +} + +// OrgPolicyRulesDeleteCmd is `onecli org policy rules delete`. +type OrgPolicyRulesDeleteCmd struct { + ID string `required:"" help:"DRAFT rule id."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgPolicyRulesDeleteCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule id: %w", err) + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would delete org policy rule "+c.ID, map[string]string{"id": c.ID}) + } + client, err := newClient() + if err != nil { + return err + } + ops := orgScopeOps(client) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + if err := client.DeleteOrgPolicyRule(newContext(), c.ID); err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{DeletedID: c.ID}, plan, pending, ops) +} + +// OrgPolicyRulesReorderCmd is `onecli org policy rules reorder`. +type OrgPolicyRulesReorderCmd struct { + OrderedIds string `required:"" name:"ordered-ids" help:"JSON array of EVERY non-default draft rule id exactly once (get the full list from 'org policy rules list --quiet id'). The server rejects a partial list."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgPolicyRulesReorderCmd) Run(out *output.Writer) error { + var ids []string + if err := json.Unmarshal([]byte(c.OrderedIds), &ids); err != nil { + return fmt.Errorf("invalid --ordered-ids JSON (expected an array of rule ids): %w", err) + } + if len(ids) == 0 { + return fmt.Errorf("--ordered-ids must name every non-default draft rule") + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would reorder org policy rules", map[string]any{"orderedIds": ids}) + } + client, err := newClient() + if err != nil { + return err + } + ops := orgScopeOps(client) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rules, err := client.ReorderOrgPolicyRules(newContext(), ids) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rules: rules}, plan, pending, ops) +} + +// OrgPolicyDefaultCmd groups the org Default Rule subcommands. +type OrgPolicyDefaultCmd struct { + Get OrgPolicyDefaultGetCmd `cmd:"" help:"Show the organization's terminal Default Rule."` + Set OrgPolicyDefaultSetCmd `cmd:"" help:"Set the org Default Rule's action."` +} + +// OrgPolicyDefaultGetCmd is `onecli org policy default get`. +type OrgPolicyDefaultGetCmd struct { + Status string `optional:"" default:"draft" help:"'draft' or 'published'."` + Fields string `optional:"" help:"Comma-separated fields to include."` +} + +func (c *OrgPolicyDefaultGetCmd) Run(out *output.Writer) error { + if c.Status != "draft" && c.Status != "published" { + return fmt.Errorf("invalid --status %q: must be 'draft' or 'published'", c.Status) + } + client, err := newClient() + if err != nil { + return err + } + rule, err := client.GetOrgPolicyDefault(newContext(), c.Status) + if err != nil { + return err + } + return out.WriteFiltered(rule, c.Fields) +} + +// OrgPolicyDefaultSetCmd is `onecli org policy default set`. +type OrgPolicyDefaultSetCmd struct { + Action string `required:"" help:"'allow' or 'block'."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgPolicyDefaultSetCmd) Run(out *output.Writer) error { + if c.Action != "allow" && c.Action != "block" { + return fmt.Errorf("invalid action %q: must be 'allow' or 'block'", c.Action) + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would set the org Default Rule action", map[string]string{"action": c.Action}) + } + client, err := newClient() + if err != nil { + return err + } + ops := orgScopeOps(client) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rule, err := client.SetOrgPolicyDefault(newContext(), c.Action) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rule: rule}, plan, pending, ops) +} + +// OrgPolicyPublishCmd is `onecli org policy publish`. +type OrgPolicyPublishCmd struct { + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *OrgPolicyPublishCmd) Run(out *output.Writer) error { + if c.DryRun { + return out.WriteDryRun("Would publish the organization's whole policy draft", struct{}{}) + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.PublishOrgPolicy(newContext()) + if err != nil { + return err + } + return out.Write(result) +} + +// OrgPolicyStatusCmd is `onecli org policy status`. +type OrgPolicyStatusCmd struct { + Fields string `optional:"" help:"Comma-separated fields to include."` +} + +func (c *OrgPolicyStatusCmd) Run(out *output.Writer) error { + client, err := newClient() + if err != nil { + return err + } + ctx := newContext() + draft, err := client.ListOrgPolicyRules(ctx, "draft") + if err != nil { + return err + } + published, err := client.ListOrgPolicyRules(ctx, "published") + if err != nil { + return err + } + dDef, err := client.GetOrgPolicyDefault(ctx, "draft") + if err != nil { + return err + } + pDef, err := client.GetOrgPolicyDefault(ctx, "published") + if err != nil { + return err + } + last, err := client.GetOrgPolicyLastPublish(ctx) + if err != nil { + return err + } + diff := api.DiffPolicyChanges(draft, published, dDef, pDef) + return out.WriteFiltered(policyStatus{ + LastPublish: last, + PendingChanges: diff.Count, + Diff: diff, + }, c.Fields) +} diff --git a/cmd/onecli/policy.go b/cmd/onecli/policy.go new file mode 100644 index 0000000..04d8cb0 --- /dev/null +++ b/cmd/onecli/policy.go @@ -0,0 +1,829 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" + "github.com/onecli/onecli-cli/pkg/validate" +) + +// The /v1/policy command family — the first-match policy engine's staged +// model (rules land in a DRAFT; a publish snapshots the whole scope draft +// into the enforced generation). Writes AUTO-PUBLISH when the scope has no +// other staged changes; pre-existing staged changes withhold the publish +// (--publish-all overrides, --no-publish always stages). The legacy `rules` +// commands keep working against pre-cutover self-hosted servers; cloud +// deployments accept only this family for writes. + +// PolicyCmd is `onecli policy` (project scope). +type PolicyCmd struct { + Rules PolicyRulesCmd `cmd:"" help:"Manage the project's policy rules (draft → publish)."` + Default PolicyDefaultCmd `cmd:"" help:"Show or set the project's terminal Default Rule."` + Publish PolicyPublishCmd `cmd:"" help:"Publish the project's staged draft (all staged changes)."` + Status PolicyStatusCmd `cmd:"" help:"Show staged changes and the last publish."` +} + +// PolicyRulesCmd groups the rule subcommands. +type PolicyRulesCmd struct { + List PolicyRulesListCmd `cmd:"" help:"List policy rules (draft by default; --status published = the enforced set)."` + Get PolicyRulesGetCmd `cmd:"" help:"Get one DRAFT rule by id."` + Create PolicyRulesCreateCmd `cmd:"" help:"Create a policy rule."` + Update PolicyRulesUpdateCmd `cmd:"" help:"Update a DRAFT policy rule."` + Delete PolicyRulesDeleteCmd `cmd:"" help:"Delete a DRAFT policy rule."` + Reorder PolicyRulesReorderCmd `cmd:"" help:"Reorder the draft (full id permutation)."` +} + +// ── Shared write/publish plumbing (used by the org variants too) ──────────── + +// policyWriteOutcome is the JSON envelope every policy write emits: the +// written entity plus the publish outcome. +type policyWriteOutcome struct { + Rule *api.PolicyRule `json:"rule,omitempty"` + Rules []api.PolicyRule `json:"rules,omitempty"` + DeletedID string `json:"deletedId,omitempty"` + Published bool `json:"published"` + Generation *int `json:"generation,omitempty"` + PublishSkipped string `json:"publishSkipped,omitempty"` + PendingChanges *int `json:"pendingChanges,omitempty"` +} + +// policyPublishPlan carries the publish flags shared by every write command. +type policyPublishPlan struct { + NoPublish bool + PublishAll bool +} + +// validate rejects the contradictory flag pair loudly (agents hallucinate +// flag combinations; silence would hide which one won). +func (p policyPublishPlan) validate() error { + if p.NoPublish && p.PublishAll { + return fmt.Errorf("--no-publish and --publish-all are contradictory — pass at most one") + } + return nil +} + +// policyScopeOps abstracts project vs org so the guard/publish flow is +// written once. +type policyScopeOps struct { + diffCount func() (int, error) + publish func() (*api.PolicyPublishResult, error) + statusCmd string + publishCmd string + publishFlag string +} + +func projectScopeOps(client *api.Client, project string) policyScopeOps { + return policyScopeOps{ + diffCount: func() (int, error) { + return stagedPolicyDiffCount(client, project, false) + }, + publish: func() (*api.PolicyPublishResult, error) { + return client.PublishPolicy(newContext(), project) + }, + statusCmd: "onecli policy status", + publishCmd: "onecli policy publish", + publishFlag: "--publish-all", + } +} + +// stagedPolicyDiffCount composes the staged diff the way the server's web +// console does (no aggregate endpoint exists): draft + published rules, +// draft + published default, diffed client-side. +func stagedPolicyDiffCount(client *api.Client, project string, org bool) (int, error) { + ctx := newContext() + var ( + draft, published []api.PolicyRule + dDef, pDef *api.PolicyRule + err error + ) + if org { + if draft, err = client.ListOrgPolicyRules(ctx, "draft"); err != nil { + return 0, err + } + if published, err = client.ListOrgPolicyRules(ctx, "published"); err != nil { + return 0, err + } + if dDef, err = client.GetOrgPolicyDefault(ctx, "draft"); err != nil { + return 0, err + } + if pDef, err = client.GetOrgPolicyDefault(ctx, "published"); err != nil { + return 0, err + } + } else { + if draft, err = client.ListPolicyRules(ctx, project, "draft"); err != nil { + return 0, err + } + if published, err = client.ListPolicyRules(ctx, project, "published"); err != nil { + return 0, err + } + if dDef, err = client.GetPolicyDefault(ctx, project, "draft"); err != nil { + return 0, err + } + if pDef, err = client.GetPolicyDefault(ctx, project, "published"); err != nil { + return 0, err + } + } + diff := api.DiffPolicyChanges(draft, published, dDef, pDef) + return diff.Count, nil +} + +// prePublishPending returns the scope's staged-change count BEFORE a write +// (the write itself would contaminate the count), or -1 when the check is +// skipped (--no-publish / --publish-all make the count irrelevant). +func prePublishPending(plan policyPublishPlan, ops policyScopeOps) (int, error) { + if err := plan.validate(); err != nil { + return 0, err + } + if plan.NoPublish || plan.PublishAll { + return -1, nil + } + return ops.diffCount() +} + +// finishPolicyWrite completes a successful write per the publish plan: +// stage-only, withheld (other staged changes exist), or auto-publish. The +// publish snapshots the WHOLE scope draft, so pre-existing staged changes +// withhold it unless --publish-all consents. +func finishPolicyWrite( + out *output.Writer, + outcome policyWriteOutcome, + plan policyPublishPlan, + pending int, + ops policyScopeOps, +) error { + if plan.NoPublish { + if err := out.Write(outcome); err != nil { + return err + } + out.Stderr(fmt.Sprintf("Staged (not enforced). Run '%s' to publish.", ops.publishCmd)) + return nil + } + if !plan.PublishAll && pending > 0 { + outcome.PublishSkipped = "draft-has-other-staged-changes" + outcome.PendingChanges = &pending + if err := out.Write(outcome); err != nil { + return err + } + out.Stderr(fmt.Sprintf( + "Publish withheld: the draft already has %d other staged change(s). Review with '%s', publish everything with '%s', or pass %s.", + pending, ops.statusCmd, ops.publishCmd, ops.publishFlag, + )) + return nil + } + result, err := ops.publish() + if err != nil { + if writeErr := out.Write(outcome); writeErr != nil { + return writeErr + } + return fmt.Errorf( + "publish failed: %s — the change IS staged; review with '%s', retry with '%s' (the failure may involve rules staged by others)", + err.Error(), ops.statusCmd, ops.publishCmd, + ) + } + outcome.Published = true + outcome.Generation = &result.Generation + return out.Write(outcome) +} + +// requireProjectForOrgKey fails fast when an org key is used for a +// project-scope policy command without a project selection — the server +// would reject with a misleading auth error otherwise. +func requireProjectForOrgKey(project string) error { + if project != "" { + return nil + } + if strings.HasPrefix(loadStoredAPIKey(), "oc_org_") { + return fmt.Errorf( + "an organization API key needs an explicit project for 'onecli policy' — pass --project , set ONECLI_PROJECT, or run 'onecli config set project '", + ) + } + return nil +} + +// parsePolicyTargets parses the --targets JSON array (syntax only — the +// server owns semantic validation). +func parsePolicyTargets(raw string) ([]api.PolicyTarget, error) { + if raw == "" { + return nil, nil + } + var targets []api.PolicyTarget + if err := json.Unmarshal([]byte(raw), &targets); err != nil { + return nil, fmt.Errorf("invalid --targets JSON (expected an array of target objects): %w", err) + } + return targets, nil +} + +// parsePolicyIdentities parses the --identities JSON array. +func parsePolicyIdentities(raw string) ([]api.PolicyIdentity, error) { + if raw == "" { + return nil, nil + } + var identities []api.PolicyIdentity + if err := json.Unmarshal([]byte(raw), &identities); err != nil { + return nil, fmt.Errorf("invalid --identities JSON (expected an array of {type,id} objects): %w", err) + } + return identities, nil +} + +// parsePolicyConditions parses --conditions into a raw message: a JSON array +// (behavioral conditions), an object (session policy), or the literal 'null' +// (update only: clears). Syntax-checked only. +func parsePolicyConditions(raw string) (*json.RawMessage, error) { + if raw == "" { + return nil, nil + } + if !json.Valid([]byte(raw)) { + return nil, fmt.Errorf("invalid --conditions JSON") + } + msg := json.RawMessage(raw) + return &msg, nil +} + +// validatePolicyAction rejects anything but the v2 actions with a pointer at +// the modifier flags that replaced the legacy actions. +func validatePolicyAction(action string) error { + switch action { + case "allow", "block": + return nil + case "rate_limit", "manual_approval": + return fmt.Errorf( + "action %q is a legacy action — in the policy engine use action 'allow' with --rate-limit/--rate-limit-window or --require-approval", + action, + ) + default: + return fmt.Errorf("invalid action %q: must be 'allow' or 'block'", action) + } +} + +// buildCreatePolicyInput assembles the create payload from --json XOR the +// field flags (combining them is rejected — unlike the legacy commands' +// silent override — so a hallucinated mixed invocation fails loudly). +func buildCreatePolicyInput( + rawJSON, name, action, description, targetsRaw, identitiesRaw, conditionsRaw string, + enabled *bool, rateLimit *int, rateLimitWindow string, requireApproval *bool, +) (api.CreatePolicyRuleInput, error) { + var input api.CreatePolicyRuleInput + fieldFlagsUsed := name != "" || action != "" || description != "" || + targetsRaw != "" || identitiesRaw != "" || conditionsRaw != "" || + enabled != nil || rateLimit != nil || rateLimitWindow != "" || requireApproval != nil + if rawJSON != "" { + if fieldFlagsUsed { + return input, fmt.Errorf("--json is the full payload — do not combine it with field flags") + } + if err := json.Unmarshal([]byte(rawJSON), &input); err != nil { + return input, fmt.Errorf("invalid --json payload: %w", err) + } + } else { + if name == "" || action == "" || targetsRaw == "" { + return input, fmt.Errorf("--name, --action, and --targets are required (or pass the full payload via --json)") + } + targets, err := parsePolicyTargets(targetsRaw) + if err != nil { + return input, err + } + identities, err := parsePolicyIdentities(identitiesRaw) + if err != nil { + return input, err + } + conditions, err := parsePolicyConditions(conditionsRaw) + if err != nil { + return input, err + } + input = api.CreatePolicyRuleInput{ + Name: name, + Description: description, + Enabled: enabled, + Action: action, + RateLimit: rateLimit, + RateLimitWindow: rateLimitWindow, + RequireApproval: requireApproval, + Conditions: conditions, + Identities: identities, + Targets: targets, + } + } + if err := validatePolicyAction(input.Action); err != nil { + return input, err + } + return input, nil +} + +// ── Reads ─────────────────────────────────────────────────────────────────── + +// PolicyRulesListCmd is `onecli policy rules list`. +type PolicyRulesListCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + Status string `optional:"" default:"draft" help:"Rule set: 'draft' (working copy) or 'published' (enforced)."` + Fields string `optional:"" help:"Comma-separated fields to include."` + Quiet string `optional:"" name:"quiet" help:"Output only the specified field, one per line (e.g. 'id')."` + Max int `optional:"" default:"0" help:"Max rules to output (0 = all — reorder needs the complete list)."` +} + +func (c *PolicyRulesListCmd) Run(out *output.Writer) error { + if c.Status != "draft" && c.Status != "published" { + return fmt.Errorf("invalid --status %q: must be 'draft' or 'published'", c.Status) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + rules, err := client.ListPolicyRules(newContext(), project, c.Status) + if err != nil { + return err + } + if c.Max > 0 && len(rules) > c.Max { + rules = rules[:c.Max] + } + if c.Quiet != "" { + return out.WriteQuiet(rules, c.Quiet) + } + return out.WriteFiltered(rules, c.Fields) +} + +// PolicyRulesGetCmd is `onecli policy rules get`. +type PolicyRulesGetCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + ID string `required:"" help:"DRAFT rule id (published row ids regenerate every publish — match published rules by logicalId via 'rules list --status published')."` + Fields string `optional:"" help:"Comma-separated fields to include."` +} + +func (c *PolicyRulesGetCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule id: %w", err) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + rule, err := client.GetPolicyRule(newContext(), project, c.ID) + if err != nil { + return err + } + return out.WriteFiltered(rule, c.Fields) +} + +// ── Writes ────────────────────────────────────────────────────────────────── + +// PolicyRulesCreateCmd is `onecli policy rules create`. +type PolicyRulesCreateCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + Name string `optional:"" help:"Display name (required unless --json)."` + Action string `optional:"" help:"'allow' or 'block' (required unless --json). Legacy 'rate_limit'/'manual_approval' become allow + modifiers."` + Targets string `optional:"" help:"JSON array of targets (required unless --json), e.g. '[{\"kind\":\"app\",\"provider\":\"gmail\",\"tools\":[\"send_email\"]}]' or '[{\"kind\":\"network\",\"hostPattern\":\"api.example.com\"}]'."` + Identities string `optional:"" help:"JSON array of identities, e.g. '[{\"type\":\"agent\",\"id\":\"\"}]'. Omit for all agents."` + Description string `optional:"" help:"Rule description."` + Enabled *bool `optional:"" help:"Whether the rule is enabled (default true)."` + RateLimit *int `optional:"" name:"rate-limit" help:"Max requests per window (allow rules only; pair with --rate-limit-window)."` + RateLimitWindow string `optional:"" name:"rate-limit-window" help:"'minute', 'hour', or 'day'."` + RequireApproval *bool `optional:"" name:"require-approval" help:"Require manual approval (allow rules only)."` + Conditions string `optional:"" help:"JSON: a conditions array, or a session-policy object ({\"repositories\":[…]} / {\"folders\":[…]})."` + Json string `optional:"" help:"Raw JSON payload for the full rule. Do not combine with field flags."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *PolicyRulesCreateCmd) Run(out *output.Writer) error { + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + input, err := buildCreatePolicyInput( + c.Json, c.Name, c.Action, c.Description, c.Targets, c.Identities, + c.Conditions, c.Enabled, c.RateLimit, c.RateLimitWindow, c.RequireApproval, + ) + if err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would create policy rule (draft; publish per --no-publish/--publish-all)", input) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + ops := projectScopeOps(client, project) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rule, err := client.CreatePolicyRule(newContext(), project, input) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rule: rule}, plan, pending, ops) +} + +// PolicyRulesUpdateCmd is `onecli policy rules update`. Field flags follow +// the legacy update convention: a non-empty value sets the field; omitted +// flags leave it unchanged; --conditions accepts the literal 'null' to clear. +type PolicyRulesUpdateCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + ID string `required:"" help:"DRAFT rule id."` + Name string `optional:"" help:"New display name."` + Action string `optional:"" help:"'allow' or 'block'."` + Targets string `optional:"" help:"JSON array replacing the rule's targets."` + Identities string `optional:"" help:"JSON array replacing the rule's identities ('[]' = all agents)."` + Description string `optional:"" help:"New description."` + Enabled *bool `optional:"" help:"Enable or disable the rule."` + RateLimit *int `optional:"" name:"rate-limit" help:"New rate limit (pair with --rate-limit-window)."` + RateLimitWindow string `optional:"" name:"rate-limit-window" help:"'minute', 'hour', or 'day'."` + RequireApproval *bool `optional:"" name:"require-approval" help:"Require manual approval."` + Conditions string `optional:"" help:"JSON conditions (array or session-policy object); 'null' clears."` + Json string `optional:"" help:"Raw JSON PATCH payload, sent verbatim (an explicit JSON null clears a nullable field, e.g. '{\"rateLimit\":null}'). Do not combine with field flags."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +// buildInput assembles the PATCH payload (same XOR contract as create). The +// --json path returns the payload VERBATIM as json.RawMessage — round-tripping +// it through the typed pointer struct would silently drop explicit JSON nulls +// (the only way to CLEAR rateLimit/conditions/… server-side) and unknown +// fields; it is probed for validation only. The flag path builds the typed +// struct (flags can set fields, never clear them — except --conditions null). +func (c *PolicyRulesUpdateCmd) buildInput() (any, error) { + var input api.UpdatePolicyRuleInput + fieldFlagsUsed := c.Name != "" || c.Action != "" || c.Description != "" || + c.Targets != "" || c.Identities != "" || c.Conditions != "" || + c.Enabled != nil || c.RateLimit != nil || c.RateLimitWindow != "" || + c.RequireApproval != nil + if c.Json != "" { + if fieldFlagsUsed { + return nil, fmt.Errorf("--json is the full payload — do not combine it with field flags") + } + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(c.Json), &fields); err != nil { + return nil, fmt.Errorf("invalid --json payload (expected a JSON object): %w", err) + } + if len(fields) == 0 { + return nil, fmt.Errorf("--json must set at least one field") + } + var probe api.UpdatePolicyRuleInput + if err := json.Unmarshal([]byte(c.Json), &probe); err != nil { + return nil, fmt.Errorf("invalid --json payload: %w", err) + } + if probe.Action != nil { + if err := validatePolicyAction(*probe.Action); err != nil { + return nil, err + } + } + return json.RawMessage(c.Json), nil + } + if !fieldFlagsUsed { + return input, fmt.Errorf("provide at least one field flag or --json") + } + if c.Name != "" { + input.Name = &c.Name + } + if c.Action != "" { + if err := validatePolicyAction(c.Action); err != nil { + return input, err + } + input.Action = &c.Action + } + if c.Description != "" { + input.Description = &c.Description + } + input.Enabled = c.Enabled + input.RateLimit = c.RateLimit + if c.RateLimitWindow != "" { + input.RateLimitWindow = &c.RateLimitWindow + } + input.RequireApproval = c.RequireApproval + if c.Targets != "" { + targets, err := parsePolicyTargets(c.Targets) + if err != nil { + return input, err + } + input.Targets = &targets + } + if c.Identities != "" { + identities, err := parsePolicyIdentities(c.Identities) + if err != nil { + return input, err + } + input.Identities = &identities + } + if c.Conditions != "" { + conditions, err := parsePolicyConditions(c.Conditions) + if err != nil { + return input, err + } + input.Conditions = conditions + } + return input, nil +} + +func (c *PolicyRulesUpdateCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule id: %w", err) + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + input, err := c.buildInput() + if err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would update policy rule "+c.ID, input) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + ops := projectScopeOps(client, project) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rule, err := client.UpdatePolicyRule(newContext(), project, c.ID, input) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rule: rule}, plan, pending, ops) +} + +// PolicyRulesDeleteCmd is `onecli policy rules delete`. +type PolicyRulesDeleteCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + ID string `required:"" help:"DRAFT rule id."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *PolicyRulesDeleteCmd) Run(out *output.Writer) error { + if err := validate.ResourceID(c.ID); err != nil { + return fmt.Errorf("invalid rule id: %w", err) + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would delete policy rule "+c.ID, map[string]string{"id": c.ID}) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + ops := projectScopeOps(client, project) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + if err := client.DeletePolicyRule(newContext(), project, c.ID); err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{DeletedID: c.ID}, plan, pending, ops) +} + +// PolicyRulesReorderCmd is `onecli policy rules reorder`. +type PolicyRulesReorderCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + OrderedIds string `required:"" name:"ordered-ids" help:"JSON array of EVERY non-default draft rule id exactly once (get the full list from 'policy rules list --quiet id'). The server rejects a partial list."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *PolicyRulesReorderCmd) Run(out *output.Writer) error { + var ids []string + if err := json.Unmarshal([]byte(c.OrderedIds), &ids); err != nil { + return fmt.Errorf("invalid --ordered-ids JSON (expected an array of rule ids): %w", err) + } + if len(ids) == 0 { + return fmt.Errorf("--ordered-ids must name every non-default draft rule") + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would reorder policy rules", map[string]any{"orderedIds": ids}) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + ops := projectScopeOps(client, project) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rules, err := client.ReorderPolicyRules(newContext(), project, ids) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rules: rules}, plan, pending, ops) +} + +// ── Default rule / publish / status ───────────────────────────────────────── + +// PolicyDefaultCmd groups the Default Rule subcommands. +type PolicyDefaultCmd struct { + Get PolicyDefaultGetCmd `cmd:"" help:"Show the terminal Default Rule."` + Set PolicyDefaultSetCmd `cmd:"" help:"Set the Default Rule's action."` +} + +// PolicyDefaultGetCmd is `onecli policy default get`. +type PolicyDefaultGetCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + Status string `optional:"" default:"draft" help:"'draft' or 'published'."` + Fields string `optional:"" help:"Comma-separated fields to include."` +} + +func (c *PolicyDefaultGetCmd) Run(out *output.Writer) error { + if c.Status != "draft" && c.Status != "published" { + return fmt.Errorf("invalid --status %q: must be 'draft' or 'published'", c.Status) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + rule, err := client.GetPolicyDefault(newContext(), project, c.Status) + if err != nil { + return err + } + return out.WriteFiltered(rule, c.Fields) +} + +// PolicyDefaultSetCmd is `onecli policy default set`. +type PolicyDefaultSetCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + Action string `required:"" help:"'allow' or 'block'."` + NoPublish bool `optional:"" name:"no-publish" help:"Stage only; do not publish."` + PublishAll bool `optional:"" name:"publish-all" help:"Publish even when the draft holds other staged changes."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *PolicyDefaultSetCmd) Run(out *output.Writer) error { + if c.Action != "allow" && c.Action != "block" { + return fmt.Errorf("invalid action %q: must be 'allow' or 'block'", c.Action) + } + plan := policyPublishPlan{NoPublish: c.NoPublish, PublishAll: c.PublishAll} + if err := plan.validate(); err != nil { + return err + } + if c.DryRun { + return out.WriteDryRun("Would set the Default Rule action", map[string]string{"action": c.Action}) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + ops := projectScopeOps(client, project) + pending, err := prePublishPending(plan, ops) + if err != nil { + return err + } + rule, err := client.SetPolicyDefault(newContext(), project, c.Action) + if err != nil { + return err + } + return finishPolicyWrite(out, policyWriteOutcome{Rule: rule}, plan, pending, ops) +} + +// PolicyPublishCmd is `onecli policy publish` — publishes the WHOLE draft. +type PolicyPublishCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + DryRun bool `optional:"" name:"dry-run" help:"Validate the request without executing it."` +} + +func (c *PolicyPublishCmd) Run(out *output.Writer) error { + if c.DryRun { + return out.WriteDryRun("Would publish the project's whole policy draft", struct{}{}) + } + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + result, err := client.PublishPolicy(newContext(), project) + if err != nil { + return err + } + return out.Write(result) +} + +// PolicyStatusCmd is `onecli policy status` — the staged diff + last publish. +type PolicyStatusCmd struct { + Project string `optional:"" short:"p" help:"Project slug."` + Fields string `optional:"" help:"Comma-separated fields to include."` +} + +// policyStatus is the status command's output envelope. +type policyStatus struct { + LastPublish *api.PolicyLastPublish `json:"lastPublish"` + PendingChanges int `json:"pendingChanges"` + Diff api.PolicyDiff `json:"diff"` +} + +func (c *PolicyStatusCmd) Run(out *output.Writer) error { + project, err := resolveProject(c.Project) + if err != nil { + return err + } + if err := requireProjectForOrgKey(project); err != nil { + return err + } + client, err := newClient() + if err != nil { + return err + } + ctx := newContext() + draft, err := client.ListPolicyRules(ctx, project, "draft") + if err != nil { + return err + } + published, err := client.ListPolicyRules(ctx, project, "published") + if err != nil { + return err + } + dDef, err := client.GetPolicyDefault(ctx, project, "draft") + if err != nil { + return err + } + pDef, err := client.GetPolicyDefault(ctx, project, "published") + if err != nil { + return err + } + last, err := client.GetPolicyLastPublish(ctx, project) + if err != nil { + return err + } + diff := api.DiffPolicyChanges(draft, published, dDef, pDef) + return out.WriteFiltered(policyStatus{ + LastPublish: last, + PendingChanges: diff.Count, + Diff: diff, + }, c.Fields) +} diff --git a/cmd/onecli/policy_test.go b/cmd/onecli/policy_test.go new file mode 100644 index 0000000..80d341e --- /dev/null +++ b/cmd/onecli/policy_test.go @@ -0,0 +1,309 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/onecli/onecli-cli/internal/api" + "github.com/onecli/onecli-cli/pkg/output" +) + +// Command-layer helper units for the policy family: the --json XOR field-flag +// contract, the required trio, the legacy-action guidance, and JSON parsing. + +func TestBuildCreatePolicyInputJSONPayload(t *testing.T) { + input, err := buildCreatePolicyInput( + `{"name":"n","action":"block","targets":[{"kind":"network","hostPattern":"a.com"}]}`, + "", "", "", "", "", "", nil, nil, "", nil, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if input.Name != "n" || input.Action != "block" || len(input.Targets) != 1 { + t.Errorf("input = %+v", input) + } +} + +func TestBuildCreatePolicyInputRejectsCombining(t *testing.T) { + _, err := buildCreatePolicyInput( + `{"name":"n","action":"block"}`, + "also-a-name", "", "", "", "", "", nil, nil, "", nil, + ) + if err == nil || !strings.Contains(err.Error(), "do not combine") { + t.Errorf("want the combine rejection, got %v", err) + } +} + +func TestBuildCreatePolicyInputRequiredTrio(t *testing.T) { + _, err := buildCreatePolicyInput( + "", "n", "block", "", "", "", "", nil, nil, "", nil, + ) + if err == nil || !strings.Contains(err.Error(), "--targets") { + t.Errorf("want the required-trio error naming --targets, got %v", err) + } +} + +func TestBuildCreatePolicyInputLegacyActionGuidance(t *testing.T) { + for _, action := range []string{"rate_limit", "manual_approval"} { + _, err := buildCreatePolicyInput( + "", "n", action, "", `[{"kind":"network","hostPattern":"a.com"}]`, "", "", nil, nil, "", nil, + ) + if err == nil || !strings.Contains(err.Error(), "legacy action") { + t.Errorf("action %q: want the legacy-action guidance, got %v", action, err) + } + } +} + +func TestBuildCreatePolicyInputFieldFlags(t *testing.T) { + limit := 10 + approval := true + input, err := buildCreatePolicyInput( + "", "n", "allow", "d", + `[{"kind":"app","provider":"gmail","tools":["send_email"]}]`, + `[{"type":"agent","id":"a1"}]`, + `[{"target":"body","operator":"contains","value":"x"}]`, + nil, &limit, "minute", &approval, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if input.Targets[0].Provider != "gmail" || input.Identities[0].ID != "a1" { + t.Errorf("input = %+v", input) + } + if input.RateLimit == nil || *input.RateLimit != 10 || input.RateLimitWindow != "minute" { + t.Errorf("rate fields = %+v %q", input.RateLimit, input.RateLimitWindow) + } + if input.Conditions == nil { + t.Error("conditions must be set") + } +} + +func TestParsePolicyHelpersRejectBadJSON(t *testing.T) { + if _, err := parsePolicyTargets(`{"not":"an array"}`); err == nil { + t.Error("targets: want error for non-array") + } + if _, err := parsePolicyIdentities(`"nope"`); err == nil { + t.Error("identities: want error for non-array") + } + if _, err := parsePolicyConditions(`{broken`); err == nil { + t.Error("conditions: want error for invalid JSON") + } + if raw, err := parsePolicyConditions(`null`); err != nil || raw == nil || string(*raw) != "null" { + t.Errorf("conditions 'null' must pass through as a raw null, got %v %v", raw, err) + } +} + +func TestUpdateInputRequiresSomething(t *testing.T) { + cmd := PolicyRulesUpdateCmd{ID: "r1"} + _, err := cmd.buildInput() + if err == nil || !strings.Contains(err.Error(), "at least one") { + t.Errorf("want the at-least-one error, got %v", err) + } +} + +func TestUpdateBuildInputRawJSONVerbatim(t *testing.T) { + // --json must pass through as json.RawMessage: explicit nulls are the only + // way to CLEAR nullable fields, and a typed round-trip would drop them. + payload := `{"name":"x","rateLimit":null,"conditions":null}` + cmd := PolicyRulesUpdateCmd{ID: "r1", Json: payload} + v, err := cmd.buildInput() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, ok := v.(json.RawMessage) + if !ok { + t.Fatalf("buildInput --json must return json.RawMessage, got %T", v) + } + if string(raw) != payload { + t.Errorf("payload = %s, want verbatim %s", raw, payload) + } +} + +func TestUpdateBuildInputRawJSONRejections(t *testing.T) { + cases := []struct{ name, payload, want string }{ + {"non-object", `[1]`, "expected a JSON object"}, + {"empty-object", `{}`, "at least one field"}, + {"null", `null`, "at least one field"}, + {"legacy-action", `{"action":"manual_approval"}`, "legacy action"}, + {"invalid-action", `{"action":"warn"}`, "invalid action"}, + {"action-wrong-type", `{"action":5}`, "invalid --json payload"}, + } + for _, tc := range cases { + cmd := PolicyRulesUpdateCmd{ID: "r1", Json: tc.payload} + if _, err := cmd.buildInput(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: got %v, want %q", tc.name, err, tc.want) + } + } +} + +// TestStagedPolicyDiffCountComposition pins the guard's 4-GET feed: draft + +// published rules, draft + published defaults — and that the composed count +// honors the diff lib's laws (derived rows excluded, default flip counted). +func TestStagedPolicyDiffCountComposition(t *testing.T) { + custom := func(id, logical, name string, priority int, status string) string { + return fmt.Sprintf( + `{"id":%q,"logicalId":%q,"source":"custom","status":%q,"priority":%d,"enabled":true,"isDefault":false,"name":%q,"action":"allow","requireApproval":false,"description":null,"rateLimit":null,"rateLimitWindow":null,"identities":[],"targets":[{"kind":"network","hostPattern":"a.com"}],"createdAt":"2026-01-01"}`, + id, logical, status, priority, name, + ) + } + blocklist := func(id, logical, status string, priority int) string { + return fmt.Sprintf( + `{"id":%q,"logicalId":%q,"source":"blocklist","status":%q,"priority":%d,"enabled":true,"isDefault":false,"name":"bl","action":"block","requireApproval":false,"description":null,"rateLimit":null,"rateLimitWindow":null,"identities":[],"targets":[],"createdAt":"2026-01-01"}`, + id, logical, status, priority, + ) + } + defaultRule := func(id, action, status string) string { + return fmt.Sprintf( + `{"id":%q,"logicalId":"l-def","source":"default","status":%q,"priority":99,"enabled":true,"isDefault":true,"name":"Default Rule","action":%q,"requireApproval":false,"description":null,"rateLimit":null,"rateLimitWindow":null,"identities":[],"targets":[],"createdAt":"2026-01-01"}`, + id, status, action, + ) + } + + var requests []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/health" { + _, _ = w.Write([]byte(`{"status":"ok"}`)) + return + } + requests = append(requests, r.Method+" "+r.URL.Path+"?status="+r.URL.Query().Get("status")) + switch { + case r.URL.Path == "/v1/org/policy/rules" && r.URL.Query().Get("status") == "draft": + // One kept custom, one ADDED custom, blocklist noise (fresh logicalId). + _, _ = fmt.Fprintf(w, "[%s,%s,%s]", + custom("d1", "l-keep", "keep", 0, "draft"), + custom("d2", "l-new", "new", 1, "draft"), + blocklist("d3", "bl-fresh", "draft", 2), + ) + case r.URL.Path == "/v1/org/policy/rules" && r.URL.Query().Get("status") == "published": + _, _ = fmt.Fprintf(w, "[%s,%s]", + custom("p1", "l-keep", "keep", 0, "published"), + blocklist("p2", "bl-old", "published", 1), + ) + case r.URL.Path == "/v1/org/policy/default" && r.URL.Query().Get("status") == "draft": + _, _ = fmt.Fprint(w, defaultRule("dd", "block", "draft")) + case r.URL.Path == "/v1/org/policy/default" && r.URL.Query().Get("status") == "published": + _, _ = fmt.Fprint(w, defaultRule("pd", "allow", "published")) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.String()) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + count, err := stagedPolicyDiffCount(api.New(srv.URL, "oc_key"), "", true) + if err != nil { + t.Fatalf("stagedPolicyDiffCount: %v", err) + } + if count != 2 { + t.Errorf("count = %d, want 2 (one added custom + the default flip; blocklist noise excluded)", count) + } + want := []string{ + "GET /v1/org/policy/rules?status=draft", + "GET /v1/org/policy/rules?status=published", + "GET /v1/org/policy/default?status=draft", + "GET /v1/org/policy/default?status=published", + } + if len(requests) != len(want) { + t.Fatalf("requests = %v, want %v", requests, want) + } + for i := range want { + if requests[i] != want[i] { + t.Errorf("request[%d] = %q, want %q", i, requests[i], want[i]) + } + } +} + +// ── The publish guard flow (pure, via injected ops) ───────────────────────── + +func newTestWriter(t *testing.T) *output.Writer { + t.Helper() + return output.NewWithWriters(io.Discard, io.Discard) +} + +func guardOps(pending int, publishErr error, published *int) policyScopeOps { + return policyScopeOps{ + diffCount: func() (int, error) { return pending, nil }, + publish: func() (*api.PolicyPublishResult, error) { + if publishErr != nil { + return nil, publishErr + } + *published++ + return &api.PolicyPublishResult{Generation: 9, RuleCount: 3}, nil + }, + statusCmd: "onecli policy status", + publishCmd: "onecli policy publish", + publishFlag: "--publish-all", + } +} + +func TestPublishPlanContradictionRejected(t *testing.T) { + _, err := prePublishPending( + policyPublishPlan{NoPublish: true, PublishAll: true}, + policyScopeOps{}, + ) + if err == nil || !strings.Contains(err.Error(), "contradictory") { + t.Errorf("want the contradiction rejection, got %v", err) + } +} + +func TestGuardWithholdsPublishWhenDraftDirty(t *testing.T) { + published := 0 + ops := guardOps(2, nil, &published) + plan := policyPublishPlan{} + pending, err := prePublishPending(plan, ops) + if err != nil || pending != 2 { + t.Fatalf("pending = %d, err = %v", pending, err) + } + out := newTestWriter(t) + if err := finishPolicyWrite(out, policyWriteOutcome{DeletedID: "r1"}, plan, pending, ops); err != nil { + t.Fatalf("withheld publish must not error: %v", err) + } + if published != 0 { + t.Error("publish must be withheld when the draft has staged changes") + } +} + +func TestGuardPublishesCleanDraft(t *testing.T) { + published := 0 + ops := guardOps(0, nil, &published) + plan := policyPublishPlan{} + pending, _ := prePublishPending(plan, ops) + out := newTestWriter(t) + if err := finishPolicyWrite(out, policyWriteOutcome{DeletedID: "r1"}, plan, pending, ops); err != nil { + t.Fatalf("clean publish must not error: %v", err) + } + if published != 1 { + t.Errorf("published = %d, want 1", published) + } +} + +func TestGuardPublishAllOverridesDirtyDraft(t *testing.T) { + published := 0 + ops := guardOps(5, nil, &published) + plan := policyPublishPlan{PublishAll: true} + pending, _ := prePublishPending(plan, ops) // -1: check skipped + out := newTestWriter(t) + if err := finishPolicyWrite(out, policyWriteOutcome{DeletedID: "r1"}, plan, pending, ops); err != nil { + t.Fatalf("publish-all must not error: %v", err) + } + if published != 1 { + t.Errorf("published = %d, want 1", published) + } +} + +func TestGuardPublishFailureReturnsErrorAfterWrite(t *testing.T) { + published := 0 + ops := guardOps(0, fmt.Errorf("plan gate rejected a staged rule"), &published) + plan := policyPublishPlan{} + pending, _ := prePublishPending(plan, ops) + out := newTestWriter(t) + err := finishPolicyWrite(out, policyWriteOutcome{DeletedID: "r1"}, plan, pending, ops) + if err == nil || !strings.Contains(err.Error(), "the change IS staged") { + t.Errorf("want the staged-but-unpublished error, got %v", err) + } +} diff --git a/internal/api/policy.go b/internal/api/policy.go new file mode 100644 index 0000000..cc7b086 --- /dev/null +++ b/internal/api/policy.go @@ -0,0 +1,371 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +// The /v1/policy surface (and its /v1/org/policy mirror): the first-match +// policy engine's staged model. Writes land in a DRAFT; POST /publish +// snapshots the whole scope draft into a new published generation; the +// gateway enforces the published set only (?status=published, max +// generation). Published row ids are ephemeral (regenerated every publish) — +// `logicalId` is the only identity stable across statuses and generations. + +// PolicyRule is a rule row from /v1/policy (draft working copy or a +// published snapshot). Source "custom" rows are user-owned; "blocklist" and +// "equipment" rows are system-managed (equipment is injection-only) and +// "default" is the scope's terminal Default Rule. +type PolicyRule struct { + ID string `json:"id"` + Scope string `json:"scope"` + Status string `json:"status"` + Generation int `json:"generation"` + Priority int `json:"priority"` + Enabled bool `json:"enabled"` + IsDefault bool `json:"isDefault"` + LogicalID string `json:"logicalId"` + Source string `json:"source"` + Name string `json:"name"` + Description *string `json:"description"` + Action string `json:"action"` + RateLimit *int `json:"rateLimit"` + RateLimitWindow *string `json:"rateLimitWindow"` + RequireApproval bool `json:"requireApproval"` + Conditions any `json:"conditions,omitempty"` + Identities []PolicyIdentity `json:"identities"` + Targets []PolicyTarget `json:"targets"` + CreatedAt string `json:"createdAt"` +} + +// PolicyIdentity names a principal a rule applies to. Project rules accept +// type "agent" only; org rules accept "agentGroup", "user", or "group". +// An empty identity list means "any". +type PolicyIdentity struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// PolicyTarget is one destination a rule matches — a tagged union on Kind: +// "app" (Provider + optional Tools/ConnectionScope), "connection" +// (ConnectionID + optional Tools), "secret" (SecretID XOR SecretScope), or +// "network" (HostPattern + optional PathPattern/Method). Optional fields are +// omitted when empty; the server owns semantic validation. +type PolicyTarget struct { + Kind string `json:"kind"` + Provider string `json:"provider,omitempty"` + Tools []string `json:"tools,omitempty"` + ConnectionScope string `json:"connectionScope,omitempty"` + ConnectionID string `json:"connectionId,omitempty"` + SecretID string `json:"secretId,omitempty"` + SecretScope string `json:"secretScope,omitempty"` + HostPattern string `json:"hostPattern,omitempty"` + PathPattern string `json:"pathPattern,omitempty"` + Method string `json:"method,omitempty"` +} + +// CreatePolicyRuleInput is the POST /v1/policy/rules body. Targets must name +// at least one destination; RateLimit/RateLimitWindow are paired and — like +// RequireApproval — valid only on action "allow". +type CreatePolicyRuleInput struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Action string `json:"action"` + RateLimit *int `json:"rateLimit,omitempty"` + RateLimitWindow string `json:"rateLimitWindow,omitempty"` + RequireApproval *bool `json:"requireApproval,omitempty"` + Conditions *json.RawMessage `json:"conditions,omitempty"` + Identities []PolicyIdentity `json:"identities,omitempty"` + Targets []PolicyTarget `json:"targets,omitempty"` +} + +// UpdatePolicyRuleInput is the flag-built PATCH body — nil omits a field +// (omitempty), so this struct can only SET values, never clear them. Explicit +// JSON-null clears (rateLimit: null, conditions: null, …) are expressed via +// the raw --json path, which bypasses this struct entirely and sends the +// payload verbatim as json.RawMessage. +type UpdatePolicyRuleInput struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Action *string `json:"action,omitempty"` + RateLimit *int `json:"rateLimit,omitempty"` + RateLimitWindow *string `json:"rateLimitWindow,omitempty"` + RequireApproval *bool `json:"requireApproval,omitempty"` + Conditions *json.RawMessage `json:"conditions,omitempty"` + Identities *[]PolicyIdentity `json:"identities,omitempty"` + Targets *[]PolicyTarget `json:"targets,omitempty"` +} + +// PolicyPublishResult is the POST /v1/policy/publish response. +type PolicyPublishResult struct { + Generation int `json:"generation"` + RuleCount int `json:"ruleCount"` +} + +// PolicyLastPublish describes the most recent publish; the API returns JSON +// null when the scope was never published (surfaced here as a nil pointer). +type PolicyLastPublish struct { + Generation int `json:"generation"` + RuleCount int `json:"ruleCount"` + AppliedAt string `json:"appliedAt"` + AppliedBy *struct { + Name *string `json:"name"` + Email string `json:"email"` + } `json:"appliedBy"` +} + +// reorderPolicyInput is the PUT /v1/policy/rules/order body. +type reorderPolicyInput struct { + OrderedIDs []string `json:"orderedIds"` +} + +// setPolicyDefaultInput is the PATCH /v1/policy/default body. +type setPolicyDefaultInput struct { + Action string `json:"action"` +} + +// mapPolicyRouteErr upgrades a route-miss 404 into an actionable message: a +// resource-level 404 from a current server carries type "not_found_error"; +// a 404 WITHOUT it means the route itself doesn't exist — an older server +// (the /api-prefix fallback) or, for the org surface, a community edition +// without the org policy routes. +func mapPolicyRouteErr(err error, org bool) error { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return err + } + if apiErr.StatusCode != http.StatusNotFound || apiErr.Type == "not_found_error" { + return err + } + if org { + return fmt.Errorf( + "this server does not support the org policy API — it requires OneCLI Cloud or an Enterprise self-hosted release; legacy 'onecli org rules' commands work here", + ) + } + return fmt.Errorf( + "this server does not support the policy API — it requires OneCLI Cloud or an updated self-hosted release; legacy 'onecli rules' commands work here", + ) +} + +func policyStatusQuery(base, status string) string { + if status == "" { + return base + } + return base + "?status=" + url.QueryEscape(status) +} + +// ── Project scope (/v1/policy) — project selects via doProject ────────────── + +// ListPolicyRules lists the scope's non-default rules (draft by default; +// status "published" = the enforced set at the active generation). +func (c *Client) ListPolicyRules(ctx context.Context, project, status string) ([]PolicyRule, error) { + var rules []PolicyRule + path := policyStatusQuery("/v1/policy/rules", status) + if err := c.doProject(ctx, http.MethodGet, path, project, nil, &rules); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return rules, nil +} + +// GetPolicyRule fetches one DRAFT rule (the working copy). Published row ids +// are ephemeral — list with status "published" and match by logicalId. +func (c *Client) GetPolicyRule(ctx context.Context, project, id string) (*PolicyRule, error) { + var rule PolicyRule + path := "/v1/policy/rules/" + url.PathEscape(id) + if err := c.doProject(ctx, http.MethodGet, path, project, nil, &rule); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return &rule, nil +} + +// CreatePolicyRule creates a DRAFT rule (staged until publish). +func (c *Client) CreatePolicyRule(ctx context.Context, project string, input CreatePolicyRuleInput) (*PolicyRule, error) { + var rule PolicyRule + if err := c.doProject(ctx, http.MethodPost, "/v1/policy/rules", project, input, &rule); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return &rule, nil +} + +// UpdatePolicyRule patches a DRAFT rule. The body is sent EXACTLY as given +// (any JSON-marshalable value): callers building from flags pass an +// UpdatePolicyRuleInput; the raw --json path passes json.RawMessage so +// explicit nulls (which clear nullable fields server-side) survive — a typed +// pointer round-trip would silently drop them. +func (c *Client) UpdatePolicyRule(ctx context.Context, project, id string, body any) (*PolicyRule, error) { + var rule PolicyRule + path := "/v1/policy/rules/" + url.PathEscape(id) + if err := c.doProject(ctx, http.MethodPatch, path, project, body, &rule); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return &rule, nil +} + +// DeletePolicyRule removes a DRAFT rule (staged until publish). +func (c *Client) DeletePolicyRule(ctx context.Context, project, id string) error { + path := "/v1/policy/rules/" + url.PathEscape(id) + if err := c.doProject(ctx, http.MethodDelete, path, project, nil, nil); err != nil { + return mapPolicyRouteErr(err, false) + } + return nil +} + +// ReorderPolicyRules sets the draft's first-match order. orderedIDs must +// name EVERY non-default draft rule exactly once (system rows included) — +// the server 409s on any mismatch. +func (c *Client) ReorderPolicyRules(ctx context.Context, project string, orderedIDs []string) ([]PolicyRule, error) { + var rules []PolicyRule + input := reorderPolicyInput{OrderedIDs: orderedIDs} + if err := c.doProject(ctx, http.MethodPut, "/v1/policy/rules/order", project, input, &rules); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return rules, nil +} + +// GetPolicyDefault fetches the scope's terminal Default Rule (a virtual +// default is returned when none is persisted yet — it reflects the scope's +// baseline posture, not necessarily an enforced snapshot). +func (c *Client) GetPolicyDefault(ctx context.Context, project, status string) (*PolicyRule, error) { + var rule PolicyRule + path := policyStatusQuery("/v1/policy/default", status) + if err := c.doProject(ctx, http.MethodGet, path, project, nil, &rule); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return &rule, nil +} + +// SetPolicyDefault sets the Default Rule's action on the draft. +func (c *Client) SetPolicyDefault(ctx context.Context, project, action string) (*PolicyRule, error) { + var rule PolicyRule + input := setPolicyDefaultInput{Action: action} + if err := c.doProject(ctx, http.MethodPatch, "/v1/policy/default", project, input, &rule); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return &rule, nil +} + +// PublishPolicy snapshots the WHOLE scope draft into a new published +// generation — including any changes staged by other users. +func (c *Client) PublishPolicy(ctx context.Context, project string) (*PolicyPublishResult, error) { + var result PolicyPublishResult + if err := c.doProject(ctx, http.MethodPost, "/v1/policy/publish", project, struct{}{}, &result); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return &result, nil +} + +// GetPolicyLastPublish returns the most recent publish, or nil when the +// scope was never published. +func (c *Client) GetPolicyLastPublish(ctx context.Context, project string) (*PolicyLastPublish, error) { + var last *PolicyLastPublish + if err := c.doProject(ctx, http.MethodGet, "/v1/policy/last-publish", project, nil, &last); err != nil { + return nil, mapPolicyRouteErr(err, false) + } + return last, nil +} + +// ── Org scope (/v1/org/policy) — the key's org; admin role required ───────── + +// ListOrgPolicyRules lists the org scope's non-default rules. +func (c *Client) ListOrgPolicyRules(ctx context.Context, status string) ([]PolicyRule, error) { + var rules []PolicyRule + path := policyStatusQuery("/v1/org/policy/rules", status) + if err := c.do(ctx, http.MethodGet, path, nil, &rules); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return rules, nil +} + +// GetOrgPolicyRule fetches one DRAFT org rule. +func (c *Client) GetOrgPolicyRule(ctx context.Context, id string) (*PolicyRule, error) { + var rule PolicyRule + path := "/v1/org/policy/rules/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodGet, path, nil, &rule); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return &rule, nil +} + +// CreateOrgPolicyRule creates a DRAFT org rule. +func (c *Client) CreateOrgPolicyRule(ctx context.Context, input CreatePolicyRuleInput) (*PolicyRule, error) { + var rule PolicyRule + if err := c.do(ctx, http.MethodPost, "/v1/org/policy/rules", input, &rule); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return &rule, nil +} + +// UpdateOrgPolicyRule patches a DRAFT org rule (body semantics as +// UpdatePolicyRule: sent exactly as given so raw --json nulls survive). +func (c *Client) UpdateOrgPolicyRule(ctx context.Context, id string, body any) (*PolicyRule, error) { + var rule PolicyRule + path := "/v1/org/policy/rules/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodPatch, path, body, &rule); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return &rule, nil +} + +// DeleteOrgPolicyRule removes a DRAFT org rule. +func (c *Client) DeleteOrgPolicyRule(ctx context.Context, id string) error { + path := "/v1/org/policy/rules/" + url.PathEscape(id) + if err := c.do(ctx, http.MethodDelete, path, nil, nil); err != nil { + return mapPolicyRouteErr(err, true) + } + return nil +} + +// ReorderOrgPolicyRules sets the org draft's first-match order (full +// permutation contract, as ReorderPolicyRules). +func (c *Client) ReorderOrgPolicyRules(ctx context.Context, orderedIDs []string) ([]PolicyRule, error) { + var rules []PolicyRule + input := reorderPolicyInput{OrderedIDs: orderedIDs} + if err := c.do(ctx, http.MethodPut, "/v1/org/policy/rules/order", input, &rules); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return rules, nil +} + +// GetOrgPolicyDefault fetches the org's terminal Default Rule. +func (c *Client) GetOrgPolicyDefault(ctx context.Context, status string) (*PolicyRule, error) { + var rule PolicyRule + path := policyStatusQuery("/v1/org/policy/default", status) + if err := c.do(ctx, http.MethodGet, path, nil, &rule); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return &rule, nil +} + +// SetOrgPolicyDefault sets the org Default Rule's action on the draft. +func (c *Client) SetOrgPolicyDefault(ctx context.Context, action string) (*PolicyRule, error) { + var rule PolicyRule + input := setPolicyDefaultInput{Action: action} + if err := c.do(ctx, http.MethodPatch, "/v1/org/policy/default", input, &rule); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return &rule, nil +} + +// PublishOrgPolicy snapshots the whole ORG draft into a new generation. +func (c *Client) PublishOrgPolicy(ctx context.Context) (*PolicyPublishResult, error) { + var result PolicyPublishResult + if err := c.do(ctx, http.MethodPost, "/v1/org/policy/publish", struct{}{}, &result); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return &result, nil +} + +// GetOrgPolicyLastPublish returns the org's most recent publish, or nil. +func (c *Client) GetOrgPolicyLastPublish(ctx context.Context) (*PolicyLastPublish, error) { + var last *PolicyLastPublish + if err := c.do(ctx, http.MethodGet, "/v1/org/policy/last-publish", nil, &last); err != nil { + return nil, mapPolicyRouteErr(err, true) + } + return last, nil +} diff --git a/internal/api/policy_test.go b/internal/api/policy_test.go new file mode 100644 index 0000000..d330f8b --- /dev/null +++ b/internal/api/policy_test.go @@ -0,0 +1,243 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Round-trips for the /v1/policy client: payload encoding for every target +// kind, scope/status routing, the reorder body, and the error-shape mapping +// external callers depend on (410 legacy / 422 validation / 403 flag-off / +// the route-miss 404 upgrade). + +func TestCreatePolicyRuleEncodesAllTargetKinds(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/policy/rules" || r.Method != http.MethodPost { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&captured); err != nil { + t.Fatalf("decoding body: %v", err) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":"r1","logicalId":"l1","name":"n","action":"block"}`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + enabled := true + rule, err := client.CreatePolicyRule(context.Background(), "", CreatePolicyRuleInput{ + Name: "n", + Action: "block", + Enabled: &enabled, + Identities: []PolicyIdentity{ + {Type: "agent", ID: "a1"}, + }, + Targets: []PolicyTarget{ + {Kind: "app", Provider: "gmail", Tools: []string{"send_email"}}, + {Kind: "connection", ConnectionID: "c1", Tools: []string{"git_push"}}, + {Kind: "secret", SecretScope: "project"}, + {Kind: "network", HostPattern: "api.example.com", PathPattern: "/v1/*", Method: "POST"}, + }, + }) + if err != nil { + t.Fatalf("CreatePolicyRule: %v", err) + } + if rule.ID != "r1" { + t.Errorf("rule.ID = %q, want r1", rule.ID) + } + + targets, ok := captured["targets"].([]any) + if !ok || len(targets) != 4 { + t.Fatalf("targets = %#v, want 4 entries", captured["targets"]) + } + conn := targets[1].(map[string]any) + if conn["connectionId"] != "c1" { + t.Errorf("connection target field = %#v, want connectionId=c1 (the contract field name)", conn) + } + if _, present := conn["secretId"]; present { + t.Errorf("connection target must omit unrelated fields, got %#v", conn) + } + secret := targets[2].(map[string]any) + if secret["secretScope"] != "project" { + t.Errorf("secret target = %#v, want secretScope=project", secret) + } + if _, present := captured["rateLimit"]; present { + t.Errorf("absent rateLimit must be omitted, got %#v", captured["rateLimit"]) + } +} + +func TestListPolicyRulesStatusQueryAndOrgPath(t *testing.T) { + var paths []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.String()) + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + if _, err := client.ListPolicyRules(context.Background(), "", "published"); err != nil { + t.Fatalf("ListPolicyRules: %v", err) + } + if _, err := client.ListOrgPolicyRules(context.Background(), "draft"); err != nil { + t.Fatalf("ListOrgPolicyRules: %v", err) + } + if paths[0] != "/v1/policy/rules?status=published" { + t.Errorf("project path = %q", paths[0]) + } + if paths[1] != "/v1/org/policy/rules?status=draft" { + t.Errorf("org path = %q", paths[1]) + } +} + +func TestReorderPolicyRulesBody(t *testing.T) { + var captured struct { + OrderedIDs []string `json:"orderedIds"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/policy/rules/order" || r.Method != http.MethodPut { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&captured) + _, _ = w.Write([]byte(`[]`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + if _, err := client.ReorderPolicyRules(context.Background(), "", []string{"a", "b"}); err != nil { + t.Fatalf("ReorderPolicyRules: %v", err) + } + if len(captured.OrderedIDs) != 2 || captured.OrderedIDs[0] != "a" { + t.Errorf("orderedIds = %#v", captured.OrderedIDs) + } +} + +func TestPolicyErrorShapesPassThrough(t *testing.T) { + tests := []struct { + name string + status int + body string + wantStatus int + wantSub string + }{ + { + name: "410 legacy-gone carries the server message", + status: http.StatusGone, + body: `{"error":{"message":"managed through the policy API","type":"invalid_request_error"}}`, + wantStatus: 410, + wantSub: "policy API", + }, + { + name: "422 validation carries the Zod issue", + status: http.StatusUnprocessableEntity, + body: `{"error":{"message":"A rule must name at least one target.","type":"validation_error"}}`, + wantStatus: 422, + wantSub: "at least one target", + }, + { + name: "403 editing-off carries the flag message", + status: http.StatusForbidden, + body: `{"error":{"message":"Policy editing is not enabled for this deployment yet.","type":"authentication_error"}}`, + wantStatus: 403, + wantSub: "not enabled", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + client := newWithPrefix(srv.URL, "oc_key", "/v1") + _, err := client.CreatePolicyRule(context.Background(), "", CreatePolicyRuleInput{Name: "n", Action: "block"}) + apiErr, ok := err.(*APIError) + if !ok { + t.Fatalf("want *APIError, got %T: %v", err, err) + } + if apiErr.StatusCode != tt.wantStatus { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.wantStatus) + } + if !strings.Contains(apiErr.Message, tt.wantSub) { + t.Errorf("Message = %q, want substring %q", apiErr.Message, tt.wantSub) + } + }) + } +} + +func TestPolicyRouteMiss404IsUpgraded(t *testing.T) { + // A 404 WITHOUT type "not_found_error" is a route miss (old server, or + // community edition for the org surface) — upgraded to guidance. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"Unrecognized request URL (GET: /v1/org/policy/rules).","type":"invalid_request_error"}}`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + _, err := client.ListOrgPolicyRules(context.Background(), "") + if err == nil { + t.Fatal("want error") + } + if !strings.Contains(err.Error(), "does not support the org policy API") { + t.Errorf("err = %q, want the org guidance", err.Error()) + } + + // A RESOURCE 404 (type not_found_error) passes through untouched. + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":{"message":"Rule not found","type":"not_found_error"}}`)) + })) + defer srv2.Close() + client2 := newWithPrefix(srv2.URL, "oc_key", "/v1") + _, err2 := client2.GetPolicyRule(context.Background(), "", "missing") + apiErr, ok := err2.(*APIError) + if !ok || apiErr.StatusCode != 404 || apiErr.Message != "Rule not found" { + t.Errorf("resource 404 must pass through, got %T %v", err2, err2) + } +} + +func TestGetPolicyLastPublishNull(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`null`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + last, err := client.GetPolicyLastPublish(context.Background(), "") + if err != nil { + t.Fatalf("GetPolicyLastPublish: %v", err) + } + if last != nil { + t.Errorf("never-published must yield nil, got %#v", last) + } +} + +func TestUpdatePolicyRuleSendsRawBodyVerbatim(t *testing.T) { + // The --json path passes json.RawMessage: explicit nulls (the only way to + // CLEAR rateLimit/conditions server-side) must reach the wire — a typed + // pointer round-trip would drop them as omitempty absences. + var rawBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/policy/rules/r1" || r.Method != http.MethodPatch { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + rawBody, _ = io.ReadAll(r.Body) + _, _ = w.Write([]byte(`{"id":"r1","logicalId":"l1","name":"x","action":"allow"}`)) + })) + defer srv.Close() + + client := newWithPrefix(srv.URL, "oc_key", "/v1") + payload := `{"conditions":null,"name":"x","rateLimit":null}` + if _, err := client.UpdatePolicyRule(context.Background(), "", "r1", json.RawMessage(payload)); err != nil { + t.Fatalf("UpdatePolicyRule: %v", err) + } + if string(rawBody) != payload { + t.Errorf("body = %s, want the verbatim payload (explicit nulls must survive)", rawBody) + } +} diff --git a/internal/api/policydiff.go b/internal/api/policydiff.go new file mode 100644 index 0000000..81a891d --- /dev/null +++ b/internal/api/policydiff.go @@ -0,0 +1,237 @@ +package api + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +// A faithful Go port of the OneCLI server's staged-changes diff +// (packages/api/src/lib/policy-diff.ts) — keep the two in sync. +// The diff covers CUSTOM rules + the Default Rule's action only: bridge- +// derived rows (blocklist/equipment) re-materialize with fresh logicalIds and +// legitimately differ between draft and published, so they never count as +// staged changes. Rules are keyed by logicalId — the generation-stable +// identity (row ids regenerate on every publish). + +// PolicyRuleChange is one added/changed/removed entry in a PolicyDiff. +type PolicyRuleChange struct { + LogicalID string `json:"logicalId"` + Name string `json:"name"` + Action string `json:"action"` + Details []string `json:"details"` +} + +// PolicyDefaultChange records a staged Default Rule action flip. +type PolicyDefaultChange struct { + From string `json:"from"` + To string `json:"to"` +} + +// PolicyDiff summarizes the staged (draft vs published) differences. +type PolicyDiff struct { + Added []PolicyRuleChange `json:"added"` + Changed []PolicyRuleChange `json:"changed"` + Removed []PolicyRuleChange `json:"removed"` + Reordered bool `json:"reordered"` + DefaultChange *PolicyDefaultChange `json:"defaultChange"` + Count int `json:"count"` +} + +// customsOf filters to user-owned rules (source "custom", non-default) in +// priority order — the only rows the staged diff considers. +func customsOf(rules []PolicyRule) []PolicyRule { + customs := make([]PolicyRule, 0, len(rules)) + for _, r := range rules { + if r.Source == "custom" && !r.IsDefault { + customs = append(customs, r) + } + } + sort.SliceStable(customs, func(i, j int) bool { + return customs[i].Priority < customs[j].Priority + }) + return customs +} + +func rateLabel(r PolicyRule) string { + if r.RateLimit != nil && r.RateLimitWindow != nil { + return fmt.Sprintf("%d/%s", *r.RateLimit, *r.RateLimitWindow) + } + return "none" +} + +func identitySig(r PolicyRule) string { + parts := make([]string, 0, len(r.Identities)) + for _, i := range r.Identities { + parts = append(parts, i.Type+":"+i.ID) + } + sort.Strings(parts) + return strings.Join(parts, ",") +} + +// targetSig canonicalizes a rule's targets for equality. Both sides come +// from the same server serialization, so a deterministic re-marshal of each +// target (fixed struct field order), sorted, is equality-faithful — the +// mirror of the TS lib's JSON.stringify-and-sort. +func targetSig(r PolicyRule) string { + parts := make([]string, 0, len(r.Targets)) + for _, t := range r.Targets { + b, err := json.Marshal(t) + if err != nil { + parts = append(parts, fmt.Sprintf("%+v", t)) + continue + } + parts = append(parts, string(b)) + } + sort.Strings(parts) + return strings.Join(parts, ",") +} + +func conditionsSig(r PolicyRule) string { + if r.Conditions == nil { + return "" + } + b, err := json.Marshal(r.Conditions) + if err != nil { + return fmt.Sprintf("%+v", r.Conditions) + } + return string(b) +} + +func actionLabel(a string) string { + if a == "allow" { + return "Allow" + } + return "Block" +} + +// strPtrEq mirrors the TS lib's `(a ?? null) !== (b ?? null)` comparison: +// nil and "" are DISTINCT values (collapsing them would hide a real staged +// edit — or invent one — and miscount the publish guard's pending total). +func strPtrEq(a, b *string) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +// describeRuleChanges lists the compact, human field-level differences +// between the draft and published versions of one rule. +func describeRuleChanges(draft, published PolicyRule) []string { + var details []string + if draft.Name != published.Name { + details = append(details, fmt.Sprintf("Renamed from %q", published.Name)) + } + if !strPtrEq(draft.Description, published.Description) { + details = append(details, "Description edited") + } + if draft.Action != published.Action { + details = append(details, fmt.Sprintf( + "Action: %s → %s", actionLabel(published.Action), actionLabel(draft.Action), + )) + } + if draft.Enabled != published.Enabled { + if draft.Enabled { + details = append(details, "Enabled") + } else { + details = append(details, "Disabled") + } + } + if identitySig(draft) != identitySig(published) { + details = append(details, "Applies-to edited") + } + if targetSig(draft) != targetSig(published) { + details = append(details, "Target edited") + } + if draft.RequireApproval != published.RequireApproval { + if draft.RequireApproval { + details = append(details, "Now requires approval") + } else { + details = append(details, "Approval removed") + } + } + if rateLabel(draft) != rateLabel(published) { + details = append(details, fmt.Sprintf( + "Rate limit: %s → %s", rateLabel(published), rateLabel(draft), + )) + } + if conditionsSig(draft) != conditionsSig(published) { + details = append(details, "Conditions edited") + } + return details +} + +// DiffPolicyChanges computes the staged diff between the draft and the +// active published generation (pass the two GET /rules lists plus the two +// GET /default results; nil defaults are tolerated). +func DiffPolicyChanges(draftRules, publishedRules []PolicyRule, draftDefault, publishedDefault *PolicyRule) PolicyDiff { + draft := customsOf(draftRules) + published := customsOf(publishedRules) + + publishedByID := make(map[string]PolicyRule, len(published)) + for _, r := range published { + publishedByID[r.LogicalID] = r + } + draftByID := make(map[string]PolicyRule, len(draft)) + for _, r := range draft { + draftByID[r.LogicalID] = r + } + + diff := PolicyDiff{ + Added: []PolicyRuleChange{}, + Changed: []PolicyRuleChange{}, + Removed: []PolicyRuleChange{}, + } + + for _, r := range draft { + prev, ok := publishedByID[r.LogicalID] + if !ok { + diff.Added = append(diff.Added, PolicyRuleChange{ + LogicalID: r.LogicalID, Name: r.Name, Action: r.Action, Details: []string{}, + }) + continue + } + if details := describeRuleChanges(r, prev); len(details) > 0 { + diff.Changed = append(diff.Changed, PolicyRuleChange{ + LogicalID: r.LogicalID, Name: r.Name, Action: r.Action, Details: details, + }) + } + } + for _, r := range published { + if _, ok := draftByID[r.LogicalID]; !ok { + diff.Removed = append(diff.Removed, PolicyRuleChange{ + LogicalID: r.LogicalID, Name: r.Name, Action: r.Action, Details: []string{}, + }) + } + } + + // Reorder: the rules both sides share, compared by relative order. + var draftOrder, publishedOrder []string + for _, r := range draft { + if _, ok := publishedByID[r.LogicalID]; ok { + draftOrder = append(draftOrder, r.LogicalID) + } + } + for _, r := range published { + if _, ok := draftByID[r.LogicalID]; ok { + publishedOrder = append(publishedOrder, r.LogicalID) + } + } + diff.Reordered = strings.Join(draftOrder, "|") != strings.Join(publishedOrder, "|") + + if draftDefault != nil && publishedDefault != nil && draftDefault.Action != publishedDefault.Action { + diff.DefaultChange = &PolicyDefaultChange{ + From: publishedDefault.Action, To: draftDefault.Action, + } + } + + diff.Count = len(diff.Added) + len(diff.Changed) + len(diff.Removed) + if diff.Reordered { + diff.Count++ + } + if diff.DefaultChange != nil { + diff.Count++ + } + return diff +} diff --git a/internal/api/policydiff_test.go b/internal/api/policydiff_test.go new file mode 100644 index 0000000..f988243 --- /dev/null +++ b/internal/api/policydiff_test.go @@ -0,0 +1,155 @@ +package api + +import "testing" + +// Golden cases mirroring the OneCLI server's policy-diff lib +// (packages/api/src/lib/policy-diff.ts) — the two implementations must agree. + +func diffRule(over func(*PolicyRule)) PolicyRule { + r := PolicyRule{ + ID: "id", + LogicalID: "l1", + Source: "custom", + Status: "draft", + Priority: 0, + Enabled: true, + Name: "rule", + Action: "allow", + Targets: []PolicyTarget{ + {Kind: "network", HostPattern: "a.com"}, + }, + } + if over != nil { + over(&r) + } + return r +} + +func TestDiffCleanScopeIsZero(t *testing.T) { + draft := []PolicyRule{diffRule(nil)} + published := []PolicyRule{diffRule(func(r *PolicyRule) { + r.ID = "other-row-id" // row ids differ across generations — irrelevant + r.Status = "published" + r.Priority = 7 // absolute priorities differ; relative order governs + })} + dDef := diffRule(func(r *PolicyRule) { r.IsDefault = true; r.Source = "default"; r.Action = "block" }) + pDef := diffRule(func(r *PolicyRule) { r.IsDefault = true; r.Source = "default"; r.Action = "block" }) + + diff := DiffPolicyChanges(draft, published, &dDef, &pDef) + if diff.Count != 0 { + t.Fatalf("clean scope: Count = %d, want 0 (%+v)", diff.Count, diff) + } +} + +func TestDiffExcludesDerivedSources(t *testing.T) { + // Derived rows churn logicalIds each rematerialization — they must never + // count as staged changes. + draft := []PolicyRule{ + diffRule(func(r *PolicyRule) { r.Source = "blocklist"; r.LogicalID = "fresh-1" }), + diffRule(func(r *PolicyRule) { r.Source = "equipment"; r.LogicalID = "fresh-2" }), + } + published := []PolicyRule{ + diffRule(func(r *PolicyRule) { r.Source = "blocklist"; r.LogicalID = "old-1"; r.Status = "published" }), + } + diff := DiffPolicyChanges(draft, published, nil, nil) + if diff.Count != 0 { + t.Fatalf("derived-only scope: Count = %d, want 0", diff.Count) + } +} + +func TestDiffAddedChangedRemoved(t *testing.T) { + draft := []PolicyRule{ + diffRule(func(r *PolicyRule) { r.LogicalID = "keep"; r.Action = "block" }), // changed + diffRule(func(r *PolicyRule) { r.LogicalID = "new"; r.Priority = 1 }), // added + } + published := []PolicyRule{ + diffRule(func(r *PolicyRule) { r.LogicalID = "keep"; r.Status = "published" }), + diffRule(func(r *PolicyRule) { r.LogicalID = "gone"; r.Status = "published"; r.Priority = 1 }), + } + diff := DiffPolicyChanges(draft, published, nil, nil) + if len(diff.Added) != 1 || diff.Added[0].LogicalID != "new" { + t.Errorf("Added = %+v", diff.Added) + } + if len(diff.Changed) != 1 || diff.Changed[0].LogicalID != "keep" { + t.Errorf("Changed = %+v", diff.Changed) + } + if len(diff.Changed) == 1 && diff.Changed[0].Details[0] != "Action: Allow → Block" { + t.Errorf("Changed details = %+v", diff.Changed[0].Details) + } + if len(diff.Removed) != 1 || diff.Removed[0].LogicalID != "gone" { + t.Errorf("Removed = %+v", diff.Removed) + } + if diff.Count != 3 { + t.Errorf("Count = %d, want 3", diff.Count) + } +} + +func TestDiffReorderAndDefaultChange(t *testing.T) { + draft := []PolicyRule{ + diffRule(func(r *PolicyRule) { r.LogicalID = "a"; r.Priority = 0 }), + diffRule(func(r *PolicyRule) { r.LogicalID = "b"; r.Priority = 1 }), + } + published := []PolicyRule{ + diffRule(func(r *PolicyRule) { r.LogicalID = "b"; r.Priority = 0; r.Status = "published" }), + diffRule(func(r *PolicyRule) { r.LogicalID = "a"; r.Priority = 1; r.Status = "published" }), + } + dDef := diffRule(func(r *PolicyRule) { r.IsDefault = true; r.Action = "block" }) + pDef := diffRule(func(r *PolicyRule) { r.IsDefault = true; r.Action = "allow" }) + + diff := DiffPolicyChanges(draft, published, &dDef, &pDef) + if !diff.Reordered { + t.Error("Reordered = false, want true") + } + if diff.DefaultChange == nil || diff.DefaultChange.From != "allow" || diff.DefaultChange.To != "block" { + t.Errorf("DefaultChange = %+v", diff.DefaultChange) + } + if diff.Count != 2 { + t.Errorf("Count = %d, want 2 (reorder + default)", diff.Count) + } +} + +func TestDiffFieldSensitivity(t *testing.T) { + base := func() PolicyRule { + return diffRule(func(r *PolicyRule) { r.Status = "published" }) + } + limit := 5 + window := "minute" + desc := "documented" + emptyDesc := "" + cases := []struct { + name string + mutate func(*PolicyRule) + detail string + }{ + {"rename", func(r *PolicyRule) { r.Name = "renamed" }, `Renamed from "rule"`}, + {"description", func(r *PolicyRule) { r.Description = &desc }, "Description edited"}, + // The TS lib compares `?? null`, so "" and null are DISTINCT values — + // collapsing them would hide (or invent) a staged edit. + {"description-empty-vs-null", func(r *PolicyRule) { r.Description = &emptyDesc }, "Description edited"}, + {"approval", func(r *PolicyRule) { r.RequireApproval = true }, "Now requires approval"}, + {"rate", func(r *PolicyRule) { r.RateLimit = &limit; r.RateLimitWindow = &window }, "Rate limit: none → 5/minute"}, + {"disable", func(r *PolicyRule) { r.Enabled = false }, "Disabled"}, + {"identity", func(r *PolicyRule) { r.Identities = []PolicyIdentity{{Type: "agent", ID: "a"}} }, "Applies-to edited"}, + {"target", func(r *PolicyRule) { r.Targets = []PolicyTarget{{Kind: "network", HostPattern: "b.com"}} }, "Target edited"}, + {"conditions", func(r *PolicyRule) { r.Conditions = []any{map[string]any{"target": "body"}} }, "Conditions edited"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + draftRow := diffRule(nil) + tc.mutate(&draftRow) + diff := DiffPolicyChanges([]PolicyRule{draftRow}, []PolicyRule{base()}, nil, nil) + if len(diff.Changed) != 1 { + t.Fatalf("Changed = %+v, want 1 entry", diff.Changed) + } + found := false + for _, d := range diff.Changed[0].Details { + if d == tc.detail { + found = true + } + } + if !found { + t.Errorf("Details = %+v, want %q", diff.Changed[0].Details, tc.detail) + } + }) + } +} diff --git a/pkg/exitcode/exitcode.go b/pkg/exitcode/exitcode.go index 5ace42a..5a11ada 100644 --- a/pkg/exitcode/exitcode.go +++ b/pkg/exitcode/exitcode.go @@ -16,4 +16,9 @@ const ( CodeNotFound = "NOT_FOUND" CodeConflict = "CONFLICT" CodeForbidden = "FORBIDDEN" + // CodeGone marks a retired endpoint (HTTP 410) — the action field names + // the replacement. CodeValidation marks a server-rejected payload (422). + // Both exit with the general Error code — the string code is the signal. + CodeGone = "GONE" + CodeValidation = "VALIDATION_ERROR" ) diff --git a/skills/onecli/SKILL.md b/skills/onecli/SKILL.md index dcb2e37..972bba7 100644 --- a/skills/onecli/SKILL.md +++ b/skills/onecli/SKILL.md @@ -57,6 +57,37 @@ onecli rules create --name "Limit Anthropic calls" \ --action rate_limit --rate-limit 100 --rate-limit-window hour ``` +Cloud deployments reject legacy `rules` writes (410) — use the `policy` +family there. Pre-cutover self-hosted servers still accept legacy writes and +reject `policy` writes (403); org policy routes are Cloud/Enterprise-only. + +### Manage policy-engine rules (cloud) + +```bash +onecli policy rules create --name "Limit Anthropic calls" --action allow \ + --targets '[{"kind":"network","hostPattern":"api.anthropic.com"}]' \ + --rate-limit 100 --rate-limit-window hour +onecli policy status +``` + +Policy invariants (make these explicit — they are not intuitable): + +- Writes land in a DRAFT and AUTO-PUBLISH only when the draft has no other + staged changes; otherwise the publish is withheld (`publishSkipped` in the + output) — review with `policy status`, then `policy publish` or re-run + with `--publish-all`. A publish snapshots the WHOLE draft, including + changes staged by other users. +- Enforced state = `policy rules list --status published`. Compare rules + across draft/published by `logicalId`, NEVER by `id` (published row ids + regenerate on every publish). +- `policy rules reorder --ordered-ids` must name EVERY non-default draft + rule exactly once (including system-managed blocklist/equipment rows the + web console hides) — take the full list from + `policy rules list --quiet id` (the default is unlimited). +- Always use `--dry-run` first on mutating policy commands. +- On pre-cutover self-hosted servers, `policy rules list` shows a staging + store that is NOT enforced (the legacy model still enforces there). + ### Check agent configuration ```bash