152 lines
7.6 KiB
Markdown
152 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.
|