diff --git a/policies/README.md b/policies/README.md new file mode 100644 index 0000000..2c38fce --- /dev/null +++ b/policies/README.md @@ -0,0 +1,86 @@ +# ProofedMesh Policies (OPA / Rego) + +Rego-Policies für die drei in [`docs/architecture/v1.1.md`](../docs/architecture/v1.1.md) Abschnitt 6 definierten Policy-Klassen. Alle Policies nutzen **Rego v1** (`import rego.v1`). + +## Policy-Klassen + +| Klasse | Package-Präfix | Fragt der | +| ----------------- | ------------------------------------ | ------------------------------ | +| Transformationen | `proofedmesh.transformations.` | Signing Service | +| Senken | `proofedmesh.sinks.` | Verification Service / Adapter | +| Datenquellen | `proofedmesh.data_sources.` | Session Scope Token Service | + +Default in jedem Package ist `allow := false` — explizites Zulassen ist erforderlich (deny-by-default). + +## Dateistruktur + +``` +policies/ + transformations/ + expense.rego + expense_test.rego + sinks/ + expense_db_app.rego + expense_db_app_test.rego + data_sources/ + expense_db_app.rego + expense_db_app_test.rego +``` + +## Input-Verträge + +### Transformationen + +```json +{ + "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" }, + "sink_target": "sink:expense-db-app", + "approval": { "allowed_transformations": [...], "allowed_sinks": [...] }, + "input_payload": { ... canonical fields ... }, + "output_payload": { ... sink fields ... } +} +``` + +### Senken + +```json +{ + "agent_id": "agent-sink-posting", + "sink_id": "sink:expense-db-app", + "action": "expense.post", + "payload": { ... sink.db.expense ... }, + "approval": { "allowed_sinks": [...] } +} +``` + +### Datenquellen (für Session Scope Token Service) + +```json +{ + "agent_id": "agent-travel", + "user_sub": "user-12345", + "session_id": "SES-...", + "data_source": "ds:expense-db-app", + "resource": "project_assignments", + "filters": { "employee_id": "EMP-12345" }, + "operation": "read" +} +``` + +Zusätzliche Daten (nicht `input`, sondern über `data.*` bereitgestellt): `data.employee_mapping.by_sub` (user_sub → employee_id), `data.agent_registry.by_id` (Agent-Metadaten), `data.sinks.by_id`, `data.data_sources.by_id`. Trust-API lädt diese bei jedem Policy-Call aus Postgres und übergibt sie via OPA Partial-Evaluation oder bei Bedarf als `input.data`. + +## Lokal testen + +```bash +opa test policies/ -v +opa fmt --list --diff policies/ # formatting check +``` + +Beides läuft auch in CI (siehe `.forgejo/workflows/validate.yml`). + +## Policy-Versionierung + +Jede Policy-Datei bekommt bei Breaking Changes eine neue Hauptversion. Historische Versionen bleiben parallel, solange Attestierungen mit `policy_version` darauf verweisen (Retention 1 Jahr). Rego-Bundles für Deployment werden aus dem jeweiligen Branch gebaut; der SHA-256 des Bundles landet in jeder Decision als `policy_version` (siehe ADR-0002, Abschnitt „Token-Format"). diff --git a/policies/data_sources/expense_db_app.rego b/policies/data_sources/expense_db_app.rego new file mode 100644 index 0000000..b84b0f7 --- /dev/null +++ b/policies/data_sources/expense_db_app.rego @@ -0,0 +1,99 @@ +# METADATA +# title: Data source policy for ds:expense-db-app +# description: | +# Governs which agents may read which resources in whose name on the +# own DB-app data source. Requires an active user session for all +# current reads (requires_user_context=TRUE for every registered +# agent_data_source_scope). +# authors: +# - CTO Agent +# related_resources: +# - ref: docs/architecture/v1.1.md#user-content-3b-data-source-registry +# - ref: docs/architecture/v1.1.md#user-content-3c-session-scope-tokens--delegation-agent-im-auftrag-von-benutzer +package proofedmesh.data_sources.expense_db_app + +import rego.v1 + +default allow := false + +allow if { + input.data_source == "ds:expense-db-app" + input.operation == "read" + input.user_sub != "" + input.session_id != "" + some rule in agent_scope_rules + rule.agent_id == input.agent_id + rule.resource == input.resource + filters_within_scope(rule) + user_bound_filters_ok(rule) +} + +# Rule table: who may read what, with which filter dimensions and user-binding mode. +agent_scope_rules := [ + { + "agent_id": "agent-travel", + "resource": "project_assignments", + "allowed_filters": {"employee_id", "active_on"}, + "required_filters": {"employee_id"}, + "require_own_employee": true, + }, + { + "agent_id": "agent-sink-mapping", + "resource": "account_assignments", + "allowed_filters": {"project_code", "cost_center"}, + "required_filters": {"project_code"}, + "require_own_employee": false, + }, + { + "agent_id": "agent-sink-mapping", + "resource": "cost_centers", + "allowed_filters": {"active"}, + "required_filters": set(), + "require_own_employee": false, + }, +] + +# Only the declared filter keys are allowed; required filters must be present. +filters_within_scope(rule) if { + provided := {k | some k; _ := input.filters[k]} + count(provided - rule.allowed_filters) == 0 + count(rule.required_filters - provided) == 0 +} + +# When require_own_employee=true, the employee_id filter must map to input.user_sub. +# The mapping is loaded as external data: data.employee_mapping.by_sub[user_sub] -> "EMP-...". +user_bound_filters_ok(rule) if { + not rule.require_own_employee +} + +user_bound_filters_ok(rule) if { + rule.require_own_employee + expected := data.employee_mapping.by_sub[input.user_sub] + input.filters.employee_id == expected +} + +deny_reason contains msg if { + input.data_source != "ds:expense-db-app" + msg := sprintf("wrong data_source: %s", [input.data_source]) +} + +deny_reason contains msg if { + input.operation != "read" + msg := sprintf("operation %s not supported", [input.operation]) +} + +deny_reason contains msg if { + input.user_sub == "" + msg := "missing user_sub (requires_user_context=true for all registered scopes)" +} + +deny_reason contains msg if { + not any_scope_rule_matches + msg := sprintf("no scope rule for agent=%s resource=%s", [input.agent_id, input.resource]) +} + +any_scope_rule_matches if { + some rule in agent_scope_rules + rule.agent_id == input.agent_id + rule.resource == input.resource +} diff --git a/policies/data_sources/expense_db_app_test.rego b/policies/data_sources/expense_db_app_test.rego new file mode 100644 index 0000000..7742810 --- /dev/null +++ b/policies/data_sources/expense_db_app_test.rego @@ -0,0 +1,110 @@ +package proofedmesh.data_sources.expense_db_app_test + +import data.proofedmesh.data_sources.expense_db_app as ds +import rego.v1 + +employee_mapping := {"by_sub": {"user-12345": "EMP-12345"}} + +test_allow_travel_own_assignments if { + ds.allow with input as { + "agent_id": "agent-travel", + "user_sub": "user-12345", + "session_id": "SES-abc", + "data_source": "ds:expense-db-app", + "resource": "project_assignments", + "filters": {"employee_id": "EMP-12345"}, + "operation": "read", + } + with data.employee_mapping as employee_mapping +} + +test_deny_travel_other_employee if { + not ds.allow with input as { + "agent_id": "agent-travel", + "user_sub": "user-12345", + "session_id": "SES-abc", + "data_source": "ds:expense-db-app", + "resource": "project_assignments", + "filters": {"employee_id": "EMP-99999"}, + "operation": "read", + } + with data.employee_mapping as employee_mapping +} + +test_deny_missing_required_filter if { + not ds.allow with input as { + "agent_id": "agent-travel", + "user_sub": "user-12345", + "session_id": "SES-abc", + "data_source": "ds:expense-db-app", + "resource": "project_assignments", + "filters": {"active_on": "2026-10-25"}, + "operation": "read", + } + with data.employee_mapping as employee_mapping +} + +test_deny_disallowed_filter_key if { + not ds.allow with input as { + "agent_id": "agent-travel", + "user_sub": "user-12345", + "session_id": "SES-abc", + "data_source": "ds:expense-db-app", + "resource": "project_assignments", + "filters": {"employee_id": "EMP-12345", "department": "ENG"}, + "operation": "read", + } + with data.employee_mapping as employee_mapping +} + +test_allow_mapping_account_assignments if { + ds.allow with input as { + "agent_id": "agent-sink-mapping", + "user_sub": "user-12345", + "session_id": "SES-abc", + "data_source": "ds:expense-db-app", + "resource": "account_assignments", + "filters": {"project_code": "P-42"}, + "operation": "read", + } + with data.employee_mapping as employee_mapping +} + +test_deny_missing_user_sub if { + not ds.allow with input as { + "agent_id": "agent-travel", + "user_sub": "", + "session_id": "SES-abc", + "data_source": "ds:expense-db-app", + "resource": "project_assignments", + "filters": {"employee_id": "EMP-12345"}, + "operation": "read", + } + with data.employee_mapping as employee_mapping +} + +test_deny_wrong_data_source if { + not ds.allow with input as { + "agent_id": "agent-travel", + "user_sub": "user-12345", + "session_id": "SES-abc", + "data_source": "ds:other", + "resource": "project_assignments", + "filters": {"employee_id": "EMP-12345"}, + "operation": "read", + } + with data.employee_mapping as employee_mapping +} + +test_deny_write_op if { + not ds.allow with input as { + "agent_id": "agent-travel", + "user_sub": "user-12345", + "session_id": "SES-abc", + "data_source": "ds:expense-db-app", + "resource": "project_assignments", + "filters": {"employee_id": "EMP-12345"}, + "operation": "write", + } + with data.employee_mapping as employee_mapping +} diff --git a/policies/sinks/expense_db_app.rego b/policies/sinks/expense_db_app.rego new file mode 100644 index 0000000..765f3ac --- /dev/null +++ b/policies/sinks/expense_db_app.rego @@ -0,0 +1,63 @@ +# METADATA +# title: Sink policy for sink:expense-db-app +# description: | +# Only the posting agent may call the own DB-app sink, only with the +# expense.post action, and only with the sink.db.expense payload schema +# that was explicitly listed in the approval's allowed_sinks. +# authors: +# - CTO Agent +# related_resources: +# - ref: docs/architecture/v1.1.md#user-content-70-vertrag-jedes-senken-adapters +# - ref: schemas/sink/db/expense/v1.0.0.json +package proofedmesh.sinks.expense_db_app + +import rego.v1 + +default allow := false + +allowed_agent := "agent-sink-posting" + +allowed_actions := {"expense.post"} + +allow if { + input.sink_id == "sink:expense-db-app" + input.agent_id == allowed_agent + input.action in allowed_actions + payload_schema_ok + approval_lists_sink +} + +payload_schema_ok if { + input.payload.schema.name == "sink.db.expense" + startswith(input.payload.schema.version, "1.") +} + +approval_lists_sink if { + some s in input.approval.allowed_sinks + s == "sink:expense-db-app" +} + +deny_reason contains msg if { + input.sink_id != "sink:expense-db-app" + msg := sprintf("wrong sink_id: %s", [input.sink_id]) +} + +deny_reason contains msg if { + input.agent_id != allowed_agent + msg := sprintf("agent %s not allowed to post to sink:expense-db-app", [input.agent_id]) +} + +deny_reason contains msg if { + not input.action in allowed_actions + msg := sprintf("action %s not allowed on sink:expense-db-app", [input.action]) +} + +deny_reason contains msg if { + not payload_schema_ok + msg := "payload schema mismatch (expected sink.db.expense v1.x)" +} + +deny_reason contains msg if { + not approval_lists_sink + msg := "sink:expense-db-app not in approval.allowed_sinks" +} diff --git a/policies/sinks/expense_db_app_test.rego b/policies/sinks/expense_db_app_test.rego new file mode 100644 index 0000000..2139b8c --- /dev/null +++ b/policies/sinks/expense_db_app_test.rego @@ -0,0 +1,62 @@ +package proofedmesh.sinks.expense_db_app_test + +import data.proofedmesh.sinks.expense_db_app as sink +import rego.v1 + +good_payload := { + "schema": {"name": "sink.db.expense", "version": "1.0.0"}, + "workflow_id": "WF-2026-0001", +} + +good_approval := {"allowed_sinks": ["sink:expense-db-app"]} + +test_allow_posting if { + sink.allow with input as { + "sink_id": "sink:expense-db-app", + "agent_id": "agent-sink-posting", + "action": "expense.post", + "payload": good_payload, + "approval": good_approval, + } +} + +test_deny_wrong_agent if { + not sink.allow with input as { + "sink_id": "sink:expense-db-app", + "agent_id": "agent-travel", + "action": "expense.post", + "payload": good_payload, + "approval": good_approval, + } +} + +test_deny_wrong_action if { + not sink.allow with input as { + "sink_id": "sink:expense-db-app", + "agent_id": "agent-sink-posting", + "action": "expense.update", + "payload": good_payload, + "approval": good_approval, + } +} + +test_deny_wrong_schema if { + bad := {"schema": {"name": "sink.sap.expense", "version": "1.0.0"}} + not sink.allow with input as { + "sink_id": "sink:expense-db-app", + "agent_id": "agent-sink-posting", + "action": "expense.post", + "payload": bad, + "approval": good_approval, + } +} + +test_deny_sink_not_in_approval if { + not sink.allow with input as { + "sink_id": "sink:expense-db-app", + "agent_id": "agent-sink-posting", + "action": "expense.post", + "payload": good_payload, + "approval": {"allowed_sinks": ["sink:sap-s4hana-prod"]}, + } +} diff --git a/policies/transformations/expense.rego b/policies/transformations/expense.rego new file mode 100644 index 0000000..a7658b3 --- /dev/null +++ b/policies/transformations/expense.rego @@ -0,0 +1,118 @@ +# 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. +# authors: +# - CTO Agent +# 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/sink/db/expense/v1.0.0.json +package proofedmesh.transformations.expense + +import rego.v1 + +default allow := false + +# Allow only known agent + operation combinations. +allow if { + some rule in agent_allowed + rule.agent_id == input.agent_id + rule.operation == input.operation + schemas_match(rule) + invariants_hold +} + +# Rule table — the authoritative map of what each agent may do. +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-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"}, + }, +] + +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. +invariants_hold if { + total_amount_preserved + currency_preserved + employee_id_preserved +} + +# total_amount from the canonical input must appear unchanged in the sink output. +# Only enforced when both sides carry the fields. +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 +} + +# 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" +} + +any_rule_matches if { + some rule in agent_allowed + rule.agent_id == input.agent_id + rule.operation == input.operation + schemas_match(rule) +} diff --git a/policies/transformations/expense_test.rego b/policies/transformations/expense_test.rego new file mode 100644 index 0000000..b86d3c3 --- /dev/null +++ b/policies/transformations/expense_test.rego @@ -0,0 +1,108 @@ +package proofedmesh.transformations.expense_test + +import data.proofedmesh.transformations.expense +import rego.v1 + +canonical_payload := { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "428.50", +} + +sink_payload := {"expense": { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "428.50", +}} + +test_allow_map_to_sink if { + expense.allow with input as { + "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"}, + "input_payload": canonical_payload, + "output_payload": sink_payload, + } +} + +test_deny_unknown_agent if { + not expense.allow with input as { + "agent_id": "agent-rogue", + "operation": "expense.map_to_sink", + "input_schema": {"name": "expense.canonical", "version": "1.0.0"}, + "output_schema": {"name": "sink.db.expense", "version": "1.0.0"}, + "input_payload": canonical_payload, + "output_payload": sink_payload, + } +} + +test_deny_schema_mismatch if { + not expense.allow with input as { + "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": "2.0.0"}, + "input_payload": canonical_payload, + "output_payload": sink_payload, + } +} + +test_deny_total_amount_change if { + tampered := {"expense": { + "employee_id": "EMP-12345", + "currency": "EUR", + "total_amount": "999.99", + }} + not expense.allow with input as { + "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"}, + "input_payload": canonical_payload, + "output_payload": tampered, + } +} + +test_deny_currency_change if { + tampered := {"expense": { + "employee_id": "EMP-12345", + "currency": "USD", + "total_amount": "428.50", + }} + not expense.allow with input as { + "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"}, + "input_payload": canonical_payload, + "output_payload": tampered, + } +} + +test_deny_employee_change if { + tampered := {"expense": { + "employee_id": "EMP-99999", + "currency": "EUR", + "total_amount": "428.50", + }} + not expense.allow with input as { + "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"}, + "input_payload": canonical_payload, + "output_payload": tampered, + } +} + +test_deny_posting_agent_cannot_map if { + not expense.allow with input as { + "agent_id": "agent-sink-posting", + "operation": "expense.map_to_sink", + "input_schema": {"name": "expense.canonical", "version": "1.0.0"}, + "output_schema": {"name": "sink.db.expense", "version": "1.0.0"}, + "input_payload": canonical_payload, + "output_payload": sink_payload, + } +}