diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cd84f383..940ec4a00 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -121,7 +121,7 @@ In `src/registry/index.ts`: - `"org"` — adds `accountIdentifier`, `orgIdentifier` - `"account"` — adds `accountIdentifier` only -Use `supportedScopes` when one Harness resource type can operate at multiple levels. For example, connectors, services, environments, infrastructure, secrets, and templates support `["account", "org", "project"]`. Tool callers select the level with `resource_scope`, and `harness_describe` surfaces both `supportedScopes` and a `scopeHint`. If `resource_scope` is omitted, the registry uses the resource's default `scope`; `scopeOptional: true` resources may omit org/project unless explicitly passed. +Use `supportedScopes` when one Harness resource type can operate at multiple levels. For example, connectors, services, environments, infrastructure, secrets, templates, policies, and policy sets support `["account", "org", "project"]`. Tool callers select the level with `resource_scope`, and `harness_describe` surfaces both `supportedScopes` and a `scopeHint`. If `resource_scope` is omitted, the registry uses the resource's default `scope`; `scopeOptional: true` resources may omit org/project unless explicitly passed. **pathParams** maps tool input field names to URL placeholders. For `path: "/api/things/{thingId}"` and `pathParams: { resource_id: "thingId" }`, the user's `resource_id` value replaces `{thingId}`. diff --git a/README.md b/README.md index 6102ec64d..6726e3a39 100644 --- a/README.md +++ b/README.md @@ -656,7 +656,7 @@ The server exposes 11 MCP tools. Most API tools accept `org_id` and `project_id` - `resource_scope: "org"` sends `accountIdentifier` and `orgIdentifier`. - `resource_scope: "project"` sends account, org, and project identifiers. -Current multi-scope resources include `connector`, `service`, `environment`, `infrastructure`, `secret`, `file_store`, and `template`. If `resource_scope` is omitted, the registry uses the resource's default scope and configured defaults, except resources marked as optional scope may omit org/project unless explicitly passed. Harness URLs can also set the scope automatically when the path contains account-level or project-level context. +Current multi-scope resources include `connector`, `service`, `environment`, `infrastructure`, `secret`, `file_store`, `template`, `policy`, and `policy_set`. If `resource_scope` is omitted, the registry uses the resource's default scope and configured defaults, except resources marked as optional scope may omit org/project unless explicitly passed. Harness URLs can also set the scope automatically when the path contains account-level or project-level context. **Structured output:** Every tool declares an MCP `outputSchema`. `harness_list` normalizes list-like Harness responses into object-shaped structured content so strict clients can validate it: top-level arrays become `{ "items": [...], "total": , "page": }`, and common wrapper keys such as `content`, `data`, `body`, `objects`, or `features` are hoisted to `items` when needed. The text response still contains the compact JSON payload returned to all clients. diff --git a/docs/gemini.md b/docs/gemini.md index dcd19dd01..1f1af05ca 100644 --- a/docs/gemini.md +++ b/docs/gemini.md @@ -134,7 +134,7 @@ Write operations (`harness_create`, `harness_update`, `harness_delete`, `harness Secret values are never exposed — only metadata (name, type, scope). -Multi-scope resources such as connectors, services, environments, infrastructure, secrets, and templates can be called with `resource_scope: "account" | "org" | "project"`. Use `harness_describe(resource_type="...")` to see `supportedScopes`. +Multi-scope resources such as connectors, services, environments, infrastructure, secrets, templates, policies, and policy sets can be called with `resource_scope: "account" | "org" | "project"`. Use `harness_describe(resource_type="...")` to see `supportedScopes`. ## Setup diff --git a/docs/testing/policy/test_plan.md b/docs/testing/policy/test_plan.md index 974bfc8fb..4afa639f6 100644 --- a/docs/testing/policy/test_plan.md +++ b/docs/testing/policy/test_plan.md @@ -32,6 +32,8 @@ | TC-pol-014 | Update | Update rego source | `harness_update(resource_type="policy", policy_id="test_policy", body={rego: "package harness\ndefault allow = false"})` | Rego source updated | | TC-pol-015 | Delete | Delete policy | `harness_delete(resource_type="policy", policy_id="test_policy")` | Policy deleted | | TC-pol-016 | Scope | Custom org and project | `harness_list(resource_type="policy", org_id="custom_org", project_id="custom_project")` | Returns policies for specified scope | +| TC-pol-016a | Scope | Account-level list | `harness_list(resource_type="policy", resource_scope="account")` | Returns account-level policies (no org/project query params) | +| TC-pol-016b | Scope | Org-level list | `harness_list(resource_type="policy", resource_scope="org", org_id="custom_org")` | Returns org-level policies (org only, no project) | | TC-pol-017 | Error | Get nonexistent policy | `harness_get(resource_type="policy", policy_id="nonexistent")` | Returns not found error | | TC-pol-018 | Error | Create without rego | `harness_create(resource_type="policy", body={identifier: "bad_policy", name: "Bad"})` | Returns validation error (rego required) | | TC-pol-019 | Error | Create duplicate identifier | `harness_create(resource_type="policy", body={identifier: "", name: "Dup", rego: "package x"})` | Returns conflict error | @@ -41,6 +43,7 @@ ## Notes - Uses v1 API: `/pm/api/v1/policies` - List uses `per_page` instead of standard `pageSize` (mapped from `size`) +- Supports account/org/project via `resource_scope` (default: project) - Create body: identifier (required), name (required), rego (required), git_connector_ref (optional), git_path (optional), git_repo (optional) - Update body (PATCH): name (optional), rego (optional) - Deep link: `/ng/account/{accountId}/all/orgs/{orgIdentifier}/projects/{projectIdentifier}/settings/governance/policies/edit/{identifier}` diff --git a/docs/testing/policy_set/test_plan.md b/docs/testing/policy_set/test_plan.md index 363ed683b..6ffc500dd 100644 --- a/docs/testing/policy_set/test_plan.md +++ b/docs/testing/policy_set/test_plan.md @@ -36,6 +36,8 @@ | TC-ps-018 | Update | Update policies list | `harness_update(resource_type="policy_set", policy_set_id="test_ps", body={policies: [{identifier: "", severity: "warning"}]})` | Linked policies updated | | TC-ps-019 | Delete | Delete policy set | `harness_delete(resource_type="policy_set", policy_set_id="test_ps")` | Policy set deleted | | TC-ps-020 | Scope | Custom org and project | `harness_list(resource_type="policy_set", org_id="custom_org", project_id="custom_project")` | Returns policy sets for specified scope | +| TC-ps-020a | Scope | Account-level list | `harness_list(resource_type="policy_set", resource_scope="account")` | Returns account-level policy sets (no org/project query params) | +| TC-ps-020b | Scope | Org-level list | `harness_list(resource_type="policy_set", resource_scope="org", org_id="custom_org")` | Returns org-level policy sets (org only, no project) | | TC-ps-021 | Error | Get nonexistent policy set | `harness_get(resource_type="policy_set", policy_set_id="nonexistent")` | Returns not found error | | TC-ps-022 | Error | Create without required fields | `harness_create(resource_type="policy_set", body={identifier: "bad_ps", name: "Bad"})` | Returns validation error (action, type, enabled required) | | TC-ps-023 | Edge | Search with no matches | `harness_list(resource_type="policy_set", search_term="zzz_nonexistent_zzz")` | Returns empty list | @@ -44,6 +46,7 @@ ## Notes - Uses v1 API: `/pm/api/v1/policysets` - List uses `per_page` instead of standard `pageSize` (mapped from `size`) +- Supports account/org/project via `resource_scope` (default: project) - Create body: identifier (required), name (required), action (required), type (required), enabled (required), description (optional), policies (optional array of {identifier, severity}), yaml_version (optional) - Update body (PATCH): name, action, type, enabled, description, policies, resource_groups, entity_selector — all optional - action values: onrun, onsave, onpush, etc. diff --git a/src/registry/toolsets/governance.ts b/src/registry/toolsets/governance.ts index 1eb810cfd..c316d9320 100644 --- a/src/registry/toolsets/governance.ts +++ b/src/registry/toolsets/governance.ts @@ -67,6 +67,7 @@ export const governanceToolset: ToolsetDefinition = { resourceType: "policy", displayName: "OPA Policy", description: "OPA Rego policy for Harness governance. Supports full CRUD. " + + "Use resource_scope='account'|'org'|'project' to target account-, org-, or project-level policies. Default is project. " + "Use this for SCS/SBOM enforcement — create deny-list or allow-list policies that control which components are permitted in your supply chain. " + "Policies are written in Rego and evaluated against SBOM components during enforcement. " + "To list SBOM/SSCA-enforcement policies specifically, pass filter type='sbom_enforcement' " @@ -80,6 +81,7 @@ export const governanceToolset: ToolsetDefinition = { ], toolset: "governance", scope: "project", + supportedScopes: ["account", "org", "project"], identifierFields: ["policy_id"], deepLinkTemplate: "/ng/account/{accountId}/all/orgs/{orgIdentifier}/projects/{projectIdentifier}/settings/governance/policies/edit/{identifier}", listFilterFields: [ @@ -151,6 +153,7 @@ export const governanceToolset: ToolsetDefinition = { resourceType: "policy_set", displayName: "OPA Policy Set", description: "Policy set grouping OPA policies with an enforcement action and an entity type. Supports full CRUD. " + + "Use resource_scope='account'|'org'|'project' to target account-, org-, or project-level policy sets. Default is project. " + "For SCS/SBOM enforcement, use filter type='sbom' to list the policy sets that apply deny-list or allow-list rules during artifact scans — " + "the SBOM enforcement step (e.g. action='onstep') binds these sets to the artifact pipeline. " + "IMPORTANT TYPE ASYMMETRY: policy sets use type='sbom', while the individual policies they contain use type='sbom_enforcement'. " @@ -166,6 +169,7 @@ export const governanceToolset: ToolsetDefinition = { ], toolset: "governance", scope: "project", + supportedScopes: ["account", "org", "project"], identifierFields: ["policy_set_id"], deepLinkTemplate: "/ng/account/{accountId}/all/orgs/{orgIdentifier}/projects/{projectIdentifier}/settings/governance/policy-sets/{identifier}", listFilterFields: [ diff --git a/tasks/todo.md b/tasks/todo.md index f86b0ae62..14d52e67a 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,5 +1,19 @@ # Harness MCP Server — Task Tracking +## OPA policy multi-scope (this session) +- [x] Add `supportedScopes: ["account", "org", "project"]` + description hints for `policy` and `policy_set` +- [x] Extend governance tests for supportedScopes and `resource_scope` query-param dispatch +- [x] Update README multi-scope list and policy/policy_set test plans; `pnpm build && pnpm docs:generate` +- [x] Run typecheck, governance tests, and standards:check + +### Plan +- Mirror connectors/secrets: keep default `scope: "project"`, add `supportedScopes`, do not set `scopeOptional`. +- No endpoint path changes — registry scope injection already handles org/project query params. + +### Review +- `policy` / `policy_set` declare `supportedScopes: ["account", "org", "project"]`. +- Governance + registry unit tests: 193 passed; standards:check: 77 passed; build/typecheck/docs:generate OK. + ## IaCM workspace create/update (merged #793) - [x] Add create/update tests and implement `iacm_workspace` writes - [x] bodySchema + medium_write policy + project-scope preflight diff --git a/tests/registry/governance.test.ts b/tests/registry/governance.test.ts index fb7a69f83..792e561cb 100644 --- a/tests/registry/governance.test.ts +++ b/tests/registry/governance.test.ts @@ -6,10 +6,14 @@ * - policy + policy_set descriptions mention SCS/SBOM enforcement * - policy + policy_set have relatedResources cross-referencing SCS compliance * - Structural integrity of governance CRUD operations + * - policy + policy_set support account/org/project via resource_scope */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { governanceToolset } from "../../src/registry/toolsets/governance.js"; +import { Registry } from "../../src/registry/index.js"; import type { ResourceDefinition, EndpointSpec } from "../../src/registry/types.js"; +import type { Config } from "../../src/config.js"; +import type { HarnessClient } from "../../src/client/harness-client.js"; /** Helper: find a resource definition by resourceType */ function findResource(type: string): ResourceDefinition { @@ -26,6 +30,33 @@ function getOp(type: string, op: string): EndpointSpec { return spec; } +function makeConfig(overrides: Partial = {}): Config { + return { + HARNESS_API_KEY: "pat.test", + HARNESS_ACCOUNT_ID: "test-account", + HARNESS_BASE_URL: "https://app.harness.io", + HARNESS_ORG: "default", + HARNESS_PROJECT: "test-project", + HARNESS_API_TIMEOUT_MS: 30000, + HARNESS_MAX_RETRIES: 3, + HARNESS_MAX_BODY_SIZE_MB: 10, + HARNESS_RATE_LIMIT_RPS: 10, + HARNESS_READ_ONLY: false, + HARNESS_SKIP_ELICITATION: false, + HARNESS_ALLOW_HTTP: false, + HARNESS_FME_BASE_URL: "https://api.split.io", + LOG_LEVEL: "info", + ...overrides, + }; +} + +function makeClient(requestFn?: (...args: unknown[]) => unknown): HarnessClient { + return { + request: requestFn ?? vi.fn().mockResolvedValue({ data: [] }), + account: "test-account", + } as unknown as HarnessClient; +} + // ─── P3-10: policy resource — SCS/SBOM enforcement enhancements ───────────── describe("P3-10: policy resource SCS enhancements", () => { @@ -373,3 +404,96 @@ describe("SSCA-6347 review: policy_set body schemas document SBOM type", () => { expect(actionField!.description).toContain("onstep"); }); }); + +// ─── Multi-scope: account / org / project via resource_scope ──────────────── + +describe("policy and policy_set multi-scope support", () => { + it.each(["policy", "policy_set"] as const)( + "%s declares supportedScopes account/org/project without scopeOptional", + (resourceType) => { + const res = findResource(resourceType); + expect(res.scope).toBe("project"); + expect(res.supportedScopes).toEqual(["account", "org", "project"]); + expect(res.scopeOptional).toBeFalsy(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s description documents resource_scope for account/org/project", + (resourceType) => { + const res = findResource(resourceType); + expect(res.description).toContain("resource_scope='account'|'org'|'project'"); + expect(res.description).toMatch(/Default is project/i); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s list omits org/project for resource_scope=account", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({ data: [] }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, resourceType, "list", { + resource_scope: "account", + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record }; + expect(call.params.orgIdentifier).toBeUndefined(); + expect(call.params.projectIdentifier).toBeUndefined(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s list injects only org for resource_scope=org", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({ data: [] }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, resourceType, "list", { + resource_scope: "org", + org_id: "platform", + }); + + const call = mockRequest.mock.calls[0][0] as { params: Record }; + expect(call.params.orgIdentifier).toBe("platform"); + expect(call.params.projectIdentifier).toBeUndefined(); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s list keeps project default when resource_scope is omitted", + async (resourceType) => { + const registry = new Registry(makeConfig({ HARNESS_TOOLSETS: "governance" })); + const mockRequest = vi.fn().mockResolvedValue({ data: [] }); + const client = makeClient(mockRequest); + + await registry.dispatch(client, resourceType, "list", {}); + + const call = mockRequest.mock.calls[0][0] as { params: Record }; + expect(call.params.orgIdentifier).toBe("default"); + expect(call.params.projectIdentifier).toBe("test-project"); + }, + ); + + it.each(["policy", "policy_set"] as const)( + "%s throws when resource_scope=org lacks org_id and HARNESS_ORG", + async (resourceType) => { + const registry = new Registry(makeConfig({ + HARNESS_TOOLSETS: "governance", + HARNESS_ORG: undefined, + HARNESS_PROJECT: undefined, + })); + const mockRequest = vi.fn().mockResolvedValue({ data: [] }); + const client = makeClient(mockRequest); + + await expect( + registry.dispatch(client, resourceType, "list", { + resource_scope: "org", + }), + ).rejects.toThrow(/resource_scope "org" requires org_id or HARNESS_ORG/); + expect(mockRequest).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/tests/registry/registry.test.ts b/tests/registry/registry.test.ts index 42bc80acb..cfe211aa0 100644 --- a/tests/registry/registry.test.ts +++ b/tests/registry/registry.test.ts @@ -637,6 +637,8 @@ describe("Registry", () => { ["infrastructure", "infrastructure"], ["secrets", "secret"], ["templates", "template"], + ["governance", "policy"], + ["governance", "policy_set"], ])("supports account/org/project list scoping for %s", async (toolset, resourceType) => { const scopedRegistry = new Registry(makeConfig({ HARNESS_TOOLSETS: toolset })); const mockRequest = vi.fn().mockResolvedValue({