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
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:
@@ -3,7 +3,9 @@ import { getOrder, setStatusIfCurrent } from "@/lib/orders-store";
|
|||||||
import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
||||||
|
|
||||||
// GET /api/orders/{id}
|
// GET /api/orders/{id}
|
||||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
|
const auth = await requireRouteAuth(req);
|
||||||
|
if (auth.response) return auth.response;
|
||||||
const { id } = await ctx.params;
|
const { id } = await ctx.params;
|
||||||
const order = await getOrder(id);
|
const order = await getOrder(id);
|
||||||
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
|
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getOrder } from "@/lib/orders-store";
|
import { getOrder } from "@/lib/orders-store";
|
||||||
|
import { requireRouteAuth } from "@/src/auth/require-route-auth";
|
||||||
|
|
||||||
// POST /api/payments/[order_id]/deposit
|
// POST /api/payments/[order_id]/deposit
|
||||||
// Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent.
|
// Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent.
|
||||||
@@ -7,6 +8,8 @@ export async function POST(
|
|||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ order_id: string }> },
|
{ params }: { params: Promise<{ order_id: string }> },
|
||||||
) {
|
) {
|
||||||
|
const auth = await requireRouteAuth(request);
|
||||||
|
if (auth.response) return auth.response;
|
||||||
const { order_id } = await params;
|
const { order_id } = await params;
|
||||||
|
|
||||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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 |
|
| Sanktions-/PEP-screening | `GET /api/screen?q=` | L2 | Nej — proxar mot OpenSanctions |
|
||||||
| AI-parsning av fritext | `POST /api/ai/parse` | L2 | Nej |
|
| AI-parsning av fritext | `POST /api/ai/parse` | L2 | Nej |
|
||||||
| Skapa order | `POST /api/orders` | L2 | Nej |
|
| Skapa order | `POST /api/orders` | L2 | Nej |
|
||||||
| **Lista alla ordrar** | `GET /api/orders` | **L3** | **Nej** — ingen org-scoping *(live: 200, `[]`)* |
|
| **Lista alla ordrar** | `GET /api/orders` | **L3** | Route-auth i source; org-scoping saknas |
|
||||||
| Läs/ändra order | `GET,PATCH /api/orders/{id}` | L3 | Nej |
|
| 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** | **Nej** *(live-verifierat: 200 anonymt)* |
|
| **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** | Nej |
|
| 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** | **Nej** — sätter dessutom status `delivered` (§8 S4) |
|
| **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` |
|
| Stripe-webhook | `POST /api/webhooks/stripe` | L3 | Signaturkontroll via `STRIPE_WEBHOOK_SECRET` |
|
||||||
| Prenumerationer | `GET,POST /api/subscriptions` | L2 | Nej |
|
| Prenumerationer | `GET,POST /api/subscriptions` | L2 | Nej |
|
||||||
| Filuppladdning | `GET,POST /api/uploads` | L3 | Nej (`501` utan R2) |
|
| 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
|
också verifierad auth. **Fortfarande öppet före live-läge:** orderägarskap
|
||||||
och tenant-bindning vid checkout/acceptance behöver implementeras och
|
och tenant-bindning vid checkout/acceptance behöver implementeras och
|
||||||
verifieras; en verifierad identitet ensam är inte per-order auktorisation.
|
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`).
|
- `SECURITY.md` finns med rapporteringsväg (`security@siax.io`).
|
||||||
|
|
||||||
### KÄNDA LUCKOR
|
### 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
|
`X-Tenant-Id`-konflikt hjälper inte — den kontrollerar bara *headern*, inte
|
||||||
*bodyn*. **Tenantisolering är inte implementerad.**
|
*bodyn*. **Tenantisolering är inte implementerad.**
|
||||||
|
|
||||||
**S3 — Hela det icke-`/api/0pt`-prefixade API:t saknar auth.**
|
**S3 — Historiskt anonymt icke-`/api/0pt`-API; delvis åtgärdat i source.**
|
||||||
Matchern täcker bara `/api/0pt/:path*`. Ordrar, adminmetrik, betalningar,
|
Matchern täcker bara `/api/0pt/:path*`. Orderkö/adminmetrik, enskild
|
||||||
prenumerationer, uppladdningar, AI-parsning och DB-grants är oskyddade i koden.
|
order-GET, deposit- och release-checkout samt order-PATCH har nu explicita
|
||||||
*(live-verifierat: `GET /api/admin/metrics` och `GET /api/orders` svarar 200
|
route-auth-grindar i source. De äldre live-observationerna av anonymt 200
|
||||||
anonymt.)* De är tomma idag enbart för att produktionsinstansen saknar databas.
|
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.**
|
**S4 — Historisk escrow-bugg åtgärdad i source; tenantägande återstår.**
|
||||||
`POST /api/payments/{order_id}/release` kräver ingen identitet. Den skapar en
|
`POST /api/payments/{order_id}/release` kräver nu route-auth och skapar
|
||||||
Stripe-checkout på `order.balance_ore` och kör därefter
|
Checkout först efter `accepted`. En skapad session sätter inte `delivered`;
|
||||||
`setStatus(order_id, "delivered")` **oavsett om betalningen genomförs**.
|
endast en signerad, betald och matchande Stripe-webhook får göra det.
|
||||||
Statusövergången sker alltså på begäran, inte på betalning. (Skyddas i praktiken
|
Cross-tenant-skydd av den enskilda ordern saknas fortfarande, så livebetalning
|
||||||
idag bara av att `getOrder` inte hittar något utan databas.)
|
får inte aktiveras innan ADR-001 är genomförd och verifierad.
|
||||||
|
|
||||||
**S5 — CL0UD-policyklienten är fail-open utan produktionsspärr.**
|
**S5 — CL0UD-policyklienten är fail-open utan produktionsspärr.**
|
||||||
`src/clients/cl0ud-client.ts:45` — saknas `CL0UD_URL` returneras alltid
|
`src/clients/cl0ud-client.ts:45` — saknas `CL0UD_URL` returneras alltid
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { GET as ordersGET } from '@/app/api/orders/route';
|
import { GET as ordersGET } from '@/app/api/orders/route';
|
||||||
|
import { GET as orderGET } from '@/app/api/orders/[id]/route';
|
||||||
import { GET as adminMetricsGET } from '@/app/api/admin/metrics/route';
|
import { GET as adminMetricsGET } from '@/app/api/admin/metrics/route';
|
||||||
|
import { POST as depositPOST } from '@/app/api/payments/[order_id]/deposit/route';
|
||||||
|
|
||||||
function req(url: string, headers: Record<string, string> = {}): NextRequest {
|
function req(url: string, headers: Record<string, string> = {}): NextRequest {
|
||||||
return new NextRequest(new URL(url, 'http://localhost'), { headers });
|
return new NextRequest(new URL(url, 'http://localhost'), { headers });
|
||||||
@@ -49,6 +51,20 @@ describe('anonymous-access regression: /api/orders (GET) and /api/admin/metrics
|
|||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('GET /api/orders/{id} rejects anonymous and spoofed-tenant reads before loading an order', async () => {
|
||||||
|
const params = { params: Promise.resolve({ id: 'order-unknown' }) };
|
||||||
|
expect((await orderGET(req('/api/orders/order-unknown'), params)).status).toBe(401);
|
||||||
|
expect((await orderGET(req('/api/orders/order-unknown', {
|
||||||
|
'x-0pt-verified-tenant': 'tenant_attacker',
|
||||||
|
}), params)).status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deposit checkout rejects anonymous callers before checking Stripe credentials or order id', async () => {
|
||||||
|
const request = req('/api/payments/order-unknown/deposit');
|
||||||
|
const result = await depositPOST(request, { params: Promise.resolve({ order_id: 'order-unknown' }) });
|
||||||
|
expect(result.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
it('REGRESSION: GET /api/admin/metrics 401s with no Authorization header (was: 200 + financial aggregates)', async () => {
|
it('REGRESSION: GET /api/admin/metrics 401s with no Authorization header (was: 200 + financial aggregates)', async () => {
|
||||||
const res = await adminMetricsGET(req('/api/admin/metrics'));
|
const res = await adminMetricsGET(req('/api/admin/metrics'));
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { NextRequest } from "next/server";
|
|||||||
vi.mock("@/lib/orders-store", () => ({
|
vi.mock("@/lib/orders-store", () => ({
|
||||||
getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }),
|
getOrder: vi.fn().mockResolvedValue({ order_id: "order-1", deposit_ore: 5000 }),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/src/auth/require-route-auth", () => ({
|
||||||
|
requireRouteAuth: vi.fn().mockResolvedValue({ ctx: { tenantId: "tenant-test" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
import { POST } from "@/app/api/payments/[order_id]/deposit/route";
|
import { POST } from "@/app/api/payments/[order_id]/deposit/route";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user