fix(security): gate production diagnostics
This commit is contained in:
parent
9d3c0774ec
commit
56a051a422
@ -1,27 +1,16 @@
|
|||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { getAllFlags } from './lib/feature-flags.js';
|
import { SignJWT } from 'jose';
|
||||||
import { getBufferedEvents, flushEvents } from './lib/telemetry.js';
|
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
import { PRODUCT_ID, productConfig } from './lib/product-config.js';
|
import { productConfig } from './lib/product-config.js';
|
||||||
|
import { assertDiagnosticsAccess, diagnosticsRoutes } from './lib/diagnostics-routes.js';
|
||||||
|
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
app = Fastify({ logger: false });
|
app = Fastify({ logger: false });
|
||||||
app.get('/api/diagnostics/flags', async () => getAllFlags());
|
await diagnosticsRoutes(app);
|
||||||
app.get('/api/diagnostics/telemetry', async () => ({ events: getBufferedEvents() }));
|
|
||||||
app.post('/api/diagnostics/telemetry/flush', async () => ({ flushed: flushEvents().length }));
|
|
||||||
app.get('/api/diagnostics/config', async () => ({
|
|
||||||
productId: PRODUCT_ID,
|
|
||||||
serviceName: config.SERVICE_NAME,
|
|
||||||
port: config.PORT,
|
|
||||||
nodeEnv: config.NODE_ENV,
|
|
||||||
dbProvider: config.DB_PROVIDER,
|
|
||||||
telemetryEnabled: config.TELEMETRY_ENABLED,
|
|
||||||
featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED,
|
|
||||||
}));
|
|
||||||
app.get('/health', async (req) => ({
|
app.get('/health', async (req) => ({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
service: config.SERVICE_NAME,
|
service: config.SERVICE_NAME,
|
||||||
@ -41,6 +30,18 @@ afterAll(async () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function makeToken(role: string) {
|
||||||
|
return new SignJWT({
|
||||||
|
sub: 'diagnostics-user',
|
||||||
|
role,
|
||||||
|
type: 'access',
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('5m')
|
||||||
|
.sign(new TextEncoder().encode(config.JWT_SECRET));
|
||||||
|
}
|
||||||
|
|
||||||
describe('diagnostics routes', () => {
|
describe('diagnostics routes', () => {
|
||||||
it('GET /api/diagnostics/flags returns feature flags', async () => {
|
it('GET /api/diagnostics/flags returns feature flags', async () => {
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/diagnostics/flags' });
|
const res = await app.inject({ method: 'GET', url: '/api/diagnostics/flags' });
|
||||||
@ -101,4 +102,29 @@ describe('diagnostics routes', () => {
|
|||||||
expect(typeof body.displayName).toBe('string');
|
expect(typeof body.displayName).toBe('string');
|
||||||
expect(typeof body.backendPort).toBe('number');
|
expect(typeof body.backendPort).toBe('number');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps diagnostics open outside production for local smoke ergonomics', async () => {
|
||||||
|
await expect(assertDiagnosticsAccess({ headers: {} }, 'development')).resolves.toBeUndefined();
|
||||||
|
await expect(assertDiagnosticsAccess({ headers: {} }, 'test')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires auth for diagnostics in production', async () => {
|
||||||
|
await expect(assertDiagnosticsAccess({ headers: {} }, 'production')).rejects.toThrow('Unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows production diagnostics for admin or owner roles only', async () => {
|
||||||
|
const adminToken = await makeToken('admin');
|
||||||
|
const ownerToken = await makeToken('owner');
|
||||||
|
const viewerToken = await makeToken('viewer');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
assertDiagnosticsAccess({ headers: { authorization: `Bearer ${adminToken}` } }, 'production')
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
assertDiagnosticsAccess({ headers: { authorization: `Bearer ${ownerToken}` } }, 'production')
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await expect(
|
||||||
|
assertDiagnosticsAccess({ headers: { authorization: `Bearer ${viewerToken}` } }, 'production')
|
||||||
|
).rejects.toThrow('Insufficient permissions');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
53
backend/src/lib/diagnostics-routes.ts
Normal file
53
backend/src/lib/diagnostics-routes.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { requireRole } from './auth.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { getAllFlags } from './feature-flags.js';
|
||||||
|
import { PRODUCT_ID } from './product-config.js';
|
||||||
|
import { flushEvents, getBufferedEvents } from './telemetry.js';
|
||||||
|
|
||||||
|
type AuthLikeRequest = { headers: { authorization?: string } };
|
||||||
|
type DiagnosticsHandler = (req: AuthLikeRequest) => Promise<unknown>;
|
||||||
|
type DiagnosticsRouteApp = {
|
||||||
|
get: (path: string, handler: DiagnosticsHandler) => unknown;
|
||||||
|
post: (path: string, handler: DiagnosticsHandler) => unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function assertDiagnosticsAccess(
|
||||||
|
req: AuthLikeRequest,
|
||||||
|
nodeEnv: string = config.NODE_ENV,
|
||||||
|
): Promise<void> {
|
||||||
|
if (nodeEnv !== 'production') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await requireRole(req, 'admin', 'owner');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diagnosticsRoutes(app: DiagnosticsRouteApp) {
|
||||||
|
app.get('/api/diagnostics/flags', async req => {
|
||||||
|
await assertDiagnosticsAccess(req);
|
||||||
|
return getAllFlags();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/diagnostics/telemetry', async req => {
|
||||||
|
await assertDiagnosticsAccess(req);
|
||||||
|
return { events: getBufferedEvents() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/diagnostics/telemetry/flush', async req => {
|
||||||
|
await assertDiagnosticsAccess(req);
|
||||||
|
return { flushed: flushEvents().length };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/diagnostics/config', async req => {
|
||||||
|
await assertDiagnosticsAccess(req);
|
||||||
|
return {
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
serviceName: config.SERVICE_NAME,
|
||||||
|
port: config.PORT,
|
||||||
|
nodeEnv: config.NODE_ENV,
|
||||||
|
dbProvider: config.DB_PROVIDER,
|
||||||
|
telemetryEnabled: config.TELEMETRY_ENABLED,
|
||||||
|
featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ const registerOptionalJwtContextMock = vi.fn(async () => undefined);
|
|||||||
const startServiceMock = vi.fn(async () => undefined);
|
const startServiceMock = vi.fn(async () => undefined);
|
||||||
const initCosmosIfNeededMock = vi.fn(async () => undefined);
|
const initCosmosIfNeededMock = vi.fn(async () => undefined);
|
||||||
const initDatastoreMock = vi.fn(() => undefined);
|
const initDatastoreMock = vi.fn(() => undefined);
|
||||||
|
const diagnosticsRoutesMock = vi.fn(async () => undefined);
|
||||||
|
|
||||||
const appMock = {
|
const appMock = {
|
||||||
register: vi.fn(async () => undefined),
|
register: vi.fn(async () => undefined),
|
||||||
@ -61,6 +62,7 @@ vi.mock('./lib/field-encrypt.js', () => ({ initEncryption: vi.fn(async () => und
|
|||||||
vi.mock('./lib/request-context.js', () => ({ getUserId: vi.fn(), getRequestProductId: vi.fn() }));
|
vi.mock('./lib/request-context.js', () => ({ getUserId: vi.fn(), getRequestProductId: vi.fn() }));
|
||||||
vi.mock('./lib/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) }));
|
vi.mock('./lib/feature-flags.js', () => ({ getAllFlags: vi.fn(() => ({})) }));
|
||||||
vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) }));
|
vi.mock('./lib/telemetry.js', () => ({ getBufferedEvents: vi.fn(() => []), flushEvents: vi.fn(() => []) }));
|
||||||
|
vi.mock('./lib/diagnostics-routes.js', () => ({ diagnosticsRoutes: diagnosticsRoutesMock }));
|
||||||
vi.mock('./modules/note-shares/repository.js', () => ({ findShareByToken: vi.fn(async () => null) }));
|
vi.mock('./modules/note-shares/repository.js', () => ({ findShareByToken: vi.fn(async () => null) }));
|
||||||
vi.mock('./modules/notes/repository.js', () => ({ getNote: vi.fn(async () => null) }));
|
vi.mock('./modules/notes/repository.js', () => ({ getNote: vi.fn(async () => null) }));
|
||||||
|
|
||||||
@ -81,6 +83,7 @@ describe('server bootstrap', () => {
|
|||||||
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
||||||
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
|
||||||
expect(appMock.register).toHaveBeenCalledTimes(14);
|
expect(appMock.register).toHaveBeenCalledTimes(14);
|
||||||
|
expect(diagnosticsRoutesMock).toHaveBeenCalledWith(appMock);
|
||||||
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
|
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4016, host: '0.0.0.0' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,9 +19,8 @@ import { initEncryption } from './lib/field-encrypt.js';
|
|||||||
import { initWebhookSubscriber, stopWebhookSubscriber } from './lib/webhook-subscriber.js';
|
import { initWebhookSubscriber, stopWebhookSubscriber } from './lib/webhook-subscriber.js';
|
||||||
import { initDatastore } from './lib/datastore.js';
|
import { initDatastore } from './lib/datastore.js';
|
||||||
import { config } from './lib/config.js';
|
import { config } from './lib/config.js';
|
||||||
import { getAllFlags } from './lib/feature-flags.js';
|
|
||||||
import { getBufferedEvents, flushEvents } from './lib/telemetry.js';
|
|
||||||
import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js';
|
import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js';
|
||||||
|
import { diagnosticsRoutes } from './lib/diagnostics-routes.js';
|
||||||
import type { JwtPayload } from './lib/request-context.js';
|
import type { JwtPayload } from './lib/request-context.js';
|
||||||
import { findShareByToken } from './modules/note-shares/repository.js';
|
import { findShareByToken } from './modules/note-shares/repository.js';
|
||||||
import * as noteRepo from './modules/notes/repository.js';
|
import * as noteRepo from './modules/notes/repository.js';
|
||||||
@ -108,19 +107,8 @@ app.get('/api/bootstrap', async () => ({
|
|||||||
backendPort: config.PORT,
|
backendPort: config.PORT,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ── Diagnostics routes (no auth) ────────────────────────────────
|
// ── Diagnostics routes (dev/test open, production admin/owner gated) ───────
|
||||||
app.get('/api/diagnostics/flags', async () => getAllFlags());
|
await diagnosticsRoutes(app);
|
||||||
app.get('/api/diagnostics/telemetry', async () => ({ events: getBufferedEvents() }));
|
|
||||||
app.post('/api/diagnostics/telemetry/flush', async () => ({ flushed: flushEvents().length }));
|
|
||||||
app.get('/api/diagnostics/config', async () => ({
|
|
||||||
productId: PRODUCT_ID,
|
|
||||||
serviceName: config.SERVICE_NAME,
|
|
||||||
port: config.PORT,
|
|
||||||
nodeEnv: config.NODE_ENV,
|
|
||||||
dbProvider: config.DB_PROVIDER,
|
|
||||||
telemetryEnabled: config.TELEMETRY_ENABLED,
|
|
||||||
featureFlagsEnabled: config.FEATURE_FLAGS_ENABLED,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await initEncryption(PRODUCT_ID, app.log);
|
await initEncryption(PRODUCT_ID, app.log);
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user