policies+ci: v1.1.0 allocation support — expense.rego enforces per-item sum invariant, 5 new tests (25/25 pass), CI validates examples per schema version
This commit is contained in:
parent
81e5f334b5
commit
5d309e16c1
3 changed files with 213 additions and 12 deletions
|
|
@ -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 <path>/vX.Y.Z.json; example payloads for that version
|
||||
# live under <path>/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"
|
||||
|
|
|
|||
|
|
@ -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 <cto@basicstack.de>
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue