policies: add receipts + per_diem_rates data-source policies, per_diem transformation policy, extend expense.rego for v1.2.0 (54/54 tests pass)
This commit is contained in:
parent
27c45b356a
commit
c0472691c8
9 changed files with 552 additions and 7 deletions
|
|
@ -17,14 +17,20 @@ Default in jedem Package ist `allow := false` — explizites Zulassen ist erford
|
||||||
```
|
```
|
||||||
policies/
|
policies/
|
||||||
transformations/
|
transformations/
|
||||||
expense.rego
|
expense.rego — v1.0.0/v1.1.0/v1.2.0 map_to_sink + normalize, with allocation-sum invariant
|
||||||
expense_test.rego
|
expense_test.rego
|
||||||
|
per_diem.rego — expense.compute_per_diem (agent-per-diem-calculator → v1.2.0 output)
|
||||||
|
per_diem_test.rego
|
||||||
sinks/
|
sinks/
|
||||||
expense_db_app.rego
|
expense_db_app.rego
|
||||||
expense_db_app_test.rego
|
expense_db_app_test.rego
|
||||||
data_sources/
|
data_sources/
|
||||||
expense_db_app.rego
|
expense_db_app.rego
|
||||||
expense_db_app_test.rego
|
expense_db_app_test.rego
|
||||||
|
receipts.rego — ds:receipts write (agent-travel) + read-by-content_hash (agent-sink-*)
|
||||||
|
receipts_test.rego
|
||||||
|
per_diem_rates.rego — ds:per-diem-rates read (agent-per-diem-calculator only, country+valid_on required)
|
||||||
|
per_diem_rates_test.rego
|
||||||
```
|
```
|
||||||
|
|
||||||
## Input-Verträge
|
## Input-Verträge
|
||||||
|
|
|
||||||
61
policies/data_sources/per_diem_rates.rego
Normal file
61
policies/data_sources/per_diem_rates.rego
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# METADATA
|
||||||
|
# title: Data source policy for ds:per-diem-rates
|
||||||
|
# description: |
|
||||||
|
# Only agent-per-diem-calculator may read per-diem rates, and only within
|
||||||
|
# an active user session. Filters country+valid_on are required; city is
|
||||||
|
# optional. No write operations through this policy; rate maintenance is a
|
||||||
|
# separate admin flow.
|
||||||
|
# authors:
|
||||||
|
# - CTO Agent <cto@basicstack.de>
|
||||||
|
# related_resources:
|
||||||
|
# - ref: docs/architecture/v1.1.md#user-content-62-deterministische-regelwerke-am-beispiel-spesensätze-verpflegungsmehraufwand
|
||||||
|
# - ref: schemas/ds/per_diem/v1.0.0.json
|
||||||
|
package proofedmesh.data_sources.per_diem_rates
|
||||||
|
|
||||||
|
import rego.v1
|
||||||
|
|
||||||
|
default allow := false
|
||||||
|
|
||||||
|
allow if {
|
||||||
|
input.data_source == "ds:per-diem-rates"
|
||||||
|
input.operation == "read"
|
||||||
|
input.user_sub != ""
|
||||||
|
input.session_id != ""
|
||||||
|
input.agent_id == "agent-per-diem-calculator"
|
||||||
|
input.resource == "rates"
|
||||||
|
filters_ok
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed_filters := {"country", "city", "valid_on"}
|
||||||
|
required_filters := {"country", "valid_on"}
|
||||||
|
|
||||||
|
filters_ok if {
|
||||||
|
provided := {k | some k; _ := input.filters[k]}
|
||||||
|
count(provided - allowed_filters) == 0
|
||||||
|
count(required_filters - provided) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.data_source != "ds:per-diem-rates"
|
||||||
|
msg := sprintf("wrong data_source: %s", [input.data_source])
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.operation != "read"
|
||||||
|
msg := sprintf("operation %s not allowed on ds:per-diem-rates (reads only)", [input.operation])
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.user_sub == ""
|
||||||
|
msg := "missing user_sub (per-diem reads require active user session)"
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.agent_id != "agent-per-diem-calculator"
|
||||||
|
msg := sprintf("agent %s not allowed to read per-diem rates (only agent-per-diem-calculator)", [input.agent_id])
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
not filters_ok
|
||||||
|
msg := "filters must include country and valid_on; only country, city, valid_on allowed"
|
||||||
|
}
|
||||||
70
policies/data_sources/per_diem_rates_test.rego
Normal file
70
policies/data_sources/per_diem_rates_test.rego
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
package proofedmesh.data_sources.per_diem_rates_test
|
||||||
|
|
||||||
|
import data.proofedmesh.data_sources.per_diem_rates as pdr
|
||||||
|
import rego.v1
|
||||||
|
|
||||||
|
base := {
|
||||||
|
"data_source": "ds:per-diem-rates",
|
||||||
|
"user_sub": "user-12345",
|
||||||
|
"session_id": "SES-abc",
|
||||||
|
"operation": "read",
|
||||||
|
"resource": "rates",
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allow_calculator_country_valid_on if {
|
||||||
|
pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"filters": {"country": "DE", "valid_on": "2026-10-02"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allow_calculator_with_city if {
|
||||||
|
pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"filters": {"country": "DE", "city": "Berlin", "valid_on": "2026-10-02"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_missing_country if {
|
||||||
|
not pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"filters": {"valid_on": "2026-10-02"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_missing_valid_on if {
|
||||||
|
not pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"filters": {"country": "DE"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_other_agent if {
|
||||||
|
not pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-travel",
|
||||||
|
"filters": {"country": "DE", "valid_on": "2026-10-02"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_write_operation if {
|
||||||
|
not pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"operation": "write",
|
||||||
|
"filters": {"country": "DE", "valid_on": "2026-10-02"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_no_user_session if {
|
||||||
|
not pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"user_sub": "",
|
||||||
|
"filters": {"country": "DE", "valid_on": "2026-10-02"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_extra_filter_key if {
|
||||||
|
not pdr.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"filters": {"country": "DE", "valid_on": "2026-10-02", "max_rate": "99"},
|
||||||
|
})
|
||||||
|
}
|
||||||
79
policies/data_sources/receipts.rego
Normal file
79
policies/data_sources/receipts.rego
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# METADATA
|
||||||
|
# title: Data source policy for ds:receipts
|
||||||
|
# description: |
|
||||||
|
# Governs who may upload (write) and who may read receipts.
|
||||||
|
# Receipts are PII. agent-travel uploads during the chat; downstream agents
|
||||||
|
# may only read by content_hash and only within an active user session.
|
||||||
|
# authors:
|
||||||
|
# - CTO Agent <cto@basicstack.de>
|
||||||
|
# related_resources:
|
||||||
|
# - ref: docs/architecture/v1.1.md#user-content-47-belege-receipts--upload-hashbindung-lesezugriff-entlang-der-kette
|
||||||
|
# - ref: schemas/ds/receipts/v1.0.0.json
|
||||||
|
package proofedmesh.data_sources.receipts
|
||||||
|
|
||||||
|
import rego.v1
|
||||||
|
|
||||||
|
default allow := false
|
||||||
|
|
||||||
|
allow if {
|
||||||
|
input.data_source == "ds:receipts"
|
||||||
|
input.user_sub != ""
|
||||||
|
input.session_id != ""
|
||||||
|
some rule in agent_scope_rules
|
||||||
|
rule.agent_id == input.agent_id
|
||||||
|
rule.operation == input.operation
|
||||||
|
filters_ok(rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
agent_scope_rules := [
|
||||||
|
{
|
||||||
|
"agent_id": "agent-travel",
|
||||||
|
"operation": "write",
|
||||||
|
"required_filters": set(),
|
||||||
|
"allowed_filters": set(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "agent-sink-mapping",
|
||||||
|
"operation": "read",
|
||||||
|
"required_filters": {"content_hash"},
|
||||||
|
"allowed_filters": {"content_hash"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"agent_id": "agent-sink-posting",
|
||||||
|
"operation": "read",
|
||||||
|
"required_filters": {"content_hash"},
|
||||||
|
"allowed_filters": {"content_hash"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
filters_ok(rule) if {
|
||||||
|
rule.operation == "write"
|
||||||
|
}
|
||||||
|
|
||||||
|
filters_ok(rule) if {
|
||||||
|
rule.operation == "read"
|
||||||
|
provided := {k | some k; _ := input.filters[k]}
|
||||||
|
count(provided - rule.allowed_filters) == 0
|
||||||
|
count(rule.required_filters - provided) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.data_source != "ds:receipts"
|
||||||
|
msg := sprintf("wrong data_source: %s", [input.data_source])
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.user_sub == ""
|
||||||
|
msg := "missing user_sub (receipts always require an active user session)"
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
not any_rule_matches
|
||||||
|
msg := sprintf("no scope rule for agent=%s op=%s", [input.agent_id, input.operation])
|
||||||
|
}
|
||||||
|
|
||||||
|
any_rule_matches if {
|
||||||
|
some rule in agent_scope_rules
|
||||||
|
rule.agent_id == input.agent_id
|
||||||
|
rule.operation == input.operation
|
||||||
|
}
|
||||||
91
policies/data_sources/receipts_test.rego
Normal file
91
policies/data_sources/receipts_test.rego
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
package proofedmesh.data_sources.receipts_test
|
||||||
|
|
||||||
|
import data.proofedmesh.data_sources.receipts
|
||||||
|
import rego.v1
|
||||||
|
|
||||||
|
base := {
|
||||||
|
"data_source": "ds:receipts",
|
||||||
|
"user_sub": "user-12345",
|
||||||
|
"session_id": "SES-abc",
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allow_travel_write if {
|
||||||
|
receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-travel",
|
||||||
|
"operation": "write",
|
||||||
|
"filters": {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allow_mapping_read_by_content_hash if {
|
||||||
|
receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-sink-mapping",
|
||||||
|
"operation": "read",
|
||||||
|
"filters": {"content_hash": "sha256:aa"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allow_posting_read_by_content_hash if {
|
||||||
|
receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-sink-posting",
|
||||||
|
"operation": "read",
|
||||||
|
"filters": {"content_hash": "sha256:aa"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_read_without_content_hash if {
|
||||||
|
not receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-sink-mapping",
|
||||||
|
"operation": "read",
|
||||||
|
"filters": {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_read_with_extra_filter if {
|
||||||
|
not receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-sink-mapping",
|
||||||
|
"operation": "read",
|
||||||
|
"filters": {"content_hash": "sha256:aa", "list_all": "yes"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_travel_read if {
|
||||||
|
not receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-travel",
|
||||||
|
"operation": "read",
|
||||||
|
"filters": {"content_hash": "sha256:aa"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_posting_write if {
|
||||||
|
not receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-sink-posting",
|
||||||
|
"operation": "write",
|
||||||
|
"filters": {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_no_user_session if {
|
||||||
|
not receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-travel",
|
||||||
|
"operation": "write",
|
||||||
|
"filters": {},
|
||||||
|
"user_sub": "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_unknown_agent if {
|
||||||
|
not receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-rogue",
|
||||||
|
"operation": "read",
|
||||||
|
"filters": {"content_hash": "sha256:aa"},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_list_operation if {
|
||||||
|
not receipts.allow with input as object.union(base, {
|
||||||
|
"agent_id": "agent-sink-mapping",
|
||||||
|
"operation": "list",
|
||||||
|
"filters": {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,18 @@ agent_allowed := [
|
||||||
"input_schema": {"name": "expense.canonical", "version": "1.1.0"},
|
"input_schema": {"name": "expense.canonical", "version": "1.1.0"},
|
||||||
"output_schema": {"name": "sink.db.expense", "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 {
|
schemas_match(rule) if {
|
||||||
|
|
@ -110,21 +122,21 @@ employee_id_preserved if {
|
||||||
# Allocation invariants (v1.1.0 sink mappings only)
|
# Allocation invariants (v1.1.0 sink mappings only)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
# Vacuously true unless the output is a sink.db.expense v1.1.0 payload.
|
# Vacuously true unless the output is a sink.db.expense v1.1.0 or v1.2.0 payload.
|
||||||
allocations_consistent(rule) if {
|
allocations_consistent(rule) if {
|
||||||
not sink_v1_1(rule)
|
not sink_with_allocations(rule)
|
||||||
}
|
}
|
||||||
|
|
||||||
allocations_consistent(rule) if {
|
allocations_consistent(rule) if {
|
||||||
sink_v1_1(rule)
|
sink_with_allocations(rule)
|
||||||
every item in input.output_payload.expense.items {
|
every item in input.output_payload.expense.items {
|
||||||
item_allocation_sum_matches(item)
|
item_allocation_sum_matches(item)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sink_v1_1(rule) if {
|
sink_with_allocations(rule) if {
|
||||||
rule.output_schema.name == "sink.db.expense"
|
rule.output_schema.name == "sink.db.expense"
|
||||||
rule.output_schema.version == "1.1.0"
|
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
|
# Monetary amounts are decimal strings like "428.50". We parse to cents (integer-valued
|
||||||
|
|
@ -172,7 +184,7 @@ deny_reason contains msg if {
|
||||||
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)
|
||||||
sink_v1_1(rule)
|
sink_with_allocations(rule)
|
||||||
some i, item in input.output_payload.expense.items
|
some i, item in input.output_payload.expense.items
|
||||||
not item_allocation_sum_matches(item)
|
not item_allocation_sum_matches(item)
|
||||||
msg := sprintf("item %d: sum(allocations.amount) != item.amount", [i])
|
msg := sprintf("item %d: sum(allocations.amount) != item.amount", [i])
|
||||||
|
|
|
||||||
|
|
@ -234,3 +234,65 @@ test_allow_v110_normalize if {
|
||||||
"output_payload": {"expense": canonical_v11},
|
"output_payload": {"expense": canonical_v11},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# v1.2.0 — allocations + receipts + per-diem items (map_to_sink)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
canonical_v12 := {
|
||||||
|
"employee_id": "EMP-12345",
|
||||||
|
"currency": "EUR",
|
||||||
|
"total_amount": "484.50",
|
||||||
|
}
|
||||||
|
|
||||||
|
sink_v12_mixed := {"expense": {
|
||||||
|
"employee_id": "EMP-12345",
|
||||||
|
"currency": "EUR",
|
||||||
|
"total_amount": "484.50",
|
||||||
|
"items": [
|
||||||
|
{"amount": "328.50", "allocations": [{"amount": "328.50"}]},
|
||||||
|
{"amount": "14.00", "allocations": [{"amount": "14.00"}]},
|
||||||
|
{"amount": "28.00", "allocations": [{"amount": "28.00"}]},
|
||||||
|
{"amount": "14.00", "allocations": [{"amount": "14.00"}]},
|
||||||
|
{"amount": "100.00", "allocations": [{"amount": "100.00"}]},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
|
||||||
|
test_allow_v120_map_to_sink_mixed_items if {
|
||||||
|
expense.allow with input as {
|
||||||
|
"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"},
|
||||||
|
"input_payload": canonical_v12,
|
||||||
|
"output_payload": sink_v12_mixed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_v120_allocation_sum_mismatch if {
|
||||||
|
bad := {"expense": {
|
||||||
|
"employee_id": "EMP-12345",
|
||||||
|
"currency": "EUR",
|
||||||
|
"total_amount": "484.50",
|
||||||
|
"items": [{"amount": "328.50", "allocations": [{"amount": "328.51"}]}],
|
||||||
|
}}
|
||||||
|
not expense.allow with input as {
|
||||||
|
"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"},
|
||||||
|
"input_payload": canonical_v12,
|
||||||
|
"output_payload": bad,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allow_v120_normalize if {
|
||||||
|
expense.allow with input as {
|
||||||
|
"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"},
|
||||||
|
"input_payload": canonical_v12,
|
||||||
|
"output_payload": {"expense": canonical_v12},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
83
policies/transformations/per_diem.rego
Normal file
83
policies/transformations/per_diem.rego
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# METADATA
|
||||||
|
# title: Transformation policy for per-diem compute
|
||||||
|
# description: |
|
||||||
|
# Only agent-per-diem-calculator may run expense.compute_per_diem.
|
||||||
|
# Output must consist exclusively of items whose type is PER_DIEM_*,
|
||||||
|
# each with a computed_from.transform_attestation_ref back-reference
|
||||||
|
# and a positive amount. Enforces separation between LLM agents (free-form)
|
||||||
|
# and deterministic compute agents (strict shape, no free-form fields).
|
||||||
|
# authors:
|
||||||
|
# - CTO Agent <cto@basicstack.de>
|
||||||
|
# related_resources:
|
||||||
|
# - ref: docs/architecture/v1.1.md#user-content-62-deterministische-regelwerke-am-beispiel-spesensätze-verpflegungsmehraufwand
|
||||||
|
# - ref: schemas/expense/canonical/v1.2.0.json
|
||||||
|
package proofedmesh.transformations.per_diem
|
||||||
|
|
||||||
|
import rego.v1
|
||||||
|
|
||||||
|
default allow := false
|
||||||
|
|
||||||
|
allow if {
|
||||||
|
input.agent_id == "agent-per-diem-calculator"
|
||||||
|
input.operation == "expense.compute_per_diem"
|
||||||
|
input.output_schema.name == "expense.canonical"
|
||||||
|
input.output_schema.version == "1.2.0"
|
||||||
|
all_items_are_per_diem
|
||||||
|
all_items_have_computed_from
|
||||||
|
all_amounts_positive
|
||||||
|
}
|
||||||
|
|
||||||
|
per_diem_types := {"PER_DIEM_FULL", "PER_DIEM_PARTIAL", "PER_DIEM_ARRIVAL", "PER_DIEM_DEPARTURE"}
|
||||||
|
|
||||||
|
all_items_are_per_diem if {
|
||||||
|
every item in input.output_payload.expense_items {
|
||||||
|
item.type in per_diem_types
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
all_items_have_computed_from if {
|
||||||
|
every item in input.output_payload.expense_items {
|
||||||
|
item.computed_from.transform_attestation_ref != ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
to_cents(s) := n if {
|
||||||
|
n := round(to_number(s) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
all_amounts_positive if {
|
||||||
|
every item in input.output_payload.expense_items {
|
||||||
|
to_cents(item.amount) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.agent_id != "agent-per-diem-calculator"
|
||||||
|
msg := sprintf("agent %s not allowed to compute per_diem", [input.agent_id])
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.operation != "expense.compute_per_diem"
|
||||||
|
msg := sprintf("operation %s not allowed", [input.operation])
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.agent_id == "agent-per-diem-calculator"
|
||||||
|
input.operation == "expense.compute_per_diem"
|
||||||
|
not all_items_are_per_diem
|
||||||
|
msg := "compute output must contain only PER_DIEM_* typed items"
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.agent_id == "agent-per-diem-calculator"
|
||||||
|
input.operation == "expense.compute_per_diem"
|
||||||
|
not all_items_have_computed_from
|
||||||
|
msg := "every computed item must carry computed_from.transform_attestation_ref"
|
||||||
|
}
|
||||||
|
|
||||||
|
deny_reason contains msg if {
|
||||||
|
input.agent_id == "agent-per-diem-calculator"
|
||||||
|
input.operation == "expense.compute_per_diem"
|
||||||
|
not all_amounts_positive
|
||||||
|
msg := "all computed per-diem amounts must be strictly positive"
|
||||||
|
}
|
||||||
81
policies/transformations/per_diem_test.rego
Normal file
81
policies/transformations/per_diem_test.rego
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
package proofedmesh.transformations.per_diem_test
|
||||||
|
|
||||||
|
import data.proofedmesh.transformations.per_diem as pdc
|
||||||
|
import rego.v1
|
||||||
|
|
||||||
|
good_output := {"expense_items": [
|
||||||
|
{
|
||||||
|
"type": "PER_DIEM_ARRIVAL",
|
||||||
|
"amount": "14.00",
|
||||||
|
"incurred_on": "2026-10-01",
|
||||||
|
"computed_from": {"transform_attestation_ref": "sha256:aa", "rate_id": "PDR-42"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "PER_DIEM_FULL",
|
||||||
|
"amount": "28.00",
|
||||||
|
"incurred_on": "2026-10-02",
|
||||||
|
"computed_from": {"transform_attestation_ref": "sha256:aa", "rate_id": "PDR-42"},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
|
||||||
|
base := {
|
||||||
|
"agent_id": "agent-per-diem-calculator",
|
||||||
|
"operation": "expense.compute_per_diem",
|
||||||
|
"output_schema": {"name": "expense.canonical", "version": "1.2.0"},
|
||||||
|
"output_payload": good_output,
|
||||||
|
}
|
||||||
|
|
||||||
|
test_allow_normal_compute if {
|
||||||
|
pdc.allow with input as base
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_wrong_agent if {
|
||||||
|
not pdc.allow with input as object.union(base, {"agent_id": "agent-travel"})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_wrong_operation if {
|
||||||
|
not pdc.allow with input as object.union(base, {"operation": "expense.normalize"})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_wrong_output_schema_version if {
|
||||||
|
not pdc.allow with input as object.union(base, {"output_schema": {"name": "expense.canonical", "version": "1.1.0"}})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_non_per_diem_item if {
|
||||||
|
bad := {"expense_items": [{
|
||||||
|
"type": "HOTEL",
|
||||||
|
"amount": "100.00",
|
||||||
|
"incurred_on": "2026-10-01",
|
||||||
|
"computed_from": {"transform_attestation_ref": "sha256:aa"},
|
||||||
|
}]}
|
||||||
|
not pdc.allow with input as object.union(base, {"output_payload": bad})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_missing_computed_from if {
|
||||||
|
bad := {"expense_items": [{
|
||||||
|
"type": "PER_DIEM_FULL",
|
||||||
|
"amount": "28.00",
|
||||||
|
"incurred_on": "2026-10-02",
|
||||||
|
}]}
|
||||||
|
not pdc.allow with input as object.union(base, {"output_payload": bad})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_zero_amount if {
|
||||||
|
bad := {"expense_items": [{
|
||||||
|
"type": "PER_DIEM_FULL",
|
||||||
|
"amount": "0.00",
|
||||||
|
"incurred_on": "2026-10-02",
|
||||||
|
"computed_from": {"transform_attestation_ref": "sha256:aa"},
|
||||||
|
}]}
|
||||||
|
not pdc.allow with input as object.union(base, {"output_payload": bad})
|
||||||
|
}
|
||||||
|
|
||||||
|
test_deny_negative_amount if {
|
||||||
|
bad := {"expense_items": [{
|
||||||
|
"type": "PER_DIEM_FULL",
|
||||||
|
"amount": "-5.00",
|
||||||
|
"incurred_on": "2026-10-02",
|
||||||
|
"computed_from": {"transform_attestation_ref": "sha256:aa"},
|
||||||
|
}]}
|
||||||
|
not pdc.allow with input as object.union(base, {"output_payload": bad})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue