From 34510075cffc533a157196190a31bec77d6ae49a Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 1 May 2026 10:53:55 -0400 Subject: [PATCH 1/2] chore: add policy opa crud --- apps/api/openapi/openapi.json | 29 ++++++ apps/api/openapi/schemas/policies.jsonnet | 19 ++++ apps/api/src/routes/v1/workspaces/policies.ts | 22 +++++ apps/api/src/types/openapi.ts | 10 ++ e2e/api/schema.ts | 10 ++ e2e/tests/api/policies/policies.spec.ts | 99 +++++++++++++++++++ packages/db/src/schema/policy.ts | 2 + packages/workspace-engine-sdk/src/schema.ts | 22 +++++ 8 files changed, 213 insertions(+) diff --git a/apps/api/openapi/openapi.json b/apps/api/openapi/openapi.json index 1f926b3e7..28555773e 100644 --- a/apps/api/openapi/openapi.json +++ b/apps/api/openapi/openapi.json @@ -209,6 +209,9 @@ "gradualRollout": { "$ref": "#/components/schemas/GradualRolloutRule" }, + "planValidationOpa": { + "$ref": "#/components/schemas/PlanValidationOpaRule" + }, "retry": { "$ref": "#/components/schemas/RetryRule" }, @@ -1588,6 +1591,26 @@ ], "type": "object" }, + "PlanValidationOpaRule": { + "properties": { + "description": { + "type": "string" + }, + "name": { + "description": "Human-readable rule name; used in check output to identify which rule produced a violation.", + "type": "string" + }, + "rego": { + "description": "Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }).", + "type": "string" + } + }, + "required": [ + "name", + "rego" + ], + "type": "object" + }, "Policy": { "properties": { "createdAt": { @@ -1665,6 +1688,9 @@ "id": { "type": "string" }, + "planValidationOpa": { + "$ref": "#/components/schemas/PlanValidationOpaRule" + }, "policyId": { "type": "string" }, @@ -2858,6 +2884,9 @@ "id": { "type": "string" }, + "planValidationOpa": { + "$ref": "#/components/schemas/PlanValidationOpaRule" + }, "policyId": { "type": "string" }, diff --git a/apps/api/openapi/schemas/policies.jsonnet b/apps/api/openapi/schemas/policies.jsonnet index 1bb2f5d3e..576467699 100644 --- a/apps/api/openapi/schemas/policies.jsonnet +++ b/apps/api/openapi/schemas/policies.jsonnet @@ -91,6 +91,7 @@ local openapi = import '../lib/openapi.libsonnet'; versionCooldown: openapi.schemaRef('VersionCooldownRule'), versionSelector: openapi.schemaRef('VersionSelectorRule'), retry: openapi.schemaRef('RetryRule'), + planValidationOpa: openapi.schemaRef('PlanValidationOpaRule'), }, }, @@ -106,6 +107,7 @@ local openapi = import '../lib/openapi.libsonnet'; versionCooldown: openapi.schemaRef('VersionCooldownRule'), versionSelector: openapi.schemaRef('VersionSelectorRule'), retry: openapi.schemaRef('RetryRule'), + planValidationOpa: openapi.schemaRef('PlanValidationOpaRule'), }, }, @@ -125,6 +127,23 @@ local openapi = import '../lib/openapi.libsonnet'; versionCooldown: openapi.schemaRef('VersionCooldownRule'), versionSelector: openapi.schemaRef('VersionSelectorRule'), retry: openapi.schemaRef('RetryRule'), + planValidationOpa: openapi.schemaRef('PlanValidationOpaRule'), + }, + }, + + PlanValidationOpaRule: { + type: 'object', + required: ['name', 'rego'], + properties: { + name: { + type: 'string', + description: 'Human-readable rule name; used in check output to identify which rule produced a violation.', + }, + description: { type: 'string' }, + rego: { + type: 'string', + description: 'Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }).', + }, }, }, diff --git a/apps/api/src/routes/v1/workspaces/policies.ts b/apps/api/src/routes/v1/workspaces/policies.ts index 39868724d..b77068c1b 100644 --- a/apps/api/src/routes/v1/workspaces/policies.ts +++ b/apps/api/src/routes/v1/workspaces/policies.ts @@ -41,6 +41,9 @@ const deleteAllRulesForPolicy = async (tx: Tx, policyId: string) => { await tx .delete(schema.policyRuleVersionSelector) .where(eq(schema.policyRuleVersionSelector.policyId, policyId)); + await tx + .delete(schema.policyRulePlanValidationOpa) + .where(eq(schema.policyRulePlanValidationOpa.policyId, policyId)); }; const insertPolicyRules = async (tx: Tx, policyId: string, rules: any[]) => { @@ -128,6 +131,15 @@ const insertPolicyRules = async (tx: Tx, policyId: string, rules: any[]) => { description: rule.versionSelector.description, selector: rule.versionSelector.selector, }); + + if (rule.planValidationOpa != null) + await tx.insert(schema.policyRulePlanValidationOpa).values({ + id: ruleId, + policyId, + name: rule.planValidationOpa.name, + description: rule.planValidationOpa.description, + rego: rule.planValidationOpa.rego, + }); } }; @@ -142,6 +154,7 @@ const policyWithRules = { verificationRules: true, versionCooldownRules: true, versionSelectorRules: true, + planValidationOpaRules: true, } as const; type PolicyRow = NonNullable< @@ -262,6 +275,15 @@ const formatPolicy = (p: PolicyRow) => { }, }), ), + ...p.planValidationOpaRules.map((r) => + formatPolicyRule(r.id, r.policyId, r.createdAt, { + planValidationOpa: { + name: r.name, + rego: r.rego, + ...(r.description != null && { description: r.description }), + }, + }), + ), ]; return { diff --git a/apps/api/src/types/openapi.ts b/apps/api/src/types/openapi.ts index 8b08e1f2c..16638a299 100644 --- a/apps/api/src/types/openapi.ts +++ b/apps/api/src/types/openapi.ts @@ -1199,6 +1199,7 @@ export interface components { deploymentWindow?: components["schemas"]["DeploymentWindowRule"]; environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; gradualRollout?: components["schemas"]["GradualRolloutRule"]; + planValidationOpa?: components["schemas"]["PlanValidationOpaRule"]; retry?: components["schemas"]["RetryRule"]; verification?: components["schemas"]["VerificationRule"]; versionCooldown?: components["schemas"]["VersionCooldownRule"]; @@ -1671,6 +1672,13 @@ export interface components { [key: string]: unknown; }; }; + PlanValidationOpaRule: { + description?: string; + /** @description Human-readable rule name; used in check output to identify which rule produced a violation. */ + name: string; + /** @description Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }). */ + rego: string; + }; Policy: { createdAt: string; description?: string; @@ -1695,6 +1703,7 @@ export interface components { environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; gradualRollout?: components["schemas"]["GradualRolloutRule"]; id: string; + planValidationOpa?: components["schemas"]["PlanValidationOpaRule"]; policyId: string; retry?: components["schemas"]["RetryRule"]; verification?: components["schemas"]["VerificationRule"]; @@ -2134,6 +2143,7 @@ export interface components { environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; gradualRollout?: components["schemas"]["GradualRolloutRule"]; id?: string; + planValidationOpa?: components["schemas"]["PlanValidationOpaRule"]; policyId?: string; retry?: components["schemas"]["RetryRule"]; verification?: components["schemas"]["VerificationRule"]; diff --git a/e2e/api/schema.ts b/e2e/api/schema.ts index 8b08e1f2c..16638a299 100644 --- a/e2e/api/schema.ts +++ b/e2e/api/schema.ts @@ -1199,6 +1199,7 @@ export interface components { deploymentWindow?: components["schemas"]["DeploymentWindowRule"]; environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; gradualRollout?: components["schemas"]["GradualRolloutRule"]; + planValidationOpa?: components["schemas"]["PlanValidationOpaRule"]; retry?: components["schemas"]["RetryRule"]; verification?: components["schemas"]["VerificationRule"]; versionCooldown?: components["schemas"]["VersionCooldownRule"]; @@ -1671,6 +1672,13 @@ export interface components { [key: string]: unknown; }; }; + PlanValidationOpaRule: { + description?: string; + /** @description Human-readable rule name; used in check output to identify which rule produced a violation. */ + name: string; + /** @description Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }). */ + rego: string; + }; Policy: { createdAt: string; description?: string; @@ -1695,6 +1703,7 @@ export interface components { environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; gradualRollout?: components["schemas"]["GradualRolloutRule"]; id: string; + planValidationOpa?: components["schemas"]["PlanValidationOpaRule"]; policyId: string; retry?: components["schemas"]["RetryRule"]; verification?: components["schemas"]["VerificationRule"]; @@ -2134,6 +2143,7 @@ export interface components { environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; gradualRollout?: components["schemas"]["GradualRolloutRule"]; id?: string; + planValidationOpa?: components["schemas"]["PlanValidationOpaRule"]; policyId?: string; retry?: components["schemas"]["RetryRule"]; verification?: components["schemas"]["VerificationRule"]; diff --git a/e2e/tests/api/policies/policies.spec.ts b/e2e/tests/api/policies/policies.spec.ts index 4da23b2f7..bb8f8e88f 100644 --- a/e2e/tests/api/policies/policies.spec.ts +++ b/e2e/tests/api/policies/policies.spec.ts @@ -784,6 +784,105 @@ test.describe("Policy API", () => { }); }); + test("should create a policy with planValidationOpa rule", async ({ + api, + workspace, + }) => { + const name = `policy-opa-${faker.string.alphanumeric(8)}`; + const rego = `package ctrlplane.plan_validation + +import rego.v1 + +deny contains msg if { + input.environment.name == "production" + msg := "production deploys require approval" +} +`; + const createRes = await api.POST("/v1/workspaces/{workspaceId}/policies", { + params: { path: { workspaceId: workspace.id } }, + body: { + name, + rules: [ + { + planValidationOpa: { + name: "require-prod-approval", + description: "Production deploys must be approved", + rego, + }, + }, + ], + }, + }); + + expect(createRes.response.status).toBe(202); + const policyId = createRes.data!.id; + const rules = createRes.data!.rules; + expect(rules).toHaveLength(1); + expect(rules[0]!.planValidationOpa).toEqual({ + name: "require-prod-approval", + description: "Production deploys must be approved", + rego, + }); + + const getRes = await api.GET( + "/v1/workspaces/{workspaceId}/policies/{policyId}", + { + params: { path: { workspaceId: workspace.id, policyId } }, + }, + ); + + expect(getRes.response.status).toBe(200); + expect(getRes.data!.rules[0]!.planValidationOpa).toEqual({ + name: "require-prod-approval", + description: "Production deploys must be approved", + rego, + }); + + await api.DELETE("/v1/workspaces/{workspaceId}/policies/{policyId}", { + params: { path: { workspaceId: workspace.id, policyId } }, + }); + }); + + test("should create a policy with planValidationOpa rule omitting description", async ({ + api, + workspace, + }) => { + const name = `policy-opa-min-${faker.string.alphanumeric(8)}`; + const rego = `package ctrlplane.plan_validation + +import rego.v1 + +deny contains msg if { + msg := "always denied" +} +`; + const createRes = await api.POST("/v1/workspaces/{workspaceId}/policies", { + params: { path: { workspaceId: workspace.id } }, + body: { + name, + rules: [ + { + planValidationOpa: { name: "always-deny", rego }, + }, + ], + }, + }); + + expect(createRes.response.status).toBe(202); + const policyId = createRes.data!.id; + const rules = createRes.data!.rules; + expect(rules).toHaveLength(1); + expect(rules[0]!.planValidationOpa).toMatchObject({ + name: "always-deny", + rego, + }); + expect(rules[0]!.planValidationOpa?.description).toBeUndefined(); + + await api.DELETE("/v1/workspaces/{workspaceId}/policies/{policyId}", { + params: { path: { workspaceId: workspace.id, policyId } }, + }); + }); + test("should create a policy with environmentProgression rule", async ({ api, workspace, diff --git a/packages/db/src/schema/policy.ts b/packages/db/src/schema/policy.ts index 376dae3e1..4ba1c8523 100644 --- a/packages/db/src/schema/policy.ts +++ b/packages/db/src/schema/policy.ts @@ -11,6 +11,7 @@ import { uuid, } from "drizzle-orm/pg-core"; +import { policyRulePlanValidationOpa } from "./deployment-plan.js"; import { workspace } from "./workspace.js"; export const policy = pgTable( @@ -51,6 +52,7 @@ export const policyRelations = relations(policy, ({ many }) => ({ verificationRules: many(policyRuleVerification), versionCooldownRules: many(policyRuleVersionCooldown), versionSelectorRules: many(policyRuleVersionSelector), + planValidationOpaRules: many(policyRulePlanValidationOpa), })); export const policyRuleAnyApproval = pgTable("policy_rule_any_approval", { diff --git a/packages/workspace-engine-sdk/src/schema.ts b/packages/workspace-engine-sdk/src/schema.ts index fc1a9cbf5..80bda15f4 100644 --- a/packages/workspace-engine-sdk/src/schema.ts +++ b/packages/workspace-engine-sdk/src/schema.ts @@ -637,6 +637,27 @@ export interface components { [key: string]: unknown; }; }; + PlanValidationOpaRule: { + description?: string; + /** @description Human-readable rule name; used in check output to identify which rule produced a violation. */ + name: string; + /** @description Rego v1 source code. Must define a `deny` rule set following the Conftest convention (deny contains msg if { ... }). */ + rego: string; + }; + PlanValidationResult: { + /** Format: date-time */ + evaluatedAt: string; + id: string; + passed: boolean; + /** @description ID of the deployment_plan_target_result this validation was run against. */ + resultId: string; + /** @description Polymorphic rule id. Resolves to a specific rule type (e.g. PlanValidationOpaRule) known by the writing controller. */ + ruleId: string; + violations: components["schemas"]["PlanValidationViolation"][]; + }; + PlanValidationViolation: { + message: string; + }; Policy: { createdAt: string; description?: string; @@ -666,6 +687,7 @@ export interface components { environmentProgression?: components["schemas"]["EnvironmentProgressionRule"]; gradualRollout?: components["schemas"]["GradualRolloutRule"]; id: string; + planValidationOpa?: components["schemas"]["PlanValidationOpaRule"]; policyId: string; retry?: components["schemas"]["RetryRule"]; rollback?: components["schemas"]["RollbackRule"]; From 582df3e6a73d88c1103206adb5b2a78ad0baa62d Mon Sep 17 00:00:00 2001 From: Aditya Choudhari Date: Fri, 1 May 2026 11:38:49 -0400 Subject: [PATCH 2/2] fix --- packages/db/src/schema/deployment-plan.ts | 28 ----------------------- packages/db/src/schema/policy.ts | 28 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/db/src/schema/deployment-plan.ts b/packages/db/src/schema/deployment-plan.ts index 0c9e50bc9..e1b126679 100644 --- a/packages/db/src/schema/deployment-plan.ts +++ b/packages/db/src/schema/deployment-plan.ts @@ -13,7 +13,6 @@ import { import { deployment } from "./deployment.js"; import { environment } from "./environment.js"; -import { policy } from "./policy.js"; import { release } from "./release.js"; import { resource } from "./resource.js"; import { workspace } from "./workspace.js"; @@ -188,33 +187,6 @@ export const deploymentPlanTargetVariableRelations = relations( }), ); -export const policyRulePlanValidationOpa = pgTable( - "policy_rule_plan_validation_opa", - { - id: uuid("id").primaryKey().defaultRandom(), - policyId: uuid("policy_id") - .notNull() - .references(() => policy.id, { onDelete: "cascade" }), - name: text("name").notNull(), - description: text("description"), - rego: text("rego").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => [index().on(t.policyId)], -); - -export const policyRulePlanValidationOpaRelations = relations( - policyRulePlanValidationOpa, - ({ one }) => ({ - policy: one(policy, { - fields: [policyRulePlanValidationOpa.policyId], - references: [policy.id], - }), - }), -); - type Violation = { message: string }; export const deploymentPlanTargetResultValidation = pgTable( diff --git a/packages/db/src/schema/policy.ts b/packages/db/src/schema/policy.ts index 4ba1c8523..27e2226d8 100644 --- a/packages/db/src/schema/policy.ts +++ b/packages/db/src/schema/policy.ts @@ -11,7 +11,6 @@ import { uuid, } from "drizzle-orm/pg-core"; -import { policyRulePlanValidationOpa } from "./deployment-plan.js"; import { workspace } from "./workspace.js"; export const policy = pgTable( @@ -299,3 +298,30 @@ export const policyRuleVersionSelectorRelations = relations( }), }), ); + +export const policyRulePlanValidationOpa = pgTable( + "policy_rule_plan_validation_opa", + { + id: uuid("id").primaryKey().defaultRandom(), + policyId: uuid("policy_id") + .notNull() + .references(() => policy.id, { onDelete: "cascade" }), + name: text("name").notNull(), + description: text("description"), + rego: text("rego").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [index().on(t.policyId)], +); + +export const policyRulePlanValidationOpaRelations = relations( + policyRulePlanValidationOpa, + ({ one }) => ({ + policy: one(policy, { + fields: [policyRulePlanValidationOpa.policyId], + references: [policy.id], + }), + }), +);