ProofedMesh/services/dummy-sink/README.md
CTO (Paperclip) b45ad34169
All checks were successful
validate / JSON Schema (ajv) (push) Successful in 19s
build-images / signing-service image (push) Successful in 22s
validate / Rego (opa fmt + opa test) (push) Successful in 13s
validate / DB (migrate up/down dry-run) (push) Successful in 26s
validate / Go (vet + test) (push) Successful in 1m13s
dummy-sink: Phase-1 mock sink (sink:dummy-sink) (DEV-611)
Trivial Senken-Adapter per §7 of architecture V1.1. Accepts
{decision_id, payload_hash, payload, approval_ref, transform_ref}
on POST /api/v1/sink/dummy/deliver; calls verification-service's
/api/v1/verify/chain and branches:

- valid=true → atomic .tmp+rename write of payload to
  /data/dummy-sink/<decision_id>.json, builds a
  basicstack.de/attestations/handover/v1 statement, signs via
  signing-service with the double identity flow (Pocket-ID bearer
  + projected SA token), submits DSSE envelope to the TLog and
  returns 201 {handover_ref, log_index}.
- valid=false & replay=true → 409.
- valid=false → 403 {reasons:[…]}.

Replay protection lives in verification-service per the parent plan;
dummy-sink keeps no local replay store. The payload_hash field is
verified against both the raw and canonical form so a verifier can
reproduce either hash.

Tests use httptest-backed fakes for verify / sign / tlog and cover
the DEV-611 acceptance criteria plus the bad-request shapes:
- happy path writes the file, calls signer with the handover
  statement, submits the envelope to the TLog
- 403 keeps the disk and never calls signer/tlog
- replay returns 409 without re-signing
- payload_hash mismatch rejects before verify
- malformed bodies return 400
- verify unavailable → 502, signing denied → 403

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 20:20:40 +00:00

151 lines
7.6 KiB
Markdown

# 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.