From 5d309e16c1b5d2da600c91affe4bbf86b9c36f881b82b3741456bfec792c871c Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Sun, 25 Oct 2026 16:19:37 +0000 Subject: [PATCH] =?UTF-8?q?policies+ci:=20v1.1.0=20allocation=20support=20?= =?UTF-8?q?=E2=80=94=20expense.rego=20enforces=20per-item=20sum=20invarian?= =?UTF-8?q?t,=205=20new=20tests=20(25/25=20pass),=20CI=20validates=20examp?= =?UTF-8?q?les=20per=20schema=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .forgejo/workflows/validate.yml | 17 ++- policies/transformations/expense.rego | 80 ++++++++++++- policies/transformations/expense_test.rego | 128 +++++++++++++++++++++ 3 files changed, 213 insertions(+), 12 deletions(-) diff --git a/.forgejo/workflows/validate.yml b/.forgejo/workflows/validate.yml index 5c8b474..609c836 100644 --- a/.forgejo/workflows/validate.yml +++ b/.forgejo/workflows/validate.yml @@ -23,6 +23,7 @@ jobs: apt-get update apt-get install -y --no-install-recommends git curl ca-certificates git --version + node --version - name: Clone repository (sha256-aware) run: | @@ -41,8 +42,8 @@ jobs: - name: Validate every JSON Schema parses as JSON working-directory: ${{ github.workspace }} run: | - set -euo pipefail - find schemas -type f -name '*.json' -print | while read -r f; do + set -eu + find schemas -type f -name 'v*.json' -print | while IFS= read -r f; do echo "--- parse: $f" node -e "require('./$f')" done @@ -50,20 +51,24 @@ jobs: - name: Meta-validate each schema against JSON Schema 2020-12 working-directory: ${{ github.workspace }} run: | - set -euo pipefail + set -eu export NODE_PATH=/tmp/ajv/node_modules - find schemas -type f -name '*.json' -print | while read -r f; do + find schemas -type f -name 'v*.json' -print | while IFS= read -r f; do echo "--- meta-validate: $f" node /tmp/ajv/node_modules/.bin/ajv compile --spec=draft2020 -c=ajv-formats -s "$f" done - - name: Validate example payloads against their schemas (if any) + - name: Validate per-version example payloads against their schemas working-directory: ${{ github.workspace }} run: | set -eu export NODE_PATH=/tmp/ajv/node_modules + # Each schema file is /vX.Y.Z.json; example payloads for that version + # live under /examples/vX.Y.Z/*.json. This keeps v1.0.0 examples from + # being validated against v1.1.0 and vice-versa. find schemas -type f -name 'v*.json' -print | while IFS= read -r schema; do - dir="$(dirname "$schema")/examples" + version="$(basename "$schema" .json)" + dir="$(dirname "$schema")/examples/$version" [ -d "$dir" ] || continue find "$dir" -type f -name '*.json' -print | while IFS= read -r example; do echo "--- validate example $example against $schema" diff --git a/policies/transformations/expense.rego b/policies/transformations/expense.rego index a7658b3..9115321 100644 --- a/policies/transformations/expense.rego +++ b/policies/transformations/expense.rego @@ -3,28 +3,32 @@ # description: | # Governs which agents may perform which transformations on expense # artifacts, and enforces the invariants that must survive mapping. +# Supports expense.canonical v1.0.0 (flat single-project) and v1.1.0 +# (per-line allocations with percentage or absolute mode). # authors: # - CTO Agent # related_resources: # - ref: docs/architecture/v1.1.md#user-content-6-berechtigungssteuerung-mit-open-policy-agent # - ref: schemas/expense/canonical/v1.0.0.json +# - ref: schemas/expense/canonical/v1.1.0.json # - ref: schemas/sink/db/expense/v1.0.0.json +# - ref: schemas/sink/db/expense/v1.1.0.json package proofedmesh.transformations.expense import rego.v1 default allow := false -# Allow only known agent + operation combinations. +# Allow only known agent + operation + schema-pair combinations. allow if { some rule in agent_allowed rule.agent_id == input.agent_id rule.operation == input.operation schemas_match(rule) - invariants_hold + invariants_hold(rule) } -# Rule table — the authoritative map of what each agent may do. +# Rule table — authoritative map of allowed (agent, operation, input, output). agent_allowed := [ { "agent_id": "agent-travel", @@ -38,12 +42,24 @@ agent_allowed := [ "input_schema": {"name": "expense.canonical", "version": "1.0.0"}, "output_schema": {"name": "expense.canonical", "version": "1.0.0"}, }, + { + "agent_id": "agent-travel", + "operation": "expense.normalize", + "input_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "output_schema": {"name": "expense.canonical", "version": "1.1.0"}, + }, { "agent_id": "agent-sink-mapping", "operation": "expense.map_to_sink", "input_schema": {"name": "expense.canonical", "version": "1.0.0"}, "output_schema": {"name": "sink.db.expense", "version": "1.0.0"}, }, + { + "agent_id": "agent-sink-mapping", + "operation": "expense.map_to_sink", + "input_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "output_schema": {"name": "sink.db.expense", "version": "1.1.0"}, + }, ] schemas_match(rule) if { @@ -54,14 +70,18 @@ schemas_match(rule) if { } # Business invariants that no transformation may violate without a new approval. -invariants_hold if { +# For v1.1.0 sink mappings, we additionally enforce per-item allocation-sum. +invariants_hold(rule) if { total_amount_preserved currency_preserved employee_id_preserved + allocations_consistent(rule) } -# total_amount from the canonical input must appear unchanged in the sink output. -# Only enforced when both sides carry the fields. +# ------------------------------------------------------------------ +# Preservation invariants (apply to all versions) +# ------------------------------------------------------------------ + total_amount_preserved if { not input.input_payload.total_amount } @@ -86,7 +106,44 @@ employee_id_preserved if { input.input_payload.employee_id == input.output_payload.expense.employee_id } +# ------------------------------------------------------------------ +# Allocation invariants (v1.1.0 sink mappings only) +# ------------------------------------------------------------------ + +# Vacuously true unless the output is a sink.db.expense v1.1.0 payload. +allocations_consistent(rule) if { + not sink_v1_1(rule) +} + +allocations_consistent(rule) if { + sink_v1_1(rule) + every item in input.output_payload.expense.items { + item_allocation_sum_matches(item) + } +} + +sink_v1_1(rule) if { + rule.output_schema.name == "sink.db.expense" + rule.output_schema.version == "1.1.0" +} + +# Monetary amounts are decimal strings like "428.50". We parse to cents (integer-valued +# float) and compare for exact equality. Rego's to_number yields a float; multiplying +# by 100 and rounding gives whole cents, dodging IEEE-754 accumulation error at this scale. +to_cents(s) := n if { + n := round(to_number(s) * 100) +} + +item_allocation_sum_matches(item) if { + expected := to_cents(item.amount) + actual := sum([to_cents(a.amount) | some a in item.allocations]) + expected == actual +} + +# ------------------------------------------------------------------ # Human-readable denial reasons for debugging and audit. +# ------------------------------------------------------------------ + deny_reason contains msg if { not any_rule_matches msg := sprintf("no allow rule for agent=%s operation=%s", [input.agent_id, input.operation]) @@ -110,6 +167,17 @@ deny_reason contains msg if { msg := "employee_id changed by transformation" } +deny_reason contains msg if { + some rule in agent_allowed + rule.agent_id == input.agent_id + rule.operation == input.operation + schemas_match(rule) + sink_v1_1(rule) + some i, item in input.output_payload.expense.items + not item_allocation_sum_matches(item) + msg := sprintf("item %d: sum(allocations.amount) != item.amount", [i]) +} + any_rule_matches if { some rule in agent_allowed rule.agent_id == input.agent_id diff --git a/policies/transformations/expense_test.rego b/policies/transformations/expense_test.rego index b86d3c3..acf58d3 100644 --- a/policies/transformations/expense_test.rego +++ b/policies/transformations/expense_test.rego @@ -3,6 +3,10 @@ package proofedmesh.transformations.expense_test import data.proofedmesh.transformations.expense import rego.v1 +# ------------------------------------------------------------------ +# v1.0.0 — flat single-project (unchanged behaviour) +# ------------------------------------------------------------------ + canonical_payload := { "employee_id": "EMP-12345", "currency": "EUR", @@ -106,3 +110,127 @@ test_deny_posting_agent_cannot_map if { "output_payload": sink_payload, } } + +# ------------------------------------------------------------------ +# v1.1.0 — per-line allocations +# ------------------------------------------------------------------ + +canonical_v11 := { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "428.50", +} + +sink_v11_two_items_split := {"expense": { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "428.50", + "items": [ + { + "amount": "328.50", + "allocations": [ + {"amount": "197.10"}, + {"amount": "131.40"}, + ], + }, + { + "amount": "100.00", + "allocations": [ + {"amount": "70.00"}, + {"amount": "30.00"}, + ], + }, + ], +}} + +test_allow_v110_map_to_sink_split if { + expense.allow with input as { + "agent_id": "agent-sink-mapping", + "operation": "expense.map_to_sink", + "input_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "output_schema": {"name": "sink.db.expense", "version": "1.1.0"}, + "input_payload": canonical_v11, + "output_payload": sink_v11_two_items_split, + } +} + +test_deny_v110_allocation_sum_mismatch if { + bad_sink := {"expense": { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "428.50", + "items": [ + { + "amount": "328.50", + "allocations": [ + {"amount": "197.10"}, + {"amount": "131.50"}, # 328.60, nicht 328.50 + ], + }, + { + "amount": "100.00", + "allocations": [ + {"amount": "70.00"}, + {"amount": "30.00"}, + ], + }, + ], + }} + not expense.allow with input as { + "agent_id": "agent-sink-mapping", + "operation": "expense.map_to_sink", + "input_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "output_schema": {"name": "sink.db.expense", "version": "1.1.0"}, + "input_payload": canonical_v11, + "output_payload": bad_sink, + } +} + +test_allow_v110_single_allocation if { + one := {"expense": { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "428.50", + "items": [ + {"amount": "328.50", "allocations": [{"amount": "328.50"}]}, + {"amount": "100.00", "allocations": [{"amount": "100.00"}]}, + ], + }} + expense.allow with input as { + "agent_id": "agent-sink-mapping", + "operation": "expense.map_to_sink", + "input_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "output_schema": {"name": "sink.db.expense", "version": "1.1.0"}, + "input_payload": canonical_v11, + "output_payload": one, + } +} + +test_v110_still_enforces_total_currency_employee if { + tampered_total := {"expense": { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "999.99", + "items": [{"amount": "328.50", "allocations": [{"amount": "328.50"}]}], + }} + not expense.allow with input as { + "agent_id": "agent-sink-mapping", + "operation": "expense.map_to_sink", + "input_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "output_schema": {"name": "sink.db.expense", "version": "1.1.0"}, + "input_payload": canonical_v11, + "output_payload": tampered_total, + } +} + +test_allow_v110_normalize if { + # normalize keeps canonical v1.1.0 shape, no sink allocation check applies. + expense.allow with input as { + "agent_id": "agent-travel", + "operation": "expense.normalize", + "input_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "output_schema": {"name": "expense.canonical", "version": "1.1.0"}, + "input_payload": canonical_v11, + "output_payload": {"expense": canonical_v11}, + } +}