diff --git a/.env.example b/.env.example index 757161cb7..c97d0a969 100644 --- a/.env.example +++ b/.env.example @@ -5,9 +5,18 @@ NEO4J_PASSWORD=your-neo4j-password PUBLIC_EVAULT_SERVER_URI=http://localhost:4000 -# Rate limiting (requests per minute) +# Rate limiting (requests per minute). These are the defaults; limits set from +# the control panel's Rate Limits page override them without a restart. RATE_LIMIT_PER_PLATFORM=250 RATE_LIMIT_PER_IP=500 +# Shared by evault-core and the control panel to manage rate limit rules. The +# admin API is disabled while this is empty. Use a long random value. +RATE_LIMIT_ADMIN_SECRET="" +# Leave empty when clients reach evault-core directly. Behind a reverse proxy, +# set the proxies' addresses (e.g. "10.0.0.5" or "10.0.0.0/8"), or every client +# is rate limited as the proxy's IP. A hop count (e.g. 1) also works, but only +# if evault-core cannot be reached except through the proxy. +EVAULT_TRUST_PROXY="" REGISTRY_ENTROPY_KEY_JWK='{"kty":"EC","use":"sig","alg":"ES256","kid":"entropy-key-1","crv":"P-256","x":"your-x-value","y":"your-y-value","d":"your-d-value"}' ENCRYPTION_PASSWORD="your-encryption-password" diff --git a/docs/docs/Infrastructure/eVault.md b/docs/docs/Infrastructure/eVault.md index 76a3524c5..7476907ed 100644 --- a/docs/docs/Infrastructure/eVault.md +++ b/docs/docs/Infrastructure/eVault.md @@ -814,6 +814,68 @@ The provisioning layer supports shared tenancy (multiple W3IDs can be provisione - **Isolation**: All queries filtered by W3ID - **No Cross-Tenant Access**: Users can only access their own data (unless ACL allows) +## Rate Limiting + +The GraphQL and HTTP API (port 4000) is rate limited per minute, first per client IP and then per calling platform. Limits are managed from the control panel's **Rate Limits** page and apply immediately, without a restart. + +### How a request is decided + +1. A CORS preflight (`OPTIONS`) is always allowed and never counted. +2. The most specific IP rule covering the client decides first: a **blocked** IP gets `403`, an **exempt** IP skips every limit. +3. Otherwise the request counts against the IP's limit: its own rule, or the IP default. +4. If the request carries a valid Registry token, it also counts against that platform's limit: its own rule, or the platform default. A **blocked** platform gets `403`; an **exempt** platform skips only this step. + +A request over a limit gets `429` with a `Retry-After` header: + +```json +{ "error": "Too Many Requests", "retryAfterSeconds": 42, "scope": "platform" } +``` + +`scope` is `ip` or `platform`, naming the limit that was hit. + +### Rules + +| Applies to | Subject | Actions | +| --- | --- | --- | +| IP | A single address or a CIDR range, IPv4 or IPv6 | Set limit, block, exempt | +| Platform | The `platform` value of its Registry token, matched exactly | Set limit, block, exempt | +| Default | Every IP or platform without its own rule | Set limit | + +A limit on a range gives each address in it that limit; it is not one shared budget. Block and exempt rules are refused for ranges broader than `/8` (IPv4) or `/32` (IPv6). + +### What a platform rule can and cannot do + +The Registry signs a token for any platform name it is asked for, so a token proves it is genuine, not who sent it. The limiter verifies the signature and treats an invalid token as no platform at all, but the name itself is self-declared. Two consequences: + +- **The IP limit is the ceiling.** A platform limit or exemption never lets a caller exceed its IP limit. To give a platform more headroom, raise or exempt the IPs of its servers as well. +- **A platform block is advisory.** A blocked caller can request a token under another name. Block its IPs to stop it. Likewise, anyone can send a token carrying another platform's name and use up that platform's budget, up to their own IP limit. + +### Configuration + +| Variable | Purpose | +| --- | --- | +| `RATE_LIMIT_PER_IP` | Default requests per minute per IP (500) until a default is set in the control panel | +| `RATE_LIMIT_PER_PLATFORM` | Default requests per minute per platform (250) until a default is set in the control panel | +| `RATE_LIMIT_ADMIN_SECRET` | Shared with the control panel to manage rules. The admin API is disabled while it is empty | +| `EVAULT_TRUST_PROXY` | Leave empty when clients connect directly. Behind a reverse proxy, the proxies' addresses or CIDR ranges | + +Rules are stored in the `evault_rate_limit_rule` table, so run `pnpm --filter evault-core migration:run` when deploying. If the table or the database is unavailable, the limiter keeps the last rules it loaded, or the defaults above if it never loaded any; it never refuses all traffic. + +Behind a reverse proxy, set `EVAULT_TRUST_PROXY` to the proxies' addresses or every client is counted as the proxy's IP. A hop count (such as `1`) or `true` is also accepted, but both believe `X-Forwarded-For` from whoever connects: if the eVault port can be reached without going through the proxy, a client can choose the IP it is counted as and sidestep every IP limit and block. + +Counters are kept in memory by each eVault process. With several instances behind a load balancer each one counts separately, so the effective limit is the configured value times the number of instances, and the control panel shows the counters of the instance it reached. Rule changes reach every instance within 30 seconds. + +### Admin API + +The control panel manages rules through the provisioning port (3001), which is not rate limited, so no rule can lock an admin out. Every call needs the `x-shared-secret` header set to `RATE_LIMIT_ADMIN_SECRET`. + +| Route | Purpose | +| --- | --- | +| `GET /admin/rate-limits` | Defaults, rules and where they were loaded from | +| `GET /admin/rate-limits/usage` | Live counters per platform and IP for the current window | +| `PUT /admin/rate-limits/rules` | Create or replace the rule for a subject: `{ kind, subject, mode, limitPerMinute?, note? }` with `kind` `ip` or `platform`, `mode` `limit`, `blocked` or `exempt`, and subject `*` for a default | +| `DELETE /admin/rate-limits/rules/:id` | Remove a rule | + ## API Examples diff --git a/infrastructure/control-panel/README.md b/infrastructure/control-panel/README.md index 0a05758ae..47f142710 100644 --- a/infrastructure/control-panel/README.md +++ b/infrastructure/control-panel/README.md @@ -19,6 +19,13 @@ A SvelteKit-based control panel for monitoring and managing various services and - **Static admin allowlist**: Access is granted only if the authenticated eName exists in `config/admin-enames.json` - **No local DB**: Admin authorization is file-based and reloaded from disk when changed +### Rate Limits + +- **Per-platform and per-IP limits**: `/rate-limits` sets the eVault API's requests-per-minute limits per platform and per IP or CIDR range, and the defaults for everyone else +- **Block and exempt**: An IP, range or platform can be blocked or exempted +- **Live usage**: Shows the callers closest to their limit in the current window +- **Setup**: Set `RATE_LIMIT_ADMIN_SECRET` to the same value for the control panel and evault-core; `PUBLIC_PROVISIONER_URL` must point at evault-core's provisioning port + ## Prerequisites ### Kubernetes Access diff --git a/infrastructure/control-panel/src/lib/server/rate-limits.ts b/infrastructure/control-panel/src/lib/server/rate-limits.ts new file mode 100644 index 000000000..f74717567 --- /dev/null +++ b/infrastructure/control-panel/src/lib/server/rate-limits.ts @@ -0,0 +1,163 @@ +import { env } from '$env/dynamic/private'; +import { PUBLIC_PROVISIONER_URL, PUBLIC_REGISTRY_URL } from '$env/static/public'; +import { isAdminEName } from '$lib/server/auth/allowlist'; +import { error } from '@sveltejs/kit'; + +export type RateLimitKind = 'platform' | 'ip'; +export type RateLimitMode = 'limit' | 'blocked' | 'exempt'; +export type LimitSource = 'db' | 'env'; + +export interface RateLimitRule { + id: string; + kind: RateLimitKind; + subject: string; + mode: RateLimitMode; + limitPerMinute: number | null; + note: string | null; + updatedBy: string | null; + updatedAt: string; +} + +export interface RateLimitConfig { + windowSeconds: number; + config: { source: LimitSource; loadedAt: string }; + trustProxy: boolean | number | string; + defaults: { + platform: { limit: number; source: LimitSource }; + ip: { limit: number; source: LimitSource }; + }; + rules: RateLimitRule[]; +} + +export interface UsageRow { + subject: string; + count: number; + rejected: number; + limit: number; + mode: RateLimitMode; + matchedRule: string | null; + resetsInSeconds: number; +} + +export interface RateLimitUsage { + generatedAt: string; + windowSeconds: number; + platforms: UsageRow[]; + ips: UsageRow[]; + truncated: boolean; +} + +/** An evault-core answer worth showing the admin as it is. */ +export class RateLimitApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly field?: string + ) { + super(message); + } +} + +/** + * The signed-in admin's eName. The allowlist is re-read here because the + * session cookie outlives a removal from it. + */ +export async function requireAdmin(locals: App.Locals): Promise { + const ename = locals.user?.ename; + if (!ename || !(await isAdminEName(ename))) { + throw error(403, 'Not an admin'); + } + return ename; +} + +async function call(path: string, init: RequestInit = {}): Promise { + const secret = env.RATE_LIMIT_ADMIN_SECRET; + if (!secret) { + throw new RateLimitApiError( + 'RATE_LIMIT_ADMIN_SECRET is not set for the control panel', + 503 + ); + } + const baseUrl = PUBLIC_PROVISIONER_URL || 'http://localhost:3001'; + + let response: Response; + try { + response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + 'Content-Type': 'application/json', + 'x-shared-secret': secret, + ...init.headers + }, + signal: AbortSignal.timeout(10000) + }); + } catch { + throw new RateLimitApiError('Could not reach evault-core', 502); + } + + if (response.ok) return response; + if (response.status === 401) { + throw new RateLimitApiError( + 'evault-core rejected RATE_LIMIT_ADMIN_SECRET; it must match on both services', + 502 + ); + } + if (response.status === 503) { + throw new RateLimitApiError('RATE_LIMIT_ADMIN_SECRET is not set for evault-core', 503); + } + const body = await response.json().catch(() => null); + throw new RateLimitApiError( + body?.error ?? `evault-core answered ${response.status}`, + response.status, + body?.field + ); +} + +export async function getRateLimitConfig(): Promise { + return (await call('/admin/rate-limits')).json(); +} + +export async function getRateLimitUsage(): Promise { + return (await call('/admin/rate-limits/usage')).json(); +} + +export async function saveRateLimitRule( + rule: Record, + updatedBy: string +): Promise { + const response = await call('/admin/rate-limits/rules', { + method: 'PUT', + body: JSON.stringify({ ...rule, updatedBy }) + }); + return (await response.json()).rule; +} + +export async function deleteRateLimitRule(id: string, removedBy: string): Promise { + await call(`/admin/rate-limits/rules/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { 'x-admin-ename': removedBy } + }); +} + +/** Platform names the Registry lists, to offer before they have traffic. */ +export async function getKnownPlatforms(): Promise { + if (!PUBLIC_REGISTRY_URL) return []; + try { + const response = await fetch(new URL('/platforms', PUBLIC_REGISTRY_URL), { + signal: AbortSignal.timeout(5000) + }); + if (!response.ok) return []; + const platforms: unknown[] = await response.json(); + return platforms.filter((p): p is string => typeof p === 'string' && p.trim() !== ''); + } catch { + return []; + } +} + +/** The JSON answer for a failed call, keeping evault-core's own message. */ +export function errorResponse(err: unknown): Response { + if (err instanceof RateLimitApiError) { + return Response.json({ error: err.message, field: err.field }, { status: err.status }); + } + throw err; +} diff --git a/infrastructure/control-panel/src/routes/+layout.svelte b/infrastructure/control-panel/src/routes/+layout.svelte index 9e6115575..ee0975ffc 100644 --- a/infrastructure/control-panel/src/routes/+layout.svelte +++ b/infrastructure/control-panel/src/routes/+layout.svelte @@ -16,6 +16,7 @@ { label: 'Monitoring', href: '/monitoring' }, { label: 'Actions', href: '/actions' }, { label: 'Notifications', href: '/notifications' }, + { label: 'Rate Limits', href: '/rate-limits' }, { label: 'Visualizer', href: '/visualizer' } ]; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/+server.ts new file mode 100644 index 000000000..6fc254a01 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/+server.ts @@ -0,0 +1,21 @@ +import { + errorResponse, + getKnownPlatforms, + getRateLimitConfig, + requireAdmin +} from '$lib/server/rate-limits'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const GET: RequestHandler = async ({ locals }) => { + await requireAdmin(locals); + try { + const [config, knownPlatforms] = await Promise.all([ + getRateLimitConfig(), + getKnownPlatforms() + ]); + return json({ ...config, knownPlatforms }); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/rules/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/rules/+server.ts new file mode 100644 index 000000000..b434b224a --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/rules/+server.ts @@ -0,0 +1,16 @@ +import { errorResponse, requireAdmin, saveRateLimitRule } from '$lib/server/rate-limits'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const PUT: RequestHandler = async ({ locals, request }) => { + const admin = await requireAdmin(locals); + const body = await request.json().catch(() => null); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return json({ error: 'Expected a JSON object' }, { status: 400 }); + } + try { + return json({ rule: await saveRateLimitRule(body, admin) }); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/rules/[id]/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/rules/[id]/+server.ts new file mode 100644 index 000000000..07acd48d0 --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/rules/[id]/+server.ts @@ -0,0 +1,12 @@ +import { deleteRateLimitRule, errorResponse, requireAdmin } from '$lib/server/rate-limits'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const DELETE: RequestHandler = async ({ locals, params }) => { + const admin = await requireAdmin(locals); + try { + await deleteRateLimitRule(params.id ?? '', admin); + return new Response(null, { status: 204 }); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/api/rate-limits/usage/+server.ts b/infrastructure/control-panel/src/routes/api/rate-limits/usage/+server.ts new file mode 100644 index 000000000..9d505a73b --- /dev/null +++ b/infrastructure/control-panel/src/routes/api/rate-limits/usage/+server.ts @@ -0,0 +1,12 @@ +import { errorResponse, getRateLimitUsage, requireAdmin } from '$lib/server/rate-limits'; +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from '@sveltejs/kit'; + +export const GET: RequestHandler = async ({ locals }) => { + await requireAdmin(locals); + try { + return json(await getRateLimitUsage()); + } catch (err) { + return errorResponse(err); + } +}; diff --git a/infrastructure/control-panel/src/routes/rate-limits/+page.svelte b/infrastructure/control-panel/src/routes/rate-limits/+page.svelte new file mode 100644 index 000000000..1ef0b36bb --- /dev/null +++ b/infrastructure/control-panel/src/routes/rate-limits/+page.svelte @@ -0,0 +1,782 @@ + + +{#snippet usageCell(row: UsageRow | undefined)} + {#if row} + row.limit ? 'text-danger-500 font-semibold' : ''}> + {row.count} / {row.limit} + + {#if row.rejected > 0} + {row.rejected} rejected + {/if} + {:else} + — + {/if} +{/snippet} + +{#snippet modeBadge(mode: RateLimitMode, label: string)} + + {label} + +{/snippet} + +
+
+

