Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions indexer/src/admin-authorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* #935 – Admin REST endpoints authorization
*
* Tests verify that admin endpoints require API key authorization.
*/
import Fastify from "fastify";
import { PrismaClient } from "@prisma/client";

const db = {
webhook: {
create: jest.fn().mockResolvedValue({ id: "1", url: "http://test", active: true }),
delete: jest.fn().mockResolvedValue({}),
findMany: jest.fn().mockResolvedValue([]),
},
webhookFailure: {
findMany: jest.fn().mockResolvedValue([]),
count: jest.fn().mockResolvedValue(0),
},
} as unknown as PrismaClient;

describe("#935 admin endpoint authorization", () => {
it("rejects POST /webhooks without API key when API_KEY is set", async () => {
process.env.API_KEY = "test-key";
const fastify = Fastify({ logger: false });

fastify.post<{ Body: { url: string; secret: string } }>(
"/webhooks",
async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
reply.code(201);
return { id: "1" };
}
);

const res = await fastify.inject({
method: "POST",
url: "/webhooks",
payload: { url: "http://test", secret: "secret" },
});

expect(res.statusCode).toBe(401);
delete process.env.API_KEY;
});

it("accepts POST /webhooks with valid API key", async () => {
process.env.API_KEY = "test-key";
const fastify = Fastify({ logger: false });

fastify.post<{ Body: { url: string; secret: string } }>(
"/webhooks",
async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
reply.code(201);
return { id: "1" };
}
);

const res = await fastify.inject({
method: "POST",
url: "/webhooks",
headers: { "x-api-key": "test-key" },
payload: { url: "http://test", secret: "secret" },
});

expect(res.statusCode).toBe(201);
delete process.env.API_KEY;
});

it("rejects DELETE /webhooks/:id without API key when API_KEY is set", async () => {
process.env.API_KEY = "test-key";
const fastify = Fastify({ logger: false });

fastify.delete<{ Params: { id: string } }>(
"/webhooks/:id",
async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
reply.code(204);
}
);

const res = await fastify.inject({
method: "DELETE",
url: "/webhooks/1",
});

expect(res.statusCode).toBe(401);
delete process.env.API_KEY;
});

it("rejects POST /admin/reindex without API key when API_KEY is set", async () => {
process.env.API_KEY = "test-key";
const fastify = Fastify({ logger: false });

fastify.post<{ Querystring: { from?: string } }>(
"/admin/reindex",
async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
reply.code(202);
return { message: "ok" };
}
);

const res = await fastify.inject({
method: "POST",
url: "/admin/reindex",
});

expect(res.statusCode).toBe(401);
delete process.env.API_KEY;
});

it("rejects GET /admin/webhook-failures without API key when API_KEY is set", async () => {
process.env.API_KEY = "test-key";
const fastify = Fastify({ logger: false });

fastify.get("/admin/webhook-failures", async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
reply.code(200);
return { items: [] };
});

const res = await fastify.inject({
method: "GET",
url: "/admin/webhook-failures",
});

expect(res.statusCode).toBe(401);
delete process.env.API_KEY;
});

it("allows all admin endpoints when API_KEY is not set", async () => {
delete process.env.API_KEY;
const fastify = Fastify({ logger: false });

fastify.post("/admin/reindex", async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized" };
}
reply.code(202);
return { message: "ok" };
});

const res = await fastify.inject({
method: "POST",
url: "/admin/reindex",
});

expect(res.statusCode).toBe(202);
});
});
45 changes: 45 additions & 0 deletions indexer/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { startIndexer, getLastLedger, reindex } from "./indexer";
import { buildResolvers } from "./graphql";
import { getMetrics } from "./metrics";
import Redis from "ioredis";
import { validate, parse } from "graphql";
import { createComplexityLimitRule } from "graphql-query-complexity";
import { depthLimit } from "graphql-depth-limit";
import { randomUUID } from "crypto";

const db = new PrismaClient();

Expand All @@ -27,6 +31,14 @@ if (redis) {
});
}

const logger = {
info: (...args: unknown[]) => console.log(...args),
error: (...args: unknown[]) => console.error(...args),
debug: (...args: unknown[]) => console.debug(...args),
};

