ProofedMesh/services/dummy-sink/README.md

152 lines
7.6 KiB
Markdown
Raw Normal View History

# dummy-sink
Phase-1 Mock-Senke (`sink:dummy-sink`) per [DEV-611](/DEV/issues/DEV-611),
parent [DEV-606](/DEV/issues/DEV-606).
Trivial sink adapter that implements the §7 Senken-Vertrag of the
architecture V1.1 at the minimum needed to close the Phase-1 trust chain:
it accepts a decision, calls the verification-service, persists the
payload to local storage, and emits a signed handover attestation.
## Endpoint
```
POST /api/v1/sink/dummy/deliver
Content-Type: application/json
{
"decision_id": "dec-123",
"payload_hash": "sha256:<64-hex>",
"payload": { ... },
"approval_ref": "sha256:<64-hex>",
"transform_ref": "sha256:<64-hex>"
}
```
`payload_hash` must match either `sha256(raw payload bytes)` or
`sha256(canonical(payload))` — a verifier can reproduce either form.
### Flow
1. Verify the submitted hash matches `payload`.
2. `POST {VERIFY_URL}/api/v1/verify/chain` with
`{decision_id, payload_hash, approval_ref, transform_ref, sink_target}`.
3. Branch on the verdict:
- `valid: true` →
1. Write `payload` atomically to
`{DATA_DIR}/{decision_id}.json` (`.tmp` + rename).
2. Build the handover statement (predicateType
`https://basicstack.de/attestations/handover/v1`) that binds
`decision_id`, `payload_hash`, `approval_ref`, `transform_ref`,
and `sink_record_ref` into the trust chain.
3. `POST {SIGNING_URL}/api/v1/sign/dsse` with the double identity
headers (Pocket-ID bearer + projected SA token).
4. `POST {TLOG_URL}/api/v1/log/entries` with the signed envelope
+ `content_hash`.
5. `201 Created { handover_ref, log_index, decision_id }`.
- `valid: false, replay: true` → `409 Conflict`.
- `valid: false` → `403 Forbidden { error: { reasons: [...] } }`.
Replay protection intentionally lives in the verification-service
(parent plan §Subtask 6); dummy-sink keeps no replay table. A second
delivery with the same `decision_id` is funneled through the
verification-service, which marks it as a replay and the sink translates
that to `409`.
## Non-scope
- Real sinks (DB-App, SAP, webhooks) — all in Phase 4+.
- No request authentication of the inbound caller; dummy-sink is reachable
only from in-cluster callers behind NetworkPolicy (Phase-1-minimum).
- No local replay store; the verification-service owns `decision_id`
consumption.
## Configuration
| Flag / env | Default | Purpose |
| ----------------------------------------------- | --------------------------------------------- | -------------------------------------------------------------- |
| `-listen` | `:8083` | HTTP listen address. |
| `-data-dir` | `/data/dummy-sink` | Where delivered payloads are written as `<decision_id>.json`. |
| `-verify-url` | *(required)* | Base URL of verification-service. |
| `-signing-url` | *(required)* | Base URL of signing-service. |
| `-tlog-url` | *(required)* | Base URL of transparency-log. |
| `-sink-id` | `sink:dummy-sink` | Logical sink identifier (used in verify + handover attestation). |
| `-actor` | `agent-dummy-sink` | Agent registry id of the dummy-sink identity. |
| `-signing-key-ref` | `trust-api/agent-dummy-sink-2026-q4` | `key_ref` the handover attestation is signed with. |
| `-pocket-id-issuer` (env `POCKET_ID_ISSUER`) | *(required in prod)* | Pocket-ID OIDC issuer URL. |
| `-pocket-id-client-id` (env `POCKET_ID_CLIENT_ID`) | *(required in prod)* | OIDC client id for this agent. |
| `-pocket-id-client-secret` (env `POCKET_ID_CLIENT_SECRET`) | *(required in prod)* | OIDC client secret for this agent. |
| `-pocket-id-audience` (env `POCKET_ID_AUDIENCE`) | `trust-api` | OIDC token audience. |
| `-workload-token-path` (env `DUMMY_SINK_WORKLOAD_TOKEN_PATH`) | `/var/run/secrets/tokens/trust-api` | Projected SA token path. |
| `-dev-pocket-id-token` | *(unset)* | **Dev/CI only.** Static Pocket-ID access token. |
| `-dev-workload-token` | *(unset)* | **Dev/CI only.** Static projected SA token. |
## Handover attestation (`basicstack.de/attestations/handover/v1`)
```jsonc
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "handover/<decision_id>",
"digest": { "sha256": "<64-hex payload digest>" }
}
],
"predicateType": "https://basicstack.de/attestations/handover/v1",
"predicate": {
"decision_id": "<id>",
"payload_hash": "sha256:<64-hex>",
"approval_ref": "sha256:<64-hex>",
"transform_ref": "sha256:<64-hex>",
"sink_id": "sink:dummy-sink",
"sink_record_ref": "file://<DATA_DIR>/<decision_id>.json",
"materials": [
{ "name": "approval/<approval_ref>", "attestation_ref": "<approval_ref>" },
{ "name": "transform/<transform_ref>", "attestation_ref": "<transform_ref>" }
],
"actor": "agent-dummy-sink",
"issued_at": "<RFC3339 UTC>"
}
}
```
The subject digest is `sha256(payload)` so a verifier can detect any
tampering between what the agent handed the sink and what the sink
persisted. The two `materials[]` entries pin the attestation chain back
to the (approval, transform) that authorised this handover.
## Tests
```
cd services/dummy-sink
go vet ./...
go test ./...
```
The HTTP tests spin up the dummy-sink against httptest-backed fakes for
verify / sign / tlog and cover the DEV-611 acceptance criteria:
- **Happy path.** `valid=true` → `201` + file on disk + signer called with
the handover statement + TLog entry whose `content_hash` matches the
returned `handover_ref`.
- **Chain invalid.** `valid=false` → `403` with `error.reasons`, no file
written, signer and TLog never called.
- **Replay.** Second call with the same `decision_id` where the
verifier returns `replay: true` → `409`, signer not re-called.
- **Payload hash mismatch.** Client-sent `payload_hash` disagrees with
both the raw and canonical hash → `400`, verifier never called.
- **Bad request shapes.** Missing fields / malformed `sha256:` prefix →
`400`.
- **Verifier unavailable / signer denied.** → `502` / `403` with the
appropriate code so operators can distinguish transport failures
from policy denials.
## Known Phase-1 integration gap
The testagent-transform CLI shipped in [DEV-610](/DEV/issues/DEV-610) still
posts to `/api/v1/handover` with payload-in-body + `X-Attestation-Ref`
header, whereas this sink implements the DEV-611 contract
(`/api/v1/sink/dummy/deliver` with structured JSON). Aligning the agent
is parent-plan Subtask 7 (`make phase1-smoke`); DEV-611 scope is sink-side
only.