198 lines
6 KiB
Rego
198 lines
6 KiB
Rego
# METADATA
|
|
# title: Transformation policies for expense workflows
|
|
# 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 + 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(rule)
|
|
}
|
|
|
|
# Rule table — authoritative map of allowed (agent, operation, input, output).
|
|
agent_allowed := [
|
|
{
|
|
"agent_id": "agent-travel",
|
|
"operation": "expense.extract_receipts",
|
|
"input_schema": {"name": "receipt.raw", "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.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"},
|
|
},
|
|
{
|
|
"agent_id": "agent-travel",
|
|
"operation": "expense.normalize",
|
|
"input_schema": {"name": "expense.canonical", "version": "1.2.0"},
|
|
"output_schema": {"name": "expense.canonical", "version": "1.2.0"},
|
|
},
|
|
{
|
|
"agent_id": "agent-sink-mapping",
|
|
"operation": "expense.map_to_sink",
|
|
"input_schema": {"name": "expense.canonical", "version": "1.2.0"},
|
|
"output_schema": {"name": "sink.db.expense", "version": "1.2.0"},
|
|
},
|
|
]
|
|
|
|
schemas_match(rule) if {
|
|
input.input_schema.name == rule.input_schema.name
|
|
input.input_schema.version == rule.input_schema.version
|
|
input.output_schema.name == rule.output_schema.name
|
|
input.output_schema.version == rule.output_schema.version
|
|
}
|
|
|
|
# Business invariants that no transformation may violate without a new approval.
|
|
# 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)
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Preservation invariants (apply to all versions)
|
|
# ------------------------------------------------------------------
|
|
|
|
total_amount_preserved if {
|
|
not input.input_payload.total_amount
|
|
}
|
|
|
|
total_amount_preserved if {
|
|
input.input_payload.total_amount == input.output_payload.expense.total_amount
|
|
}
|
|
|
|
currency_preserved if {
|
|
not input.input_payload.currency
|
|
}
|
|
|
|
currency_preserved if {
|
|
input.input_payload.currency == input.output_payload.expense.currency
|
|
}
|
|
|
|
employee_id_preserved if {
|
|
not input.input_payload.employee_id
|
|
}
|
|
|
|
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 or v1.2.0 payload.
|
|
allocations_consistent(rule) if {
|
|
not sink_with_allocations(rule)
|
|
}
|
|
|
|
allocations_consistent(rule) if {
|
|
sink_with_allocations(rule)
|
|
every item in input.output_payload.expense.items {
|
|
item_allocation_sum_matches(item)
|
|
}
|
|
}
|
|
|
|
sink_with_allocations(rule) if {
|
|
rule.output_schema.name == "sink.db.expense"
|
|
rule.output_schema.version in {"1.1.0", "1.2.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])
|
|
}
|
|
|
|
deny_reason contains msg if {
|
|
any_rule_matches
|
|
not total_amount_preserved
|
|
msg := "total_amount changed by transformation"
|
|
}
|
|
|
|
deny_reason contains msg if {
|
|
any_rule_matches
|
|
not currency_preserved
|
|
msg := "currency changed by transformation"
|
|
}
|
|
|
|
deny_reason contains msg if {
|
|
any_rule_matches
|
|
not employee_id_preserved
|
|
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_with_allocations(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
|
|
rule.operation == input.operation
|
|
schemas_match(rule)
|
|
}
|