Wire auth middleware into all products, add docker-compose and init-db script
- Auth middleware (JWT + API key + RBAC) copied into P3/P4/P5/P6 - All server entry points now register auth hooks + auth routes - Webhook and Slack endpoints skip JWT auth (use HMAC/signature) - docker-compose.yml: shared Postgres + Redis + Meilisearch, all 4 Node products as services - init-db.sh: creates per-product databases and runs migrations - P1 (Rust) and P2 (Go agent) run standalone, not in compose
This commit is contained in:
122
products/03-alert-intelligence/src/auth/middleware.ts
Normal file
122
products/03-alert-intelligence/src/auth/middleware.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const logger = pino({ name: 'auth' });
|
||||||
|
|
||||||
|
export interface AuthPayload {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
|
*/
|
||||||
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.decorateRequest('tenantId', '');
|
||||||
|
app.decorateRequest('userId', '');
|
||||||
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Skip health check
|
||||||
|
if (req.url === '/health') return;
|
||||||
|
// Skip webhook endpoints (they use HMAC auth)
|
||||||
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
|
// Skip Slack endpoints (they use Slack signature)
|
||||||
|
if (req.url.startsWith('/slack/')) return;
|
||||||
|
|
||||||
|
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
// API key auth: dd0c_ prefix + 32 hex chars
|
||||||
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
||||||
|
// TODO: Look up by prefix, bcrypt compare full key
|
||||||
|
// For now, reject — real implementation needs DB lookup
|
||||||
|
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||||
|
(req as any).tenantId = payload.tenantId;
|
||||||
|
(req as any).userId = payload.userId;
|
||||||
|
(req as any).userRole = payload.role;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(401).send({ error: 'Missing authentication' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-based access control check.
|
||||||
|
* Use in route handlers: requireRole(req, reply, 'admin')
|
||||||
|
*/
|
||||||
|
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||||
|
const hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
|
const requiredLevel = hierarchy[minRole] ?? 0;
|
||||||
|
|
||||||
|
if (userLevel < requiredLevel) {
|
||||||
|
reply.status(403).send({ error: `Requires ${minRole} role or higher` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token (for login/signup endpoints).
|
||||||
|
*/
|
||||||
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login endpoint registration.
|
||||||
|
*/
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signupSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
tenant_name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
// TODO: Look up user by email, bcrypt compare password
|
||||||
|
// For now, return placeholder
|
||||||
|
return reply.status(501).send({ error: 'Login not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
|
const body = signupSchema.parse(req.body);
|
||||||
|
// TODO: Create tenant + user, hash password with bcrypt
|
||||||
|
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
|
return {
|
||||||
|
tenant_id: (req as any).tenantId,
|
||||||
|
user_id: (req as any).userId,
|
||||||
|
role: (req as any).userRole,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import cors from '@fastify/cors';
|
|||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { config } from './config/index.js';
|
import { config } from './config/index.js';
|
||||||
|
import { pool } from './data/db.js';
|
||||||
|
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||||
import { registerWebhookRoutes } from './api/webhooks.js';
|
import { registerWebhookRoutes } from './api/webhooks.js';
|
||||||
import { registerIncidentRoutes } from './api/incidents.js';
|
import { registerIncidentRoutes } from './api/incidents.js';
|
||||||
import { registerNotificationRoutes } from './api/notifications.js';
|
import { registerNotificationRoutes } from './api/notifications.js';
|
||||||
@@ -14,8 +16,11 @@ const app = Fastify({ logger: true });
|
|||||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
|
|
||||||
|
registerAuth(app, config.JWT_SECRET, pool);
|
||||||
|
|
||||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-alert' }));
|
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-alert' }));
|
||||||
|
|
||||||
|
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||||
registerWebhookRoutes(app);
|
registerWebhookRoutes(app);
|
||||||
registerIncidentRoutes(app);
|
registerIncidentRoutes(app);
|
||||||
registerNotificationRoutes(app);
|
registerNotificationRoutes(app);
|
||||||
|
|||||||
122
products/04-lightweight-idp/src/auth/middleware.ts
Normal file
122
products/04-lightweight-idp/src/auth/middleware.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const logger = pino({ name: 'auth' });
|
||||||
|
|
||||||
|
export interface AuthPayload {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
|
*/
|
||||||
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.decorateRequest('tenantId', '');
|
||||||
|
app.decorateRequest('userId', '');
|
||||||
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Skip health check
|
||||||
|
if (req.url === '/health') return;
|
||||||
|
// Skip webhook endpoints (they use HMAC auth)
|
||||||
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
|
// Skip Slack endpoints (they use Slack signature)
|
||||||
|
if (req.url.startsWith('/slack/')) return;
|
||||||
|
|
||||||
|
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
// API key auth: dd0c_ prefix + 32 hex chars
|
||||||
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
||||||
|
// TODO: Look up by prefix, bcrypt compare full key
|
||||||
|
// For now, reject — real implementation needs DB lookup
|
||||||
|
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||||
|
(req as any).tenantId = payload.tenantId;
|
||||||
|
(req as any).userId = payload.userId;
|
||||||
|
(req as any).userRole = payload.role;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(401).send({ error: 'Missing authentication' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-based access control check.
|
||||||
|
* Use in route handlers: requireRole(req, reply, 'admin')
|
||||||
|
*/
|
||||||
|
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||||
|
const hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
|
const requiredLevel = hierarchy[minRole] ?? 0;
|
||||||
|
|
||||||
|
if (userLevel < requiredLevel) {
|
||||||
|
reply.status(403).send({ error: `Requires ${minRole} role or higher` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token (for login/signup endpoints).
|
||||||
|
*/
|
||||||
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login endpoint registration.
|
||||||
|
*/
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signupSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
tenant_name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
// TODO: Look up user by email, bcrypt compare password
|
||||||
|
// For now, return placeholder
|
||||||
|
return reply.status(501).send({ error: 'Login not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
|
const body = signupSchema.parse(req.body);
|
||||||
|
// TODO: Create tenant + user, hash password with bcrypt
|
||||||
|
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
|
return {
|
||||||
|
tenant_id: (req as any).tenantId,
|
||||||
|
user_id: (req as any).userId,
|
||||||
|
role: (req as any).userRole,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import cors from '@fastify/cors';
|
|||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { config } from './config/index.js';
|
import { config } from './config/index.js';
|
||||||
|
import { pool } from './data/db.js';
|
||||||
|
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||||
import { registerServiceRoutes } from './api/services.js';
|
import { registerServiceRoutes } from './api/services.js';
|
||||||
import { registerDiscoveryRoutes } from './api/discovery.js';
|
import { registerDiscoveryRoutes } from './api/discovery.js';
|
||||||
import { registerSearchRoutes } from './api/search.js';
|
import { registerSearchRoutes } from './api/search.js';
|
||||||
@@ -14,8 +16,11 @@ const app = Fastify({ logger: true });
|
|||||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
|
|
||||||
|
registerAuth(app, config.JWT_SECRET, pool);
|
||||||
|
|
||||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' }));
|
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' }));
|
||||||
|
|
||||||
|
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||||
registerServiceRoutes(app);
|
registerServiceRoutes(app);
|
||||||
registerDiscoveryRoutes(app);
|
registerDiscoveryRoutes(app);
|
||||||
registerSearchRoutes(app);
|
registerSearchRoutes(app);
|
||||||
|
|||||||
122
products/05-aws-cost-anomaly/src/auth/middleware.ts
Normal file
122
products/05-aws-cost-anomaly/src/auth/middleware.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const logger = pino({ name: 'auth' });
|
||||||
|
|
||||||
|
export interface AuthPayload {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
|
*/
|
||||||
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.decorateRequest('tenantId', '');
|
||||||
|
app.decorateRequest('userId', '');
|
||||||
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Skip health check
|
||||||
|
if (req.url === '/health') return;
|
||||||
|
// Skip webhook endpoints (they use HMAC auth)
|
||||||
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
|
// Skip Slack endpoints (they use Slack signature)
|
||||||
|
if (req.url.startsWith('/slack/')) return;
|
||||||
|
|
||||||
|
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
// API key auth: dd0c_ prefix + 32 hex chars
|
||||||
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
||||||
|
// TODO: Look up by prefix, bcrypt compare full key
|
||||||
|
// For now, reject — real implementation needs DB lookup
|
||||||
|
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||||
|
(req as any).tenantId = payload.tenantId;
|
||||||
|
(req as any).userId = payload.userId;
|
||||||
|
(req as any).userRole = payload.role;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(401).send({ error: 'Missing authentication' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-based access control check.
|
||||||
|
* Use in route handlers: requireRole(req, reply, 'admin')
|
||||||
|
*/
|
||||||
|
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||||
|
const hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
|
const requiredLevel = hierarchy[minRole] ?? 0;
|
||||||
|
|
||||||
|
if (userLevel < requiredLevel) {
|
||||||
|
reply.status(403).send({ error: `Requires ${minRole} role or higher` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token (for login/signup endpoints).
|
||||||
|
*/
|
||||||
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login endpoint registration.
|
||||||
|
*/
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signupSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
tenant_name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
// TODO: Look up user by email, bcrypt compare password
|
||||||
|
// For now, return placeholder
|
||||||
|
return reply.status(501).send({ error: 'Login not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
|
const body = signupSchema.parse(req.body);
|
||||||
|
// TODO: Create tenant + user, hash password with bcrypt
|
||||||
|
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
|
return {
|
||||||
|
tenant_id: (req as any).tenantId,
|
||||||
|
user_id: (req as any).userId,
|
||||||
|
role: (req as any).userRole,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import Fastify from 'fastify';
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { config } from './config/index.js';
|
import { config } from './config/index.js';
|
||||||
|
import { pool } from './data/db.js';
|
||||||
|
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||||
import { registerAnomalyRoutes } from './api/anomalies.js';
|
import { registerAnomalyRoutes } from './api/anomalies.js';
|
||||||
import { registerBaselineRoutes } from './api/baselines.js';
|
import { registerBaselineRoutes } from './api/baselines.js';
|
||||||
import { registerGovernanceRoutes } from './api/governance.js';
|
import { registerGovernanceRoutes } from './api/governance.js';
|
||||||
@@ -13,8 +15,11 @@ const app = Fastify({ logger: true });
|
|||||||
|
|
||||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||||
|
|
||||||
|
registerAuth(app, config.JWT_SECRET, pool);
|
||||||
|
|
||||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-cost' }));
|
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-cost' }));
|
||||||
|
|
||||||
|
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||||
registerIngestionRoutes(app);
|
registerIngestionRoutes(app);
|
||||||
registerAnomalyRoutes(app);
|
registerAnomalyRoutes(app);
|
||||||
registerBaselineRoutes(app);
|
registerBaselineRoutes(app);
|
||||||
|
|||||||
122
products/06-runbook-automation/saas/src/auth/middleware.ts
Normal file
122
products/06-runbook-automation/saas/src/auth/middleware.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
import pino from 'pino';
|
||||||
|
|
||||||
|
const logger = pino({ name: 'auth' });
|
||||||
|
|
||||||
|
export interface AuthPayload {
|
||||||
|
tenantId: string;
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||||
|
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||||
|
*/
|
||||||
|
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.decorateRequest('tenantId', '');
|
||||||
|
app.decorateRequest('userId', '');
|
||||||
|
app.decorateRequest('userRole', 'viewer');
|
||||||
|
|
||||||
|
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
// Skip health check
|
||||||
|
if (req.url === '/health') return;
|
||||||
|
// Skip webhook endpoints (they use HMAC auth)
|
||||||
|
if (req.url.startsWith('/webhooks/')) return;
|
||||||
|
// Skip Slack endpoints (they use Slack signature)
|
||||||
|
if (req.url.startsWith('/slack/')) return;
|
||||||
|
|
||||||
|
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||||
|
const authHeader = req.headers['authorization'];
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
// API key auth: dd0c_ prefix + 32 hex chars
|
||||||
|
if (!apiKey.startsWith('dd0c_') || apiKey.length !== 37) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars for fast lookup
|
||||||
|
// TODO: Look up by prefix, bcrypt compare full key
|
||||||
|
// For now, reject — real implementation needs DB lookup
|
||||||
|
return reply.status(401).send({ error: 'API key auth not yet implemented' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authHeader?.startsWith('Bearer ')) {
|
||||||
|
const token = authHeader.slice(7);
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||||
|
(req as any).tenantId = payload.tenantId;
|
||||||
|
(req as any).userId = payload.userId;
|
||||||
|
(req as any).userRole = payload.role;
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(401).send({ error: 'Invalid or expired token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(401).send({ error: 'Missing authentication' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Role-based access control check.
|
||||||
|
* Use in route handlers: requireRole(req, reply, 'admin')
|
||||||
|
*/
|
||||||
|
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||||
|
const hierarchy: Record<string, number> = { owner: 4, admin: 3, member: 2, viewer: 1 };
|
||||||
|
const userLevel = hierarchy[(req as any).userRole] ?? 0;
|
||||||
|
const requiredLevel = hierarchy[minRole] ?? 0;
|
||||||
|
|
||||||
|
if (userLevel < requiredLevel) {
|
||||||
|
reply.status(403).send({ error: `Requires ${minRole} role or higher` });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a JWT token (for login/signup endpoints).
|
||||||
|
*/
|
||||||
|
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||||
|
return jwt.sign(payload, secret, { expiresIn });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login endpoint registration.
|
||||||
|
*/
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
});
|
||||||
|
|
||||||
|
const signupSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
tenant_name: z.string().min(1).max(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: any) {
|
||||||
|
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||||
|
const body = loginSchema.parse(req.body);
|
||||||
|
// TODO: Look up user by email, bcrypt compare password
|
||||||
|
// For now, return placeholder
|
||||||
|
return reply.status(501).send({ error: 'Login not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||||
|
const body = signupSchema.parse(req.body);
|
||||||
|
// TODO: Create tenant + user, hash password with bcrypt
|
||||||
|
return reply.status(501).send({ error: 'Signup not yet implemented' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||||
|
return {
|
||||||
|
tenant_id: (req as any).tenantId,
|
||||||
|
user_id: (req as any).userId,
|
||||||
|
role: (req as any).userRole,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import cors from '@fastify/cors';
|
|||||||
import helmet from '@fastify/helmet';
|
import helmet from '@fastify/helmet';
|
||||||
import pino from 'pino';
|
import pino from 'pino';
|
||||||
import { config } from './config/index.js';
|
import { config } from './config/index.js';
|
||||||
|
import { pool } from './data/db.js';
|
||||||
|
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||||
import { registerRunbookRoutes } from './api/runbooks.js';
|
import { registerRunbookRoutes } from './api/runbooks.js';
|
||||||
import { registerApprovalRoutes } from './api/approvals.js';
|
import { registerApprovalRoutes } from './api/approvals.js';
|
||||||
import { registerSlackRoutes } from './slackbot/handler.js';
|
import { registerSlackRoutes } from './slackbot/handler.js';
|
||||||
@@ -14,10 +16,11 @@ const app = Fastify({ logger: true });
|
|||||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||||
await app.register(helmet);
|
await app.register(helmet);
|
||||||
|
|
||||||
// Health check
|
registerAuth(app, config.JWT_SECRET, pool);
|
||||||
|
|
||||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-run' }));
|
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-run' }));
|
||||||
|
|
||||||
// API routes
|
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||||
registerRunbookRoutes(app);
|
registerRunbookRoutes(app);
|
||||||
registerApprovalRoutes(app);
|
registerApprovalRoutes(app);
|
||||||
registerSlackRoutes(app);
|
registerSlackRoutes(app);
|
||||||
|
|||||||
123
products/docker-compose.yml
Normal file
123
products/docker-compose.yml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# dd0c Local Development Stack
|
||||||
|
#
|
||||||
|
# Usage: docker compose up -d
|
||||||
|
# All services share one Postgres and one Redis instance.
|
||||||
|
# Caddy handles TLS and routing for *.dd0c.localhost
|
||||||
|
|
||||||
|
services:
|
||||||
|
# --- Shared Infrastructure ---
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: dd0c
|
||||||
|
POSTGRES_PASSWORD: dd0c-dev
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- pg_data:/var/lib/postgresql/data
|
||||||
|
- ./products/01-llm-cost-router/migrations:/docker-entrypoint-initdb.d/01-route:ro
|
||||||
|
- ./products/02-iac-drift-detection/saas/migrations:/docker-entrypoint-initdb.d/02-drift:ro
|
||||||
|
- ./products/03-alert-intelligence/migrations:/docker-entrypoint-initdb.d/03-alert:ro
|
||||||
|
- ./products/04-lightweight-idp/migrations:/docker-entrypoint-initdb.d/04-portal:ro
|
||||||
|
- ./products/05-aws-cost-anomaly/migrations:/docker-entrypoint-initdb.d/05-cost:ro
|
||||||
|
- ./products/06-runbook-automation/saas/migrations:/docker-entrypoint-initdb.d/06-run:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U dd0c"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
meilisearch:
|
||||||
|
image: getmeili/meilisearch:v1.8
|
||||||
|
environment:
|
||||||
|
MEILI_ENV: development
|
||||||
|
ports:
|
||||||
|
- "7700:7700"
|
||||||
|
volumes:
|
||||||
|
- meili_data:/meili_data
|
||||||
|
|
||||||
|
# --- dd0c Products ---
|
||||||
|
# P3: Alert Intelligence
|
||||||
|
alert:
|
||||||
|
build:
|
||||||
|
context: ./products/03-alert-intelligence
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3003:3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_alert
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||||
|
LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
|
||||||
|
# P4: Lightweight IDP / Service Catalog
|
||||||
|
portal:
|
||||||
|
build:
|
||||||
|
context: ./products/04-lightweight-idp
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3004:3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_portal
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
MEILI_URL: http://meilisearch:7700
|
||||||
|
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||||
|
LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
meilisearch: { condition: service_started }
|
||||||
|
|
||||||
|
# P5: AWS Cost Anomaly Detection
|
||||||
|
cost:
|
||||||
|
build:
|
||||||
|
context: ./products/05-aws-cost-anomaly
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3005:3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_cost
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||||
|
ANOMALY_THRESHOLD: "50"
|
||||||
|
LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
|
||||||
|
# P6: Runbook Automation (SaaS)
|
||||||
|
run:
|
||||||
|
build:
|
||||||
|
context: ./products/06-runbook-automation/saas
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3006:3000"
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_run
|
||||||
|
REDIS_URL: redis://redis:6379
|
||||||
|
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||||
|
LOG_LEVEL: info
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
redis: { condition: service_healthy }
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_data:
|
||||||
|
meili_data:
|
||||||
46
products/init-db.sh
Executable file
46
products/init-db.sh
Executable file
@@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# dd0c init-db: creates per-product databases and runs migrations
|
||||||
|
# Usage: ./init-db.sh [postgres-url]
|
||||||
|
|
||||||
|
PG_URL="${1:-postgresql://dd0c:dd0c-dev@localhost:5432}"
|
||||||
|
|
||||||
|
DATABASES=(dd0c_route dd0c_drift dd0c_alert dd0c_portal dd0c_cost dd0c_run)
|
||||||
|
|
||||||
|
echo "Creating databases..."
|
||||||
|
for db in "${DATABASES[@]}"; do
|
||||||
|
psql "$PG_URL/postgres" -tc "SELECT 1 FROM pg_database WHERE datname = '$db'" | grep -q 1 \
|
||||||
|
|| psql "$PG_URL/postgres" -c "CREATE DATABASE $db" 2>/dev/null
|
||||||
|
echo " ✓ $db"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Running migrations..."
|
||||||
|
|
||||||
|
PRODUCTS=(
|
||||||
|
"01-llm-cost-router:dd0c_route"
|
||||||
|
"02-iac-drift-detection/saas:dd0c_drift"
|
||||||
|
"03-alert-intelligence:dd0c_alert"
|
||||||
|
"04-lightweight-idp:dd0c_portal"
|
||||||
|
"05-aws-cost-anomaly:dd0c_cost"
|
||||||
|
"06-runbook-automation/saas:dd0c_run"
|
||||||
|
)
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
for entry in "${PRODUCTS[@]}"; do
|
||||||
|
product="${entry%%:*}"
|
||||||
|
db="${entry##*:}"
|
||||||
|
migration_dir="$SCRIPT_DIR/$product/migrations"
|
||||||
|
|
||||||
|
if [ -d "$migration_dir" ]; then
|
||||||
|
for sql in "$migration_dir"/*.sql; do
|
||||||
|
echo " $db ← $(basename "$sql")"
|
||||||
|
psql "$PG_URL/$db" -f "$sql" 2>/dev/null || echo " (already applied or error)"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Done. All databases ready."
|
||||||
Reference in New Issue
Block a user