commit 59cacfdfabd5e161fd80fb9d7934b682a54fd156 Author: jdevega Date: Tue Sep 15 19:05:03 2026 +0200 feat: scaffold deployment-policies monorepo OPA/Rego deployment gateway policies: 5 rules (valid-environment, require-approvals, no-self-approval, block-weekends, freeze-window), a deploy-gate combining them, JSON schemas, scenario-driven tests, Gitea Actions CI (verify + publish to generic registry), changesets versioning, Makefile and scripts. diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..8fb37ba --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,38 @@ +# Changesets + +This repository uses [Changesets](https://github.com/changesets/changesets) to version the OPA +policy packages. Every `policies/rules/*` and `policies/gates/*` package is an independent npm +workspace; changesets records which packages changed so versions can be bumped consistently. + +## Adding a changeset + +Run from the repository root: + +```sh +make changeset +``` + +It opens an interactive prompt: select the packages you changed, the bump type (`patch` for +fixes/behaviour tweaks, `minor` for new rules, `major` for breaking input/data contract changes) +and a summary. This writes a file under `.changeset/`. + +Commit the changeset file together with your policy code changes. + +## Releasing + +When changesets are present on `main`, run: + +```sh +make version +``` + +This applies all changesets, rewrites the `version` field of affected `package.json`s and +regenerates `CHANGELOG.md` files (formatted with `oxfmt`). Commit the resulting bump. + +Bundles are built from `package.json#version`, so the registry upload uses the new version. + +## Gitea Actions + +CI verifies (format, lint, schema check, tests) and builds bundles on every push/PR. On `main` +the `publish` job uploads the bundles to the Gitea generic package registry using the +`GITEA_TOKEN` repository secret (requires `write:package` scope). \ No newline at end of file diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..6b1b2bd --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [], + "format": "oxfmt", + "privatePackages": { + "version": true, + "tag": false + } +} \ No newline at end of file diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..28f7083 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,59 @@ +name: ci + +on: + push: + branches: + - main + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install npm deps + run: npm ci + + - name: Install OPA + run: ./scripts/install-opa.sh + + - name: Check, lint and test + run: ./scripts/check.sh + + - name: Build bundles + run: ./scripts/build.sh + + publish: + if: github.ref == 'refs/heads/main' + needs: verify + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install npm deps + run: npm ci + + - name: Install OPA + run: ./scripts/install-opa.sh + + - name: Build bundles + run: ./scripts/build.sh + + - name: Publish bundles to Gitea package registry + env: + GITEA_URL: https://gitea.devegamoreno.com + GITEA_OWNER: jdevega + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: ./scripts/publish.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d789c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +tools/opa +*.log +.DS_Store \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ff39cfa --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ +SHELL := /bin/bash +SCRIPT_DIR := scripts +OPA := tools/opa + +.PHONY: help install check fmt lint test build publish changeset version + +help: + @echo "Targets:" + @echo " install Install pinned OPA binary into tools/" + @echo " check Rego fmt + regal lint (if present) + schema-check + tests" + @echo " fmt Format all .rego files with opa fmt" + @echo " lint Run regal lint (requires regal on PATH)" + @echo " test Run opa test for every rule and gate" + @echo " build Build OPA bundles into dist/" + @echo " publish Publish bundles to Gitea generic registry (needs GITEA_TOKEN)" + @echo " changeset Create a changeset for a new version" + @echo " version Apply changesets to bump package versions" + +install: + $(SCRIPT_DIR)/install-opa.sh + +check: + $(SCRIPT_DIR)/check.sh + +fmt: + $(OPA) fmt -w policies + +lint: + [ -z "$$(command -v regal)" ] || regal lint policies --format github + +test: + @for d in policies/rules/*/; do \ + name="$$(basename "$$d")"; \ + echo "== test $$name"; \ + $(OPA) test "$$d/policy.rego" "$$d/policy_test.rego" "$$d/scenarios.json" || exit 1; \ + done + @rules=""; \ + for d in policies/rules/*/; do rules="$$rules $$d/policy.rego"; done; \ + echo "== test deploy-gate"; \ + $(OPA) test policies/gates/deploy-gate/gate_test.rego policies/gates/deploy-gate/scenarios.json $$rules + +build: + $(SCRIPT_DIR)/build.sh + +publish: + $(SCRIPT_DIR)/publish.sh + +changeset: + npx changeset + +version: + npx changeset version \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..77d9815 --- /dev/null +++ b/README.md @@ -0,0 +1,172 @@ +# deployment-policies + +Small, composable OPA/Rego policies that act as **deployment gateways**. Each rule decides one +narrow question about a deployment request (environment validity, approvals, weekends, freeze +windows…), and gates bundle the rules you want to enforce together. + +- **Language:** Rego (OPA 1.x, `rego.v1` syntax) +- **Hosting:** Gitea (`gitea.devegamoreno.com`), repo `jdevega/deployment-policies` +- **CI/CD:** Gitea Actions (verify + build on every push/PR, publish on `main`) +- **Distribution:** Gitea generic package registry (rules and gates as OPA bundles) +- **Versioning:** actions/Changesets over npm workspaces + +## Repository layout + +``` +policies/ + rules/ + / # one deployable policy = one bundle + policy.rego # module in package rules.; exposes allow + violations + policy_test.rego # tests driven by scenarios.json + scenarios.json # input/data/expectation fixtures (loaded at data.scenarios) + schema/ # JSON Schemas for input and data (opa check --schema) + package.json # version + identity + gates/ + / # one deployable gate = bundle of the rules it depends on + gate_test.rego # integration tests over the combined rules + scenarios.json + schema/ + package.json # lists rule packages as dependencies +scripts/ # install-opa, check, build, publish +.gitea/workflows/ci.yml # Gitea Actions pipeline +Makefile # convenience targets +``` + +## Requirements + +- Node.js ≥ 22.11 (workspaces + changesets v3) +- `make`, `curl` (or `wget`) +- OPA binary installed via `make install` (pinned release, no Docker) +- `regal` on `PATH` for linting (optional, skipped if absent) + +## Quick start + +```sh +make install # install OPA v1.20.2 to tools/opa +npm ci # install changesets + oxfmt +make check # format check + regal lint + schema check + tests +make build # build rule and gate bundles into dist/ +``` + +## Rego conventions + +Every policy module targets **separate evaluation** and follows the same contract: + +- `package rules.` — one package per rule, no cross-file imports between rules. +- `allow` (boolean, `default false`) — this rule alone permits the deployment. +- `violations` (set of `{"code", "message"}`) — the reasons for denial. +- Input is read from `input.deployment` / `input.approvals`. +- Environment configuration and freeze windows are read from `data.deploygate.*` + (`config..min_approvals`, `config..block_weekends`, `freeze_windows[]`). + +Combining policies is done **at runtime** by the consumer: load one or more bundles, evaluate +each rule under `data.rules.` separately, and fold the `violations`/`allow` results +together. Gates simply ship several rules in one bundle for convenience. + +## Input contract + +| Field | Type | Used by | +| ----------------------------- | -------- | ---------------------------------- | +| `input.deployment.environment`| string | all rules | +| `input.deployment.service` | string | (informational) | +| `input.deployment.version` | string | (informational) | +| `input.deployment.requested_by`| string | no-self-approval | +| `input.deployment.created_at` | RFC3339 | block-weekends, freeze-window | +| `input.approvals[].by` | string | no-self-approval | +| `input.approvals[].active` | boolean | require-approvals, no-self-approval| +| `input.approvals[].at` | RFC3339 | (informational) | + +## Data contract + +| Field | Type | Read by | +| ---------------------------------------- | ------ | ------------------------------- | +| `data.deploygate.config..min_approvals` | int | require-approvals | +| `data.deploygate.config..block_weekends`| bool | block-weekends | +| `data.deploygate.freeze_windows[].environment` | string | freeze-window | +| `data.deploygate.freeze_windows[].start` | RFC3339 | freeze-window | +| `data.deploygate.freeze_windows[].end` | RFC3339 | freeze-window | + +Config lives in the data document (not in the bundle), so rules never embed environment-specific +settings. Bundles scope their roots to `rules`, letting callers merge `data.deploygate.*`. + +## Available rules + +| Rule | Package | Denies when | +| ----------------- | ------------------------ | ----------- | +| `valid-environment`| `rules.valid_environment`| environment is not in `data.deploygate.config` | +| `require-approvals`| `rules.require_approvals`| fewer active approvals than `min_approvals` | +| `no-self-approval` | `rules.no_self_approval` | an active approver is the requestor | +| `block-weekends` | `rules.block_weekends` | `created_at` is Sat/Sun and env has `block_weekends` | +| `freeze-window` | `rules.freeze_window` | deploy falls inside a matching freeze window | + +## Available gates + +| Gate | Package | Includes | +| ------------- | -------------------- | -------- | +| `deploy-gate` | `gate.deploy_gate_test` | all 5 rules | + +A gate's `package.json` lists its rules under `dependencies`; changesets bumps the gate when any +of its rules change, and `scripts/build.sh` bundles exactly those rule modules into the gate. + +## Testing + +Each rule carries `scenarios.json` with `{ scenarios: { : { input, data, expect } } }`. +`opa test` loads it at `data.scenarios` (top-level key, not filename). Tests assert both the +expected `violations` codes and `allow`. See `scripts/check.sh`; run with `make test`. + +## Building and publishing + +```sh +make build # dist/rules///bundle.tar.gz and dist/gates///bundle.tar.gz +make publish # upload all bundles to the Gitea generic registry +``` + +`publish.sh` needs: + +- `GITEA_TOKEN` (API token with `write:package`) +- `GITEA_URL` (default `https://gitea.devegamoreno.com`) +- `GITEA_OWNER` (default `jdevega`) + +Artifacts land at: + +``` +/api/packages/{owner}/generic/rule-//bundle.tar.gz +/api/packages/{owner}/generic/gate-//bundle.tar.gz +``` + +Bundles are built fresh in CI (`publish` job on `main`) and uploaded with `curl`. + +## Versioning + +On a new commit, run `make changeset`, select the changed packages and bump types, and commit the +generated changeset. Later run `make version` to apply them. See +[`.changeset/README.md`](.changeset/README.md). + +## CI + +`.gitea/workflows/ci.yml`: + +1. `verify` — `npm ci`, install OPA, `scripts/check.sh`, `scripts/build.sh` (every push/PR). +2. `publish` — on `main` only, rebuilds and uploads bundles with `GITEA_TOKEN`. + +Create the `GITEA_TOKEN` repository secret on Gitea with at least `write:package` scope. + +## Evaluating a bundle + +```sh +opa eval \ + --bundle dist/rules/valid-environment/0.0.0/bundle.tar.gz \ + --data deploygate.json \ + --input request.json \ + 'data.rules.valid_environment' +``` + +Or load a gate bundle and fuse the rule results yourself: + +```sh +opa eval --bundle dist/gates/deploy-gate//bundle.tar.gz --data deploygate.json \ + --input request.json \ + '{codes: {c | some r in [data.rules.valid_environment, data.rules.require_approvals]; some v in r.violations; c := v.code}}' +``` + +See the input/data contracts above for `deploygate.json` and `request.json` shapes. \ No newline at end of file diff --git a/docs/tech-stack.md b/docs/tech-stack.md new file mode 100644 index 0000000..4d6b074 --- /dev/null +++ b/docs/tech-stack.md @@ -0,0 +1,65 @@ +# Tech Stack + +## Overview + +| Layer | Technology | Version | Purpose | +| --------------- | ---------- | ------- | ------- | +| Policy language | Rego | OPA 1.x (`rego.v1`) | Authoring deployment-gateway policy rules and gates | +| Policy engine | OPA | `v1.20.2` (pinned) | Evaluating, testing, type-checking and bundling the policies | +| Policy linter | regal | `v0.42.0` | Static linting of Rego best practices (optional in CI) | +| Bundles | OPA bundle format | — | Distributable `.tar.gz` bundles (rule modules at `data.rules.*`) | +| Schemas | JSON Schema (draft 2020-12) | — | Static validation of `input` and `data` via `opa check --schema` | +| Versioning | Changesets | `@changesets/cli@3.0.3` | Tracking per-policy version bumps across npm workspaces | +| Changelog formatting | oxfmt | `^0.68.0` | Formatting generated changelogs/changesets via `format: "oxfmt"` | +| Package management | npm workspaces | npm 11 / Node 24 | Monorepo layout (`policies/rules/*`, `policies/gates/*`) | +| CI/CD | Gitea Actions | Gitea 1.27.0 | Verify, test, build and publish on `main` | +| Package registry| Gitea generic registry | — | Storing published rule/gate bundles | +| Build tooling | GNU make + bash | — | `Makefile` targets wrapping the scripts | +| Hosting | Gitea self-hosted | `1.27.0` | Source control, CI runners, package registry (gitea.devegamoreno.com) | + +## Justification + +- **OPA + Rego**: standard, policy-as-code engine for authorization/gating; bundles are the + portable distribution unit; binary CLI avoids container runtime requirements. +- **Pinned OPA `v1.20.2`**: reproducible engine version locally and in CI via `scripts/install-opa.sh` + (no Docker), so tests and `opa build` see identical behaviour everywhere. +- **`rego.v1`**: canonical OPA 1.x syntax (import of `rego.v1`), future-proof and unambiguous. +- **Per-rule JSON Schemas**: `opa check --strict --schema` catches input/data type mistakes at + authoring time instead of runtime. +- **Separate evaluation / small rules**: each rule is an independent package+bundle with a small, + testable contract; consumers combine the results at runtime, enabling reuse across gates. +- **Changesets over npm workspaces**: each rule/gate is an npm package solely for versioning; + changesets links repository-level changes to published registry versions. `oxfmt` is used only + to format changelog markdown (there is no `prettier-plugin-rego` on npm), while Rego formatting + is enforced by `opa fmt` in CI. +- **Gitea generic registry**: stores arbitrary files (`.tar.gz` bundles) under + `/api/packages/{owner}/generic/{package}/{version}/{file}` without adding an external registry + dependency. +- **npm workspaces** (over pnpm/yarn): zero extra tooling; changesets v3 requires Node ≥ 22.11 and + npm ≥ 10.9, both met. + +## Tools not used (and why) + +- **Docker/containers**: excluded by design; OPA ships a static binary, so containers add + unnecessary runtime complexity for tests, checks and bundle builds. +- **prettier-plugin-rego**: does not exist on npm (verified 404); Rego formatting is + instead owned by `opa fmt`. +- **pnpm/yarn**: changesets supports them, but npm workspaces suffice since these packages are + private versioning shims, not published JS packages. + +## Integration points + +- **Gitea Actions** reads `secrets.GITEA_TOKEN` (scope `write:package`) and + `GITEA_OWNER`/`GITEA_URL`; publish job runs only on `main`. +- **`scripts/publish.sh`** uploads via `curl` to the generic registry; package names are + prefixed `rule-*` and `gate-*`. +- **Consumer contract**: bundles scope roots to `rules` so operators can supply + `data.deploygate.*` configuration separately and still have static schema checks apply. + +## Environment (local dev machine) + +- macOS (darwin/arm64), zsh +- Node.js `v24.18.0`, npm `11.16.0` +- git `2.50.1`, GNU make +- `tools/opa` (OPA `v1.20.2` binary committed to the repo working tree via install script, not git) +- regal `v0.42.0` optional on `PATH` \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..af50a56 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "deployment-policies", + "private": true, + "version": "0.0.0", + "description": "Small composable OPA/Rego deployment gateway policies, tested, linted and published to the Gitea generic package registry.", + "workspaces": [ + "policies/rules/*", + "policies/gates/*" + ], + "devDependencies": { + "@changesets/cli": "^3.0.3", + "oxfmt": "^0.68.0" + } +} \ No newline at end of file diff --git a/policies/gates/deploy-gate/gate_test.rego b/policies/gates/deploy-gate/gate_test.rego new file mode 100644 index 0000000..075201d --- /dev/null +++ b/policies/gates/deploy-gate/gate_test.rego @@ -0,0 +1,49 @@ +# METADATA +# schemas: +# - input: schema["input"] +# - data: schema["data"] + +package gate.deploy_gate_test + +import data.rules.block_weekends as block_weekends +import data.rules.freeze_window as freeze_window +import data.rules.no_self_approval as no_self_approval +import data.rules.require_approvals as require_approvals +import data.rules.valid_environment as valid_environment +import rego.v1 + +violations_for[scenario_name] contains code if { + some scenario_name, scenario in data.scenarios + some v in valid_environment.violations with input as scenario.input with data.deploygate as scenario.data + code := v.code +} + +violations_for[scenario_name] contains code if { + some scenario_name, scenario in data.scenarios + some v in require_approvals.violations with input as scenario.input with data.deploygate as scenario.data + code := v.code +} + +violations_for[scenario_name] contains code if { + some scenario_name, scenario in data.scenarios + some v in no_self_approval.violations with input as scenario.input with data.deploygate as scenario.data + code := v.code +} + +violations_for[scenario_name] contains code if { + some scenario_name, scenario in data.scenarios + some v in block_weekends.violations with input as scenario.input with data.deploygate as scenario.data + code := v.code +} + +violations_for[scenario_name] contains code if { + some scenario_name, scenario in data.scenarios + some v in freeze_window.violations with input as scenario.input with data.deploygate as scenario.data + code := v.code +} + +test_scenario[scenario_name] if { + some scenario_name, scenario in data.scenarios + expected := {code | code := scenario.expect.violations[_]} + {code | code := violations_for[scenario_name][_]} == expected +} diff --git a/policies/gates/deploy-gate/package.json b/policies/gates/deploy-gate/package.json new file mode 100644 index 0000000..dd7c59f --- /dev/null +++ b/policies/gates/deploy-gate/package.json @@ -0,0 +1,13 @@ +{ + "name": "deploy-gate", + "version": "0.0.0", + "description": "Combined deployment gateway: validates the environment, approval counts, self-approval, weekends and freeze windows.", + "private": true, + "dependencies": { + "valid-environment": "0.0.0", + "require-approvals": "0.0.0", + "no-self-approval": "0.0.0", + "block-weekends": "0.0.0", + "freeze-window": "0.0.0" + } +} \ No newline at end of file diff --git a/policies/gates/deploy-gate/scenarios.json b/policies/gates/deploy-gate/scenarios.json new file mode 100644 index 0000000..ba2717d --- /dev/null +++ b/policies/gates/deploy-gate/scenarios.json @@ -0,0 +1,107 @@ +{ + "scenarios": { + "all_pass": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true}, + {"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2, "block_weekends": true} + }, + "freeze_windows": [ + {"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"} + ] + }, + "expect": { + "allow": true, + "violations": [] + } + }, + "weekend_plus_no_approvals": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-12T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-12T09:00:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2, "block_weekends": true} + }, + "freeze_windows": [] + }, + "expect": { + "allow": false, + "violations": ["weekend_deploy", "insufficient_approvals"] + } + }, + "freeze_window_blocks": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-12-24T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-12-24T09:00:00Z", "active": true}, + {"by": "carol", "at": "2026-12-24T09:30:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2, "block_weekends": false} + }, + "freeze_windows": [ + {"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"} + ] + }, + "expect": { + "allow": false, + "violations": ["deploy_in_freeze"] + } + }, + "self_approval": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "alice", "at": "2026-09-15T09:00:00Z", "active": true}, + {"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2, "block_weekends": true} + }, + "freeze_windows": [] + }, + "expect": { + "allow": false, + "violations": ["self_approval"] + } + } + } +} \ No newline at end of file diff --git a/policies/gates/deploy-gate/schema/data.json b/policies/gates/deploy-gate/schema/data.json new file mode 100644 index 0000000..9808858 --- /dev/null +++ b/policies/gates/deploy-gate/schema/data.json @@ -0,0 +1,45 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deploygate": { + "type": "object", + "additionalProperties": true, + "properties": { + "config": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true + } + }, + "freeze_windows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["environment", "start", "end"], + "properties": { + "environment": {"type": "string"}, + "start": {"type": "string", "format": "date-time"}, + "end": {"type": "string", "format": "date-time"} + } + } + } + } + }, + "rules": { + "type": "object", + "additionalProperties": true + }, + "gate": { + "type": "object", + "additionalProperties": true + }, + "scenarios": { + "type": "object", + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/policies/gates/deploy-gate/schema/input.json b/policies/gates/deploy-gate/schema/input.json new file mode 100644 index 0000000..d1f8006 --- /dev/null +++ b/policies/gates/deploy-gate/schema/input.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deployment": { + "type": "object", + "additionalProperties": true, + "required": ["environment", "service", "requested_by", "created_at"], + "properties": { + "environment": {"type": "string"}, + "service": {"type": "string"}, + "requested_by": {"type": "string"}, + "created_at": {"type": "string", "format": "date-time"} + } + }, + "approvals": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["by", "active"], + "properties": { + "by": {"type": "string"}, + "active": {"type": "boolean"}, + "at": {"type": "string", "format": "date-time"} + } + } + } + } +} \ No newline at end of file diff --git a/policies/rules/block-weekends/package.json b/policies/rules/block-weekends/package.json new file mode 100644 index 0000000..7214396 --- /dev/null +++ b/policies/rules/block-weekends/package.json @@ -0,0 +1,6 @@ +{ + "name": "block-weekends", + "version": "0.0.0", + "description": "Denies deployments that fall on a weekend when the environment has block_weekends enabled.", + "private": true +} \ No newline at end of file diff --git a/policies/rules/block-weekends/policy.rego b/policies/rules/block-weekends/policy.rego new file mode 100644 index 0000000..ee8cd72 --- /dev/null +++ b/policies/rules/block-weekends/policy.rego @@ -0,0 +1,21 @@ +# METADATA +# schemas: +# - input: schema["input"] +# - data: schema["data"] + +package rules.block_weekends + +import rego.v1 + +default allow := false + +allow if { + count(violations) == 0 +} + +violations contains {"code": "weekend_deploy", "message": sprintf("deployments blocked on %s for environment %q", [weekday, input.deployment.environment])} if { + input.deployment + weekday := time.weekday(time.parse_rfc3339_ns(input.deployment.created_at)) + weekday in {"Saturday", "Sunday"} + data.deploygate.config[input.deployment.environment].block_weekends +} diff --git a/policies/rules/block-weekends/policy_test.rego b/policies/rules/block-weekends/policy_test.rego new file mode 100644 index 0000000..0f37895 --- /dev/null +++ b/policies/rules/block-weekends/policy_test.rego @@ -0,0 +1,12 @@ +package rules.block_weekends_test + +import data.rules.block_weekends as rule +import rego.v1 + +test_scenarios[scenario_name] if { + some scenario_name, scenario in data.scenarios + actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data + expected := {code | code := scenario.expect.violations[_]} + actual == expected + rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data +} diff --git a/policies/rules/block-weekends/scenarios.json b/policies/rules/block-weekends/scenarios.json new file mode 100644 index 0000000..bdc2bbd --- /dev/null +++ b/policies/rules/block-weekends/scenarios.json @@ -0,0 +1,107 @@ +{ + "scenarios": { + "weekday_ok": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "config": { + "production": {"block_weekends": true} + } + }, + "expect": { + "allow": true, + "violations": [] + } + }, + "saturday_blocked": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-12T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "config": { + "production": {"block_weekends": true} + } + }, + "expect": { + "allow": false, + "violations": ["weekend_deploy"] + } + }, + "sunday_blocked": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-13T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "config": { + "production": {"block_weekends": true} + } + }, + "expect": { + "allow": false, + "violations": ["weekend_deploy"] + } + }, + "weekend_not_blocked_when_disabled": { + "input": { + "deployment": { + "environment": "development", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-12T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "config": { + "development": {"block_weekends": false} + } + }, + "expect": { + "allow": true, + "violations": [] + } + }, + "weekend_ignored_when_config_missing": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-12T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "config": {} + }, + "expect": { + "allow": true, + "violations": [] + } + } + } +} \ No newline at end of file diff --git a/policies/rules/block-weekends/schema/data.json b/policies/rules/block-weekends/schema/data.json new file mode 100644 index 0000000..005fcd9 --- /dev/null +++ b/policies/rules/block-weekends/schema/data.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deploygate": { + "type": "object", + "additionalProperties": true, + "properties": { + "config": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "block_weekends": {"type": "boolean"} + } + } + } + } + }, + "rules": { + "type": "object", + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/policies/rules/block-weekends/schema/input.json b/policies/rules/block-weekends/schema/input.json new file mode 100644 index 0000000..f136724 --- /dev/null +++ b/policies/rules/block-weekends/schema/input.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deployment": { + "type": "object", + "additionalProperties": true, + "required": ["environment", "created_at"], + "properties": { + "environment": {"type": "string"}, + "created_at": {"type": "string", "format": "date-time"} + } + } + } +} \ No newline at end of file diff --git a/policies/rules/freeze-window/package.json b/policies/rules/freeze-window/package.json new file mode 100644 index 0000000..0bbbc56 --- /dev/null +++ b/policies/rules/freeze-window/package.json @@ -0,0 +1,6 @@ +{ + "name": "freeze-window", + "version": "0.0.0", + "description": "Denies deployments that fall inside a scheduled freeze window for the environment.", + "private": true +} \ No newline at end of file diff --git a/policies/rules/freeze-window/policy.rego b/policies/rules/freeze-window/policy.rego new file mode 100644 index 0000000..3797385 --- /dev/null +++ b/policies/rules/freeze-window/policy.rego @@ -0,0 +1,22 @@ +# METADATA +# schemas: +# - input: schema["input"] +# - data: schema["data"] + +package rules.freeze_window + +import rego.v1 + +default allow := false + +allow if { + count(violations) == 0 +} + +violations contains {"code": "deploy_in_freeze", "message": sprintf("deployment within freeze window starting %s ending %s", [window.start, window.end])} if { + some window in data.deploygate.freeze_windows + window.environment == input.deployment.environment + created := time.parse_rfc3339_ns(input.deployment.created_at) + created >= time.parse_rfc3339_ns(window.start) + created <= time.parse_rfc3339_ns(window.end) +} diff --git a/policies/rules/freeze-window/policy_test.rego b/policies/rules/freeze-window/policy_test.rego new file mode 100644 index 0000000..f61f9a8 --- /dev/null +++ b/policies/rules/freeze-window/policy_test.rego @@ -0,0 +1,12 @@ +package rules.freeze_window_test + +import data.rules.freeze_window as rule +import rego.v1 + +test_scenarios[scenario_name] if { + some scenario_name, scenario in data.scenarios + actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data + expected := {code | code := scenario.expect.violations[_]} + actual == expected + rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data +} diff --git a/policies/rules/freeze-window/scenarios.json b/policies/rules/freeze-window/scenarios.json new file mode 100644 index 0000000..6e97a13 --- /dev/null +++ b/policies/rules/freeze-window/scenarios.json @@ -0,0 +1,67 @@ +{ + "scenarios": { + "outside_freeze": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "freeze_windows": [ + {"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"} + ] + }, + "expect": { + "allow": true, + "violations": [] + } + }, + "inside_freeze": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-12-24T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "freeze_windows": [ + {"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"} + ] + }, + "expect": { + "allow": false, + "violations": ["deploy_in_freeze"] + } + }, + "window_for_other_environment": { + "input": { + "deployment": { + "environment": "development", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-12-24T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "freeze_windows": [ + {"environment": "production", "start": "2026-12-20T00:00:00Z", "end": "2026-12-31T23:59:59Z"} + ] + }, + "expect": { + "allow": true, + "violations": [] + } + } + } +} \ No newline at end of file diff --git a/policies/rules/freeze-window/schema/data.json b/policies/rules/freeze-window/schema/data.json new file mode 100644 index 0000000..562ff39 --- /dev/null +++ b/policies/rules/freeze-window/schema/data.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deploygate": { + "type": "object", + "additionalProperties": true, + "properties": { + "freeze_windows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["environment", "start", "end"], + "properties": { + "environment": {"type": "string"}, + "start": {"type": "string", "format": "date-time"}, + "end": {"type": "string", "format": "date-time"} + } + } + } + } + }, + "rules": { + "type": "object", + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/policies/rules/freeze-window/schema/input.json b/policies/rules/freeze-window/schema/input.json new file mode 100644 index 0000000..f136724 --- /dev/null +++ b/policies/rules/freeze-window/schema/input.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deployment": { + "type": "object", + "additionalProperties": true, + "required": ["environment", "created_at"], + "properties": { + "environment": {"type": "string"}, + "created_at": {"type": "string", "format": "date-time"} + } + } + } +} \ No newline at end of file diff --git a/policies/rules/no-self-approval/package.json b/policies/rules/no-self-approval/package.json new file mode 100644 index 0000000..bfa9383 --- /dev/null +++ b/policies/rules/no-self-approval/package.json @@ -0,0 +1,6 @@ +{ + "name": "no-self-approval", + "version": "0.0.0", + "description": "Denies deployments where the requestor approved their own deployment.", + "private": true +} \ No newline at end of file diff --git a/policies/rules/no-self-approval/policy.rego b/policies/rules/no-self-approval/policy.rego new file mode 100644 index 0000000..96c0830 --- /dev/null +++ b/policies/rules/no-self-approval/policy.rego @@ -0,0 +1,25 @@ +# METADATA +# schemas: +# - input: schema["input"] +# - data: schema["data"] + +package rules.no_self_approval + +import rego.v1 + +default allow := false + +allow if { + count(violations) == 0 +} + +violations contains {"code": "self_approval", "message": sprintf("approval by %q is the deployment requestor", [input.deployment.requested_by])} if { + some approval in input.approvals + approval.active + approval.by == input.deployment.requested_by +} + +violations contains {"code": "missing_requestor", "message": "deployment has no requestor"} if { + input.deployment + not input.deployment.requested_by +} diff --git a/policies/rules/no-self-approval/policy_test.rego b/policies/rules/no-self-approval/policy_test.rego new file mode 100644 index 0000000..4f89565 --- /dev/null +++ b/policies/rules/no-self-approval/policy_test.rego @@ -0,0 +1,12 @@ +package rules.no_self_approval_test + +import data.rules.no_self_approval as rule +import rego.v1 + +test_scenarios[scenario_name] if { + some scenario_name, scenario in data.scenarios + actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data + expected := {code | code := scenario.expect.violations[_]} + actual == expected + rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data +} diff --git a/policies/rules/no-self-approval/scenarios.json b/policies/rules/no-self-approval/scenarios.json new file mode 100644 index 0000000..3c2641f --- /dev/null +++ b/policies/rules/no-self-approval/scenarios.json @@ -0,0 +1,76 @@ +{ + "scenarios": { + "others_approve": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true}, + {"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2} + } + }, + "expect": { + "allow": true, + "violations": [] + } + }, + "self_approval": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "alice", "at": "2026-09-15T09:00:00Z", "active": true}, + {"by": "bob", "at": "2026-09-15T09:30:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2} + } + }, + "expect": { + "allow": false, + "violations": ["self_approval"] + } + }, + "revoked_self_approval_ignored": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "alice", "at": "2026-09-15T09:00:00Z", "active": false}, + {"by": "bob", "at": "2026-09-15T09:30:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2} + } + }, + "expect": { + "allow": true, + "violations": [] + } + } + } +} \ No newline at end of file diff --git a/policies/rules/no-self-approval/schema/data.json b/policies/rules/no-self-approval/schema/data.json new file mode 100644 index 0000000..96fe7f1 --- /dev/null +++ b/policies/rules/no-self-approval/schema/data.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deploygate": { + "type": "object", + "additionalProperties": true + }, + "rules": { + "type": "object", + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/policies/rules/no-self-approval/schema/input.json b/policies/rules/no-self-approval/schema/input.json new file mode 100644 index 0000000..197fb38 --- /dev/null +++ b/policies/rules/no-self-approval/schema/input.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deployment": { + "type": "object", + "additionalProperties": true, + "required": ["requested_by"], + "properties": { + "requested_by": {"type": "string"} + } + }, + "approvals": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["active", "by"], + "properties": { + "active": {"type": "boolean"}, + "by": {"type": "string"} + } + } + } + } +} \ No newline at end of file diff --git a/policies/rules/require-approvals/package.json b/policies/rules/require-approvals/package.json new file mode 100644 index 0000000..4f08196 --- /dev/null +++ b/policies/rules/require-approvals/package.json @@ -0,0 +1,6 @@ +{ + "name": "require-approvals", + "version": "0.0.0", + "description": "Denies deployments that do not have a sufficient number of active approvals.", + "private": true +} \ No newline at end of file diff --git a/policies/rules/require-approvals/policy.rego b/policies/rules/require-approvals/policy.rego new file mode 100644 index 0000000..3366ac5 --- /dev/null +++ b/policies/rules/require-approvals/policy.rego @@ -0,0 +1,34 @@ +# METADATA +# schemas: +# - input: schema["input"] +# - data: schema["data"] + +package rules.require_approvals + +import rego.v1 + +default allow := false + +allow if { + count(violations) == 0 +} + +violations contains {"code": "insufficient_approvals", "message": sprintf("need at least %d approval(s), got %d", [min_approvals, count(active_approvals)])} if { + cfg := data.deploygate.config[input.deployment.environment] + cfg.min_approvals > 0 + count(active_approvals) < cfg.min_approvals +} + +min_approvals := data.deploygate.config[input.deployment.environment].min_approvals if { + input.deployment +} + +active_approvals contains approval if { + some approval in input.approvals + approval.active +} + +violations contains {"code": "not_configured", "message": sprintf("no min_approvals configured for environment %q", [input.deployment.environment])} if { + input.deployment + not data.deploygate.config[input.deployment.environment].min_approvals +} diff --git a/policies/rules/require-approvals/policy_test.rego b/policies/rules/require-approvals/policy_test.rego new file mode 100644 index 0000000..97b0b86 --- /dev/null +++ b/policies/rules/require-approvals/policy_test.rego @@ -0,0 +1,12 @@ +package rules.require_approvals_test + +import data.rules.require_approvals as rule +import rego.v1 + +test_scenarios[scenario_name] if { + some scenario_name, scenario in data.scenarios + actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data + expected := {code | code := scenario.expect.violations[_]} + actual == expected + rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data +} diff --git a/policies/rules/require-approvals/scenarios.json b/policies/rules/require-approvals/scenarios.json new file mode 100644 index 0000000..c35f029 --- /dev/null +++ b/policies/rules/require-approvals/scenarios.json @@ -0,0 +1,96 @@ +{ + "scenarios": { + "enough_approvals": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true}, + {"by": "carol", "at": "2026-09-15T09:30:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2} + } + }, + "expect": { + "allow": true, + "violations": [] + } + }, + "insufficient_approvals": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2} + } + }, + "expect": { + "allow": false, + "violations": ["insufficient_approvals"] + } + }, + "revoked_approval_not_counted": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true}, + {"by": "carol", "at": "2026-09-15T09:30:00Z", "active": false} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2} + } + }, + "expect": { + "allow": false, + "violations": ["insufficient_approvals"] + } + }, + "no_approval_required": { + "input": { + "deployment": { + "environment": "development", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [] + }, + "data": { + "config": { + "development": {"min_approvals": 0} + } + }, + "expect": { + "allow": true, + "violations": [] + } + } + } +} \ No newline at end of file diff --git a/policies/rules/require-approvals/schema/data.json b/policies/rules/require-approvals/schema/data.json new file mode 100644 index 0000000..dd7d82c --- /dev/null +++ b/policies/rules/require-approvals/schema/data.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deploygate": { + "type": "object", + "additionalProperties": true, + "properties": { + "config": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": true, + "properties": { + "min_approvals": {"type": "integer", "minimum": 0} + } + } + } + } + }, + "rules": { + "type": "object", + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/policies/rules/require-approvals/schema/input.json b/policies/rules/require-approvals/schema/input.json new file mode 100644 index 0000000..430f273 --- /dev/null +++ b/policies/rules/require-approvals/schema/input.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deployment": { + "type": "object", + "additionalProperties": true, + "required": ["environment"], + "properties": { + "environment": {"type": "string"} + } + }, + "approvals": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true, + "required": ["active"], + "properties": { + "active": {"type": "boolean"} + } + } + } + } +} \ No newline at end of file diff --git a/policies/rules/valid-environment/package.json b/policies/rules/valid-environment/package.json new file mode 100644 index 0000000..0600cde --- /dev/null +++ b/policies/rules/valid-environment/package.json @@ -0,0 +1,6 @@ +{ + "name": "valid-environment", + "version": "0.0.0", + "description": "Denies deployments to environments that are not configured in data.deploygate.config.", + "private": true +} \ No newline at end of file diff --git a/policies/rules/valid-environment/policy.rego b/policies/rules/valid-environment/policy.rego new file mode 100644 index 0000000..59a1bb0 --- /dev/null +++ b/policies/rules/valid-environment/policy.rego @@ -0,0 +1,19 @@ +# METADATA +# schemas: +# - input: schema["input"] +# - data: schema["data"] + +package rules.valid_environment + +import rego.v1 + +default allow := false + +allow if { + count(violations) == 0 +} + +violations contains {"code": "unknown_environment", "message": sprintf("environment %q is not configured", [input.deployment.environment])} if { + input.deployment + not data.deploygate.config[input.deployment.environment] +} diff --git a/policies/rules/valid-environment/policy_test.rego b/policies/rules/valid-environment/policy_test.rego new file mode 100644 index 0000000..7c8e623 --- /dev/null +++ b/policies/rules/valid-environment/policy_test.rego @@ -0,0 +1,12 @@ +package rules.valid_environment_test + +import data.rules.valid_environment as rule +import rego.v1 + +test_scenarios[scenario_name] if { + some scenario_name, scenario in data.scenarios + actual := {code | code := rule.violations[_].code} with input as scenario.input with data.deploygate as scenario.data + expected := {code | code := scenario.expect.violations[_]} + actual == expected + rule.allow == scenario.expect.allow with input as scenario.input with data.deploygate as scenario.data +} diff --git a/policies/rules/valid-environment/scenarios.json b/policies/rules/valid-environment/scenarios.json new file mode 100644 index 0000000..9dca09b --- /dev/null +++ b/policies/rules/valid-environment/scenarios.json @@ -0,0 +1,50 @@ +{ + "scenarios": { + "valid_production": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true} + ] + }, + "data": { + "config": { + "production": {"min_approvals": 2, "block_weekends": true} + } + }, + "expect": { + "allow": true, + "violations": [] + } + }, + "unknown_environment": { + "input": { + "deployment": { + "environment": "production", + "service": "payments-api", + "version": "1.2.3", + "requested_by": "alice", + "created_at": "2026-09-15T10:00:00Z" + }, + "approvals": [ + {"by": "bob", "at": "2026-09-15T09:00:00Z", "active": true} + ] + }, + "data": { + "config": { + "staging": {"min_approvals": 1} + } + }, + "expect": { + "allow": false, + "violations": ["unknown_environment"] + } + } + } +} \ No newline at end of file diff --git a/policies/rules/valid-environment/schema/data.json b/policies/rules/valid-environment/schema/data.json new file mode 100644 index 0000000..5372b58 --- /dev/null +++ b/policies/rules/valid-environment/schema/data.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deploygate": { + "type": "object", + "additionalProperties": true, + "required": ["config"], + "properties": { + "config": { + "type": "object", + "additionalProperties": { + "type": "object" + } + } + } + }, + "rules": { + "type": "object", + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/policies/rules/valid-environment/schema/input.json b/policies/rules/valid-environment/schema/input.json new file mode 100644 index 0000000..b11f90a --- /dev/null +++ b/policies/rules/valid-environment/schema/input.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "deployment": { + "type": "object", + "additionalProperties": true, + "required": ["environment"], + "properties": { + "environment": {"type": "string"} + } + } + } +} \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..ee4908c --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# Builds deployable OPA bundles for every rule and gate. +# +# Composition model: SEPARATE EVALUATION. +# - Each rule is built as its own bundle where the module lives at data.rules.. +# - Each gate bundles the policy.rego of all its dependency rules; consumers evaluate +# data.rules. per rule and combine results at runtime. + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +rm -rf "$ARTIFACTS_DIR" +STAGE_DIR="$(mktemp -d)" +trap 'rm -rf "$STAGE_DIR"' EXIT + +MANIFEST='{"revision":"","roots":["rules"],"rego_version":1}' + +write_manifest() { + printf '%s' "$MANIFEST" > "$1/.manifest" +} + +# --- rules --- +for d in "$POLICIES_DIR"/rules/*/; do + name="$(repo_name "$d")" + version="$(repo_version "$d")" + out="$ARTIFACTS_DIR/rules/$name/$version" + mkdir -p "$out" + echo "==> build rule $name@$version" + mkdir -p "$STAGE_DIR/rule/$name" + cp "$d/policy.rego" "$STAGE_DIR/rule/$name/policy.rego" + write_manifest "$STAGE_DIR/rule/$name" + (cd "$STAGE_DIR/rule/$name" && "$OPA" build -b . -o "$out/bundle.tar.gz") +done + +# --- gates --- +for g in "$POLICIES_DIR"/gates/*/; do + name="$(repo_name "$g")" + version="$(repo_version "$g")" + out="$ARTIFACTS_DIR/gates/$name/$version" + mkdir -p "$out" + echo "==> build gate $name@$version" + stage="$STAGE_DIR/gate/$name" + for dep in $(node -p "Object.keys(require('$g/package.json').dependencies ?? {}).join(' ')"); do + for r in "$POLICIES_DIR"/rules/*/; do + rname="$(repo_name "$r")" + if [ "$rname" = "$dep" ]; then + mkdir -p "$stage/$rname" + cp "$r/policy.rego" "$stage/$rname/policy.rego" + break + fi + done + done + [ -d "$stage" ] || { echo "ERROR: gate $name has no buildable dependencies" >&2; exit 1; } + write_manifest "$stage" + (cd "$stage" && "$OPA" build -b . -o "$out/bundle.tar.gz") +done + +echo "Artifacts written under $ARTIFACTS_DIR" \ No newline at end of file diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..e21d9af --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Lints, schema-checks and tests every rule and gate. + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +fail() { + echo "FAIL: $1" >&2 + exit 1 +} + +# Rego formatting. +echo "==> opa fmt --fail" +"$OPA" fmt --fail --diff "$POLICIES_DIR" || fail "rego files are not formatted (run 'make fmt' or 'opa fmt -w')" + +# Lint with regal if available. +if command -v regal >/dev/null 2>&1; then + echo "==> regal lint" + regal lint "$POLICIES_DIR" --format github || fail "regal lint found issues" +else + echo "==> regal not found, skipping" +fi + +RULES_ARG="" +rule_dirs=( "$POLICIES_DIR"/rules/*/ ) +[ "${#rule_dirs[@]}" -gt 0 ] && [ -d "${rule_dirs[0]}" ] || fail "no rules found" + +# Schema-check + unit tests per rule (runs are isolated so each rule's scenarios.json loads at data.scenarios). +for d in "${rule_dirs[@]}"; do + name="$(basename "$d")" + echo "==> check ${name}" + "$OPA" check --strict --schema "$d/schema" "$d/policy.rego" "$d/policy_test.rego" || fail "check ${name}" + echo "==> test ${name}" + "$OPA" test "$d/policy.rego" "$d/policy_test.rego" "$d/scenarios.json" || fail "test ${name}" + RULES_ARG="$RULES_ARG $d/policy.rego" +done + +# Gates: check + tests with all rule policy.rego files loaded so data.rules.* resolves. +gate_dirs=( "$POLICIES_DIR"/gates/*/ ) +for g in "${gate_dirs[@]}"; do + [ -d "$g" ] || continue + name="$(basename "$g")" + echo "==> check ${name}" + "$OPA" check --strict --schema "$g/schema" "$g/gate_test.rego" || fail "check ${name}" + echo "==> test ${name}" + # shellcheck disable=SC2086 + "$OPA" test "$g/gate_test.rego" "$g/scenarios.json" $RULES_ARG || fail "test ${name}" +done + +echo "All checks passed." \ No newline at end of file diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..e1fdf3c --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Shared helpers for policy management scripts. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +POLICIES_DIR="$ROOT_DIR/policies" +TOOLS_DIR="$ROOT_DIR/tools" +OPA="$TOOLS_DIR/opa" +ARTIFACTS_DIR="$ROOT_DIR/dist" + +repo_name() { + node -p "require('$1/package.json').name" +} + +repo_version() { + node -p "require('$1/package.json').version" +} \ No newline at end of file diff --git a/scripts/install-opa.sh b/scripts/install-opa.sh new file mode 100755 index 0000000..38f1160 --- /dev/null +++ b/scripts/install-opa.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Installs a pinned OPA release binary into tools/opa. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TOOLS_DIR="$ROOT_DIR/tools" +OPA_VERSION="${OPA_VERSION:-1.20.2}" + +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="amd64" ;; + arm64) ARCH="arm64" ;; +esac + +URL="https://openpolicyagent.org/downloads/v${OPA_VERSION}/opa_${OS}_${ARCH}_static" + +mkdir -p "$TOOLS_DIR" +if command -v curl >/dev/null 2>&1; then + curl -fsSL "$URL" -o "$TOOLS_DIR/opa" +else + wget -qO "$TOOLS_DIR/opa" "$URL" +fi +chmod +x "$TOOLS_DIR/opa" + +echo "Installed OPA v${OPA_VERSION} (${OS}/${ARCH}) to $TOOLS_DIR/opa" +"$TOOLS_DIR/opa" version | head -1 \ No newline at end of file diff --git a/scripts/publish.sh b/scripts/publish.sh new file mode 100755 index 0000000..4457785 --- /dev/null +++ b/scripts/publish.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Publishes every built rule/gate bundle to the Gitea generic package registry. +# +# Requires: +# - GITEA_TOKEN API token with write:package scope +# - GITEA_URL e.g. https://gitea.devegamoreno.com (default: env or https://gitea.devegamoreno.com) +# - GITEA_OWNER repo/package owner (default: git remote owner or jdevega) +# +# Registry path: /api/packages/{owner}/generic/{package}/{version}/{file} + +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/common.sh" + +GITEA_URL="${GITEA_URL:-https://gitea.devegamoreno.com}" +GITEA_OWNER="${GITEA_OWNER:-jdevega}" +GITEA_TOKEN="${GITEA_TOKEN:-}" +[ -n "$GITEA_TOKEN" ] || { echo "ERROR: GITEA_TOKEN is not set" >&2; exit 1; } + +publish_file() { + local pkg="$1" version="$2" file="$3" + local url="$GITEA_URL/api/packages/$GITEA_OWNER/generic/$pkg/$version/$(basename "$file")" + echo "==> publish $pkg@$version $(basename "$file")" + curl -fsSL \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/octet-stream" \ + --upload-file "$file" \ + "$url" + echo +} + +if [ ! -d "$ARTIFACTS_DIR" ]; then + echo "No artifacts found. Run 'make build' first." >&2 + exit 1 +fi + +for bundle in "$ARTIFACTS_DIR"/rules/*/*/bundle.tar.gz; do + path="${bundle#"$ARTIFACTS_DIR/rules/"}" + name="${path%%/*}" + version="$(basename "$(dirname "$bundle")")" + publish_file "rule-$name" "$version" "$bundle" +done + +for bundle in "$ARTIFACTS_DIR"/gates/*/*/bundle.tar.gz; do + path="${bundle#"$ARTIFACTS_DIR/gates/"}" + name="${path%%/*}" + version="$(basename "$(dirname "$bundle")")" + publish_file "gate-$name" "$version" "$bundle" +done + +echo "Publish complete." \ No newline at end of file