From 9e6060be3a536fb7ba24deb2dfdab6622a12f21a Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 12 Mar 2026 19:53:59 +0100 Subject: [PATCH] feat(CNAP-424): add approval-required policy action with actionable fail-fast errors --- src/approvals.ts | 89 +++++++++ src/errors.ts | 41 +++++ src/index.ts | 16 ++ src/platform/api.ts | 59 ++++++ src/policy/engine.ts | 144 +++++++++++++++ src/policy/types.ts | 31 ++++ test/approvals.test.ts | 399 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 779 insertions(+) create mode 100644 src/approvals.ts create mode 100644 src/errors.ts create mode 100644 src/platform/api.ts create mode 100644 src/policy/engine.ts create mode 100644 src/policy/types.ts create mode 100644 test/approvals.test.ts diff --git a/src/approvals.ts b/src/approvals.ts new file mode 100644 index 0000000..a11c0e1 --- /dev/null +++ b/src/approvals.ts @@ -0,0 +1,89 @@ +import type { PolicyRule } from "./policy/types.js"; + +export type ApprovalStatus = "pending" | "approved" | "denied"; + +export interface PendingApproval { + id: string; + namespace: string; + method: string; + path: string; + rule: PolicyRule; + requestedAt: number; + status: ApprovalStatus; + resolvedAt?: number; + ttlMs: number; // approval valid for this many ms after grant +} + +export class ApprovalStore { + private approvals = new Map(); + private ttlMs: number; + + constructor(ttlMs = 3600_000) { + this.ttlMs = ttlMs; + } + + create(params: { + namespace: string; + method: string; + path: string; + rule: PolicyRule; + }): PendingApproval { + const approval: PendingApproval = { + id: globalThis.crypto.randomUUID(), + ...params, + requestedAt: Date.now(), + status: "pending", + ttlMs: this.ttlMs, + }; + this.approvals.set(approval.id, approval); + return approval; + } + + get(id: string): PendingApproval | undefined { + return this.approvals.get(id); + } + + list(): PendingApproval[] { + return [...this.approvals.values()]; + } + + approve(id: string): PendingApproval | undefined { + const a = this.approvals.get(id); + if (!a || a.status !== "pending") return undefined; + a.status = "approved"; + a.resolvedAt = Date.now(); + return a; + } + + deny(id: string): PendingApproval | undefined { + const a = this.approvals.get(id); + if (!a || a.status !== "pending") return undefined; + a.status = "denied"; + a.resolvedAt = Date.now(); + return a; + } + + /** Returns true if there's an approved (non-expired) approval for this request */ + isApproved(params: { + namespace: string; + method: string; + path: string; + rule: PolicyRule; + }): boolean { + const now = Date.now(); + for (const a of this.approvals.values()) { + if ( + a.status === "approved" && + a.namespace === params.namespace && + a.method === params.method && + a.path === params.path && + a.rule.message === params.rule.message && + a.resolvedAt !== undefined && + now - a.resolvedAt < a.ttlMs + ) { + return true; + } + } + return false; + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..3de31c2 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,41 @@ +import type { PolicyRule } from "./policy/types.js"; + +export class CodemodeError extends Error { + constructor(message: string) { + super(message); + this.name = this.constructor.name; + } +} + +export class ApprovalRequiredError extends CodemodeError { + code = "APPROVAL_REQUIRED" as const; + namespace: string; + method: string; + path: string; + rule: PolicyRule; + approvalId: string; + approvalUrl?: string; + + constructor(params: { + namespace: string; + method: string; + path: string; + rule: PolicyRule; + approvalId: string; + approvalUrl?: string; + }) { + const msg = [ + `APPROVAL_REQUIRED: ${params.method} ${params.path} in namespace "${params.namespace}" requires approval.`, + params.rule.message ? `Rule: "${params.rule.message}"` : null, + params.approvalUrl ? `Request approval at: ${params.approvalUrl}` : null, + `After approval is granted, retry the same request.`, + ].filter(Boolean).join("\n"); + super(msg); + this.namespace = params.namespace; + this.method = params.method; + this.path = params.path; + this.rule = params.rule; + this.approvalId = params.approvalId; + this.approvalUrl = params.approvalUrl; + } +} diff --git a/src/index.ts b/src/index.ts index 36aa329..1806b94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,3 +28,19 @@ export { resolveRefs, processSpec, extractTags, extractServerBasePath } from "./ // Response truncation export { truncateResponse } from "./truncate.js"; + +// Errors +export { CodemodeError, ApprovalRequiredError } from "./errors.js"; + +// Approvals +export { ApprovalStore } from "./approvals.js"; +export type { ApprovalStatus, PendingApproval } from "./approvals.js"; + +// Policy +export { PolicyEngine } from "./policy/engine.js"; +export type { Policy, PolicyAction, PolicyRule } from "./policy/types.js"; +export type { PolicyEngineOptions } from "./policy/engine.js"; + +// Platform API +export { createPlatformApi } from "./platform/api.js"; +export type { PlatformApiOptions } from "./platform/api.js"; diff --git a/src/platform/api.ts b/src/platform/api.ts new file mode 100644 index 0000000..a2563a1 --- /dev/null +++ b/src/platform/api.ts @@ -0,0 +1,59 @@ +import { Hono } from "hono"; +import type { ApprovalStore } from "../approvals.js"; + +/** + * Options for creating the platform API router. + */ +export interface PlatformApiOptions { + approvalStore?: ApprovalStore; +} + +/** + * Create the platform REST API Hono app. + * + * Routes: + * GET /v1/approvals — list all approvals + * POST /v1/approvals/:id/approve — approve a pending approval + * POST /v1/approvals/:id/deny — deny a pending approval + */ +export function createPlatformApi(options: PlatformApiOptions = {}): Hono { + const app = new Hono(); + + // ── Approvals ─────────────────────────────────────────────────────────────── + + app.get("/v1/approvals", (c) => { + const store = options.approvalStore; + if (!store) { + return c.json({ error: "Approval store not configured" }, 503); + } + return c.json({ approvals: store.list() }); + }); + + app.post("/v1/approvals/:id/approve", (c) => { + const store = options.approvalStore; + if (!store) { + return c.json({ error: "Approval store not configured" }, 503); + } + const id = c.req.param("id"); + const approval = store.approve(id); + if (!approval) { + return c.json({ error: `Approval ${id} not found or not pending` }, 404); + } + return c.json({ approval }); + }); + + app.post("/v1/approvals/:id/deny", (c) => { + const store = options.approvalStore; + if (!store) { + return c.json({ error: "Approval store not configured" }, 503); + } + const id = c.req.param("id"); + const approval = store.deny(id); + if (!approval) { + return c.json({ error: `Approval ${id} not found or not pending` }, 404); + } + return c.json({ approval }); + }); + + return app; +} diff --git a/src/policy/engine.ts b/src/policy/engine.ts new file mode 100644 index 0000000..288fbc3 --- /dev/null +++ b/src/policy/engine.ts @@ -0,0 +1,144 @@ +import type { ApprovalStore } from "../approvals.js"; +import { ApprovalRequiredError } from "../errors.js"; +import type { Policy, PolicyRule } from "./types.js"; + +/** + * Convert a glob-style pattern to a RegExp. + * Supports: + * - `*` — matches any single path segment (no slashes) + * - `**` — matches any suffix (including slashes) + */ +function patternToRegex(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex specials (not * ?) + .replace(/\*\*/g, "__DOUBLE_STAR__") + .replace(/\*/g, "[^/]*") + .replace(/__DOUBLE_STAR__/g, ".*"); + return new RegExp(`^${escaped}$`, "i"); +} + +/** + * Find the first matching rule for a given method + path pair. + */ +function findMatchingRule( + rules: PolicyRule[], + method: string, + path: string, +): PolicyRule | undefined { + const upperMethod = method.toUpperCase(); + for (const rule of rules) { + const methodMatches = + rule.method === "*" || rule.method.toUpperCase() === upperMethod; + if (!methodMatches) continue; + + const pathRegex = patternToRegex(rule.path); + if (pathRegex.test(path)) { + return rule; + } + } + return undefined; +} + +/** + * Options for constructing a PolicyEngine. + */ +export interface PolicyEngineOptions { + /** Policies to evaluate in order. First matching rule across all policies wins. */ + policies: Policy[]; + /** Optional approval store — required to support `action: "approval"` rules. */ + approvalStore?: ApprovalStore; + /** Base URL used to construct approvalUrl in ApprovalRequiredError. */ + approvalBaseUrl?: string; +} + +/** + * PolicyEngine evaluates request policies and enforces allow/deny/approval rules. + */ +export class PolicyEngine { + private policies: Policy[]; + private approvalStore: ApprovalStore | undefined; + private approvalBaseUrl: string; + + constructor(options: PolicyEngineOptions) { + this.policies = options.policies; + this.approvalStore = options.approvalStore; + this.approvalBaseUrl = options.approvalBaseUrl ?? "http://localhost"; + } + + /** + * Evaluate a request against all policies. + * + * - "allow" → returns normally + * - "deny" → throws Error + * - "approval" → checks ApprovalStore; if approved passes through; if not, creates pending approval and throws ApprovalRequiredError + * + * If no rule matches, the request is allowed by default. + */ + evaluate(params: { + namespace: string; + method: string; + path: string; + }): void { + const { namespace, method, path } = params; + + let matchedRule: PolicyRule | undefined; + + for (const policy of this.policies) { + const rule = findMatchingRule(policy.rules, method, path); + if (rule) { + matchedRule = rule; + break; + } + } + + if (!matchedRule) { + // Default: allow + return; + } + + if (matchedRule.action === "allow") { + return; + } + + if (matchedRule.action === "deny") { + const msg = matchedRule.message + ? `Request denied: ${matchedRule.message}` + : `Request denied: ${method} ${path} is not allowed.`; + throw new Error(msg); + } + + if (matchedRule.action === "approval") { + if (!this.approvalStore) { + throw new Error( + "Policy requires approval but no ApprovalStore is configured.", + ); + } + + // Check if already approved + if ( + this.approvalStore.isApproved({ namespace, method, path, rule: matchedRule }) + ) { + return; + } + + // Create a pending approval record + const approval = this.approvalStore.create({ + namespace, + method, + path, + rule: matchedRule, + }); + + const approvalUrl = `${this.approvalBaseUrl}/v1/approvals/${approval.id}/approve`; + + throw new ApprovalRequiredError({ + namespace, + method, + path, + rule: matchedRule, + approvalId: approval.id, + approvalUrl, + }); + } + } +} diff --git a/src/policy/types.ts b/src/policy/types.ts new file mode 100644 index 0000000..a03b7ae --- /dev/null +++ b/src/policy/types.ts @@ -0,0 +1,31 @@ +/** + * Policy action — what to do when a rule matches. + */ +export type PolicyAction = "allow" | "deny" | "approval"; + +/** + * A single policy rule that matches requests by method/path pattern. + */ +export interface PolicyRule { + /** HTTP method to match (case-insensitive). Use "*" to match any method. */ + method: string; + /** Path pattern to match. Supports wildcards: "*" matches a single segment, "**" matches any suffix. */ + path: string; + /** Action to take when this rule matches. */ + action: PolicyAction; + /** Optional human-readable message to include in errors. */ + message?: string; +} + +/** + * A policy is a named set of rules evaluated in order. + * The first matching rule wins. If no rule matches, the default is "allow". + */ +export interface Policy { + /** Unique identifier for this policy. */ + id: string; + /** Human-readable name. */ + name: string; + /** Ordered list of rules — first match wins. */ + rules: PolicyRule[]; +} diff --git a/test/approvals.test.ts b/test/approvals.test.ts new file mode 100644 index 0000000..fe23746 --- /dev/null +++ b/test/approvals.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it, vi } from "vitest"; +import { ApprovalStore } from "../src/approvals.js"; +import { ApprovalRequiredError } from "../src/errors.js"; +import { PolicyEngine } from "../src/policy/engine.js"; +import type { Policy } from "../src/policy/types.js"; + +// ── ApprovalStore ───────────────────────────────────────────────────────────── + +describe("ApprovalStore", () => { + const rule = { method: "POST", path: "/v1/clusters", action: "approval" as const, message: "Requires admin approval" }; + + it("creates a pending approval", () => { + const store = new ApprovalStore(); + const approval = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + expect(approval.id).toBeTruthy(); + expect(approval.status).toBe("pending"); + expect(approval.namespace).toBe("k8s"); + expect(approval.method).toBe("POST"); + expect(approval.path).toBe("/v1/clusters"); + expect(approval.rule).toBe(rule); + }); + + it("get returns the created approval", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + expect(store.get(a.id)).toBe(a); + }); + + it("get returns undefined for unknown id", () => { + const store = new ApprovalStore(); + expect(store.get("nonexistent")).toBeUndefined(); + }); + + it("list returns all approvals", () => { + const store = new ApprovalStore(); + store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.create({ namespace: "k8s", method: "DELETE", path: "/v1/clusters/abc", rule }); + expect(store.list()).toHaveLength(2); + }); + + it("approve transitions status to approved", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + const approved = store.approve(a.id); + expect(approved?.status).toBe("approved"); + expect(approved?.resolvedAt).toBeDefined(); + }); + + it("approve returns undefined for already-approved approval", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.approve(a.id); + expect(store.approve(a.id)).toBeUndefined(); + }); + + it("deny transitions status to denied", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + const denied = store.deny(a.id); + expect(denied?.status).toBe("denied"); + expect(denied?.resolvedAt).toBeDefined(); + }); + + it("deny returns undefined for already-denied approval", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.deny(a.id); + expect(store.deny(a.id)).toBeUndefined(); + }); + + describe("isApproved", () => { + it("returns false when no approval exists", () => { + const store = new ApprovalStore(); + expect(store.isApproved({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule })).toBe(false); + }); + + it("returns false when approval is still pending", () => { + const store = new ApprovalStore(); + store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + expect(store.isApproved({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule })).toBe(false); + }); + + it("returns true when approval is approved and within TTL", () => { + const store = new ApprovalStore(3600_000); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.approve(a.id); + expect(store.isApproved({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule })).toBe(true); + }); + + it("returns false when approval is approved but TTL has expired", () => { + vi.useFakeTimers(); + const store = new ApprovalStore(1000); // 1 second TTL + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.approve(a.id); + + // Advance time past TTL + vi.advanceTimersByTime(2000); + + expect(store.isApproved({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule })).toBe(false); + vi.useRealTimers(); + }); + + it("returns false when approval is denied", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.deny(a.id); + expect(store.isApproved({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule })).toBe(false); + }); + + it("does not match different namespace", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.approve(a.id); + expect(store.isApproved({ namespace: "other", method: "POST", path: "/v1/clusters", rule })).toBe(false); + }); + + it("does not match different method", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.approve(a.id); + expect(store.isApproved({ namespace: "k8s", method: "DELETE", path: "/v1/clusters", rule })).toBe(false); + }); + + it("does not match different path", () => { + const store = new ApprovalStore(); + const a = store.create({ namespace: "k8s", method: "POST", path: "/v1/clusters", rule }); + store.approve(a.id); + expect(store.isApproved({ namespace: "k8s", method: "POST", path: "/v1/nodes", rule })).toBe(false); + }); + }); +}); + +// ── ApprovalRequiredError ───────────────────────────────────────────────────── + +describe("ApprovalRequiredError", () => { + const rule = { method: "POST", path: "/v1/clusters", action: "approval" as const, message: "Requires admin approval" }; + + it("has correct code", () => { + const err = new ApprovalRequiredError({ + namespace: "k8s", + method: "POST", + path: "/v1/clusters", + rule, + approvalId: "abc-123", + }); + expect(err.code).toBe("APPROVAL_REQUIRED"); + }); + + it("contains namespace, method, path in message", () => { + const err = new ApprovalRequiredError({ + namespace: "k8s", + method: "POST", + path: "/v1/clusters", + rule, + approvalId: "abc-123", + }); + expect(err.message).toContain("POST"); + expect(err.message).toContain("/v1/clusters"); + expect(err.message).toContain('"k8s"'); + }); + + it("includes rule message when present", () => { + const err = new ApprovalRequiredError({ + namespace: "k8s", + method: "POST", + path: "/v1/clusters", + rule, + approvalId: "abc-123", + }); + expect(err.message).toContain("Requires admin approval"); + }); + + it("includes approvalUrl when provided", () => { + const err = new ApprovalRequiredError({ + namespace: "k8s", + method: "POST", + path: "/v1/clusters", + rule, + approvalId: "abc-123", + approvalUrl: "http://localhost/v1/approvals/abc-123/approve", + }); + expect(err.message).toContain("http://localhost/v1/approvals/abc-123/approve"); + }); + + it("includes retry instruction", () => { + const err = new ApprovalRequiredError({ + namespace: "k8s", + method: "POST", + path: "/v1/clusters", + rule, + approvalId: "abc-123", + }); + expect(err.message).toContain("retry the same request"); + }); + + it("is an instance of Error", () => { + const err = new ApprovalRequiredError({ + namespace: "k8s", + method: "POST", + path: "/v1/clusters", + rule, + approvalId: "abc-123", + }); + expect(err).toBeInstanceOf(Error); + }); + + it("stores all fields", () => { + const err = new ApprovalRequiredError({ + namespace: "k8s", + method: "POST", + path: "/v1/clusters", + rule, + approvalId: "abc-123", + approvalUrl: "http://localhost/v1/approvals/abc-123/approve", + }); + expect(err.namespace).toBe("k8s"); + expect(err.method).toBe("POST"); + expect(err.path).toBe("/v1/clusters"); + expect(err.rule).toBe(rule); + expect(err.approvalId).toBe("abc-123"); + expect(err.approvalUrl).toBe("http://localhost/v1/approvals/abc-123/approve"); + }); +}); + +// ── PolicyEngine ────────────────────────────────────────────────────────────── + +describe("PolicyEngine", () => { + const approvalRule = { + method: "POST", + path: "/v1/clusters", + action: "approval" as const, + message: "Requires admin approval", + }; + + const denyRule = { + method: "DELETE", + path: "/v1/clusters/**", + action: "deny" as const, + message: "Deletions are not allowed", + }; + + const allowRule = { + method: "GET", + path: "/v1/**", + action: "allow" as const, + }; + + const policy: Policy = { + id: "test-policy", + name: "Test Policy", + rules: [approvalRule, denyRule, allowRule], + }; + + it("allows requests when no rule matches (default allow)", () => { + const engine = new PolicyEngine({ policies: [policy] }); + expect(() => engine.evaluate({ namespace: "k8s", method: "PATCH", path: "/v1/other" })).not.toThrow(); + }); + + it("allows requests matching an allow rule", () => { + const engine = new PolicyEngine({ policies: [policy] }); + expect(() => engine.evaluate({ namespace: "k8s", method: "GET", path: "/v1/clusters" })).not.toThrow(); + }); + + it("throws Error for deny rules", () => { + const engine = new PolicyEngine({ policies: [policy] }); + expect(() => engine.evaluate({ namespace: "k8s", method: "DELETE", path: "/v1/clusters/abc" })).toThrow("Deletions are not allowed"); + }); + + it("throws ApprovalRequiredError when approval rule matches and no store", () => { + const engine = new PolicyEngine({ policies: [policy] }); + expect(() => engine.evaluate({ namespace: "k8s", method: "POST", path: "/v1/clusters" })).toThrow( + "Policy requires approval but no ApprovalStore is configured.", + ); + }); + + it("throws ApprovalRequiredError when approval rule matches (with store, no approval yet)", () => { + const store = new ApprovalStore(); + const engine = new PolicyEngine({ policies: [policy], approvalStore: store }); + + expect(() => engine.evaluate({ namespace: "k8s", method: "POST", path: "/v1/clusters" })).toThrow( + ApprovalRequiredError, + ); + }); + + it("creates a pending approval in store when throwing ApprovalRequiredError", () => { + const store = new ApprovalStore(); + const engine = new PolicyEngine({ policies: [policy], approvalStore: store }); + + try { + engine.evaluate({ namespace: "k8s", method: "POST", path: "/v1/clusters" }); + } catch { + // expected + } + + const approvals = store.list(); + expect(approvals).toHaveLength(1); + expect(approvals[0]?.status).toBe("pending"); + expect(approvals[0]?.method).toBe("POST"); + expect(approvals[0]?.path).toBe("/v1/clusters"); + }); + + it("ApprovalRequiredError contains approvalUrl", () => { + const store = new ApprovalStore(); + const engine = new PolicyEngine({ + policies: [policy], + approvalStore: store, + approvalBaseUrl: "http://platform.example.com", + }); + + let caught: ApprovalRequiredError | undefined; + try { + engine.evaluate({ namespace: "k8s", method: "POST", path: "/v1/clusters" }); + } catch (err) { + if (err instanceof ApprovalRequiredError) caught = err; + } + + expect(caught).toBeDefined(); + expect(caught?.approvalUrl).toContain("http://platform.example.com/v1/approvals/"); + expect(caught?.approvalUrl).toContain("/approve"); + }); + + it("allows request after approval is granted (retry scenario)", () => { + const store = new ApprovalStore(); + const engine = new PolicyEngine({ policies: [policy], approvalStore: store }); + + // First call — should throw + let approvalId: string | undefined; + try { + engine.evaluate({ namespace: "k8s", method: "POST", path: "/v1/clusters" }); + } catch (err) { + if (err instanceof ApprovalRequiredError) { + approvalId = err.approvalId; + } + } + + expect(approvalId).toBeDefined(); + + // Approve it + store.approve(approvalId!); + + // Retry — should pass through + expect(() => engine.evaluate({ namespace: "k8s", method: "POST", path: "/v1/clusters" })).not.toThrow(); + }); + + it("does not allow request from a different namespace after approval", () => { + const store = new ApprovalStore(); + const engine = new PolicyEngine({ policies: [policy], approvalStore: store }); + + // First call for namespace "k8s" + let approvalId: string | undefined; + try { + engine.evaluate({ namespace: "k8s", method: "POST", path: "/v1/clusters" }); + } catch (err) { + if (err instanceof ApprovalRequiredError) approvalId = err.approvalId; + } + + store.approve(approvalId!); + + // Different namespace — should still require approval + expect(() => engine.evaluate({ namespace: "other", method: "POST", path: "/v1/clusters" })).toThrow( + ApprovalRequiredError, + ); + }); + + describe("path pattern matching", () => { + it("matches wildcard * in path", () => { + const p: Policy = { + id: "p", + name: "P", + rules: [{ method: "DELETE", path: "/v1/clusters/*", action: "deny" }], + }; + const engine = new PolicyEngine({ policies: [p] }); + expect(() => engine.evaluate({ namespace: "k8s", method: "DELETE", path: "/v1/clusters/my-cluster" })).toThrow(); + expect(() => engine.evaluate({ namespace: "k8s", method: "DELETE", path: "/v1/clusters/a/b" })).not.toThrow(); + }); + + it("matches double wildcard ** in path", () => { + const p: Policy = { + id: "p", + name: "P", + rules: [{ method: "DELETE", path: "/v1/**", action: "deny" }], + }; + const engine = new PolicyEngine({ policies: [p] }); + expect(() => engine.evaluate({ namespace: "k8s", method: "DELETE", path: "/v1/clusters/abc" })).toThrow(); + expect(() => engine.evaluate({ namespace: "k8s", method: "DELETE", path: "/v1/nodes" })).toThrow(); + }); + + it("matches wildcard method *", () => { + const p: Policy = { + id: "p", + name: "P", + rules: [{ method: "*", path: "/admin/**", action: "deny" }], + }; + const engine = new PolicyEngine({ policies: [p] }); + expect(() => engine.evaluate({ namespace: "k8s", method: "GET", path: "/admin/settings" })).toThrow(); + expect(() => engine.evaluate({ namespace: "k8s", method: "POST", path: "/admin/users" })).toThrow(); + }); + }); +});