policies: add Rego skeletons for transformations, sink expense_db_app, and ds expense_db_app (20/20 opa test pass, rego.v1)

This commit is contained in:
CTO Agent 2026-10-25 15:28:12 +00:00
parent 71259de4c8
commit e721073767
7 changed files with 646 additions and 0 deletions

86
policies/README.md Normal file
View file

@ -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.<domain>` | Signing Service |
| Senken | `proofedmesh.sinks.<sink_id_slug>` | Verification Service / Adapter |
| Datenquellen | `proofedmesh.data_sources.<ds_id_slug>` | 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").

View file

@ -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 <cto@basicstack.de>
# 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
}

View file

@ -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
}

View file

@ -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 <cto@basicstack.de>
# 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"
}

View file

@ -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"]},
}
}

View file

@ -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 <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/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)
}

View file

@ -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,
}
}