Rate Limits

+

+ Requests to the eVault API are limited per IP, then per platform. Changes apply + immediately, without a restart. +

+ {#if loadError} +

+ {loadError} +

+ {:else if config} +
+ + Rules: {config.config.source === 'db' + ? 'loaded from the database' + : 'database unavailable, using env defaults'} + + Loaded: {new Date(config.config.loadedAt).toLocaleTimeString()} + Window: {config.windowSeconds}s + Proxy trust: {trustProxyLabel(config.trustProxy)} +
+

+ Platform names are self-declared, so a platform rule never lifts a caller above its + IP limit. To give a platform more headroom, also raise the limit on its server IPs. + Counters are kept per evault-core instance. +

+ {/if} +
+ + {#if config} +
+

Defaults

+

+ Requests per minute for any platform or IP without its own rule. +

+
+ {#each ['platform', 'ip'] as const as kind (kind)} +
+ +
+ {#if kind === 'platform'} + + {:else} + + {/if} + submitDefault(kind)} + > + Save + + {#if config.defaults[kind].source === 'db'} + resetDefault(kind)} + > + Reset to env + + {/if} +
+
+ {/each} +
+
+ +
+

Add or change a rule

+

+ Saving a rule for a subject that already has one replaces it. The most specific IP + range wins. +

+
+
+ + +
+
+ + +
+
+ + +
+ {#if formMode === 'limit'} +
+ + +
+ {/if} +
+ + +
+
+
+ + {formMode === 'limit' + ? 'Save rule' + : formMode === 'blocked' + ? 'Block…' + : 'Exempt…'} + +
+
+ +
+

Platforms

+

+ Platforms with a rule, with traffic in the current window, or listed by the + Registry. +

+
+ + + + + + + + + + + {#each platformRows as row (row.subject)} + + + + + + + {:else} + + + + {/each} + +
PlatformLimitThis window
+ {row.subject} + {#if row.rule?.note} +

{row.rule.note}

+ {/if} +
+ {@render modeBadge( + row.rule?.mode ?? 'limit', + ruleLabel(row.rule, 'platform') + )} + + {@render usageCell(row.usage)} + + + {#if row.rule} + + {/if} +
+ No platforms seen yet. +
+
+
+ +
+

IP rules

+
+ + + + + + + + + + + + {#each ipRules as rule (rule.id)} + + + + + + + + {:else} + + + + {/each} + +
IP or rangeLimitNoteChanged by
{rule.subject} + {@render modeBadge(rule.mode, ruleLabel(rule, 'ip'))} + {rule.note ?? ''} + {rule.updatedBy ?? ''} + + + +
+ No IP rules. Every IP gets the default. +
+
+
+ +
+

Live IPs

+

+ Callers in the current window, closest to their limit first. Blocked and exempt IPs + are not counted. + {#if usage?.truncated} + Showing the busiest 200. + {/if} +

+
+ + + + + + + + + + + + {#each usage?.ips ?? [] as row (row.subject)} + + + + + + + + {:else} + + + + {/each} + +
IPThis windowRuleResets in
{row.subject} + {@render usageCell(row)} + + {row.matchedRule ?? 'default'} + {row.resetsInSeconds}s + + +
+ No requests in the current window. +
+
+
+ {/if} +
+ +{#if confirmation} + +{/if} + +{#if toast} +
+

{toast.message}

+
+{/if} diff --git a/infrastructure/evault-core/package.json b/infrastructure/evault-core/package.json index 1e4c80989..0a567bdaa 100644 --- a/infrastructure/evault-core/package.json +++ b/infrastructure/evault-core/package.json @@ -33,6 +33,7 @@ "graphql-type-json": "^0.3.2", "graphql-voyager": "^2.1.0", "graphql-yoga": "^5.13.4", + "ipaddr.js": "^2.3.0", "jose": "^5.2.2", "json-schema": "^0.4.0", "multer": "1.4.5-lts.2", diff --git a/infrastructure/evault-core/src/config/database.ts b/infrastructure/evault-core/src/config/database.ts index be3988fac..09c6942e8 100644 --- a/infrastructure/evault-core/src/config/database.ts +++ b/infrastructure/evault-core/src/config/database.ts @@ -3,6 +3,7 @@ import * as dotenv from "dotenv"; import { DataSource } from "typeorm"; import { DeviceToken } from "../entities/DeviceToken"; import { Notification } from "../entities/Notification"; +import { RateLimitRule } from "../entities/RateLimitRule"; import { SecurityAnswerAttempt } from "../entities/SecurityAnswerAttempt"; import { Verification } from "../entities/Verification"; @@ -16,7 +17,13 @@ export const AppDataSource = new DataSource({ process.env.PROVISIONER_DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/registry", logging: process.env.NODE_ENV !== "production", - entities: [Verification, Notification, DeviceToken, SecurityAnswerAttempt], + entities: [ + Verification, + Notification, + DeviceToken, + SecurityAnswerAttempt, + RateLimitRule, + ], migrations: [join(__dirname, "../migrations/*.{ts,js}")], migrationsTableName: "migrations", subscribers: [], diff --git a/infrastructure/evault-core/src/controllers/RateLimitAdminController.spec.ts b/infrastructure/evault-core/src/controllers/RateLimitAdminController.spec.ts new file mode 100644 index 000000000..30ad2c284 --- /dev/null +++ b/infrastructure/evault-core/src/controllers/RateLimitAdminController.spec.ts @@ -0,0 +1,215 @@ +import "reflect-metadata"; +import type { AddressInfo } from "node:net"; +import express from "express"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { GlobalRateLimiter } from "../core/http/global-rate-limiter"; +import type { RateLimitRule } from "../entities/RateLimitRule"; +import { RateLimitConfigService } from "../services/RateLimitConfigService"; +import { RateLimitAdminController } from "./RateLimitAdminController"; + +const SECRET = "test-admin-secret"; +const RULE_ID = "3f1c2a52-6f0b-4d6e-9d57-0d0f3a1b2c3d"; + +function makeRepoStub() { + const rows = new Map(); + return { + async find() { + return [...rows.values()]; + }, + async findOne({ where }: { where: Partial }) { + return ( + [...rows.values()].find( + (r) => + r.kind === where.kind && r.subject === where.subject, + ) ?? null + ); + }, + create(partial: Partial) { + return partial as RateLimitRule; + }, + async save(row: RateLimitRule) { + row.id ??= RULE_ID; + rows.set(row.id, row); + return row; + }, + async delete({ id }: { id: string }) { + return { affected: rows.delete(id) ? 1 : 0 }; + }, + } as any; +} + +describe("RateLimitAdminController", () => { + const previousSecret = process.env.RATE_LIMIT_ADMIN_SECRET; + let server: ReturnType; + let baseUrl: string; + let config: RateLimitConfigService; + let limiter: GlobalRateLimiter; + + const call = ( + path: string, + init: RequestInit & { secret?: string | null } = {}, + ) => { + const { secret = SECRET, ...rest } = init; + return fetch(`${baseUrl}${path}`, { + ...rest, + headers: { + "content-type": "application/json", + ...(secret ? { "x-shared-secret": secret } : {}), + }, + }); + }; + + const put = (body: unknown) => + call("/admin/rate-limits/rules", { + method: "PUT", + body: JSON.stringify(body), + }); + + beforeAll(() => { + vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterAll(() => { + vi.restoreAllMocks(); + if (previousSecret === undefined) { + delete process.env.RATE_LIMIT_ADMIN_SECRET; + } else { + process.env.RATE_LIMIT_ADMIN_SECRET = previousSecret; + } + }); + + beforeEach(() => { + process.env.RATE_LIMIT_ADMIN_SECRET = SECRET; + config = new RateLimitConfigService(makeRepoStub()); + limiter = new GlobalRateLimiter( + () => config.snapshot, + async () => null, + ); + const app = express(); + app.use(express.json()); + new RateLimitAdminController(config, limiter).registerRoutes(app); + server = app.listen(0); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + it("is disabled while no secret is configured", async () => { + delete process.env.RATE_LIMIT_ADMIN_SECRET; + expect((await call("/admin/rate-limits")).status).toBe(503); + expect((await put({})).status).toBe(503); + }); + + it("rejects a missing or wrong secret on every route", async () => { + for (const secret of [null, "wrong", `${SECRET}x`]) { + expect((await call("/admin/rate-limits", { secret })).status).toBe( + 401, + ); + expect( + (await call("/admin/rate-limits/usage", { secret })).status, + ).toBe(401); + expect( + ( + await call("/admin/rate-limits/rules", { + secret, + method: "PUT", + body: "{}", + }) + ).status, + ).toBe(401); + expect( + ( + await call(`/admin/rate-limits/rules/${RULE_ID}`, { + secret, + method: "DELETE", + }) + ).status, + ).toBe(401); + } + }); + + it("lists defaults and rules", async () => { + const response = await call("/admin/rate-limits"); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + windowSeconds: 60, + config: { source: "env" }, + trustProxy: false, + defaults: { + platform: { limit: 250, source: "env" }, + ip: { limit: 500, source: "env" }, + }, + rules: [], + }); + }); + + it("saves a rule and applies it at once", async () => { + const response = await put({ + kind: "ip", + subject: "203.0.113.7", + mode: "limit", + limitPerMinute: 1, + updatedBy: "@admin", + }); + expect(response.status).toBe(200); + expect((await response.json()).rule).toMatchObject({ + id: RULE_ID, + subject: "203.0.113.7/32", + updatedBy: "@admin", + }); + + await limiter.check("GET", "203.0.113.7", null); + expect((await limiter.check("GET", "203.0.113.7", null)).allowed).toBe( + false, + ); + + const listed = await (await call("/admin/rate-limits")).json(); + expect(listed.rules).toHaveLength(1); + expect(listed.config.source).toBe("db"); + }); + + it("explains an invalid rule", async () => { + const response = await put({ + kind: "ip", + subject: "not-an-ip", + mode: "blocked", + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "Not a valid IP address or CIDR range", + field: "subject", + }); + }); + + it("removes a rule", async () => { + await put({ kind: "platform", subject: "pictique", mode: "blocked" }); + const path = `/admin/rate-limits/rules/${RULE_ID}`; + expect((await call(path, { method: "DELETE" })).status).toBe(204); + expect(config.snapshot.rules).toEqual([]); + expect((await call(path, { method: "DELETE" })).status).toBe(404); + expect( + (await call("/admin/rate-limits/rules/nope", { method: "DELETE" })) + .status, + ).toBe(404); + }); + + it("reports live usage", async () => { + await limiter.check("GET", "203.0.113.7", null); + const usage = await (await call("/admin/rate-limits/usage")).json(); + expect(usage.ips).toMatchObject([ + { subject: "203.0.113.7", count: 1, limit: 500 }, + ]); + expect(usage.platforms).toEqual([]); + }); +}); diff --git a/infrastructure/evault-core/src/controllers/RateLimitAdminController.ts b/infrastructure/evault-core/src/controllers/RateLimitAdminController.ts new file mode 100644 index 000000000..a447e1bcc --- /dev/null +++ b/infrastructure/evault-core/src/controllers/RateLimitAdminController.ts @@ -0,0 +1,121 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import type { Request, Response } from "express"; +import { validate as uuidValidate } from "uuid"; +import { + type GlobalRateLimiter, + parseTrustProxy, +} from "../core/http/global-rate-limiter"; +import { + type RateLimitConfigService, + RuleValidationError, +} from "../services/RateLimitConfigService"; + +function digest(value: string): Buffer { + return createHash("sha256").update(value).digest(); +} + +// Its own secret rather than PROVISIONER_SHARED_SECRET, which the wallet +// carries and is therefore public. +function requireAdminSecret(req: Request, res: Response): boolean { + const secret = process.env.RATE_LIMIT_ADMIN_SECRET; + if (!secret) { + res.status(503).json({ error: "Rate limit admin API disabled" }); + return false; + } + const provided = req.headers["x-shared-secret"]; + if ( + typeof provided !== "string" || + !timingSafeEqual(digest(provided), digest(secret)) + ) { + res.status(401).json({ + error: "Unauthorized: invalid or missing shared secret", + }); + return false; + } + return true; +} + +/** + * Admin API for rate limit rules, served to the control panel. It lives on + * the Express app, which the limiter does not cover, so no rule can lock an + * admin out of removing it. + */ +export class RateLimitAdminController { + constructor( + private readonly config: RateLimitConfigService, + private readonly limiter: GlobalRateLimiter, + ) {} + + registerRoutes(app: any) { + app.get("/admin/rate-limits", (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + const snapshot = this.config.snapshot; + res.json({ + windowSeconds: 60, + config: { + source: snapshot.source, + loadedAt: snapshot.loadedAt, + }, + trustProxy: parseTrustProxy(process.env.EVAULT_TRUST_PROXY), + defaults: { + platform: snapshot.platformDefault, + ip: snapshot.ipDefault, + }, + rules: snapshot.rules, + }); + }); + + app.get("/admin/rate-limits/usage", (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + res.json(this.limiter.getUsage()); + }); + + app.put( + "/admin/rate-limits/rules", + async (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + try { + const rule = await this.config.upsert(req.body ?? {}); + console.log( + `[rate-limit] ${rule.updatedBy ?? "unknown"} set ${rule.kind} ${rule.subject} to ${rule.mode}${rule.mode === "limit" ? ` ${rule.limitPerMinute}/min` : ""}`, + ); + res.json({ rule }); + } catch (error) { + if (error instanceof RuleValidationError) { + return res + .status(400) + .json({ error: error.message, field: error.field }); + } + console.error("[rate-limit] could not save rule:", error); + res.status(500).json({ error: "Could not save the rule" }); + } + }, + ); + + app.delete( + "/admin/rate-limits/rules/:id", + async (req: Request, res: Response) => { + if (!requireAdminSecret(req, res)) return; + try { + const removed = + uuidValidate(req.params.id) && + (await this.config.remove(req.params.id)); + if (!removed) { + return res + .status(404) + .json({ error: "Rule not found" }); + } + console.log( + `[rate-limit] ${req.header("x-admin-ename") ?? "unknown"} removed rule ${req.params.id}`, + ); + res.status(204).end(); + } catch (error) { + console.error("[rate-limit] could not remove rule:", error); + res.status(500).json({ + error: "Could not remove the rule", + }); + } + }, + ); + } +} diff --git a/infrastructure/evault-core/src/core/auth/registry-token.spec.ts b/infrastructure/evault-core/src/core/auth/registry-token.spec.ts new file mode 100644 index 000000000..1d3c5d68a --- /dev/null +++ b/infrastructure/evault-core/src/core/auth/registry-token.spec.ts @@ -0,0 +1,136 @@ +import axios from "axios"; +import { SignJWT, generateKeyPair } from "jose"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; +import { + getSharedTestKeyPair, + getSharedTestPublicJWK, +} from "../../test-utils/shared-test-keys"; +import { + cachedPlatformVerdicts, + resetRegistryTokenCaches, + verifyPlatform, + verifyRegistryToken, +} from "./registry-token"; + +vi.mock("axios"); +const mockedAxios = axios as any; + +describe("registry-token", () => { + const previousRegistryUrl = process.env.PUBLIC_REGISTRY_URL; + let privateKey: any; + + async function sign( + payload: Record, + options: { key?: any; expiresIn?: string | number } = {}, + ) { + return new SignJWT(payload) + .setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" }) + .setIssuedAt() + .setExpirationTime(options.expiresIn ?? "1h") + .sign(options.key ?? privateKey); + } + + beforeAll(async () => { + privateKey = (await getSharedTestKeyPair()).privateKey; + process.env.PUBLIC_REGISTRY_URL = "http://registry.test"; + }); + + afterAll(() => { + if (previousRegistryUrl === undefined) { + delete process.env.PUBLIC_REGISTRY_URL; + } else { + process.env.PUBLIC_REGISTRY_URL = previousRegistryUrl; + } + }); + + beforeEach(async () => { + vi.clearAllMocks(); + resetRegistryTokenCaches(); + mockedAxios.get.mockResolvedValue({ + data: { keys: [await getSharedTestPublicJWK()] }, + }); + }); + + it("returns the payload of a Registry-signed token", async () => { + const token = await sign({ platform: "http://pictique.test" }); + const payload = await verifyRegistryToken(token); + expect(payload.platform).toBe("http://pictique.test"); + expect(mockedAxios.get).toHaveBeenCalledWith( + "http://registry.test/.well-known/jwks.json", + expect.anything(), + ); + }); + + it("fetches the key set once for many tokens", async () => { + await verifyRegistryToken(await sign({ platform: "a" })); + await verifyRegistryToken(await sign({ platform: "b" })); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it("proves the platform of a valid token", async () => { + const token = await sign({ platform: " http://pictique.test " }); + expect(await verifyPlatform(token)).toBe("http://pictique.test"); + }); + + it("gives no platform for a token signed by another key", async () => { + const other = await generateKeyPair("ES256"); + const token = await sign( + { platform: "http://pictique.test" }, + { key: other.privateKey }, + ); + expect(await verifyPlatform(token)).toBeNull(); + }); + + it("gives no platform for an expired token", async () => { + const token = await sign( + { platform: "http://pictique.test" }, + { expiresIn: Math.floor(Date.now() / 1000) - 60 }, + ); + expect(await verifyPlatform(token)).toBeNull(); + }); + + it("gives no platform when the claim is absent or unusable", async () => { + expect(await verifyPlatform(await sign({}))).toBeNull(); + expect(await verifyPlatform(await sign({ platform: 7 }))).toBeNull(); + expect( + await verifyPlatform(await sign({ platform: "x".repeat(257) })), + ).toBeNull(); + expect(await verifyPlatform("garbage")).toBeNull(); + expect(await verifyPlatform(null)).toBeNull(); + }); + + it("asks an unreachable Registry once, then backs off", async () => { + mockedAxios.get.mockRejectedValue(new Error("registry down")); + const first = await sign({ platform: "a" }); + const second = await sign({ platform: "b" }); + expect(await verifyPlatform(first)).toBeNull(); + expect(await verifyPlatform(second)).toBeNull(); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it("does not pin a good token to no platform during an outage", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const token = await sign({ platform: "http://pictique.test" }); + mockedAxios.get.mockRejectedValueOnce(new Error("registry down")); + expect(await verifyPlatform(token)).toBeNull(); + vi.advanceTimersByTime(31_000); + expect(await verifyPlatform(token)).toBe("http://pictique.test"); + } finally { + vi.useRealTimers(); + } + }); + + it("keeps the verdict cache bounded", async () => { + for (let i = 0; i < 5010; i++) await verifyPlatform(`junk-${i}`); + expect(cachedPlatformVerdicts()).toBe(5000); + }); +}); diff --git a/infrastructure/evault-core/src/core/auth/registry-token.ts b/infrastructure/evault-core/src/core/auth/registry-token.ts new file mode 100644 index 000000000..7b89e2068 --- /dev/null +++ b/infrastructure/evault-core/src/core/auth/registry-token.ts @@ -0,0 +1,198 @@ +import { createHash } from "node:crypto"; +import axios from "axios"; +import * as jose from "jose"; +import { timed } from "../utils/timing"; + +type JwksResolver = ReturnType; + +type CachedJwks = { + jwks: JwksResolver; + fetchedAt: number; + expiresAt: number; +}; + +const JWKS_TTL_MS = 24 * 60 * 60 * 1000; +const JWKS_FETCH_TIMEOUT_MS = 5000; +// After a failed fetch, wait this long before asking the Registry again so an +// outage costs one timeout rather than one per request. +const JWKS_FAILURE_BACKOFF_MS = 30_000; +// An unknown `kid` may mean the Registry rotated its key; refetch at most +// this often. +const JWKS_ROTATION_REFETCH_MS = 60_000; + +const PLATFORM_CACHE_MAX = 5000; +const PLATFORM_CACHE_TTL_MS = 10 * 60 * 1000; +const PLATFORM_NEGATIVE_TTL_MS = 60_000; +const PLATFORM_MAX_LENGTH = 256; + +const jwksCache = new Map(); +const jwksInFlight = new Map>(); +const jwksRetryAt = new Map(); +const platformCache = new Map< + string, + { platform: string | null; expiresAt: number } +>(); + +function registryJwksUrl(): string { + const registryUrl = + process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; + if (!registryUrl) { + throw new Error("PUBLIC_REGISTRY_URL or REGISTRY_URL is not set"); + } + return new URL("/.well-known/jwks.json", registryUrl).toString(); +} + +function fetchJwks(jwksUrl: string): Promise { + let pending = jwksInFlight.get(jwksUrl); + if (!pending) { + pending = timed("registryToken.fetchJWKS", () => + axios.get(jwksUrl, { timeout: JWKS_FETCH_TIMEOUT_MS }), + ) + .then((response) => { + const now = Date.now(); + const cached = { + jwks: jose.createLocalJWKSet(response.data), + fetchedAt: now, + expiresAt: now + JWKS_TTL_MS, + }; + jwksCache.set(jwksUrl, cached); + jwksRetryAt.delete(jwksUrl); + return cached; + }) + .catch((error) => { + jwksRetryAt.set(jwksUrl, Date.now() + JWKS_FAILURE_BACKOFF_MS); + throw error; + }) + .finally(() => { + jwksInFlight.delete(jwksUrl); + }); + jwksInFlight.set(jwksUrl, pending); + } + return pending; +} + +/** + * The Registry's signing keys, cached for a day. A stale set keeps being + * served while the Registry is unreachable. + */ +async function getRegistryJwks(jwksUrl: string): Promise { + const now = Date.now(); + const cached = jwksCache.get(jwksUrl); + if (cached && cached.expiresAt > now) return cached; + + if ((jwksRetryAt.get(jwksUrl) ?? 0) > now) { + if (cached) return cached; + throw new Error("Registry JWKS is unavailable"); + } + + try { + return await fetchJwks(jwksUrl); + } catch (error) { + if (cached) return cached; + throw error; + } +} + +/** + * Verifies a Registry-issued token and returns its payload. Throws when the + * token is invalid or the Registry's keys cannot be obtained. + */ +export async function verifyRegistryToken( + token: string, + options?: jose.JWTVerifyOptions, +): Promise { + const jwksUrl = registryJwksUrl(); + const cached = await getRegistryJwks(jwksUrl); + try { + const { payload } = await jose.jwtVerify(token, cached.jwks, options); + return payload; + } catch (error) { + const rotated = + error instanceof jose.errors.JWKSNoMatchingKey && + Date.now() - cached.fetchedAt > JWKS_ROTATION_REFETCH_MS && + (jwksRetryAt.get(jwksUrl) ?? 0) <= Date.now(); + if (!rotated) throw error; + const refreshed = await fetchJwks(jwksUrl); + const { payload } = await jose.jwtVerify( + token, + refreshed.jwks, + options, + ); + return payload; + } +} + +function rememberPlatform( + key: string, + platform: string | null, + expiresAt: number, +): void { + if (platformCache.size >= PLATFORM_CACHE_MAX) { + const oldest = platformCache.keys().next().value; + if (oldest !== undefined) platformCache.delete(oldest); + } + platformCache.set(key, { platform, expiresAt }); +} + +/** + * The calling platform a token proves, or null when the token is missing, + * invalid or carries no platform. Results are cached by token hash so the + * signature is checked once per token rather than once per request. + * + * The Registry signs any platform name it is asked for, so this proves the + * token is genuine, not who the caller is. + */ +export async function verifyPlatform( + token: string | null | undefined, +): Promise { + if (!token) return null; + + const key = createHash("sha256").update(token).digest("base64"); + const now = Date.now(); + const hit = platformCache.get(key); + if (hit && hit.expiresAt > now) return hit.platform; + platformCache.delete(key); + + try { + const payload = await verifyRegistryToken(token, { + algorithms: ["ES256"], + }); + const claim = payload.platform; + const platform = + typeof claim === "string" && + claim.trim().length > 0 && + claim.trim().length <= PLATFORM_MAX_LENGTH + ? claim.trim() + : null; + const tokenExpiry = + typeof payload.exp === "number" + ? payload.exp * 1000 + : Number.POSITIVE_INFINITY; + rememberPlatform( + key, + platform, + Math.min(tokenExpiry, now + PLATFORM_CACHE_TTL_MS), + ); + return platform; + } catch (error) { + // Only a verdict on the token itself is remembered; a Registry + // outage must not pin a good token to "no platform". + if (error instanceof jose.errors.JOSEError) { + rememberPlatform(key, null, now + PLATFORM_NEGATIVE_TTL_MS); + } + return null; + } +} + +/** Test hook: how many token verdicts are cached. */ +export function cachedPlatformVerdicts(): number { + return platformCache.size; +} + +/** Test hook: forget every cached key set and verdict. */ +export function resetRegistryTokenCaches(): void { + jwksCache.clear(); + jwksInFlight.clear(); + jwksRetryAt.clear(); + platformCache.clear(); +} diff --git a/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts b/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts new file mode 100644 index 000000000..f202d8790 --- /dev/null +++ b/infrastructure/evault-core/src/core/http/global-rate-limiter.spec.ts @@ -0,0 +1,379 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RateLimitRule } from "../../entities/RateLimitRule"; +import type { RateLimitSnapshot } from "../../services/RateLimitConfigService"; +import { + GlobalRateLimiter, + createRateLimitHook, + parseTrustProxy, +} from "./global-rate-limiter"; +import { compileIpRules } from "./ip-rules"; + +type RuleSpec = Pick & { + limitPerMinute?: number; +}; + +function makeSnapshot( + specs: RuleSpec[] = [], + defaults = { platform: 3, ip: 5 }, +): RateLimitSnapshot { + const rules = specs.map( + (spec) => ({ limitPerMinute: null, ...spec }) as RateLimitRule, + ); + return { + source: "db", + loadedAt: new Date(), + platformDefault: { limit: defaults.platform, source: "env" }, + ipDefault: { limit: defaults.ip, source: "env" }, + platformRules: new Map( + rules.filter((r) => r.kind === "platform").map((r) => [r.subject, r]), + ), + matchIp: compileIpRules(rules.filter((r) => r.kind === "ip")), + rules, + }; +} + +// A token is its own platform name here; "bad" stands for an invalid one. +const platformOf = async (token: string | null) => + token && token !== "bad" ? token : null; + +function makeLimiter(specs: RuleSpec[] = [], defaults?: any) { + const state = { snapshot: makeSnapshot(specs, defaults), now: 1_000_000 }; + const verify = vi.fn(platformOf); + const limiter = new GlobalRateLimiter( + () => state.snapshot, + verify, + () => state.now, + ); + const hit = (ip = "203.0.113.7", token: string | null = null) => + limiter.check("POST", ip, token); + const hits = async (n: number, ip?: string, token?: string | null) => { + let last = await hit(ip, token); + for (let i = 1; i < n; i++) last = await hit(ip, token); + return last; + }; + return { limiter, state, verify, hit, hits }; +} + +describe("GlobalRateLimiter", () => { + it("allows requests up to the IP default, then rejects", async () => { + const { hits, hit } = makeLimiter(); + expect(await hits(5)).toEqual({ allowed: true }); + expect(await hit()).toEqual({ + allowed: false, + status: 429, + scope: "ip", + retryAfterSeconds: 60, + }); + }); + + it("counts each IP separately and a mapped address as its IPv4", async () => { + const { hits, hit } = makeLimiter(); + await hits(5, "203.0.113.7"); + expect((await hit("198.51.100.1")).allowed).toBe(true); + expect((await hit("::ffff:203.0.113.7")).allowed).toBe(false); + }); + + it("starts a new window after a minute", async () => { + const { hits, hit, state } = makeLimiter(); + await hits(6); + state.now += 60_001; + expect((await hit()).allowed).toBe(true); + }); + + it("never counts a preflight", async () => { + const { limiter, hits } = makeLimiter(); + for (let i = 0; i < 20; i++) { + await limiter.check("OPTIONS", "203.0.113.7", null); + } + expect(await hits(5)).toEqual({ allowed: true }); + }); + + it("applies a per-IP limit, raised or lowered", async () => { + const { hits, hit } = makeLimiter([ + { + kind: "ip", + subject: "203.0.113.7/32", + mode: "limit", + limitPerMinute: 8, + }, + { + kind: "ip", + subject: "198.51.100.0/24", + mode: "limit", + limitPerMinute: 1, + }, + ]); + expect((await hits(8)).allowed).toBe(true); + expect((await hit()).allowed).toBe(false); + expect((await hit("198.51.100.1")).allowed).toBe(true); + expect((await hit("198.51.100.1")).allowed).toBe(false); + // A range limit is per address, not one bucket for the range. + expect((await hit("198.51.100.2")).allowed).toBe(true); + }); + + it("blocks an IP without counting it or reading its token", async () => { + const { limiter, hit, verify } = makeLimiter([ + { kind: "ip", subject: "203.0.113.0/24", mode: "blocked" }, + ]); + expect(await hit("203.0.113.7", "pictique")).toEqual({ + allowed: false, + status: 403, + }); + expect(verify).not.toHaveBeenCalled(); + expect(limiter.getUsage().ips).toEqual([]); + }); + + it("lets an exempt IP through everything", async () => { + const { hits, verify } = makeLimiter([ + { kind: "ip", subject: "203.0.113.7/32", mode: "exempt" }, + { kind: "platform", subject: "pictique", mode: "blocked" }, + ]); + expect(await hits(50, "203.0.113.7", "pictique")).toEqual({ + allowed: true, + }); + expect(verify).not.toHaveBeenCalled(); + }); + + it("prefers the most specific IP rule", async () => { + const { hit } = makeLimiter([ + { kind: "ip", subject: "203.0.113.0/24", mode: "blocked" }, + { kind: "ip", subject: "203.0.113.7/32", mode: "exempt" }, + ]); + expect((await hit("203.0.113.7")).allowed).toBe(true); + expect((await hit("203.0.113.8")).allowed).toBe(false); + }); + + it("holds a platform to its default across IPs", async () => { + const { hit } = makeLimiter(); + for (let i = 1; i <= 3; i++) { + expect((await hit(`198.51.100.${i}`, "pictique")).allowed).toBe( + true, + ); + } + expect(await hit("198.51.100.4", "pictique")).toEqual({ + allowed: false, + status: 429, + scope: "platform", + retryAfterSeconds: 60, + }); + expect((await hit("198.51.100.5", "blabsy")).allowed).toBe(true); + }); + + it("applies a per-platform limit", async () => { + const { hit } = makeLimiter( + [ + { + kind: "platform", + subject: "pictique", + mode: "limit", + limitPerMinute: 4, + }, + ], + { platform: 1, ip: 100 }, + ); + for (let i = 0; i < 4; i++) { + expect((await hit("203.0.113.7", "pictique")).allowed).toBe(true); + } + expect((await hit("203.0.113.7", "pictique")).allowed).toBe(false); + }); + + it("keeps the IP limit as the ceiling over any platform rule", async () => { + const { hits, hit } = makeLimiter([ + { + kind: "platform", + subject: "pictique", + mode: "limit", + limitPerMinute: 1000, + }, + { kind: "platform", subject: "blabsy", mode: "exempt" }, + ]); + expect((await hits(5, "203.0.113.7", "pictique")).allowed).toBe(true); + expect(await hit("203.0.113.7", "pictique")).toMatchObject({ + status: 429, + scope: "ip", + }); + expect((await hits(5, "198.51.100.1", "blabsy")).allowed).toBe(true); + expect(await hit("198.51.100.1", "blabsy")).toMatchObject({ + status: 429, + scope: "ip", + }); + }); + + it("exempts a platform from its own bucket only", async () => { + const { limiter, hits } = makeLimiter( + [{ kind: "platform", subject: "pictique", mode: "exempt" }], + { platform: 1, ip: 100 }, + ); + expect((await hits(50, "203.0.113.7", "pictique")).allowed).toBe(true); + expect(limiter.getUsage().platforms).toEqual([]); + }); + + it("blocks a platform", async () => { + const { hit } = makeLimiter([ + { kind: "platform", subject: "pictique", mode: "blocked" }, + ]); + expect(await hit("203.0.113.7", "pictique")).toEqual({ + allowed: false, + status: 403, + }); + expect((await hit("203.0.113.7", "blabsy")).allowed).toBe(true); + }); + + it("treats an unverifiable token as no platform", async () => { + const { limiter, hits } = makeLimiter([], { platform: 1, ip: 100 }); + expect((await hits(10, "203.0.113.7", "bad")).allowed).toBe(true); + expect(limiter.getUsage().platforms).toEqual([]); + }); + + it("does not verify a token once the IP is over its limit", async () => { + const { hits, hit, verify } = makeLimiter(); + await hits(5, "203.0.113.7", "pictique"); + verify.mockClear(); + await hit("203.0.113.7", "pictique"); + expect(verify).not.toHaveBeenCalled(); + }); + + it("lifts a throttled caller as soon as its limit is raised", async () => { + const { hits, hit, state } = makeLimiter(); + await hits(6); + expect((await hit()).allowed).toBe(false); + state.snapshot = makeSnapshot([ + { + kind: "ip", + subject: "203.0.113.7/32", + mode: "limit", + limitPerMinute: 100, + }, + ]); + expect((await hit()).allowed).toBe(true); + }); + + it("reports live usage, busiest first", async () => { + const { limiter, hits, state } = makeLimiter([ + { + kind: "ip", + subject: "198.51.100.0/24", + mode: "limit", + limitPerMinute: 100, + }, + ]); + await hits(7, "203.0.113.7", "pictique"); + await hits(2, "198.51.100.1"); + state.now += 15_000; + + const usage = limiter.getUsage(); + expect(usage.windowSeconds).toBe(60); + expect(usage.truncated).toBe(false); + expect(usage.ips).toEqual([ + { + subject: "203.0.113.7", + count: 7, + rejected: 2, + limit: 5, + mode: "limit", + matchedRule: null, + resetsInSeconds: 45, + }, + { + subject: "198.51.100.1", + count: 2, + rejected: 0, + limit: 100, + mode: "limit", + matchedRule: "198.51.100.0/24", + resetsInSeconds: 45, + }, + ]); + expect(usage.platforms).toEqual([ + { + subject: "pictique", + count: 5, + rejected: 2, + limit: 3, + mode: "limit", + matchedRule: null, + resetsInSeconds: 45, + }, + ]); + + state.now += 60_000; + expect(limiter.getUsage().ips).toEqual([]); + }); +}); + +describe("createRateLimitHook", () => { + function makeReply() { + const reply: any = { headers: {} }; + reply.code = vi.fn((status: number) => { + reply.status = status; + return reply; + }); + reply.header = vi.fn((name: string, value: string) => { + reply.headers[name] = value; + return reply; + }); + reply.send = vi.fn((body: unknown) => { + reply.body = body; + return reply; + }); + return reply; + } + + const request = (authorization?: string) => + ({ + method: "POST", + ip: "203.0.113.7", + headers: { authorization }, + }) as any; + + it("passes an allowed request through untouched", async () => { + const { limiter } = makeLimiter(); + const reply = makeReply(); + const result = await createRateLimitHook(limiter)(request(), reply); + expect(result).toBeUndefined(); + expect(reply.send).not.toHaveBeenCalled(); + }); + + it("answers 429 with Retry-After and the scope", async () => { + const { limiter } = makeLimiter([], { platform: 1, ip: 100 }); + const hook = createRateLimitHook(limiter); + await hook(request("Bearer pictique"), makeReply()); + const reply = makeReply(); + expect(await hook(request("Bearer pictique"), reply)).toBe(reply); + expect(reply.status).toBe(429); + expect(reply.headers["Retry-After"]).toBe("60"); + expect(reply.body).toEqual({ + error: "Too Many Requests", + retryAfterSeconds: 60, + scope: "platform", + }); + }); + + it("answers 403 for a blocked caller", async () => { + const { limiter } = makeLimiter([ + { kind: "ip", subject: "203.0.113.7/32", mode: "blocked" }, + ]); + const reply = makeReply(); + expect(await createRateLimitHook(limiter)(request(), reply)).toBe( + reply, + ); + expect(reply.status).toBe(403); + expect(reply.body).toEqual({ error: "Forbidden" }); + }); +}); + +describe("parseTrustProxy", () => { + it("trusts nothing unless told to", () => { + expect(parseTrustProxy(undefined)).toBe(false); + expect(parseTrustProxy("")).toBe(false); + expect(parseTrustProxy("false")).toBe(false); + }); + + it("reads true, a hop count and an address list", () => { + expect(parseTrustProxy("TRUE")).toBe(true); + expect(parseTrustProxy(" 2 ")).toBe(2); + expect(parseTrustProxy("10.0.0.0/8, 127.0.0.1")).toBe( + "10.0.0.0/8, 127.0.0.1", + ); + }); +}); diff --git a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts index 4d7b51f11..399c06ad5 100644 --- a/infrastructure/evault-core/src/core/http/global-rate-limiter.ts +++ b/infrastructure/evault-core/src/core/http/global-rate-limiter.ts @@ -1,78 +1,271 @@ -import { decodeJwt } from "jose"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { RateLimitMode } from "../../entities/RateLimitRule"; +import type { RateLimitSnapshot } from "../../services/RateLimitConfigService"; +import { verifyPlatform } from "../auth/registry-token"; +import { normalizeIp } from "./ip-rules"; const WINDOW_MS = 60_000; // 1-minute window -const MAX_REQUESTS_PER_PLATFORM = - Number(process.env.RATE_LIMIT_PER_PLATFORM) || 250; -const MAX_REQUESTS_PER_IP = Number(process.env.RATE_LIMIT_PER_IP) || 500; +const USAGE_ROWS = 200; interface RateRecord { count: number; + rejected: number; windowStart: number; } -const platformRecords = new Map(); -const ipRecords = new Map(); +export type RateLimitDecision = + | { allowed: true } + | { allowed: false; status: 403 } + | { + allowed: false; + status: 429; + scope: "ip" | "platform"; + retryAfterSeconds: number; + }; -function check( - map: Map, - key: string, - limit: number, -): { allowed: boolean; retryAfterSeconds: number } { - const now = Date.now(); - let rec = map.get(key); +export interface UsageRow { + subject: string; + /** Requests seen this window, rejected ones included. */ + count: number; + rejected: number; + limit: number; + mode: RateLimitMode; + /** The rule that set the limit, or null when the default applies. */ + matchedRule: string | null; + resetsInSeconds: number; +} + +export interface RateLimitUsage { + generatedAt: Date; + windowSeconds: number; + platforms: UsageRow[]; + ips: UsageRow[]; + /** True when either list was cut to the busiest {@link USAGE_ROWS}. */ + truncated: boolean; +} + +/** + * Fixed-window request limiter for the public eVault API. + * + * Every request is held to its IP's limit first. A request carrying a valid + * Registry token is then also held to its platform's limit. The Registry + * signs any platform name, so a platform rule can only narrow what a caller + * may do: nothing a token claims lifts the IP ceiling. + * + * Counters live in this process. Several instances each count separately. + */ +export class GlobalRateLimiter { + private readonly platformRecords = new Map(); + private readonly ipRecords = new Map(); + private timer?: NodeJS.Timeout; - if (!rec || now - rec.windowStart > WINDOW_MS) { - rec = { count: 0, windowStart: now }; - map.set(key, rec); + constructor( + private readonly getSnapshot: () => RateLimitSnapshot, + private readonly platformOf: ( + token: string | null, + ) => Promise = verifyPlatform, + private readonly now: () => number = Date.now, + ) {} + + /** Starts the sweep that drops counters whose window has passed. */ + start(): void { + this.timer = setInterval(() => this.sweep(), WINDOW_MS); + this.timer.unref(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; } - rec.count++; + async check( + method: string, + rawIp: string, + token: string | null, + ): Promise { + // A CORS preflight carries no credentials and does no work. + if (method === "OPTIONS") return { allowed: true }; + + const snapshot = this.getSnapshot(); + const ip = normalizeIp(rawIp); + const ipRule = snapshot.matchIp(ip); + if (ipRule?.mode === "blocked") return { allowed: false, status: 403 }; + if (ipRule?.mode === "exempt") return { allowed: true }; - if (rec.count > limit) { - const retryAfterSeconds = Math.ceil( - (rec.windowStart + WINDOW_MS - now) / 1000, + // The IP is counted before the token is looked at, so an abusive + // caller is turned away without the cost of verifying a signature. + const ipLimit = ipRule?.limitPerMinute ?? snapshot.ipDefault.limit; + const ipResult = this.count(this.ipRecords, ip, ipLimit); + if (ipResult) return { ...ipResult, scope: "ip" }; + + const platform = await this.platformOf(token); + if (!platform) return { allowed: true }; + + // Rules may have been reloaded while the token was being verified. + const current = this.getSnapshot(); + const platformRule = current.platformRules.get(platform); + if (platformRule?.mode === "blocked") { + return { allowed: false, status: 403 }; + } + if (platformRule?.mode === "exempt") return { allowed: true }; + + const platformLimit = + platformRule?.limitPerMinute ?? current.platformDefault.limit; + const platformResult = this.count( + this.platformRecords, + platform, + platformLimit, ); - return { allowed: false, retryAfterSeconds }; + if (platformResult) return { ...platformResult, scope: "platform" }; + return { allowed: true }; } - return { allowed: true, retryAfterSeconds: 0 }; -} + /** Live counters, busiest relative to their limit first. */ + getUsage(): RateLimitUsage { + const snapshot = this.getSnapshot(); + const now = this.now(); + + const rows = ( + records: Map, + resolve: (subject: string) => { + limit: number; + mode: RateLimitMode; + matchedRule: string | null; + }, + ): UsageRow[] => { + const result: UsageRow[] = []; + for (const [subject, rec] of records) { + if (now - rec.windowStart > WINDOW_MS) continue; + result.push({ + subject, + count: rec.count, + rejected: rec.rejected, + ...resolve(subject), + resetsInSeconds: Math.ceil( + (rec.windowStart + WINDOW_MS - now) / 1000, + ), + }); + } + return result.sort( + (a, b) => b.count / b.limit - a.count / a.limit, + ); + }; + + const platforms = rows(this.platformRecords, (platform) => { + const rule = snapshot.platformRules.get(platform); + return { + limit: rule?.limitPerMinute ?? snapshot.platformDefault.limit, + mode: rule?.mode ?? "limit", + matchedRule: rule?.subject ?? null, + }; + }); + const ips = rows(this.ipRecords, (ip) => { + const rule = snapshot.matchIp(ip); + return { + limit: rule?.limitPerMinute ?? snapshot.ipDefault.limit, + mode: rule?.mode ?? "limit", + matchedRule: rule?.subject ?? null, + }; + }); + + return { + generatedAt: new Date(now), + windowSeconds: WINDOW_MS / 1000, + platforms: platforms.slice(0, USAGE_ROWS), + ips: ips.slice(0, USAGE_ROWS), + truncated: + platforms.length > USAGE_ROWS || ips.length > USAGE_ROWS, + }; + } + + private count( + map: Map, + key: string, + limit: number, + ): { allowed: false; status: 429; retryAfterSeconds: number } | null { + const now = this.now(); + let rec = map.get(key); + + if (!rec || now - rec.windowStart > WINDOW_MS) { + rec = { count: 0, rejected: 0, windowStart: now }; + map.set(key, rec); + } + + rec.count++; + + if (rec.count > limit) { + rec.rejected++; + return { + allowed: false, + status: 429, + retryAfterSeconds: Math.ceil( + (rec.windowStart + WINDOW_MS - now) / 1000, + ), + }; + } -function extractPlatform(token: string): string | null { - try { - const payload = decodeJwt(token); - return (payload as any).platform ?? null; - } catch { return null; } -} -export function checkGlobalRateLimit( - token: string | null, - ip: string, -): { allowed: boolean; retryAfterSeconds: number } { - if (token) { - const platform = extractPlatform(token); - if (platform) { - const result = check( - platformRecords, - platform, - MAX_REQUESTS_PER_PLATFORM, - ); - if (!result.allowed) return result; + // Drops stale entries to prevent memory growth + private sweep(): void { + const now = this.now(); + for (const map of [this.platformRecords, this.ipRecords]) { + for (const [key, rec] of map) { + if (now - rec.windowStart > WINDOW_MS) map.delete(key); + } } } +} + +/** The Fastify `onRequest` hook that enforces a limiter. */ +export function createRateLimitHook(limiter: GlobalRateLimiter) { + return async (request: FastifyRequest, reply: FastifyReply) => { + const authHeader = request.headers.authorization; + const token = authHeader?.startsWith("Bearer ") + ? authHeader.substring(7) + : null; + const decision = await limiter.check( + request.method, + request.ip, + token, + ); + if (decision.allowed) return; - return check(ipRecords, ip, MAX_REQUESTS_PER_IP); + // In an async onRequest hook, Fastify only short-circuits the + // handler chain if you return the reply object. Without the + // return, the response is queued but the downstream handler + // (GraphQL) still runs — turning the rate limiter into a silent + // counter. + if (decision.status === 403) { + return reply.code(403).send({ error: "Forbidden" }); + } + return reply + .code(429) + .header("Retry-After", String(decision.retryAfterSeconds)) + .send({ + error: "Too Many Requests", + retryAfterSeconds: decision.retryAfterSeconds, + scope: decision.scope, + }); + }; } -// Periodically clean up stale entries to prevent memory growth -setInterval(() => { - const now = Date.now(); - for (const [key, rec] of platformRecords) { - if (now - rec.windowStart > WINDOW_MS) platformRecords.delete(key); - } - for (const [key, rec] of ipRecords) { - if (now - rec.windowStart > WINDOW_MS) ipRecords.delete(key); - } -}, 60_000); +/** + * Reads `EVAULT_TRUST_PROXY` into Fastify's `trustProxy` option: unset or + * `false` trusts nothing, `true` trusts every hop, a number trusts that many + * hops, and anything else is a comma-separated list of proxy addresses. + * + * Only an address list checks who is speaking. `true` and a hop count + * believe X-Forwarded-For from any peer, so a client that can reach this + * port without going through the proxy picks the IP it is limited as. + */ +export function parseTrustProxy( + value: string | undefined, +): boolean | number | string { + const trimmed = value?.trim() ?? ""; + if (trimmed === "" || trimmed.toLowerCase() === "false") return false; + if (trimmed.toLowerCase() === "true") return true; + if (/^\d+$/.test(trimmed)) return Number(trimmed); + return trimmed; +} diff --git a/infrastructure/evault-core/src/core/http/ip-rules.spec.ts b/infrastructure/evault-core/src/core/http/ip-rules.spec.ts new file mode 100644 index 000000000..41ac3e78c --- /dev/null +++ b/infrastructure/evault-core/src/core/http/ip-rules.spec.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + canonicalizeSubject, + compileIpRules, + hostBits, + isTooBroad, + normalizeIp, +} from "./ip-rules"; + +describe("ip-rules", () => { + describe("normalizeIp", () => { + it("unwraps an IPv4-mapped IPv6 address", () => { + expect(normalizeIp("::ffff:203.0.113.7")).toBe("203.0.113.7"); + }); + + it("compresses IPv6 to one spelling", () => { + expect(normalizeIp("2001:0db8:0000:0000:0000:0000:0000:0001")).toBe( + "2001:db8::1", + ); + }); + + it("returns unparseable input unchanged", () => { + expect(normalizeIp("unknown")).toBe("unknown"); + }); + }); + + describe("canonicalizeSubject", () => { + it("turns a bare address into a single-address range", () => { + expect(canonicalizeSubject(" 203.0.113.7 ")).toBe("203.0.113.7/32"); + expect(canonicalizeSubject("2001:db8::1")).toBe("2001:db8::1/128"); + }); + + it("zeroes host bits", () => { + expect(canonicalizeSubject("203.0.113.7/24")).toBe("203.0.113.0/24"); + expect(canonicalizeSubject("2001:db8::1/32")).toBe("2001:db8::/32"); + }); + + it("stores a mapped IPv4 range as IPv4", () => { + expect(canonicalizeSubject("::ffff:203.0.113.7")).toBe( + "203.0.113.7/32", + ); + expect(canonicalizeSubject("::ffff:203.0.113.7/120")).toBe( + "203.0.113.0/24", + ); + }); + + it("rejects invalid input", () => { + for (const bad of [ + "", + "nope", + "10", + "1.2.3.4/33", + "1.2.3/24", + "*", + "::ffff:0:0/95", + ]) { + expect(() => canonicalizeSubject(bad)).toThrow(); + } + }); + }); + + it("flags ranges too broad to block or exempt", () => { + expect(isTooBroad("10.0.0.0/8")).toBe(false); + expect(isTooBroad("0.0.0.0/0")).toBe(true); + expect(isTooBroad("8.0.0.0/7")).toBe(true); + expect(isTooBroad("2001:db8::/32")).toBe(false); + expect(isTooBroad("2000::/3")).toBe(true); + }); + + it("counts the host bits of a range", () => { + expect(hostBits("203.0.113.0/24")).toBe(8); + expect(hostBits("203.0.113.7/32")).toBe(0); + }); + + describe("compileIpRules", () => { + const match = compileIpRules([ + { subject: "10.0.0.0/8", name: "wide" }, + { subject: "10.1.2.3/32", name: "host" }, + { subject: "10.1.0.0/16", name: "mid" }, + { subject: "2001:db8::/32", name: "v6" }, + { subject: "garbage", name: "bad" }, + ]); + + it("picks the most specific range", () => { + expect(match("10.1.2.3")?.name).toBe("host"); + expect(match("10.1.9.9")?.name).toBe("mid"); + expect(match("10.200.0.1")?.name).toBe("wide"); + }); + + it("matches a mapped IPv4 client against IPv4 rules", () => { + expect(match("::ffff:10.1.2.3")?.name).toBe("host"); + }); + + it("matches IPv6 and keeps the families apart", () => { + expect(match("2001:db8::42")?.name).toBe("v6"); + expect(match("2001:db9::1")).toBeNull(); + }); + + it("matches nothing for an uncovered or invalid address", () => { + expect(match("192.0.2.1")).toBeNull(); + expect(match("unknown")).toBeNull(); + }); + }); +}); diff --git a/infrastructure/evault-core/src/core/http/ip-rules.ts b/infrastructure/evault-core/src/core/http/ip-rules.ts new file mode 100644 index 000000000..c3f36fd4f --- /dev/null +++ b/infrastructure/evault-core/src/core/http/ip-rules.ts @@ -0,0 +1,118 @@ +import ipaddr from "ipaddr.js"; + +type Address = ipaddr.IPv4 | ipaddr.IPv6; + +// Block and exempt rules broader than this are refused: one typo should not +// cut off, or wave through, a large share of the internet. +const MIN_PREFIX = { ipv4: 8, ipv6: 32 } as const; + +function parseAddress(raw: string): Address | null { + try { + // process() unwraps IPv4-mapped IPv6 (::ffff:1.2.3.4), which is how a + // dual-stack socket reports an IPv4 client. + return ipaddr.process(raw.trim()); + } catch { + return null; + } +} + +/** + * The form of a client address used as its counter key and matched against + * rules. An address that cannot be parsed is returned unchanged. + */ +export function normalizeIp(raw: string): string { + return parseAddress(raw)?.toString() ?? raw; +} + +/** + * The canonical CIDR for an admin-entered IP or range: a bare address becomes + * a single-address range and host bits are zeroed. Throws on invalid input. + */ +export function canonicalizeSubject(input: string): string { + const value = input.trim(); + // The parser also takes shorthand such as "10" (0.0.0.10) or "1.2.3"; + // an admin typing that almost certainly meant something else. + const host = value.split("/")[0]; + if (!host.includes(":") && !ipaddr.IPv4.isValidFourPartDecimal(host)) { + throw new Error("Not a valid IP address or CIDR range"); + } + let address: Address; + let prefix: number; + if (value.includes("/")) { + try { + [address, prefix] = ipaddr.parseCIDR(value); + } catch { + throw new Error("Not a valid IP address or CIDR range"); + } + if ( + address.kind() === "ipv6" && + (address as ipaddr.IPv6).isIPv4MappedAddress() + ) { + // Clients in the mapped block are matched as IPv4, so a range + // reaching beyond it could never apply to them. + if (prefix < 96) { + throw new Error( + "An IPv4-mapped range must be /96 or narrower; enter it as IPv4", + ); + } + address = (address as ipaddr.IPv6).toIPv4Address(); + prefix -= 96; + } + } else { + const parsed = parseAddress(value); + if (!parsed) throw new Error("Not a valid IP address or CIDR range"); + address = parsed; + prefix = parsed.kind() === "ipv4" ? 32 : 128; + } + + const cidr = `${address.toString()}/${prefix}`; + const network = + address.kind() === "ipv4" + ? ipaddr.IPv4.networkAddressFromCIDR(cidr) + : ipaddr.IPv6.networkAddressFromCIDR(cidr); + return `${network.toString()}/${prefix}`; +} + +/** Whether a canonical CIDR covers more than a block or exempt rule may. */ +export function isTooBroad(subject: string): boolean { + const [address, prefix] = ipaddr.parseCIDR(subject); + return prefix < MIN_PREFIX[address.kind() as "ipv4" | "ipv6"]; +} + +/** How many addresses a canonical CIDR covers, as a power of two exponent. */ +export function hostBits(subject: string): number { + const [address, prefix] = ipaddr.parseCIDR(subject); + return (address.kind() === "ipv4" ? 32 : 128) - prefix; +} + +export type IpRuleMatcher = (ip: string) => T | null; + +/** + * Builds a matcher returning the most specific rule covering an address. + * Rules whose subject is not a valid CIDR are skipped. + */ +export function compileIpRules( + rules: T[], +): IpRuleMatcher { + const compiled: { range: [Address, number]; rule: T }[] = []; + for (const rule of rules) { + try { + compiled.push({ range: ipaddr.parseCIDR(rule.subject), rule }); + } catch { + console.warn( + `[rate-limit] ignoring rule with invalid range "${rule.subject}"`, + ); + } + } + compiled.sort((a, b) => b.range[1] - a.range[1]); + + return (ip) => { + const address = parseAddress(ip); + if (!address) return null; + for (const { range, rule } of compiled) { + if (address.kind() !== range[0].kind()) continue; + if (address.match(range)) return rule; + } + return null; + }; +} diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 476775b42..97c054ee4 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -1,6 +1,5 @@ import axios from "axios"; import type { YogaInitialContext } from "graphql-yoga"; -import * as jose from "jose"; import { type ConditionEvaluator, evaluate, @@ -10,6 +9,7 @@ import { type Principal, resolveAclBlock, } from "../acl"; +import { verifyRegistryToken } from "../auth/registry-token"; import type { DbService } from "../db/db.service"; import type { MetaEnvelope } from "../db/types"; import { timed } from "../utils/timing"; @@ -34,12 +34,6 @@ export function isEName(value: unknown): value is string { return typeof value === "string" && value.startsWith("@") && value.length > 1; } -type CachedJWKS = { - jwks: ReturnType; - expiresAt: number; -}; -const jwksCache = new Map(); -const JWKS_TTL_MS = 24 * 60 * 60 * 1000; const JWKS_FETCH_TIMEOUT_MS = 5000; const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; @@ -178,38 +172,7 @@ export class VaultAccessGuard { const token = authHeader.substring(7); // Remove 'Bearer ' prefix try { - const registryUrl = - process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; - if (!registryUrl) { - console.error("PUBLIC_REGISTRY_URL or REGISTRY_URL is not set"); - return null; - } - - const jwksUrl = new URL( - `/.well-known/jwks.json`, - registryUrl, - ).toString(); - - const now = Date.now(); - let cached = jwksCache.get(jwksUrl); - if (!cached || cached.expiresAt <= now) { - const jwksResponse = await timed( - "guard.validateToken.fetchJWKS", - () => - axios.get(jwksUrl, { - timeout: JWKS_FETCH_TIMEOUT_MS, - }), - ); - cached = { - jwks: jose.createLocalJWKSet(jwksResponse.data), - expiresAt: now + JWKS_TTL_MS, - }; - jwksCache.set(jwksUrl, cached); - } - - const { payload } = await jose.jwtVerify(token, cached.jwks); - - return payload; + return await verifyRegistryToken(token); } catch (error) { console.error("Token validation failed:", error); return null; diff --git a/infrastructure/evault-core/src/entities/RateLimitRule.ts b/infrastructure/evault-core/src/entities/RateLimitRule.ts new file mode 100644 index 000000000..105f3078f --- /dev/null +++ b/infrastructure/evault-core/src/entities/RateLimitRule.ts @@ -0,0 +1,63 @@ +import { + Check, + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from "typeorm"; + +export type RateLimitKind = "platform" | "ip"; +export type RateLimitMode = "limit" | "blocked" | "exempt"; + +/** The subject of the rule that sets a kind's default limit. */ +export const DEFAULT_SUBJECT = "*"; + +/** + * One admin-set rate limit rule. `subject` is a platform name, a canonical + * CIDR, or `*` for the default applied to every other subject of that kind. + */ +@Entity("evault_rate_limit_rule") +@Unique("UQ_evault_rate_limit_rule_subject", ["kind", "subject"]) +@Check("CHK_evault_rate_limit_rule_kind", `"kind" IN ('platform', 'ip')`) +@Check( + "CHK_evault_rate_limit_rule_mode", + `"mode" IN ('limit', 'blocked', 'exempt')`, +) +@Check( + "CHK_evault_rate_limit_rule_limit", + `("mode" = 'limit') = ("limitPerMinute" IS NOT NULL)`, +) +@Check( + "CHK_evault_rate_limit_rule_default", + `"subject" <> '*' OR "mode" = 'limit'`, +) +export class RateLimitRule { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column({ type: "varchar" }) + kind!: RateLimitKind; + + @Column({ type: "varchar", length: 256 }) + subject!: string; + + @Column({ type: "varchar" }) + mode!: RateLimitMode; + + @Column({ type: "integer", nullable: true }) + limitPerMinute!: number | null; + + @Column({ type: "varchar", length: 500, nullable: true }) + note!: string | null; + + @Column({ type: "varchar", nullable: true }) + updatedBy!: string | null; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @UpdateDateColumn({ type: "timestamptz" }) + updatedAt!: Date; +} diff --git a/infrastructure/evault-core/src/index.ts b/infrastructure/evault-core/src/index.ts index c8b5a909d..b4dce9e5b 100644 --- a/infrastructure/evault-core/src/index.ts +++ b/infrastructure/evault-core/src/index.ts @@ -9,12 +9,18 @@ import { NotificationController } from "./controllers/NotificationController"; import { ProvisioningController } from "./controllers/ProvisioningController"; import { VerificationController } from "./controllers/VerificationController"; import { LegacyVerificationController } from "./controllers/LegacyVerificationController"; +import { RateLimitAdminController } from "./controllers/RateLimitAdminController"; import { RecoveryController } from "./controllers/RecoveryController"; import { ProvisioningService } from "./services/ProvisioningService"; import { VerificationService } from "./services/VerificationService"; import { createHmacSignature } from "./utils/hmac"; -import { checkGlobalRateLimit } from "./core/http/global-rate-limiter"; +import { + GlobalRateLimiter, + createRateLimitHook, + parseTrustProxy, +} from "./core/http/global-rate-limiter"; +import { RateLimitConfigService } from "./services/RateLimitConfigService"; import fastifyCors from "@fastify/cors"; import fastify, { type FastifyInstance, @@ -84,6 +90,8 @@ let driver: Driver; let provisioningService: ProvisioningService | undefined; let awarenessOutboxDispatcher: AwarenessOutboxDispatcher | undefined; let expressServer: HttpServer | undefined; +let rateLimitConfig: RateLimitConfigService | undefined; +let rateLimiter: GlobalRateLimiter | undefined; // Initialize eVault Core const initializeEVault = async ( @@ -213,6 +221,9 @@ const initializeEVault = async ( // base64-encoded size of the largest allowed file (250MB binary ≈ 343MB // base64 + JSON overhead). Keep in sync with MAX_FILE_BYTES in graphql-server.ts. bodyLimit: 350 * 1024 * 1024, + // Off unless a proxy is declared, so a client cannot choose the IP it + // is rate limited as by sending X-Forwarded-For. + trustProxy: parseTrustProxy(process.env.EVAULT_TRUST_PROXY), }); // Register CORS plugin with relaxed settings @@ -229,28 +240,10 @@ const initializeEVault = async ( credentials: true, }); - // Global rate limiting by platform token identity (IP fallback) - fastifyServer.addHook("onRequest", async (request, reply) => { - const authHeader = request.headers.authorization; - const token = authHeader?.startsWith("Bearer ") - ? authHeader.substring(7) - : null; - const ip = request.ip; - const { allowed, retryAfterSeconds } = checkGlobalRateLimit(token, ip); - if (!allowed) { - // In an async onRequest hook, Fastify only short-circuits the - // handler chain if you return the reply object. Without the - // return, the 429 is queued but the downstream handler (GraphQL) - // still runs — turning the rate limiter into a silent counter. - return reply - .code(429) - .header("Retry-After", String(retryAfterSeconds)) - .send({ - error: "Too Many Requests", - retryAfterSeconds, - }); - } - }); + // Rate limiting per IP, then per verified platform, under admin-set rules + if (rateLimiter) { + fastifyServer.addHook("onRequest", createRateLimitHook(rateLimiter)); + } // Register HTTP routes with provisioning service if available await registerHttpRoutes( @@ -440,6 +433,18 @@ const start = async () => { provisioningController.registerRoutes(expressApp); recoveryController.registerRoutes(expressApp); + const { RateLimitRule } = await import("./entities/RateLimitRule"); + rateLimitConfig = new RateLimitConfigService( + AppDataSource.getRepository(RateLimitRule), + ); + await rateLimitConfig.start(); + const config = rateLimitConfig; + rateLimiter = new GlobalRateLimiter(() => config.snapshot); + rateLimiter.start(); + new RateLimitAdminController(config, rateLimiter).registerRoutes( + expressApp, + ); + // Start eVault Core (Fastify + GraphQL) with provisioning service first await initializeEVault(provisioningService); @@ -460,6 +465,8 @@ start(); async function shutdown(signal: string): Promise { console.log(`${signal} received, shutting down eVault`); await awarenessOutboxDispatcher?.stop(); + rateLimitConfig?.stop(); + rateLimiter?.stop(); await fastifyServer?.close(); if (expressServer) { await new Promise((resolve) => diff --git a/infrastructure/evault-core/src/migrations/1791786717906-EvaultRateLimitRule.ts b/infrastructure/evault-core/src/migrations/1791786717906-EvaultRateLimitRule.ts new file mode 100644 index 000000000..c23d2bb3c --- /dev/null +++ b/infrastructure/evault-core/src/migrations/1791786717906-EvaultRateLimitRule.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class EvaultRateLimitRule1791786717906 implements MigrationInterface { + name = 'EvaultRateLimitRule1791786717906' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "evault_rate_limit_rule" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "kind" character varying NOT NULL, "subject" character varying(256) NOT NULL, "mode" character varying NOT NULL, "limitPerMinute" integer, "note" character varying(500), "updatedBy" character varying, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "UQ_evault_rate_limit_rule_subject" UNIQUE ("kind", "subject"), CONSTRAINT "CHK_evault_rate_limit_rule_kind" CHECK ("kind" IN ('platform', 'ip')), CONSTRAINT "CHK_evault_rate_limit_rule_mode" CHECK ("mode" IN ('limit', 'blocked', 'exempt')), CONSTRAINT "CHK_evault_rate_limit_rule_limit" CHECK (("mode" = 'limit') = ("limitPerMinute" IS NOT NULL)), CONSTRAINT "CHK_evault_rate_limit_rule_default" CHECK ("subject" <> '*' OR "mode" = 'limit'), CONSTRAINT "PK_evault_rate_limit_rule" PRIMARY KEY ("id"))`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "evault_rate_limit_rule"`); + } + +} diff --git a/infrastructure/evault-core/src/services/RateLimitConfigService.spec.ts b/infrastructure/evault-core/src/services/RateLimitConfigService.spec.ts new file mode 100644 index 000000000..9557a90d9 --- /dev/null +++ b/infrastructure/evault-core/src/services/RateLimitConfigService.spec.ts @@ -0,0 +1,268 @@ +import "reflect-metadata"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RateLimitRule } from "../entities/RateLimitRule"; +import { + RateLimitConfigService, + RuleValidationError, + validateRuleInput, +} from "./RateLimitConfigService"; + +function makeRepoStub(initial: Partial[] = []) { + const rows = new Map(); + let nextId = 1; + const add = (partial: Partial) => { + const row = { + id: partial.id ?? `id-${nextId++}`, + limitPerMinute: null, + note: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + ...partial, + } as RateLimitRule; + rows.set(row.id, row); + return row; + }; + initial.forEach(add); + const stub = { + failing: false, + async find() { + if (stub.failing) throw new Error("db down"); + return [...rows.values()]; + }, + async findOne({ where }: { where: Partial }) { + return ( + [...rows.values()].find( + (r) => + r.kind === where.kind && r.subject === where.subject, + ) ?? null + ); + }, + create(partial: Partial) { + return partial as RateLimitRule; + }, + async save(row: RateLimitRule) { + return add(row); + }, + async delete({ id }: { id: string }) { + return { affected: rows.delete(id) ? 1 : 0 }; + }, + }; + return stub as any; +} + +describe("RateLimitConfigService", () => { + const saved = { ...process.env }; + + beforeEach(() => { + delete process.env.RATE_LIMIT_PER_PLATFORM; + delete process.env.RATE_LIMIT_PER_IP; + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + process.env = { ...saved }; + vi.restoreAllMocks(); + }); + + it("uses the built-in defaults before anything is loaded", () => { + const service = new RateLimitConfigService(makeRepoStub()); + expect(service.snapshot.source).toBe("env"); + expect(service.snapshot.platformDefault).toEqual({ + limit: 250, + source: "env", + }); + expect(service.snapshot.ipDefault).toEqual({ + limit: 500, + source: "env", + }); + }); + + it("takes defaults from env when the table has none", async () => { + process.env.RATE_LIMIT_PER_PLATFORM = "40"; + process.env.RATE_LIMIT_PER_IP = "nonsense"; + const service = new RateLimitConfigService(makeRepoStub()); + await service.reload(); + expect(service.snapshot.source).toBe("db"); + expect(service.snapshot.platformDefault.limit).toBe(40); + expect(service.snapshot.ipDefault.limit).toBe(500); + }); + + it("lets a stored default override env", async () => { + process.env.RATE_LIMIT_PER_IP = "40"; + const service = new RateLimitConfigService( + makeRepoStub([ + { kind: "ip", subject: "*", mode: "limit", limitPerMinute: 9 }, + ]), + ); + await service.reload(); + expect(service.snapshot.ipDefault).toEqual({ limit: 9, source: "db" }); + expect(service.snapshot.matchIp("203.0.113.7")).toBeNull(); + }); + + it("indexes platform and IP rules", async () => { + const service = new RateLimitConfigService( + makeRepoStub([ + { + kind: "platform", + subject: "http://pictique.test", + mode: "limit", + limitPerMinute: 1000, + }, + { kind: "ip", subject: "203.0.113.0/24", mode: "blocked" }, + ]), + ); + await service.reload(); + expect( + service.snapshot.platformRules.get("http://pictique.test") + ?.limitPerMinute, + ).toBe(1000); + expect(service.snapshot.matchIp("203.0.113.7")?.mode).toBe("blocked"); + }); + + it("skips a malformed row and keeps the rest", async () => { + const service = new RateLimitConfigService( + makeRepoStub([ + { kind: "ip", subject: "203.0.113.7/32", mode: "limit" }, + { kind: "ip", subject: "198.51.100.1/32", mode: "exempt" }, + ]), + ); + await service.reload(); + expect(service.snapshot.rules).toHaveLength(1); + expect(service.snapshot.matchIp("198.51.100.1")?.mode).toBe("exempt"); + }); + + it("falls back to env defaults when the first load fails", async () => { + const repo = makeRepoStub(); + repo.failing = true; + const service = new RateLimitConfigService(repo); + await service.reload(); + expect(service.snapshot.source).toBe("env"); + expect(service.snapshot.ipDefault.limit).toBe(500); + }); + + it("keeps the last loaded rules when a later load fails", async () => { + const repo = makeRepoStub([ + { kind: "ip", subject: "203.0.113.7/32", mode: "blocked" }, + ]); + const service = new RateLimitConfigService(repo); + await service.reload(); + repo.failing = true; + await service.reload(); + expect(service.snapshot.matchIp("203.0.113.7")?.mode).toBe("blocked"); + }); + + it("applies a write immediately and replaces a subject's rule", async () => { + const service = new RateLimitConfigService(makeRepoStub()); + await service.upsert({ + kind: "ip", + subject: "203.0.113.7", + mode: "limit", + limitPerMinute: 5, + }); + expect(service.snapshot.matchIp("203.0.113.7")?.limitPerMinute).toBe(5); + + const replaced = await service.upsert({ + kind: "ip", + subject: "203.0.113.7/32", + mode: "exempt", + }); + expect(service.snapshot.rules).toHaveLength(1); + expect(service.snapshot.matchIp("203.0.113.7")?.mode).toBe("exempt"); + expect(replaced.limitPerMinute).toBeNull(); + + expect(await service.remove(replaced.id)).toBe(true); + expect(service.snapshot.matchIp("203.0.113.7")).toBeNull(); + expect(await service.remove("missing")).toBe(false); + }); + + describe("validateRuleInput", () => { + const field = (input: any) => { + try { + validateRuleInput(input); + return null; + } catch (error) { + expect(error).toBeInstanceOf(RuleValidationError); + return (error as RuleValidationError).field; + } + }; + + it("accepts and canonicalises a valid rule", () => { + expect( + validateRuleInput({ + kind: "ip", + subject: "203.0.113.7/24", + mode: "limit", + limitPerMinute: 10, + note: "office", + }), + ).toEqual({ + kind: "ip", + subject: "203.0.113.0/24", + mode: "limit", + limitPerMinute: 10, + note: "office", + updatedBy: null, + }); + }); + + it("drops the limit of a block or exempt rule", () => { + expect( + validateRuleInput({ + kind: "platform", + subject: " http://pictique.test ", + mode: "blocked", + limitPerMinute: 10, + }), + ).toMatchObject({ + subject: "http://pictique.test", + limitPerMinute: null, + }); + }); + + it("names the field that is wrong", () => { + const ok = { + kind: "ip", + subject: "203.0.113.7", + mode: "limit", + limitPerMinute: 10, + }; + expect(field({ ...ok, kind: "user" })).toBe("kind"); + expect(field({ ...ok, mode: "allow" })).toBe("mode"); + expect(field({ ...ok, subject: "" })).toBe("subject"); + expect(field({ ...ok, subject: "nope" })).toBe("subject"); + expect(field({ ...ok, limitPerMinute: 0 })).toBe("limitPerMinute"); + expect(field({ ...ok, limitPerMinute: 1.5 })).toBe( + "limitPerMinute", + ); + expect(field({ ...ok, limitPerMinute: "10" })).toBe( + "limitPerMinute", + ); + expect(field({ ...ok, limitPerMinute: 1_000_001 })).toBe( + "limitPerMinute", + ); + expect(field({ ...ok, note: "x".repeat(501) })).toBe("note"); + expect( + field({ ...ok, kind: "platform", subject: "x".repeat(257) }), + ).toBe("subject"); + }); + + it("refuses to block or exempt a default or a huge range", () => { + expect(field({ kind: "ip", subject: "*", mode: "blocked" })).toBe( + "mode", + ); + expect( + field({ kind: "ip", subject: "0.0.0.0/0", mode: "exempt" }), + ).toBe("subject"); + expect( + field({ + kind: "ip", + subject: "0.0.0.0/0", + mode: "limit", + limitPerMinute: 5, + }), + ).toBeNull(); + }); + }); +}); diff --git a/infrastructure/evault-core/src/services/RateLimitConfigService.ts b/infrastructure/evault-core/src/services/RateLimitConfigService.ts new file mode 100644 index 000000000..5201d4f4d --- /dev/null +++ b/infrastructure/evault-core/src/services/RateLimitConfigService.ts @@ -0,0 +1,267 @@ +import type { Repository } from "typeorm"; +import { + type IpRuleMatcher, + canonicalizeSubject, + compileIpRules, + isTooBroad, +} from "../core/http/ip-rules"; +import { + DEFAULT_SUBJECT, + type RateLimitKind, + type RateLimitMode, + type RateLimitRule, +} from "../entities/RateLimitRule"; + +const POLL_MS = 30_000; +const MAX_LIMIT = 1_000_000; +const KINDS: RateLimitKind[] = ["platform", "ip"]; +const MODES: RateLimitMode[] = ["limit", "blocked", "exempt"]; + +export type LimitSource = "db" | "env"; + +export interface RateLimitSnapshot { + /** Where the rules came from: the table, or env defaults alone. */ + source: LimitSource; + loadedAt: Date; + platformDefault: { limit: number; source: LimitSource }; + ipDefault: { limit: number; source: LimitSource }; + /** Platform rules by exact platform name, defaults excluded. */ + platformRules: Map; + /** The most specific IP rule covering an address, defaults excluded. */ + matchIp: IpRuleMatcher; + /** Every usable rule, defaults included. */ + rules: RateLimitRule[]; +} + +export interface RuleInput { + kind?: unknown; + subject?: unknown; + mode?: unknown; + limitPerMinute?: unknown; + note?: unknown; + updatedBy?: unknown; +} + +export class RuleValidationError extends Error { + constructor( + message: string, + readonly field: string, + ) { + super(message); + } +} + +function envLimit(name: string, fallback: number): number { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function isUsable(rule: RateLimitRule): boolean { + if (!KINDS.includes(rule.kind) || !MODES.includes(rule.mode)) return false; + if (rule.mode === "limit") { + return Number.isInteger(rule.limitPerMinute) && rule.limitPerMinute! > 0; + } + return rule.subject !== DEFAULT_SUBJECT; +} + +function buildSnapshot( + rows: RateLimitRule[], + source: LimitSource, +): RateLimitSnapshot { + const rules: RateLimitRule[] = []; + for (const row of rows) { + if (isUsable(row)) rules.push(row); + else console.warn(`[rate-limit] ignoring malformed rule ${row.id}`); + } + + const defaultFor = (kind: RateLimitKind, env: string, fallback: number) => { + const row = rules.find( + (r) => r.kind === kind && r.subject === DEFAULT_SUBJECT, + ); + return row + ? { limit: row.limitPerMinute!, source: "db" as const } + : { limit: envLimit(env, fallback), source: "env" as const }; + }; + + const specific = rules.filter((r) => r.subject !== DEFAULT_SUBJECT); + return { + source, + loadedAt: new Date(), + platformDefault: defaultFor("platform", "RATE_LIMIT_PER_PLATFORM", 250), + ipDefault: defaultFor("ip", "RATE_LIMIT_PER_IP", 500), + platformRules: new Map( + specific + .filter((r) => r.kind === "platform") + .map((r) => [r.subject, r]), + ), + matchIp: compileIpRules(specific.filter((r) => r.kind === "ip")), + rules, + }; +} + +/** + * Checks an admin-supplied rule and returns it in stored form. IP subjects + * are canonicalised so the same range cannot be entered twice. + */ +export function validateRuleInput(input: RuleInput): { + kind: RateLimitKind; + subject: string; + mode: RateLimitMode; + limitPerMinute: number | null; + note: string | null; + updatedBy: string | null; +} { + const kind = input.kind as RateLimitKind; + if (!KINDS.includes(kind)) { + throw new RuleValidationError("kind must be platform or ip", "kind"); + } + const mode = input.mode as RateLimitMode; + if (!MODES.includes(mode)) { + throw new RuleValidationError( + "mode must be limit, blocked or exempt", + "mode", + ); + } + + if (typeof input.subject !== "string" || !input.subject.trim()) { + throw new RuleValidationError("subject is required", "subject"); + } + let subject = input.subject.trim(); + if (subject === DEFAULT_SUBJECT) { + if (mode !== "limit") { + throw new RuleValidationError( + "The default can only be a limit", + "mode", + ); + } + } else if (kind === "ip") { + try { + subject = canonicalizeSubject(subject); + } catch (error) { + throw new RuleValidationError((error as Error).message, "subject"); + } + if (mode !== "limit" && isTooBroad(subject)) { + throw new RuleValidationError( + "Range is too broad to block or exempt (minimum /8 for IPv4, /32 for IPv6)", + "subject", + ); + } + } else if (subject.length > 256) { + throw new RuleValidationError( + "subject must be at most 256 characters", + "subject", + ); + } + + let limitPerMinute: number | null = null; + if (mode === "limit") { + const limit = input.limitPerMinute; + if ( + typeof limit !== "number" || + !Number.isInteger(limit) || + limit < 1 || + limit > MAX_LIMIT + ) { + throw new RuleValidationError( + `limitPerMinute must be a whole number from 1 to ${MAX_LIMIT}`, + "limitPerMinute", + ); + } + limitPerMinute = limit; + } + + const text = (value: unknown, field: string, max: number) => { + if (value === undefined || value === null || value === "") return null; + if (typeof value !== "string" || value.length > max) { + throw new RuleValidationError( + `${field} must be text of at most ${max} characters`, + field, + ); + } + return value; + }; + + return { + kind, + subject, + mode, + limitPerMinute, + note: text(input.note, "note", 500), + updatedBy: text(input.updatedBy, "updatedBy", 256), + }; +} + +/** + * Holds the rate limit rules in memory so the request path never touches the + * database. The snapshot is rebuilt after every admin write and on a poll, so + * other instances pick a change up within {@link POLL_MS}. + * + * A failed load keeps the last good snapshot; with none, the env defaults + * apply. Rate limiting never fails closed on a database outage. + */ +export class RateLimitConfigService { + private current: RateLimitSnapshot = buildSnapshot([], "env"); + private healthy = true; + private timer?: NodeJS.Timeout; + + constructor( + private readonly repository: Pick< + Repository, + "find" | "findOne" | "save" | "create" | "delete" + >, + ) {} + + get snapshot(): RateLimitSnapshot { + return this.current; + } + + async reload(): Promise { + try { + const rows = await this.repository.find(); + this.current = buildSnapshot(rows, "db"); + if (!this.healthy) console.log("[rate-limit] rules reloaded"); + this.healthy = true; + } catch (error) { + if (this.healthy) { + console.error( + `[rate-limit] could not load rules, keeping ${this.current.source === "db" ? "the last loaded rules" : "env defaults"}:`, + error, + ); + } + this.healthy = false; + } + } + + async start(): Promise { + await this.reload(); + this.timer = setInterval(() => void this.reload(), POLL_MS); + this.timer.unref(); + } + + stop(): void { + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + } + + /** Creates the rule for a subject, or replaces the one it already has. */ + async upsert(input: RuleInput): Promise { + const rule = validateRuleInput(input); + const existing = await this.repository.findOne({ + where: { kind: rule.kind, subject: rule.subject }, + }); + const saved = await this.repository.save( + existing + ? Object.assign(existing, rule) + : this.repository.create(rule), + ); + await this.reload(); + return saved; + } + + /** Removes a rule. Returns false when there was none with that id. */ + async remove(id: string): Promise { + const result = await this.repository.delete({ id }); + await this.reload(); + return Boolean(result.affected); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4319b624..66f56a6aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -569,6 +569,9 @@ importers: graphql-yoga: specifier: ^5.13.4 version: 5.18.1(graphql@16.13.1) + ipaddr.js: + specifier: ^2.3.0 + version: 2.3.0 jose: specifier: ^5.2.2 version: 5.10.0