const requestLogger = (correlationId: string) => logger;

function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
let body = "";
Expand All @@ -36,6 +48,15 @@ function readBody(req: IncomingMessage): Promise<string> {
});
}

function isAuthorized(req: IncomingMessage): boolean {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (!expectedKey) {
return true;
}
return apiKey === expectedKey;
}

async function main() {
await db.$connect();

Expand Down Expand Up @@ -147,6 +168,12 @@ async function main() {
fastify.post<{ Body: { url: string; secret: string } }>(
"/webhooks",
async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
const { url, secret } = req.body ?? {};
if (!url || !secret) {
reply.code(400);
Expand All @@ -161,6 +188,12 @@ async function main() {
fastify.delete<{ Params: { id: string } }>(
"/webhooks/:id",
async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
try {
await db.webhook.delete({ where: { id: req.params.id } });
reply.code(204);
Expand All @@ -175,6 +208,12 @@ async function main() {
fastify.post<{ Querystring: { from?: string } }>(
"/admin/reindex",
async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
const from = req.query.from ? parseInt(req.query.from, 10) : getLastLedger();
if (isNaN(from) || from < 0) {
reply.code(400);
Expand All @@ -191,6 +230,12 @@ async function main() {
fastify.get<{
Querystring: { status?: string; eventType?: string; limit?: string; offset?: string; sort?: string };
}>("/admin/webhook-failures", async (req, reply) => {
const apiKey = req.headers["x-api-key"] as string | undefined;
const expectedKey = process.env.API_KEY;
if (expectedKey && apiKey !== expectedKey) {
reply.code(401);
return { error: "Unauthorized: valid x-api-key header required" };
}
const { status, eventType, limit: limitStr, offset: offsetStr, sort } = req.query;
const limit = Math.min(parseInt(limitStr ?? "50", 10) || 50, 200);
const offset = parseInt(offsetStr ?? "0", 10) || 0;
Expand Down
23 changes: 23 additions & 0 deletions indexer/src/indexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
indexerLagLedgers,
incrementEventProcessed,
incrementEventFailed,
incrementIssuerAttestation,
incrementIssuerRevocation,
setIssuerRateLimitRatio,
issuersTotal,
EventTypes,
} from "./metrics";
import { dispatchWebhooks } from "./webhooks";
Expand Down Expand Up @@ -118,6 +122,10 @@ async function processRange(
}
} catch (err) {
console.error(`Error processing event at ledger ${ev.ledger}:`, err);
const eventType = normalizeEventType(topicStr);
if (eventType) {
incrementEventFailed(eventType);
}
}
}

Expand Down Expand Up @@ -230,6 +238,13 @@ async function handleEvent(
});
// Invalidate issuerStats cache for this issuer
await cacheInvalidate(redis, `issuerStats:${issuerAddr}`);

// Calculate rate limit ratio (attestations / rateLimit)
const attestationCount = await db.attestation.count({
where: { issuer: issuerAddr, isRevoked: false },
});
const ratio = rateLimit > 0 ? attestationCount / rateLimit : 0;
setIssuerRateLimitRatio(issuerAddr, ratio);
return;
}

Expand Down Expand Up @@ -275,6 +290,9 @@ async function handleEvent(
}

revocationsTotal.inc();
if (attestation) {
incrementIssuerRevocation(attestation.issuer);
}
dispatchWebhooks(db, "attestation.revoked", { id: attestationId }).catch(
() => {},
);
Expand Down Expand Up @@ -310,6 +328,10 @@ async function handleEvent(
},
});

// Update issuers total count
const totalIssuers = await db.issuer.count();
issuersTotal.set(totalIssuers);

// Publish to GraphQL subscription
pubsub.publish(ISSUER_REGISTERED, {
onIssuerRegistered: {
Expand Down Expand Up @@ -387,6 +409,7 @@ async function handleEvent(
await cacheInvalidate(redis, `issuerStats:${issuer}`);

attestationsTotal.inc();
incrementIssuerAttestation(issuer);

dispatchWebhooks(db, `attestation.${topicStr}`, {
...attestation,
Expand Down
Loading