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 update
|
||||||
apt-get install -y --no-install-recommends git curl ca-certificates
|
apt-get install -y --no-install-recommends git curl ca-certificates
|
||||||
git --version
|
git --version
|
||||||
|
node --version
|
||||||
|
|
||||||
- name: Clone repository (sha256-aware)
|
- name: Clone repository (sha256-aware)
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -41,8 +42,8 @@ jobs:
|
||||||
- name: Validate every JSON Schema parses as JSON
|
- name: Validate every JSON Schema parses as JSON
|
||||||
working-directory: ${{ github.workspace }}
|
working-directory: ${{ github.workspace }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -eu
|
||||||
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 "--- parse: $f"
|
echo "--- parse: $f"
|
||||||
node -e "require('./$f')"
|
node -e "require('./$f')"
|
||||||
done
|
done
|
||||||
|
|
@ -50,20 +51,24 @@ jobs:
|
||||||
- name: Meta-validate each schema against JSON Schema 2020-12
|
- name: Meta-validate each schema against JSON Schema 2020-12
|
||||||
working-directory: ${{ github.workspace }}
|
working-directory: ${{ github.workspace }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -eu
|
||||||
export NODE_PATH=/tmp/ajv/node_modules
|
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"
|
echo "--- meta-validate: $f"
|
||||||
node /tmp/ajv/node_modules/.bin/ajv compile --spec=draft2020 -c=ajv-formats -s "$f"
|
node /tmp/ajv/node_modules/.bin/ajv compile --spec=draft2020 -c=ajv-formats -s "$f"
|
||||||
done
|
done
|
||||||
|
|
||||||
- name: Validate example payloads against their schemas (if any)
|
- name: Validate per-version example payloads against their schemas
|
||||||
working-directory: ${{ github.workspace }}
|
working-directory: ${{ github.workspace }}
|
||||||
run: |
|
run: |
|
||||||
set -eu
|
set -eu
|
||||||
export NODE_PATH=/tmp/ajv/node_modules
|
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
|
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
|
[ -d "$dir" ] || continue
|
||||||
find "$dir" -type f -name '*.json' -print | while IFS= read -r example; do
|
find "$dir" -type f -name '*.json' -print | while IFS= read -r example; do
|
||||||
echo "--- validate example $example against $schema"
|
echo "--- validate example $example against $schema"
|
||||||
|
|
|
||||||
|
|
@ -3,28 +3,32 @@
|
||||||
# description: |
|
# description: |
|
||||||
# Governs which agents may perform which transformations on expense
|
# Governs which agents may perform which transformations on expense
|
||||||
# artifacts, and enforces the invariants that must survive mapping.
|
# 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:
|
# authors:
|
||||||
# - CTO Agent <cto@basicstack.de>
|
# - CTO Agent <cto@basicstack.de>
|
||||||
# related_resources:
|
# related_resources:
|
||||||
# - ref: docs/architecture/v1.1.md#user-content-6-berechtigungssteuerung-mit-open-policy-agent
|
# - 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.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.0.0.json
|
||||||
|
# - ref: schemas/sink/db/expense/v1.1.0.json
|
||||||
package proofedmesh.transformations.expense
|
package proofedmesh.transformations.expense
|
||||||
|
|
||||||
import rego.v1
|
import rego.v1
|
||||||
|
|
||||||
default allow := false
|
default allow := false
|
||||||
|
|
||||||
# Allow only known agent + operation combinations.
|
# Allow only known agent + operation + schema-pair combinations.
|
||||||
allow if {
|
allow if {
|
||||||
some rule in agent_allowed
|
some rule in agent_allowed
|
||||||
rule.agent_id == input.agent_id
|
rule.agent_id == input.agent_id
|
||||||
rule.operation == input.operation
|
rule.operation == input.operation
|
||||||
schemas_match(rule)
|
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_allowed := [
|
||||||
{
|
{
|
||||||
"agent_id": "agent-travel",
|
"agent_id": "agent-travel",
|
||||||
|
|
@ -38,12 +42,24 @@ agent_allowed := [
|
||||||
"input_schema": {"name": "expense.canonical", "version": "1.0.0"},
|
"input_schema": {"name": "expense.canonical", "version": "1.0.0"},
|
||||||
"output_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",
|
"agent_id": "agent-sink-mapping",
|
||||||
"operation": "expense.map_to_sink",
|
"operation": "expense.map_to_sink",
|
||||||
"input_schema": {"name": "expense.canonical", "version": "1.0.0"},
|
"input_schema": {"name": "expense.canonical", "version": "1.0.0"},
|
||||||
"output_schema": {"name": "sink.db.expense", "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 {
|
schemas_match(rule) if {
|
||||||
|
|
@ -54,14 +70,18 @@ schemas_match(rule) if {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Business invariants that no transformation may violate without a new approval.
|
# 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
|
total_amount_preserved
|
||||||
currency_preserved
|
currency_preserved
|
||||||
employee_id_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 {
|
total_amount_preserved if {
|
||||||
not input.input_payload.total_amount
|
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
|
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.
|
# Human-readable denial reasons for debugging and audit.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
deny_reason contains msg if {
|
deny_reason contains msg if {
|
||||||
not any_rule_matches
|
not any_rule_matches
|
||||||
msg := sprintf("no allow rule for agent=%s operation=%s", [input.agent_id, input.operation])
|
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"
|
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 {
|
any_rule_matches if {
|
||||||
some rule in agent_allowed
|
some rule in agent_allowed
|
||||||
rule.agent_id == input.agent_id
|
rule.agent_id == input.agent_id
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,10 @@ package proofedmesh.transformations.expense_test
|
||||||
import data.proofedmesh.transformations.expense
|
import data.proofedmesh.transformations.expense
|
||||||
import rego.v1
|
import rego.v1
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# v1.0.0 — flat single-project (unchanged behaviour)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
canonical_payload := {
|
canonical_payload := {
|
||||||
"employee_id": "EMP-12345",
|
"employee_id": "EMP-12345",
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
|
|
@ -106,3 +110,127 @@ test_deny_posting_agent_cannot_map if {
|
||||||
"output_payload": sink_payload,
|
"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