diff --git a/SECURITY.md b/SECURITY.md index e7e78ac9..db685c2f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,5 +1,9 @@ # Security Policy +For the authentication and authorization model (roles, scopes, JWT flow, +API-key namespaces, cookie attributes, and route guards) see +[docs/SECURITY-MODEL.md](docs/SECURITY-MODEL.md). + ## Supported Versions Only the current `main` branch and the last tagged release are supported with security updates. diff --git a/backend/README.md b/backend/README.md index c3f4e619..68abfaf7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -247,7 +247,11 @@ The Swagger documentation provides: - Complete endpoint specifications - Request/response schemas - Interactive API testing -- Authentication details (when implemented) +- Authentication details + +For the full auth model (challenge-signature-JWT flow, role-scope table, +API-key namespaces, cookie attributes) see +[docs/SECURITY-MODEL.md](../docs/SECURITY-MODEL.md). ## Project Structure diff --git a/backend/migrations/1795000000000_loan-events-type-index.js b/backend/migrations/1795000000000_loan-events-type-index.js new file mode 100644 index 00000000..fbcc4a42 --- /dev/null +++ b/backend/migrations/1795000000000_loan-events-type-index.js @@ -0,0 +1,24 @@ +/** + * Issue #1194: Port the orphan SQL migration from src/db/migrations/ into the + * real migrations directory so node-pg-migrate actually applies it. + * + * CREATE INDEX CONCURRENTLY cannot run inside a transaction, so this migration + * uses the non-transactional option supported by node-pg-migrate. + */ + +/** @type {import('node-pg-migrate').MigrationBuilder} */ +exports.up = async (pgm) => { + pgm.noTransaction(); + pgm.sql(` + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_loan_events_type_created_at + ON loan_events (event_type, created_at) + `); +}; + +/** @type {import('node-pg-migrate').MigrationBuilder} */ +exports.down = async (pgm) => { + pgm.noTransaction(); + pgm.sql(` + DROP INDEX CONCURRENTLY IF EXISTS idx_loan_events_type_created_at + `); +}; diff --git a/backend/package.json b/backend/package.json index d23fd427..f0390cde 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "migrate:create": "node-pg-migrate create", "migrate:up": "node-pg-migrate up", "migrate:down": "node-pg-migrate down", + "_migrations_note": "Single source of truth: backend/migrations/*.js (node-pg-migrate). Do NOT add .sql files to src/db/ — they are not executed by any script.", "seed": "npm run seed:dev", "seed:dev": "tsx src/seed/index.ts", "seed:reset": "tsx src/seed/index.ts --reset" diff --git a/backend/src/__tests__/poolRouteScopes.test.ts b/backend/src/__tests__/poolRouteScopes.test.ts new file mode 100644 index 00000000..0d1c9b21 --- /dev/null +++ b/backend/src/__tests__/poolRouteScopes.test.ts @@ -0,0 +1,131 @@ +/** + * Issue #1179: Integration tests that exercise pool write route authorization + * at the route layer (through app.use) so the requireScopes middleware is + * actually exercised. + * + * The lender role in rbac.ts only grants read:pool, NOT write:pool, so all + * pool write routes (build-deposit, build-withdraw, build-emergency-withdraw, + * submit) must return 403 for a lender JWT. A borrower JWT must also be + * rejected because borrowers lack even read:pool. + */ + +import { describe, it, expect, beforeAll } from "@jest/globals"; +import request from "supertest"; +import jwt from "jsonwebtoken"; +import app from "../app.js"; + +const JWT_SECRET = "test-jwt-secret-poolscopes"; + +function mintToken( + publicKey: string, + role: "lender" | "borrower" | "admin", + scopes: string[], +): string { + return jwt.sign({ publicKey, role, scopes }, JWT_SECRET, { + expiresIn: "1h", + algorithm: "HS256", + }); +} + +const LENDER_KEY = "GLENDER000000000000000000000000000000000000000000000000001"; +const BORROWER_KEY = "GBORROWER0000000000000000000000000000000000000000000000001"; + +// lender has read:pool but NOT write:pool — as per ROLE_SCOPES in rbac.ts +const lenderToken = mintToken(LENDER_KEY, "lender", ["read:loans", "read:pool"]); +// borrower has no pool scopes at all +const borrowerToken = mintToken(BORROWER_KEY, "borrower", [ + "read:loans", + "write:repayment", + "read:score", + "read:notifications", + "write:notifications", +]); + +const POOL_WRITE_ROUTES: Array<{ method: "post"; path: string; body: Record }> = [ + { + method: "post", + path: "/api/pool/build-deposit", + body: { depositorPublicKey: LENDER_KEY, token: "GTOKEN", amount: 100 }, + }, + { + method: "post", + path: "/api/pool/build-withdraw", + body: { depositorPublicKey: LENDER_KEY, token: "GTOKEN", amount: 100 }, + }, + { + method: "post", + path: "/api/pool/build-emergency-withdraw", + body: { depositorPublicKey: LENDER_KEY, token: "GTOKEN", shares: 100 }, + }, + { + method: "post", + path: "/api/pool/submit", + body: { signedTxXdr: "AAAA" }, + }, +]; + +beforeAll(() => { + process.env.JWT_SECRET = JWT_SECRET; +}); + +describe("Pool write route authorization (#1179)", () => { + describe("lender JWT (has read:pool, missing write:pool)", () => { + for (const route of POOL_WRITE_ROUTES) { + it(`${route.method.toUpperCase()} ${route.path} → 403`, async () => { + const res = await request(app) + [route.method](route.path) + .set("Authorization", `Bearer ${lenderToken}`) + .send(route.body); + + expect(res.status).toBe(403); + }); + } + }); + + describe("borrower JWT (no pool scopes at all)", () => { + for (const route of POOL_WRITE_ROUTES) { + it(`${route.method.toUpperCase()} ${route.path} → 403`, async () => { + const res = await request(app) + [route.method](route.path) + .set("Authorization", `Bearer ${borrowerToken}`) + .send(route.body); + + // borrower also fails requireLender (role check) before even reaching + // requireScopes — expect 403 either way + expect(res.status).toBe(403); + }); + } + }); + + describe("no JWT", () => { + for (const route of POOL_WRITE_ROUTES) { + it(`${route.method.toUpperCase()} ${route.path} → 401`, async () => { + const res = await request(app) + [route.method](route.path) + .send(route.body); + + expect(res.status).toBe(401); + }); + } + }); + + describe("pool read routes are accessible with lender JWT (read:pool)", () => { + it("GET /api/pool/stats → not 403 (auth passes, may fail for other reasons)", async () => { + const res = await request(app) + .get("/api/pool/stats") + .set("Authorization", `Bearer ${lenderToken}`); + + // Auth layer passes (not 401/403) — downstream may 500 without DB + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("borrower JWT on GET /api/pool/stats → 403 (requireLender)", async () => { + const res = await request(app) + .get("/api/pool/stats") + .set("Authorization", `Bearer ${borrowerToken}`); + + expect(res.status).toBe(403); + }); + }); +}); diff --git a/backend/src/db/migrations/1700000000000_add-loan-events-type-index.sql b/backend/src/db/migrations/1700000000000_add-loan-events-type-index.sql deleted file mode 100644 index b595c616..00000000 --- a/backend/src/db/migrations/1700000000000_add-loan-events-type-index.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Migration: add index on loan_events (event_type, created_at) --- Speeds up the conditional-aggregation query in getPoolStats() - --- Up -CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_loan_events_type_created_at - ON loan_events (event_type, created_at); - --- Down --- DROP INDEX CONCURRENTLY IF EXISTS idx_loan_events_type_created_at; \ No newline at end of file diff --git a/docs/SECURITY-MODEL.md b/docs/SECURITY-MODEL.md new file mode 100644 index 00000000..ca3156d2 --- /dev/null +++ b/docs/SECURITY-MODEL.md @@ -0,0 +1,134 @@ +# RemitLend Authentication & Authorization Model + +This document describes the security model for the RemitLend backend API: how +identities are established, how roles map to scopes, and which scope guard +protects each route group. See also [SECURITY.md](../SECURITY.md) for the +vulnerability-disclosure policy. + +--- + +## Authentication flows + +### 1. Challenge–signature–JWT (primary, for wallet users) + +1. **GET /api/auth/challenge?publicKey=G…** — server returns a one-time nonce + message valid for 5 minutes. +2. Client signs the message with the Stellar Ed25519 private key. +3. **POST /api/auth/verify** — server verifies the signature via + `Keypair.verify`, resolves the role for that public key (see [Role + resolution](#role-resolution) below), and mints a JWT. +4. The JWT is returned both in the JSON body and set as a `httpOnly`, + `SameSite=strict` cookie named `remitlend_jwt` (overridable via + `JWT_COOKIE_NAME` env var). The cookie is used for SSE/EventSource + connections that cannot attach `Authorization` headers. +5. JWT lifetime: **24 hours** (`JWT_EXPIRES_IN = "24h"`). + Secret: `JWT_SECRET` environment variable (required). + +JWT payload shape (`JwtPayload` in `authService.ts`): + +```ts +{ + publicKey: string; // Stellar G… address + role: UserRole; // "admin" | "borrower" | "lender" + scopes: string[]; // derived from role via ROLE_SCOPES + iat: number; + exp: number; +} +``` + +Subsequent requests supply the JWT via: +- `Authorization: Bearer ` header, or +- the `remitlend_jwt` cookie. + +### 2. API-key authentication (for backend services / admin tooling) + +Admin operations use `x-api-key: ` instead of JWTs. Keys are configured +in the `INTERNAL_API_KEY` environment variable as a comma-separated list. + +Key formats: + +| Format | Example | Grants | +|---|---|---| +| Legacy (no scope prefix) | `mysecretkey` | All admin scopes | +| Scoped | `admin:disputes:mysecretkey` | Only `admin:disputes` | + +Available scopes: `admin:disputes`, `admin:indexer`, `admin:webhooks`, +`admin:loans`. + +Implemented in `backend/src/middleware/auth.ts` (`requireApiKey`). + +--- + +## Role resolution + +`resolveRoleForWallet(publicKey)` in `backend/src/auth/rbac.ts`: + +1. If the public key is in `ADMIN_WALLETS` (comma-separated env) → **admin**. +2. If the public key is in `LENDER_WALLETS` → **lender**. +3. Otherwise → **borrower**. + +--- + +## Role-to-scope table + +Defined in `ROLE_SCOPES` in `backend/src/auth/rbac.ts`: + +| Role | Scopes granted | +|---|---| +| `admin` | `admin:all` | +| `lender` | `read:loans`, `read:pool` | +| `borrower` | `read:loans`, `write:repayment`, `read:score`, `read:notifications`, `write:notifications` | + +> **Note:** `lender` does **not** have `write:pool`. Pool write endpoints +> (`build-deposit`, `build-withdraw`, `build-emergency-withdraw`, `submit`) +> require `write:pool`, which means lenders currently receive 403 on those +> routes. This is a known gap tracked in issue #1179. + +--- + +## Route-group authorization map + +### JWT-authenticated routes (`requireJwtAuth` + `requireScopes`) + +| Route group | Role check | Required scope | +|---|---|---| +| `GET /api/pool/stats` | `requireLender` | `read:pool` | +| `GET /api/pool/depositor/:address` | `requireLender` | `read:pool` | +| `GET /api/pool/depositor/:address/yield-history` | `requireLender` | `read:pool` | +| `GET /api/pool/:token/share-price` | `requireLender` | `read:pool` | +| `POST /api/pool/build-deposit` | `requireLender` | `write:pool` | +| `POST /api/pool/build-withdraw` | `requireLender` | `write:pool` | +| `POST /api/pool/build-emergency-withdraw` | `requireLender` | `write:pool` | +| `POST /api/pool/submit` | `requireLender` | `write:pool` | +| `GET /api/loans/*` | — | `read:loans` | +| `GET /api/indexer/loans/*` | — | `read:loans` | +| `GET/POST /api/notifications` | — | `read:notifications` / `write:notifications` | +| `POST /api/remittances` | — | `write:remittances` | +| `GET /api/remittances` | — | `read:remittances` | + +### API-key-authenticated routes (`requireApiKey(scope)`) + +| Route | Required scope | +|---|---| +| `GET /api/admin/loan-disputes` | `admin:disputes` | +| `POST /api/admin/loan-disputes/:id/resolve` | `admin:disputes` | +| `POST /api/admin/loans/check-defaults` | `admin:loans` | +| `GET /api/admin/indexer/*` | `admin:indexer` | +| `GET /api/events/status` | `admin:indexer` | +| `GET /api/indexer/events/recent` | `admin:indexer` | +| `GET/POST/DELETE /api/indexer/webhooks/*` | `admin:webhooks` | +| `GET/POST/DELETE /api/admin/webhooks/*` | `admin:webhooks` | + +--- + +## Auth middleware stack + +``` +backend/src/middleware/jwtAuth.ts — requireJwtAuth, requireLender, + requireBorrower, requireScopes +backend/src/middleware/auth.ts — requireApiKey (API-key scoped access) +backend/src/services/authService.ts — generateJwtToken, verifyJwtToken, + generateChallenge, verifySignature +backend/src/auth/rbac.ts — ROLE_SCOPES, resolveRoleForWallet, + resolveScopesForRole +```