diff --git a/policies/README.md b/policies/README.md index 2c38fce..7213b81 100644 --- a/policies/README.md +++ b/policies/README.md @@ -17,14 +17,20 @@ Default in jedem Package ist `allow := false` — explizites Zulassen ist erford ``` policies/ 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 + per_diem.rego — expense.compute_per_diem (agent-per-diem-calculator → v1.2.0 output) + per_diem_test.rego sinks/ expense_db_app.rego expense_db_app_test.rego data_sources/ expense_db_app.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 diff --git a/policies/data_sources/per_diem_rates.rego b/policies/data_sources/per_diem_rates.rego new file mode 100644 index 0000000..b796ba4 --- /dev/null +++ b/policies/data_sources/per_diem_rates.rego @@ -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 +# 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" +} diff --git a/policies/data_sources/per_diem_rates_test.rego b/policies/data_sources/per_diem_rates_test.rego new file mode 100644 index 0000000..28f1942 --- /dev/null +++ b/policies/data_sources/per_diem_rates_test.rego @@ -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"}, + }) +} diff --git a/policies/data_sources/receipts.rego b/policies/data_sources/receipts.rego new file mode 100644 index 0000000..680cc33 --- /dev/null +++ b/policies/data_sources/receipts.rego @@ -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 +# 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 +} diff --git a/policies/data_sources/receipts_test.rego b/policies/data_sources/receipts_test.rego new file mode 100644 index 0000000..4446aa9 --- /dev/null +++ b/policies/data_sources/receipts_test.rego @@ -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": {}, + }) +} diff --git a/policies/transformations/expense.rego b/policies/transformations/expense.rego index 9115321..844d3ee 100644 --- a/policies/transformations/expense.rego +++ b/policies/transformations/expense.rego @@ -60,6 +60,18 @@ agent_allowed := [ "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 { @@ -110,21 +122,21 @@ employee_id_preserved if { # 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 { - not sink_v1_1(rule) + not sink_with_allocations(rule) } allocations_consistent(rule) if { - sink_v1_1(rule) + sink_with_allocations(rule) every item in input.output_payload.expense.items { 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.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 @@ -172,7 +184,7 @@ deny_reason contains msg if { rule.agent_id == input.agent_id rule.operation == input.operation schemas_match(rule) - sink_v1_1(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]) diff --git a/policies/transformations/expense_test.rego b/policies/transformations/expense_test.rego index acf58d3..febfe4c 100644 --- a/policies/transformations/expense_test.rego +++ b/policies/transformations/expense_test.rego @@ -234,3 +234,65 @@ test_allow_v110_normalize if { "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}, + } +} diff --git a/policies/transformations/per_diem.rego b/policies/transformations/per_diem.rego new file mode 100644 index 0000000..3169a92 --- /dev/null +++ b/policies/transformations/per_diem.rego @@ -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 +# 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" +} diff --git a/policies/transformations/per_diem_test.rego b/policies/transformations/per_diem_test.rego new file mode 100644 index 0000000..7921766 --- /dev/null +++ b/policies/transformations/per_diem_test.rego @@ -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}) +}