fix(payments): authenticate order detail and deposit checkout
masterplan-lock / check (push) Successful in 4s
masterplan-lock / check (pull_request) Successful in 4s
Security Scan (SIAX Cloud) / security (push) Successful in 17s
CI (SIAX Cloud) / security (pull_request) Successful in 17s
CI (SIAX Cloud) / quality (pull_request) Successful in 36s
CI (SIAX Cloud) / security (push) Successful in 1m14s
Security Scan (SIAX Cloud) / security (pull_request) Successful in 1m12s
CI (SIAX Cloud) / quality (push) Successful in 1m22s
CI (SIAX Cloud) / integration (push) Failing after 1m22s
CI (SIAX Cloud) / integration (pull_request) Failing after 1m22s

This commit is contained in:
2026-09-15 19:09:53 +02:00
parent c7f8b3f651
commit cecc2bc637
6 changed files with 104 additions and 17 deletions
@@ -0,0 +1,58 @@
# ADR-001: Verified tenant ownership for payment orders
**Status:** Proposed
**Date:** 2026-09-15
**Deciders:** SIAX 0pt product and platform owners
## Context
The existing order configurator creates anonymous drafts, stores no verified
buyer or tenant on them, and later refers to an order by its ID. A valid
Zitadel principal proves who is calling but does not prove that this caller
owns that particular order. An order ID is an identifier, not an authorization
secret. The nullable `orders.org_id` UUID cannot directly hold the verified
tenant ID, which is a string. The repository has no automated SQL migration
runner, and legacy rows may remain unowned.
The payment flow must remain API-first and work consistently in UI, CLI and
MCP. It must not make anonymous drafts payable by treating a guessed or leaked
ID as ownership.
## Decision
Require a verified Zitadel principal before creating a payable order. Persist
`tenant_id` and buyer subject on each new order in a database migration.
Every read, status mutation and Checkout creation must use a tenant-scoped
query predicate; a cross-tenant order returns 404. The Stripe webhook uses
its verified signature plus recorded order ID and payment facts to advance
status, never a browser-supplied tenant claim. Legacy null-owned orders remain
unpayable until an audited, explicit migration or support workflow binds
them to a verified buyer. Keep the anonymous configurator as an estimate
without creating a payable order until sign-in.
## Options considered
| Option | Complexity | Scale | Consequence |
|---|---|---|---|
| Authenticate only, lookup by order ID | Low | High | Rejected: any signed-in user can access another order. |
| Bearer claim token for anonymous drafts | Medium | High | Requires capability issuance, hashing, secure browser storage and recovery. Not selected for initial production. |
| Verified tenant + buyer subject at order creation | Medium | High | Chosen: explicit ownership at source, shared API boundary. |
## Consequences
- UI must obtain a Zitadel session before order creation and send credentials
to the order and payment APIs; estimates stay anonymous.
- SQL migration must be applied and verified before the scoped code is deployed.
- Existing unowned rows are not silently assigned to a tenant.
- Idempotency keys must be scoped to the verified tenant.
- Admin operations require an explicit, audited role-bound path rather than
bypassing the buyer predicate.
## Action items
1. [ ] Build and verify the migration and deploy runner.
2. [ ] Bind create-order to verified tenant and buyer subject; scope idempotency.
3. [ ] Scope GET/PATCH/deposit/release to tenant at the SQL boundary.
4. [ ] Wire UI Zitadel login/session and CLI/MCP bearer auth; keep anonymous estimates.
5. [ ] Test cross-tenant 404, legacy unowned rejection, webhook, and staging
purchase/refund end-to-end before enabling live Stripe.
+21 -16
View File
@@ -138,11 +138,11 @@ Risknivå = SIAX L0–L5-taxonomin, bedömd av mig utifrån vad handlern gör.
| Sanktions-/PEP-screening | `GET /api/screen?q=` | L2 | Nej — proxar mot OpenSanctions |
| AI-parsning av fritext | `POST /api/ai/parse` | L2 | Nej |
| Skapa order | `POST /api/orders` | L2 | Nej |
| **Lista alla ordrar** | `GET /api/orders` | **L3** | **Nej** — ingen org-scoping *(live: 200, `[]`)* |
| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Nej |
| **Adminmetrik (orderpipeline, belopp)** | `GET /api/admin/metrics` | **L3** | **Nej** *(live-verifierat: 200 anonymt)* |
| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Nej |
| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | **Nej** — sätter dessutom status `delivered` (§8 S4) |
| **Lista alla ordrar** | `GET /api/orders` | **L3** | Route-auth i source; org-scoping saknas |
| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Route-auth i source; orderägande saknas |
| **Adminmetrik (orderpipeline, belopp)** | `GET /api/admin/metrics` | **L3** | Route-auth i source; äldre liveobservation ej omverifierad |
| Deposition (Stripe checkout) | `POST /api/payments/{order_id}/deposit` | **L4** | Route-auth i source; orderägande saknas |
| **Frigör escrow-slutbetalning** | `POST /api/payments/{order_id}/release` | **L4** | Route-auth i source; `delivered` först efter betald webhook; orderägande saknas |
| Stripe-webhook | `POST /api/webhooks/stripe` | L3 | Signaturkontroll via `STRIPE_WEBHOOK_SECRET` |
| Prenumerationer | `GET,POST /api/subscriptions` | L2 | Nej |
| Filuppladdning | `GET,POST /api/uploads` | L3 | Nej (`501` utan R2) |
@@ -356,6 +356,9 @@ Om/när Postgres provisioneras måste detta lösas separat — jfr estate-blocke
också verifierad auth. **Fortfarande öppet före live-läge:** orderägarskap
och tenant-bindning vid checkout/acceptance behöver implementeras och
verifieras; en verifierad identitet ensam är inte per-order auktorisation.
Enskild order-GET och deposit-checkout kräver nu också route-auth; dessa
två vägar är inte längre anonyma i source. Ägarbindningens beslut och
beroenden dokumenteras i `ADR-001-order-ownership.md`.
- `SECURITY.md` finns med rapporteringsväg (`security@siax.io`).
### KÄNDA LUCKOR
@@ -387,18 +390,20 @@ alltså en innehavare av ett giltigt token för tenant A skicka
`X-Tenant-Id`-konflikt hjälper inte — den kontrollerar bara *headern*, inte
*bodyn*. **Tenantisolering är inte implementerad.**
**S3 — Hela det icke-`/api/0pt`-prefixade API:t saknar auth.**
Matchern täcker bara `/api/0pt/:path*`. Ordrar, adminmetrik, betalningar,
prenumerationer, uppladdningar, AI-parsning och DB-grants är oskyddade i koden.
*(live-verifierat: `GET /api/admin/metrics` och `GET /api/orders` svarar 200
anonymt.)* De är tomma idag enbart för att produktionsinstansen saknar databas.
**S3 — Historiskt anonymt icke-`/api/0pt`-API; delvis åtgärdat i source.**
Matchern täcker bara `/api/0pt/:path*`. Orderkö/adminmetrik, enskild
order-GET, deposit- och release-checkout samt order-PATCH har nu explicita
route-auth-grindar i source. De äldre live-observationerna av anonymt 200
beskriver tidigare deploy, inte verifierat nuläge. Övriga icke-prefixade
API:er kräver egen inventering, och per-order tenantägande är fortfarande
öppet enligt ADR-001.
**S4 — Anonym anropare kan trigga escrow-slutbetalning och flagga leverans.**
`POST /api/payments/{order_id}/release` kräver ingen identitet. Den skapar en
Stripe-checkout på `order.balance_ore` och kör därefter
`setStatus(order_id, "delivered")` **oavsett om betalningen genomförs**.
Statusövergången sker alltså på begäran, inte på betalning. (Skyddas i praktiken
idag bara av att `getOrder` inte hittar något utan databas.)
**S4 — Historisk escrow-bugg åtgärdad i source; tenantägande återstår.**
`POST /api/payments/{order_id}/release` kräver nu route-auth och skapar
Checkout först efter `accepted`. En skapad session sätter inte `delivered`;
endast en signerad, betald och matchande Stripe-webhook får göra det.
Cross-tenant-skydd av den enskilda ordern saknas fortfarande, så livebetalning
får inte aktiveras innan ADR-001 är genomförd och verifierad.
**S5 — CL0UD-policyklienten är fail-open utan produktionsspärr.**
`src/clients/cl0ud-client.ts:45` — saknas `CL0UD_URL` returneras alltid