Security hardening: auth encapsulation, pool restriction, rate limiting, invites, async webhooks
Some checks failed
CI — P2 Drift (Go + Node) / agent (push) Successful in 43s
CI — P2 Drift (Go + Node) / saas (push) Failing after 5s
CI — P3 Alert / test (push) Failing after 4s
CI — P4 Portal / test (push) Failing after 4s
CI — P5 Cost / test (push) Failing after 4s
CI — P6 Run / saas (push) Failing after 5s
CI — P2 Drift (Go + Node) / build-push (push) Failing after 7s
CI — P3 Alert / build-push (push) Has been skipped
CI — P4 Portal / build-push (push) Has been skipped
CI — P5 Cost / build-push (push) Has been skipped
CI — P6 Run / build-push (push) Failing after 5s
Some checks failed
CI — P2 Drift (Go + Node) / agent (push) Successful in 43s
CI — P2 Drift (Go + Node) / saas (push) Failing after 5s
CI — P3 Alert / test (push) Failing after 4s
CI — P4 Portal / test (push) Failing after 4s
CI — P5 Cost / test (push) Failing after 4s
CI — P6 Run / saas (push) Failing after 5s
CI — P2 Drift (Go + Node) / build-push (push) Failing after 7s
CI — P3 Alert / build-push (push) Has been skipped
CI — P4 Portal / build-push (push) Has been skipped
CI — P5 Cost / build-push (push) Has been skipped
CI — P6 Run / build-push (push) Failing after 5s
Phase 1 (Security Critical):
- Auth plugin encapsulation: replaced global addHook with Fastify plugin scope
- Removed startsWith URL matching; public routes registered outside auth scope
- JWT verify now enforces algorithms: ['HS256'] (prevents algorithm confusion)
- Raw pool no longer exported from db.ts; systemQuery() + getPoolForAuth() instead
- withTenant() remains primary tenant-scoped query path
Phase 2 (Infrastructure):
- docker-compose.yml: all secrets via env var substitution (${VAR:-default})
- Per-service Postgres users (dd0c_drift, dd0c_alert, etc.) in docker-init-db.sh
- .env.example with all configurable secrets
- build-push.sh uses $REGISTRY_PASSWORD instead of hardcoded
- .gitignore excludes .env files
- @fastify/rate-limit: 100 req/min global, 5/min login, 3/min signup
- CORS_ORIGIN default changed from '*' to 'http://localhost:5173'
Phase 3 (Product):
- Team invite flow: tenant_invites table, POST /invite, GET /invites, DELETE /invites/:id
- Signup accepts optional invite_token to join existing tenant
- Async webhook ingestion (P3): LPUSH to Redis, BRPOP worker, dead-letter queue
Console:
- All 5 product modules wired: drift, alert, portal, cost, run
- PageHeader accepts children prop
- 71 modules, 70KB gzipped production build
All 6 projects compile clean (tsc --noEmit).
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Environment secrets
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -1,37 +1,28 @@
|
||||
# dd0c Environment Variables
|
||||
# Copy to .env and fill in your values
|
||||
# dd0c Environment Configuration
|
||||
# Copy to .env and fill in real values before deploying.
|
||||
# NEVER commit .env to git.
|
||||
|
||||
# --- Shared ---
|
||||
DATABASE_URL=postgresql://dd0c:dd0c-dev@localhost:5432/dd0c_alert
|
||||
REDIS_URL=redis://localhost:6379
|
||||
JWT_SECRET=change-me-to-a-real-secret-at-least-32-chars
|
||||
CORS_ORIGIN=*
|
||||
# --- Postgres ---
|
||||
POSTGRES_USER=dd0c
|
||||
POSTGRES_PASSWORD=change-me-in-production
|
||||
|
||||
# --- Per-service DB credentials (created by docker-init-db.sh) ---
|
||||
DB_DRIFT_PASSWORD=change-me-drift
|
||||
DB_ALERT_PASSWORD=change-me-alert
|
||||
DB_PORTAL_PASSWORD=change-me-portal
|
||||
DB_COST_PASSWORD=change-me-cost
|
||||
DB_RUN_PASSWORD=change-me-run
|
||||
|
||||
# --- Auth ---
|
||||
JWT_SECRET=change-me-generate-with-openssl-rand-base64-32
|
||||
|
||||
# --- Registry ---
|
||||
REGISTRY=reg.dd0c.net
|
||||
REGISTRY_PASSWORD=change-me-registry
|
||||
|
||||
# --- CORS (comma-separated origins) ---
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# --- Optional ---
|
||||
LOG_LEVEL=info
|
||||
PORT=3000
|
||||
|
||||
# --- P1: route ---
|
||||
# OPENAI_API_KEY=sk-...
|
||||
# ANTHROPIC_API_KEY=sk-ant-...
|
||||
|
||||
# --- P3: alert ---
|
||||
# DATADOG_WEBHOOK_SECRET=...
|
||||
# PAGERDUTY_WEBHOOK_SECRET=...
|
||||
# OPSGENIE_WEBHOOK_SECRET=...
|
||||
|
||||
# --- P4: portal ---
|
||||
# MEILI_URL=http://localhost:7700
|
||||
# MEILI_KEY=...
|
||||
# GITHUB_TOKEN=ghp_...
|
||||
|
||||
# --- P5: cost ---
|
||||
# AWS_ACCESS_KEY_ID=...
|
||||
# AWS_SECRET_ACCESS_KEY=...
|
||||
# ANOMALY_THRESHOLD=50
|
||||
|
||||
# --- P6: run ---
|
||||
# SLACK_BOT_TOKEN=xoxb-...
|
||||
# SLACK_SIGNING_SECRET=...
|
||||
|
||||
# --- Notifications (shared) ---
|
||||
# SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
|
||||
# RESEND_API_KEY=re_...
|
||||
ANOMALY_THRESHOLD=50
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email);
|
||||
@@ -14,6 +14,7 @@
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.0",
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/helmet": "^11.1.0",
|
||||
"pg": "^8.12.0",
|
||||
"drizzle-orm": "^0.31.0",
|
||||
|
||||
@@ -15,21 +15,11 @@ export interface AuthPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||
* Returns an onRequest hook that validates JWT or API key auth.
|
||||
* No URL matching — only register this hook inside a protected plugin scope.
|
||||
*/
|
||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (req.url === '/health' || req.url === '/version') return;
|
||||
if (req.url.startsWith('/webhooks/')) return;
|
||||
if (req.url.startsWith('/slack/')) return;
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return;
|
||||
|
||||
export function authHook(jwtSecret: string, pool: Pool) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||
}
|
||||
|
||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||
const prefix = apiKey.slice(0, 13);
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||
const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload;
|
||||
(req as any).tenantId = payload.tenantId;
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).userRole = payload.role;
|
||||
@@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
}
|
||||
|
||||
return reply.status(401).send({ error: 'Missing authentication' });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate the Fastify request with auth properties.
|
||||
* Call this once on the root app instance before registering any routes.
|
||||
*/
|
||||
export function decorateAuth(app: FastifyInstance) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
}
|
||||
|
||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||
@@ -123,11 +123,21 @@ const loginSchema = z.object({
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
tenant_name: z.string().min(1).max(100),
|
||||
tenant_name: z.string().min(1).max(100).optional(),
|
||||
invite_token: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.invite_token || data.tenant_name,
|
||||
{ message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] },
|
||||
);
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
||||
});
|
||||
|
||||
/** Public auth routes — login/signup. No auth required. */
|
||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
return { token, expires_in: '24h' };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||
app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = signupSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]);
|
||||
if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' });
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
const tenantId = tenant.rows[0].id;
|
||||
let tenantId: string;
|
||||
let role: string;
|
||||
|
||||
if (body.invite_token) {
|
||||
const invite = await client.query(
|
||||
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`,
|
||||
[body.invite_token],
|
||||
);
|
||||
if (!invite.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invalid invite token' });
|
||||
}
|
||||
const inv = invite.rows[0];
|
||||
if (inv.accepted_at) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite already accepted' });
|
||||
}
|
||||
if (new Date(inv.expires_at) < new Date()) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite expired' });
|
||||
}
|
||||
|
||||
tenantId = inv.tenant_id;
|
||||
role = inv.role;
|
||||
|
||||
await client.query(
|
||||
`UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`,
|
||||
[inv.id],
|
||||
);
|
||||
} else {
|
||||
if (!body.tenant_name) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'tenant_name is required for new signups' });
|
||||
}
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
tenantId = tenant.rows[0].id;
|
||||
role = 'owner';
|
||||
}
|
||||
|
||||
const user = await client.query(
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
[tenantId, body.email, passwordHash],
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[tenantId, body.email, passwordHash, role],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
@@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
tenantId,
|
||||
userId: user.rows[0].id,
|
||||
email: body.email,
|
||||
role: 'owner',
|
||||
role: role as AuthPayload['role'],
|
||||
}, jwtSecret);
|
||||
|
||||
return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' });
|
||||
@@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */
|
||||
export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
return {
|
||||
tenant_id: (req as any).tenantId,
|
||||
@@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
};
|
||||
});
|
||||
|
||||
// Generate API key
|
||||
app.post('/api/v1/auth/api-keys', async (req, reply) => {
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
@@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
[tenantId, userId, prefix, keyHash],
|
||||
);
|
||||
|
||||
// Return the raw key ONCE — it's never stored or retrievable again
|
||||
return reply.status(201).send({ api_key: rawKey, prefix });
|
||||
});
|
||||
|
||||
// --- Invite endpoints ---
|
||||
|
||||
app.post('/api/v1/auth/invite', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
const body = inviteSchema.parse(req.body);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const result = await pool.query(
|
||||
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING expires_at`,
|
||||
[tenantId, body.email, body.role, token, userId],
|
||||
);
|
||||
|
||||
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/invites', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, role, expires_at, created_at FROM tenant_invites
|
||||
WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { invites: result.rows };
|
||||
});
|
||||
|
||||
app.delete('/api/v1/auth/invites/:id', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`,
|
||||
[id, tenantId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' });
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ const envSchema = z.object({
|
||||
DATABASE_URL: z.string().default('postgres://dd0c:dd0c@localhost:5432/dd0c_drift'),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
JWT_SECRET: z.string().default('dev-secret-change-me'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
SQS_QUEUE_URL: z.string().optional(),
|
||||
S3_BUCKET: z.string().default('dd0c-drift-snapshots'),
|
||||
|
||||
@@ -15,7 +15,6 @@ export function createPool(connectionString: string): pg.Pool {
|
||||
* MUST be cleared when returning the connection to the pool.
|
||||
*/
|
||||
export async function setTenantContext(client: pg.PoolClient, tenantId: string): Promise<void> {
|
||||
// SET doesn't support parameterized queries — validate UUID format then interpolate
|
||||
if (!/^[0-9a-f-]{36}$/i.test(tenantId)) throw new Error('Invalid tenant ID');
|
||||
await client.query(`SET LOCAL app.tenant_id = '${tenantId}'`);
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import { config } from './config/index.js';
|
||||
import { registerProcessorRoutes } from './processor/routes.js';
|
||||
import { registerApiRoutes } from './api/routes.js';
|
||||
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||
import { createPool } from './data/db.js';
|
||||
import { createRedis } from './data/redis.js';
|
||||
import { registerProcessorRoutes } from './processor/routes.js';
|
||||
import { registerApiRoutes } from './api/routes.js';
|
||||
import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js';
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -27,19 +27,22 @@ async function start() {
|
||||
app.decorate('redis', redis);
|
||||
app.decorate('config', config);
|
||||
|
||||
// Auth
|
||||
registerAuth(app, config.jwtSecret, pool);
|
||||
decorateAuth(app);
|
||||
|
||||
// Health (before auth)
|
||||
// Public routes (no auth)
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' }));
|
||||
app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' }));
|
||||
|
||||
// Auth routes (signup/login)
|
||||
// Auth routes (public - login/signup)
|
||||
registerAuthRoutes(app, config.jwtSecret, pool);
|
||||
|
||||
// Routes
|
||||
await registerProcessorRoutes(app);
|
||||
await registerApiRoutes(app);
|
||||
// Protected routes (auth required)
|
||||
app.register(async function protectedRoutes(protectedApp) {
|
||||
protectedApp.addHook('onRequest', authHook(config.jwtSecret, pool));
|
||||
registerProtectedAuthRoutes(protectedApp, config.jwtSecret, pool);
|
||||
await registerProcessorRoutes(protectedApp);
|
||||
await registerApiRoutes(protectedApp);
|
||||
});
|
||||
|
||||
await app.listen({ port: config.port, host: '0.0.0.0' });
|
||||
app.log.info(`dd0c/drift SaaS listening on :${config.port}`);
|
||||
@@ -51,6 +54,3 @@ start().catch((err) => {
|
||||
});
|
||||
|
||||
export { app };
|
||||
// CI: 2026-03-01T06:52:14Z
|
||||
// CI fix: 06:56
|
||||
// build: 2026-03-01T22:59:34Z
|
||||
|
||||
13
products/03-alert-intelligence/migrations/003_invites.sql
Normal file
13
products/03-alert-intelligence/migrations/003_invites.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email);
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.0",
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/helmet": "^11.1.0",
|
||||
"pg": "^8.12.0",
|
||||
"ioredis": "^5.4.0",
|
||||
|
||||
@@ -4,15 +4,13 @@ import {
|
||||
validateDatadogHmac,
|
||||
validatePagerdutyHmac,
|
||||
validateOpsgenieHmac,
|
||||
normalizeDatadog,
|
||||
normalizePagerduty,
|
||||
normalizeOpsgenie,
|
||||
type CanonicalAlert,
|
||||
} from '../ingestion/webhook.js';
|
||||
import { withTenant } from '../data/db.js';
|
||||
import { redis } from '../data/redis.js';
|
||||
|
||||
const logger = pino({ name: 'api-webhooks' });
|
||||
|
||||
const REDIS_QUEUE = 'dd0c:webhooks:incoming';
|
||||
|
||||
export function registerWebhookRoutes(app: FastifyInstance) {
|
||||
// Datadog webhook
|
||||
app.post('/webhooks/datadog/:tenantSlug', async (req, reply) => {
|
||||
@@ -34,8 +32,12 @@ export function registerWebhookRoutes(app: FastifyInstance) {
|
||||
return reply.status(401).send({ error: hmac.error });
|
||||
}
|
||||
|
||||
const alert = normalizeDatadog(body);
|
||||
await ingestAlert(secret.tenantId, alert);
|
||||
await redis.lpush(REDIS_QUEUE, JSON.stringify({
|
||||
provider: 'datadog',
|
||||
tenantId: secret.tenantId,
|
||||
payload: body,
|
||||
receivedAt: Date.now(),
|
||||
}));
|
||||
return reply.status(202).send({ status: 'accepted' });
|
||||
});
|
||||
|
||||
@@ -58,8 +60,12 @@ export function registerWebhookRoutes(app: FastifyInstance) {
|
||||
return reply.status(401).send({ error: hmac.error });
|
||||
}
|
||||
|
||||
const alert = normalizePagerduty(body);
|
||||
await ingestAlert(secret.tenantId, alert);
|
||||
await redis.lpush(REDIS_QUEUE, JSON.stringify({
|
||||
provider: 'pagerduty',
|
||||
tenantId: secret.tenantId,
|
||||
payload: body,
|
||||
receivedAt: Date.now(),
|
||||
}));
|
||||
return reply.status(202).send({ status: 'accepted' });
|
||||
});
|
||||
|
||||
@@ -82,8 +88,12 @@ export function registerWebhookRoutes(app: FastifyInstance) {
|
||||
return reply.status(401).send({ error: hmac.error });
|
||||
}
|
||||
|
||||
const alert = normalizeOpsgenie(body);
|
||||
await ingestAlert(secret.tenantId, alert);
|
||||
await redis.lpush(REDIS_QUEUE, JSON.stringify({
|
||||
provider: 'opsgenie',
|
||||
tenantId: secret.tenantId,
|
||||
payload: body,
|
||||
receivedAt: Date.now(),
|
||||
}));
|
||||
return reply.status(202).send({ status: 'accepted' });
|
||||
});
|
||||
|
||||
@@ -100,41 +110,19 @@ export function registerWebhookRoutes(app: FastifyInstance) {
|
||||
return reply.status(401).send({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const alert = normalizeGrafana(body);
|
||||
await ingestAlert(secret.tenantId, alert);
|
||||
await redis.lpush(REDIS_QUEUE, JSON.stringify({
|
||||
provider: 'grafana',
|
||||
tenantId: secret.tenantId,
|
||||
payload: body,
|
||||
receivedAt: Date.now(),
|
||||
}));
|
||||
return reply.status(202).send({ status: 'accepted' });
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeGrafana(payload: any): CanonicalAlert {
|
||||
const alert = payload.alerts?.[0] ?? payload;
|
||||
return {
|
||||
sourceProvider: 'grafana' as any,
|
||||
sourceId: alert.fingerprint ?? crypto.randomUUID(),
|
||||
fingerprint: alert.fingerprint ?? '',
|
||||
title: alert.labels?.alertname ?? payload.title ?? 'Grafana Alert',
|
||||
severity: mapGrafanaSeverity(alert.labels?.severity),
|
||||
status: alert.status === 'resolved' ? 'resolved' : 'firing',
|
||||
service: alert.labels?.service,
|
||||
environment: alert.labels?.env,
|
||||
tags: alert.labels ?? {},
|
||||
rawPayload: payload,
|
||||
timestamp: alert.startsAt ? new Date(alert.startsAt).getTime() : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapGrafanaSeverity(s: string | undefined): CanonicalAlert['severity'] {
|
||||
switch (s) {
|
||||
case 'critical': return 'critical';
|
||||
case 'warning': return 'high';
|
||||
case 'info': return 'info';
|
||||
default: return 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
async function getWebhookSecret(tenantSlug: string, provider: string): Promise<{ tenantId: string; secret: string } | null> {
|
||||
const { pool } = await import('../data/db.js');
|
||||
const result = await pool.query(
|
||||
const { systemQuery } = await import('../data/db.js');
|
||||
const result = await systemQuery(
|
||||
`SELECT ws.secret, t.id as tenant_id
|
||||
FROM webhook_secrets ws
|
||||
JOIN tenants t ON ws.tenant_id = t.id
|
||||
@@ -144,51 +132,3 @@ async function getWebhookSecret(tenantSlug: string, provider: string): Promise<{
|
||||
if (!result.rows[0]) return null;
|
||||
return { tenantId: result.rows[0].tenant_id, secret: result.rows[0].secret };
|
||||
}
|
||||
|
||||
async function ingestAlert(tenantId: string, alert: CanonicalAlert): Promise<void> {
|
||||
await withTenant(tenantId, async (client) => {
|
||||
// Persist raw alert
|
||||
const alertResult = await client.query(
|
||||
`INSERT INTO alerts (tenant_id, source_provider, source_id, fingerprint, title, severity, status, service, environment, tags, raw_payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
[tenantId, alert.sourceProvider, alert.sourceId, alert.fingerprint, alert.title, alert.severity, alert.status, alert.service, alert.environment, JSON.stringify(alert.tags), JSON.stringify(alert.rawPayload)],
|
||||
);
|
||||
const alertId = alertResult.rows[0].id;
|
||||
|
||||
// Check for existing open incident with same fingerprint
|
||||
const existing = await client.query(
|
||||
`SELECT id, alert_count FROM incidents
|
||||
WHERE fingerprint = $1 AND status IN ('open', 'acknowledged')
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[alert.fingerprint],
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
// Attach to existing incident
|
||||
await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [existing.rows[0].id, alertId]);
|
||||
await client.query(
|
||||
`UPDATE incidents SET alert_count = alert_count + 1, last_alert_at = now() WHERE id = $1`,
|
||||
[existing.rows[0].id],
|
||||
);
|
||||
} else if (alert.status === 'firing') {
|
||||
// Create new incident
|
||||
const incident = await client.query(
|
||||
`INSERT INTO incidents (tenant_id, incident_key, fingerprint, service, title, severity, alert_count, first_alert_at, last_alert_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now())
|
||||
RETURNING id`,
|
||||
[tenantId, `inc_${crypto.randomUUID().slice(0, 8)}`, alert.fingerprint, alert.service ?? 'unknown', alert.title, alert.severity],
|
||||
);
|
||||
await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [incident.rows[0].id, alertId]);
|
||||
}
|
||||
|
||||
// Auto-resolve if alert status is resolved
|
||||
if (alert.status === 'resolved') {
|
||||
await client.query(
|
||||
`UPDATE incidents SET status = 'resolved', resolved_at = now()
|
||||
WHERE fingerprint = $1 AND status IN ('open', 'acknowledged')`,
|
||||
[alert.fingerprint],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,21 +15,11 @@ export interface AuthPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||
* Returns an onRequest hook that validates JWT or API key auth.
|
||||
* No URL matching — only register this hook inside a protected plugin scope.
|
||||
*/
|
||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (req.url === '/health' || req.url === '/version') return;
|
||||
if (req.url.startsWith('/webhooks/')) return;
|
||||
if (req.url.startsWith('/slack/')) return;
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return;
|
||||
|
||||
export function authHook(jwtSecret: string, pool: Pool) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||
}
|
||||
|
||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||
const prefix = apiKey.slice(0, 13);
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||
const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload;
|
||||
(req as any).tenantId = payload.tenantId;
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).userRole = payload.role;
|
||||
@@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
}
|
||||
|
||||
return reply.status(401).send({ error: 'Missing authentication' });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate the Fastify request with auth properties.
|
||||
* Call this once on the root app instance before registering any routes.
|
||||
*/
|
||||
export function decorateAuth(app: FastifyInstance) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
}
|
||||
|
||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||
@@ -123,11 +123,21 @@ const loginSchema = z.object({
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
tenant_name: z.string().min(1).max(100),
|
||||
tenant_name: z.string().min(1).max(100).optional(),
|
||||
invite_token: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.invite_token || data.tenant_name,
|
||||
{ message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] },
|
||||
);
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
||||
});
|
||||
|
||||
/** Public auth routes — login/signup. No auth required. */
|
||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
return { token, expires_in: '24h' };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||
app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = signupSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]);
|
||||
if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' });
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
const tenantId = tenant.rows[0].id;
|
||||
let tenantId: string;
|
||||
let role: string;
|
||||
|
||||
if (body.invite_token) {
|
||||
const invite = await client.query(
|
||||
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`,
|
||||
[body.invite_token],
|
||||
);
|
||||
if (!invite.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invalid invite token' });
|
||||
}
|
||||
const inv = invite.rows[0];
|
||||
if (inv.accepted_at) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite already accepted' });
|
||||
}
|
||||
if (new Date(inv.expires_at) < new Date()) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite expired' });
|
||||
}
|
||||
|
||||
tenantId = inv.tenant_id;
|
||||
role = inv.role;
|
||||
|
||||
await client.query(
|
||||
`UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`,
|
||||
[inv.id],
|
||||
);
|
||||
} else {
|
||||
if (!body.tenant_name) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'tenant_name is required for new signups' });
|
||||
}
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
tenantId = tenant.rows[0].id;
|
||||
role = 'owner';
|
||||
}
|
||||
|
||||
const user = await client.query(
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
[tenantId, body.email, passwordHash],
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[tenantId, body.email, passwordHash, role],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
@@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
tenantId,
|
||||
userId: user.rows[0].id,
|
||||
email: body.email,
|
||||
role: 'owner',
|
||||
role: role as AuthPayload['role'],
|
||||
}, jwtSecret);
|
||||
|
||||
return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' });
|
||||
@@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Protected auth routes — me, api-keys. Must be registered inside an auth-protected plugin scope. */
|
||||
export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
return {
|
||||
tenant_id: (req as any).tenantId,
|
||||
@@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
};
|
||||
});
|
||||
|
||||
// Generate API key
|
||||
app.post('/api/v1/auth/api-keys', async (req, reply) => {
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
@@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
[tenantId, userId, prefix, keyHash],
|
||||
);
|
||||
|
||||
// Return the raw key ONCE — it's never stored or retrievable again
|
||||
return reply.status(201).send({ api_key: rawKey, prefix });
|
||||
});
|
||||
|
||||
// --- Invite endpoints ---
|
||||
|
||||
app.post('/api/v1/auth/invite', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
const body = inviteSchema.parse(req.body);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const result = await pool.query(
|
||||
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING expires_at`,
|
||||
[tenantId, body.email, body.role, token, userId],
|
||||
);
|
||||
|
||||
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/invites', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, role, expires_at, created_at FROM tenant_invites
|
||||
WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { invites: result.rows };
|
||||
});
|
||||
|
||||
app.delete('/api/v1/auth/invites/:id', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`,
|
||||
[id, tenantId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' });
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ const envSchema = z.object({
|
||||
DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_alert'),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ import { config } from '../config/index.js';
|
||||
|
||||
const logger = pino({ name: 'data' });
|
||||
|
||||
export const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
|
||||
/**
|
||||
* RLS tenant isolation wrapper.
|
||||
* Sets `app.tenant_id` for the duration of the callback, then resets.
|
||||
* Prevents connection pool tenant context leakage (BMad must-have).
|
||||
*/
|
||||
export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
@@ -27,3 +26,15 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** System-level queries that intentionally bypass RLS (auth, migrations, health) */
|
||||
export async function systemQuery<T extends pg.QueryResultRow = any>(
|
||||
text: string, params?: any[]
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
/** For auth middleware that needs direct pool access for API key lookups */
|
||||
export function getPoolForAuth(): pg.Pool {
|
||||
return pool;
|
||||
}
|
||||
|
||||
9
products/03-alert-intelligence/src/data/redis.ts
Normal file
9
products/03-alert-intelligence/src/data/redis.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Redis from 'ioredis';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
export const redis = new Redis(config.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
return Math.min(times * 200, 3000);
|
||||
},
|
||||
});
|
||||
@@ -3,12 +3,13 @@ import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import pino from 'pino';
|
||||
import { config } from './config/index.js';
|
||||
import { pool } from './data/db.js';
|
||||
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||
import { getPoolForAuth } from './data/db.js';
|
||||
import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js';
|
||||
import { registerWebhookRoutes } from './api/webhooks.js';
|
||||
import { registerWebhookSecretRoutes } from './api/webhook_secrets.js';
|
||||
import { registerIncidentRoutes } from './api/incidents.js';
|
||||
import { registerNotificationRoutes } from './api/notifications.js';
|
||||
import { startWebhookProcessor } from './workers/webhook-processor.js';
|
||||
|
||||
const logger = pino({ name: 'dd0c-alert', level: config.LOG_LEVEL });
|
||||
|
||||
@@ -17,24 +18,31 @@ const app = Fastify({ logger: true });
|
||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||
await app.register(helmet);
|
||||
|
||||
registerAuth(app, config.JWT_SECRET, pool);
|
||||
const pool = getPoolForAuth();
|
||||
decorateAuth(app);
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-alert' } /* v:c4ec43c */));
|
||||
// Public routes (no auth)
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-alert' }));
|
||||
app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' }));
|
||||
|
||||
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||
registerWebhookRoutes(app);
|
||||
registerWebhookSecretRoutes(app);
|
||||
registerIncidentRoutes(app);
|
||||
registerNotificationRoutes(app);
|
||||
|
||||
// Auth routes (public - login/signup)
|
||||
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||
|
||||
// Protected routes (auth required)
|
||||
app.register(async function protectedRoutes(protectedApp) {
|
||||
protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool));
|
||||
registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool);
|
||||
registerIncidentRoutes(protectedApp);
|
||||
registerNotificationRoutes(protectedApp);
|
||||
registerWebhookSecretRoutes(protectedApp);
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: '0.0.0.0' });
|
||||
logger.info({ port: config.PORT }, 'dd0c/alert started');
|
||||
startWebhookProcessor().catch((err) => logger.error(err, 'Webhook processor crashed'));
|
||||
} catch (err) {
|
||||
logger.fatal(err, 'Failed to start');
|
||||
process.exit(1);
|
||||
}
|
||||
// Build: 2026-03-01T06:43:58Z
|
||||
// Build: Sun Mar 1 06:47:59 UTC 2026
|
||||
// CI fix: 06:56
|
||||
|
||||
149
products/03-alert-intelligence/src/workers/webhook-processor.ts
Normal file
149
products/03-alert-intelligence/src/workers/webhook-processor.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import pino from 'pino';
|
||||
import { redis } from '../data/redis.js';
|
||||
import { withTenant } from '../data/db.js';
|
||||
import {
|
||||
normalizeDatadog,
|
||||
normalizePagerduty,
|
||||
normalizeOpsgenie,
|
||||
type CanonicalAlert,
|
||||
} from '../ingestion/webhook.js';
|
||||
|
||||
const logger = pino({ name: 'webhook-processor' });
|
||||
|
||||
const INCOMING_QUEUE = 'dd0c:webhooks:incoming';
|
||||
const DEAD_LETTER_QUEUE = 'dd0c:webhooks:dead-letter';
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
interface QueuedWebhook {
|
||||
provider: string;
|
||||
tenantId: string;
|
||||
payload: any;
|
||||
receivedAt: number;
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
function normalizeGrafana(payload: any): CanonicalAlert {
|
||||
const alert = payload.alerts?.[0] ?? payload;
|
||||
return {
|
||||
sourceProvider: 'grafana' as any,
|
||||
sourceId: alert.fingerprint ?? crypto.randomUUID(),
|
||||
fingerprint: alert.fingerprint ?? '',
|
||||
title: alert.labels?.alertname ?? payload.title ?? 'Grafana Alert',
|
||||
severity: mapGrafanaSeverity(alert.labels?.severity),
|
||||
status: alert.status === 'resolved' ? 'resolved' : 'firing',
|
||||
service: alert.labels?.service,
|
||||
environment: alert.labels?.env,
|
||||
tags: alert.labels ?? {},
|
||||
rawPayload: payload,
|
||||
timestamp: alert.startsAt ? new Date(alert.startsAt).getTime() : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapGrafanaSeverity(s: string | undefined): CanonicalAlert['severity'] {
|
||||
switch (s) {
|
||||
case 'critical': return 'critical';
|
||||
case 'warning': return 'high';
|
||||
case 'info': return 'info';
|
||||
default: return 'medium';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeByProvider(provider: string, payload: any): CanonicalAlert {
|
||||
switch (provider) {
|
||||
case 'datadog': return normalizeDatadog(payload);
|
||||
case 'pagerduty': return normalizePagerduty(payload);
|
||||
case 'opsgenie': return normalizeOpsgenie(payload);
|
||||
case 'grafana': return normalizeGrafana(payload);
|
||||
default: throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function processWebhook(item: QueuedWebhook): Promise<void> {
|
||||
const alert = normalizeByProvider(item.provider, item.payload);
|
||||
|
||||
await withTenant(item.tenantId, async (client) => {
|
||||
const alertResult = await client.query(
|
||||
`INSERT INTO alerts (tenant_id, source_provider, source_id, fingerprint, title, severity, status, service, environment, tags, raw_payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id`,
|
||||
[item.tenantId, alert.sourceProvider, alert.sourceId, alert.fingerprint, alert.title, alert.severity, alert.status, alert.service, alert.environment, JSON.stringify(alert.tags), JSON.stringify(alert.rawPayload)],
|
||||
);
|
||||
const alertId = alertResult.rows[0].id;
|
||||
|
||||
const existing = await client.query(
|
||||
`SELECT id, alert_count FROM incidents
|
||||
WHERE fingerprint = $1 AND status IN ('open', 'acknowledged')
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[alert.fingerprint],
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [existing.rows[0].id, alertId]);
|
||||
await client.query(
|
||||
`UPDATE incidents SET alert_count = alert_count + 1, last_alert_at = now() WHERE id = $1`,
|
||||
[existing.rows[0].id],
|
||||
);
|
||||
} else if (alert.status === 'firing') {
|
||||
const incident = await client.query(
|
||||
`INSERT INTO incidents (tenant_id, incident_key, fingerprint, service, title, severity, alert_count, first_alert_at, last_alert_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 1, now(), now())
|
||||
RETURNING id`,
|
||||
[item.tenantId, `inc_${crypto.randomUUID().slice(0, 8)}`, alert.fingerprint, alert.service ?? 'unknown', alert.title, alert.severity],
|
||||
);
|
||||
await client.query('UPDATE alerts SET incident_id = $1 WHERE id = $2', [incident.rows[0].id, alertId]);
|
||||
}
|
||||
|
||||
if (alert.status === 'resolved') {
|
||||
await client.query(
|
||||
`UPDATE incidents SET status = 'resolved', resolved_at = now()
|
||||
WHERE fingerprint = $1 AND status IN ('open', 'acknowledged')`,
|
||||
[alert.fingerprint],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let running = false;
|
||||
|
||||
export async function startWebhookProcessor(): Promise<void> {
|
||||
running = true;
|
||||
logger.info('Webhook processor started');
|
||||
|
||||
while (running) {
|
||||
try {
|
||||
const result = await redis.brpop(INCOMING_QUEUE, 5);
|
||||
if (!result) continue;
|
||||
|
||||
const [, raw] = result;
|
||||
let item: QueuedWebhook;
|
||||
try {
|
||||
item = JSON.parse(raw);
|
||||
} catch {
|
||||
logger.error({ raw }, 'Failed to parse queued webhook');
|
||||
await redis.lpush(DEAD_LETTER_QUEUE, raw);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await processWebhook(item);
|
||||
logger.debug({ provider: item.provider, tenantId: item.tenantId }, 'Webhook processed');
|
||||
} catch (err) {
|
||||
const retries = (item.retries ?? 0) + 1;
|
||||
if (retries >= MAX_RETRIES) {
|
||||
logger.error({ err, item }, 'Webhook processing failed, moving to dead-letter queue');
|
||||
await redis.lpush(DEAD_LETTER_QUEUE, JSON.stringify({ ...item, retries, error: String(err) }));
|
||||
} else {
|
||||
logger.warn({ err, retries }, 'Webhook processing failed, retrying');
|
||||
await redis.lpush(INCOMING_QUEUE, JSON.stringify({ ...item, retries }));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Webhook processor loop error');
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function stopWebhookProcessor(): void {
|
||||
running = false;
|
||||
}
|
||||
13
products/04-lightweight-idp/migrations/003_invites.sql
Normal file
13
products/04-lightweight-idp/migrations/003_invites.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email);
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.0",
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/helmet": "^11.1.0",
|
||||
"pg": "^8.12.0",
|
||||
"ioredis": "^5.4.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import pino from 'pino';
|
||||
import { Redis } from 'ioredis';
|
||||
import { withTenant, pool } from '../data/db.js';
|
||||
import { withTenant, getPoolForAuth } from '../data/db.js';
|
||||
import { config } from '../config/index.js';
|
||||
import { AwsDiscoveryScanner } from '../discovery/aws-scanner.js';
|
||||
import { GitHubDiscoveryScanner } from '../discovery/github-scanner.js';
|
||||
@@ -10,6 +10,7 @@ import { ScheduledDiscovery } from '../discovery/scheduler.js';
|
||||
|
||||
const logger = pino({ name: 'api-discovery' });
|
||||
const redis = new Redis(config.REDIS_URL);
|
||||
const pool = getPoolForAuth();
|
||||
const scheduler = new ScheduledDiscovery(redis, pool);
|
||||
const catalog = new CatalogService(pool);
|
||||
|
||||
|
||||
@@ -15,21 +15,11 @@ export interface AuthPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||
* Returns an onRequest hook that validates JWT or API key auth.
|
||||
* No URL matching — only register this hook inside a protected plugin scope.
|
||||
*/
|
||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (req.url === '/health' || req.url === '/version') return;
|
||||
if (req.url.startsWith('/webhooks/')) return;
|
||||
if (req.url.startsWith('/slack/')) return;
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return;
|
||||
|
||||
export function authHook(jwtSecret: string, pool: Pool) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||
}
|
||||
|
||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||
const prefix = apiKey.slice(0, 13);
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||
const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload;
|
||||
(req as any).tenantId = payload.tenantId;
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).userRole = payload.role;
|
||||
@@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
}
|
||||
|
||||
return reply.status(401).send({ error: 'Missing authentication' });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate the Fastify request with auth properties.
|
||||
* Call this once on the root app instance before registering any routes.
|
||||
*/
|
||||
export function decorateAuth(app: FastifyInstance) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
}
|
||||
|
||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||
@@ -123,11 +123,21 @@ const loginSchema = z.object({
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
tenant_name: z.string().min(1).max(100),
|
||||
tenant_name: z.string().min(1).max(100).optional(),
|
||||
invite_token: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.invite_token || data.tenant_name,
|
||||
{ message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] },
|
||||
);
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
||||
});
|
||||
|
||||
/** Public auth routes — login/signup. No auth required. */
|
||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -152,29 +162,65 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
return { token, expires_in: '24h' };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||
app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = signupSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]);
|
||||
if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' });
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
const tenantId = tenant.rows[0].id;
|
||||
let tenantId: string;
|
||||
let role: string;
|
||||
|
||||
if (body.invite_token) {
|
||||
const invite = await client.query(
|
||||
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`,
|
||||
[body.invite_token],
|
||||
);
|
||||
if (!invite.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invalid invite token' });
|
||||
}
|
||||
const inv = invite.rows[0];
|
||||
if (inv.accepted_at) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite already accepted' });
|
||||
}
|
||||
if (new Date(inv.expires_at) < new Date()) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite expired' });
|
||||
}
|
||||
|
||||
tenantId = inv.tenant_id;
|
||||
role = inv.role;
|
||||
|
||||
await client.query(
|
||||
`UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`,
|
||||
[inv.id],
|
||||
);
|
||||
} else {
|
||||
const tenantName = body.tenant_name;
|
||||
if (!tenantName) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'tenant_name is required for new signups' });
|
||||
}
|
||||
const slug = tenantName.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[tenantName, slug],
|
||||
);
|
||||
tenantId = tenant.rows[0].id;
|
||||
role = 'owner';
|
||||
}
|
||||
|
||||
const user = await client.query(
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
[tenantId, body.email, passwordHash],
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[tenantId, body.email, passwordHash, role],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
@@ -183,7 +229,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
tenantId,
|
||||
userId: user.rows[0].id,
|
||||
email: body.email,
|
||||
role: 'owner',
|
||||
role: role as AuthPayload['role'],
|
||||
}, jwtSecret);
|
||||
|
||||
return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' });
|
||||
@@ -194,7 +240,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */
|
||||
export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
return {
|
||||
tenant_id: (req as any).tenantId,
|
||||
@@ -203,7 +252,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
};
|
||||
});
|
||||
|
||||
// Generate API key
|
||||
app.post('/api/v1/auth/api-keys', async (req, reply) => {
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
@@ -219,7 +267,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
[tenantId, userId, prefix, keyHash],
|
||||
);
|
||||
|
||||
// Return the raw key ONCE — it's never stored or retrievable again
|
||||
return reply.status(201).send({ api_key: rawKey, prefix });
|
||||
});
|
||||
|
||||
// --- Invite endpoints ---
|
||||
|
||||
app.post('/api/v1/auth/invite', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
const body = inviteSchema.parse(req.body);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const result = await pool.query(
|
||||
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING expires_at`,
|
||||
[tenantId, body.email, body.role, token, userId],
|
||||
);
|
||||
|
||||
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/invites', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, role, expires_at, created_at FROM tenant_invites
|
||||
WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { invites: result.rows };
|
||||
});
|
||||
|
||||
app.delete('/api/v1/auth/invites/:id', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`,
|
||||
[id, tenantId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' });
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const envSchema = z.object({
|
||||
MEILI_URL: z.string().default('http://localhost:7700'),
|
||||
MEILI_KEY: z.string().default(''),
|
||||
JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import { config } from '../config/index.js';
|
||||
|
||||
const logger = pino({ name: 'data' });
|
||||
|
||||
export const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
|
||||
/**
|
||||
* RLS tenant isolation wrapper.
|
||||
* Sets `app.tenant_id` for the duration of the callback, then resets.
|
||||
*/
|
||||
export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -22,3 +26,15 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** System-level queries that intentionally bypass RLS (auth, migrations, health) */
|
||||
export async function systemQuery<T extends pg.QueryResultRow = any>(
|
||||
text: string, params?: any[]
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
/** For auth middleware that needs direct pool access for API key lookups */
|
||||
export function getPoolForAuth(): pg.Pool {
|
||||
return pool;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import pino from 'pino';
|
||||
import { config } from './config/index.js';
|
||||
import { pool } from './data/db.js';
|
||||
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||
import { getPoolForAuth } from './data/db.js';
|
||||
import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js';
|
||||
import { registerServiceRoutes } from './api/services.js';
|
||||
import { registerDiscoveryRoutes } from './api/discovery.js';
|
||||
import { registerSearchRoutes } from './api/search.js';
|
||||
@@ -16,15 +16,24 @@ const app = Fastify({ logger: true });
|
||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||
await app.register(helmet);
|
||||
|
||||
registerAuth(app, config.JWT_SECRET, pool);
|
||||
const pool = getPoolForAuth();
|
||||
decorateAuth(app);
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' } /* v:c4ec43c */));
|
||||
// Public routes (no auth)
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-portal' }));
|
||||
app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' }));
|
||||
|
||||
// Auth routes (public - login/signup)
|
||||
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||
registerServiceRoutes(app);
|
||||
registerDiscoveryRoutes(app);
|
||||
registerSearchRoutes(app);
|
||||
|
||||
// Protected routes (auth required)
|
||||
app.register(async function protectedRoutes(protectedApp) {
|
||||
protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool));
|
||||
registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool);
|
||||
registerServiceRoutes(protectedApp);
|
||||
registerDiscoveryRoutes(protectedApp);
|
||||
registerSearchRoutes(protectedApp);
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: '0.0.0.0' });
|
||||
@@ -33,6 +42,3 @@ try {
|
||||
logger.fatal(err, 'Failed to start');
|
||||
process.exit(1);
|
||||
}
|
||||
// Build: 2026-03-01T06:43:58Z
|
||||
// CI: 2026-03-01T06:52:14Z
|
||||
// CI fix: 06:56
|
||||
|
||||
13
products/05-aws-cost-anomaly/migrations/003_invites.sql
Normal file
13
products/05-aws-cost-anomaly/migrations/003_invites.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email);
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.0",
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"pg": "^8.12.0",
|
||||
"ioredis": "^5.4.0",
|
||||
"zod": "^3.23.0",
|
||||
|
||||
@@ -15,21 +15,11 @@ export interface AuthPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||
* Returns an onRequest hook that validates JWT or API key auth.
|
||||
* No URL matching — only register this hook inside a protected plugin scope.
|
||||
*/
|
||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (req.url === '/health' || req.url === '/version') return;
|
||||
if (req.url.startsWith('/webhooks/')) return;
|
||||
if (req.url.startsWith('/slack/')) return;
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return;
|
||||
|
||||
export function authHook(jwtSecret: string, pool: Pool) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||
}
|
||||
|
||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||
const prefix = apiKey.slice(0, 13);
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||
const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload;
|
||||
(req as any).tenantId = payload.tenantId;
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).userRole = payload.role;
|
||||
@@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
}
|
||||
|
||||
return reply.status(401).send({ error: 'Missing authentication' });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate the Fastify request with auth properties.
|
||||
* Call this once on the root app instance before registering any routes.
|
||||
*/
|
||||
export function decorateAuth(app: FastifyInstance) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
}
|
||||
|
||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||
@@ -123,11 +123,21 @@ const loginSchema = z.object({
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
tenant_name: z.string().min(1).max(100),
|
||||
tenant_name: z.string().min(1).max(100).optional(),
|
||||
invite_token: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.invite_token || data.tenant_name,
|
||||
{ message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] },
|
||||
);
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
||||
});
|
||||
|
||||
/** Public auth routes — login/signup. No auth required. */
|
||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
return { token, expires_in: '24h' };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||
app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = signupSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]);
|
||||
if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' });
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
const tenantId = tenant.rows[0].id;
|
||||
let tenantId: string;
|
||||
let role: string;
|
||||
|
||||
if (body.invite_token) {
|
||||
const invite = await client.query(
|
||||
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`,
|
||||
[body.invite_token],
|
||||
);
|
||||
if (!invite.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invalid invite token' });
|
||||
}
|
||||
const inv = invite.rows[0];
|
||||
if (inv.accepted_at) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite already accepted' });
|
||||
}
|
||||
if (new Date(inv.expires_at) < new Date()) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite expired' });
|
||||
}
|
||||
|
||||
tenantId = inv.tenant_id;
|
||||
role = inv.role;
|
||||
|
||||
await client.query(
|
||||
`UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`,
|
||||
[inv.id],
|
||||
);
|
||||
} else {
|
||||
if (!body.tenant_name) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'tenant_name is required for new signups' });
|
||||
}
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
tenantId = tenant.rows[0].id;
|
||||
role = 'owner';
|
||||
}
|
||||
|
||||
const user = await client.query(
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
[tenantId, body.email, passwordHash],
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[tenantId, body.email, passwordHash, role],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
@@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
tenantId,
|
||||
userId: user.rows[0].id,
|
||||
email: body.email,
|
||||
role: 'owner',
|
||||
role: role as AuthPayload['role'],
|
||||
}, jwtSecret);
|
||||
|
||||
return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' });
|
||||
@@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */
|
||||
export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
return {
|
||||
tenant_id: (req as any).tenantId,
|
||||
@@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
};
|
||||
});
|
||||
|
||||
// Generate API key
|
||||
app.post('/api/v1/auth/api-keys', async (req, reply) => {
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
@@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
[tenantId, userId, prefix, keyHash],
|
||||
);
|
||||
|
||||
// Return the raw key ONCE — it's never stored or retrievable again
|
||||
return reply.status(201).send({ api_key: rawKey, prefix });
|
||||
});
|
||||
|
||||
// --- Invite endpoints ---
|
||||
|
||||
app.post('/api/v1/auth/invite', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
const body = inviteSchema.parse(req.body);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const result = await pool.query(
|
||||
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING expires_at`,
|
||||
[tenantId, body.email, body.role, token, userId],
|
||||
);
|
||||
|
||||
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/invites', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, role, expires_at, created_at FROM tenant_invites
|
||||
WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { invites: result.rows };
|
||||
});
|
||||
|
||||
app.delete('/api/v1/auth/invites/:id', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`,
|
||||
[id, tenantId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' });
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ const envSchema = z.object({
|
||||
DATABASE_URL: z.string().default('postgresql://localhost:5432/dd0c_cost'),
|
||||
REDIS_URL: z.string().default('redis://localhost:6379'),
|
||||
JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
ANOMALY_THRESHOLD: z.coerce.number().default(50),
|
||||
});
|
||||
|
||||
@@ -4,8 +4,12 @@ import { config } from '../config/index.js';
|
||||
|
||||
const logger = pino({ name: 'data' });
|
||||
|
||||
export const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
|
||||
/**
|
||||
* RLS tenant isolation wrapper.
|
||||
* Sets `app.tenant_id` for the duration of the callback, then resets.
|
||||
*/
|
||||
export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -22,3 +26,15 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** System-level queries that intentionally bypass RLS (auth, migrations, health) */
|
||||
export async function systemQuery<T extends pg.QueryResultRow = any>(
|
||||
text: string, params?: any[]
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
/** For auth middleware that needs direct pool access for API key lookups */
|
||||
export function getPoolForAuth(): pg.Pool {
|
||||
return pool;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import pino from 'pino';
|
||||
import { config } from './config/index.js';
|
||||
import { pool } from './data/db.js';
|
||||
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||
import { getPoolForAuth } from './data/db.js';
|
||||
import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js';
|
||||
import { registerAnomalyRoutes } from './api/anomalies.js';
|
||||
import { registerBaselineRoutes } from './api/baselines.js';
|
||||
import { registerGovernanceRoutes } from './api/governance.js';
|
||||
@@ -15,16 +15,25 @@ const app = Fastify({ logger: true });
|
||||
|
||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||
|
||||
registerAuth(app, config.JWT_SECRET, pool);
|
||||
const pool = getPoolForAuth();
|
||||
decorateAuth(app);
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-cost' } /* v:c4ec43c */));
|
||||
// Public routes (no auth)
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-cost' }));
|
||||
app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' }));
|
||||
|
||||
// Auth routes (public - login/signup)
|
||||
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||
registerIngestionRoutes(app);
|
||||
registerAnomalyRoutes(app);
|
||||
registerBaselineRoutes(app);
|
||||
registerGovernanceRoutes(app);
|
||||
|
||||
// Protected routes (auth required)
|
||||
app.register(async function protectedRoutes(protectedApp) {
|
||||
protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool));
|
||||
registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool);
|
||||
registerIngestionRoutes(protectedApp);
|
||||
registerAnomalyRoutes(protectedApp);
|
||||
registerBaselineRoutes(protectedApp);
|
||||
registerGovernanceRoutes(protectedApp);
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: '0.0.0.0' });
|
||||
@@ -33,6 +42,3 @@ try {
|
||||
logger.fatal(err, 'Failed to start');
|
||||
process.exit(1);
|
||||
}
|
||||
// Build: 2026-03-01T06:43:58Z
|
||||
// CI: 2026-03-01T06:52:14Z
|
||||
// CI fix: 06:56
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email);
|
||||
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"fastify": "^4.28.0",
|
||||
"@fastify/cors": "^9.0.0",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@fastify/helmet": "^11.1.0",
|
||||
"@fastify/websocket": "^10.0.0",
|
||||
"pg": "^8.12.0",
|
||||
|
||||
@@ -15,21 +15,11 @@ export interface AuthPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||
* Returns an onRequest hook that validates JWT or API key auth.
|
||||
* No URL matching — only register this hook inside a protected plugin scope.
|
||||
*/
|
||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (req.url === '/health' || req.url === '/version') return;
|
||||
if (req.url.startsWith('/webhooks/')) return;
|
||||
if (req.url.startsWith('/slack/')) return;
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return;
|
||||
|
||||
export function authHook(jwtSecret: string, pool: Pool) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||
}
|
||||
|
||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||
const prefix = apiKey.slice(0, 13);
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||
const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload;
|
||||
(req as any).tenantId = payload.tenantId;
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).userRole = payload.role;
|
||||
@@ -72,7 +62,17 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
}
|
||||
|
||||
return reply.status(401).send({ error: 'Missing authentication' });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorate the Fastify request with auth properties.
|
||||
* Call this once on the root app instance before registering any routes.
|
||||
*/
|
||||
export function decorateAuth(app: FastifyInstance) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
}
|
||||
|
||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||
@@ -123,11 +123,21 @@ const loginSchema = z.object({
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
tenant_name: z.string().min(1).max(100),
|
||||
tenant_name: z.string().min(1).max(100).optional(),
|
||||
invite_token: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.invite_token || data.tenant_name,
|
||||
{ message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] },
|
||||
);
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
||||
});
|
||||
|
||||
/** Public auth routes — login/signup. No auth required. */
|
||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -152,29 +162,64 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
return { token, expires_in: '24h' };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||
app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = signupSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]);
|
||||
if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' });
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
const tenantId = tenant.rows[0].id;
|
||||
let tenantId: string;
|
||||
let role: string;
|
||||
|
||||
if (body.invite_token) {
|
||||
const invite = await client.query(
|
||||
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`,
|
||||
[body.invite_token],
|
||||
);
|
||||
if (!invite.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invalid invite token' });
|
||||
}
|
||||
const inv = invite.rows[0];
|
||||
if (inv.accepted_at) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite already accepted' });
|
||||
}
|
||||
if (new Date(inv.expires_at) < new Date()) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite expired' });
|
||||
}
|
||||
|
||||
tenantId = inv.tenant_id;
|
||||
role = inv.role;
|
||||
|
||||
await client.query(
|
||||
`UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`,
|
||||
[inv.id],
|
||||
);
|
||||
} else {
|
||||
if (!body.tenant_name) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'tenant_name is required for new signups' });
|
||||
}
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
tenantId = tenant.rows[0].id;
|
||||
role = 'owner';
|
||||
}
|
||||
|
||||
const user = await client.query(
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
[tenantId, body.email, passwordHash],
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[tenantId, body.email, passwordHash, role],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
@@ -183,7 +228,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
tenantId,
|
||||
userId: user.rows[0].id,
|
||||
email: body.email,
|
||||
role: 'owner',
|
||||
role: role as AuthPayload['role'],
|
||||
}, jwtSecret);
|
||||
|
||||
return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' });
|
||||
@@ -194,7 +239,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */
|
||||
export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
return {
|
||||
tenant_id: (req as any).tenantId,
|
||||
@@ -203,7 +251,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
};
|
||||
});
|
||||
|
||||
// Generate API key
|
||||
app.post('/api/v1/auth/api-keys', async (req, reply) => {
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
@@ -219,7 +266,53 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
[tenantId, userId, prefix, keyHash],
|
||||
);
|
||||
|
||||
// Return the raw key ONCE — it's never stored or retrievable again
|
||||
return reply.status(201).send({ api_key: rawKey, prefix });
|
||||
});
|
||||
|
||||
// --- Invite endpoints ---
|
||||
|
||||
app.post('/api/v1/auth/invite', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
const body = inviteSchema.parse(req.body);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const result = await pool.query(
|
||||
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING expires_at`,
|
||||
[tenantId, body.email, body.role, token, userId],
|
||||
);
|
||||
|
||||
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/invites', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, role, expires_at, created_at FROM tenant_invites
|
||||
WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { invites: result.rows };
|
||||
});
|
||||
|
||||
app.delete('/api/v1/auth/invites/:id', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`,
|
||||
[id, tenantId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' });
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const envSchema = z.object({
|
||||
JWT_SECRET: z.string().min(32).default('dev-secret-change-me-in-production!!'),
|
||||
SLACK_BOT_TOKEN: z.string().optional(),
|
||||
SLACK_SIGNING_SECRET: z.string().optional(),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
CORS_ORIGIN: z.string().default('http://localhost:5173'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,12 @@ import { config } from '../config/index.js';
|
||||
|
||||
const logger = pino({ name: 'data' });
|
||||
|
||||
export const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
const pool = new pg.Pool({ connectionString: config.DATABASE_URL });
|
||||
|
||||
/**
|
||||
* RLS tenant isolation wrapper.
|
||||
* Sets `app.tenant_id` for the duration of the callback, then resets.
|
||||
*/
|
||||
export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -22,3 +26,15 @@ export async function withTenant<T>(tenantId: string, fn: (client: pg.PoolClient
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** System-level queries that intentionally bypass RLS (auth, migrations, health) */
|
||||
export async function systemQuery<T extends pg.QueryResultRow = any>(
|
||||
text: string, params?: any[]
|
||||
): Promise<pg.QueryResult<T>> {
|
||||
return pool.query(text, params);
|
||||
}
|
||||
|
||||
/** For auth middleware that needs direct pool access for API key lookups */
|
||||
export function getPoolForAuth(): pg.Pool {
|
||||
return pool;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import cors from '@fastify/cors';
|
||||
import helmet from '@fastify/helmet';
|
||||
import pino from 'pino';
|
||||
import { config } from './config/index.js';
|
||||
import { pool } from './data/db.js';
|
||||
import { registerAuth, registerAuthRoutes } from './auth/middleware.js';
|
||||
import { getPoolForAuth } from './data/db.js';
|
||||
import { authHook, decorateAuth, registerAuthRoutes, registerProtectedAuthRoutes } from './auth/middleware.js';
|
||||
import { registerRunbookRoutes } from './api/runbooks.js';
|
||||
import { registerApprovalRoutes } from './api/approvals.js';
|
||||
import { registerSlackRoutes } from './slackbot/handler.js';
|
||||
@@ -16,16 +16,25 @@ const app = Fastify({ logger: true });
|
||||
await app.register(cors, { origin: config.CORS_ORIGIN });
|
||||
await app.register(helmet);
|
||||
|
||||
registerAuth(app, config.JWT_SECRET, pool);
|
||||
const pool = getPoolForAuth();
|
||||
decorateAuth(app);
|
||||
|
||||
// Public routes (no auth)
|
||||
app.get('/health', async () => ({ status: 'ok', service: 'dd0c-run' }));
|
||||
app.get('/version', async () => ({ version: process.env.BUILD_SHA || 'dev', built: process.env.BUILD_TIME || 'unknown' }));
|
||||
|
||||
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||
registerRunbookRoutes(app);
|
||||
registerApprovalRoutes(app);
|
||||
registerSlackRoutes(app);
|
||||
|
||||
// Auth routes (public - login/signup)
|
||||
registerAuthRoutes(app, config.JWT_SECRET, pool);
|
||||
|
||||
// Protected routes (auth required)
|
||||
app.register(async function protectedRoutes(protectedApp) {
|
||||
protectedApp.addHook('onRequest', authHook(config.JWT_SECRET, pool));
|
||||
registerProtectedAuthRoutes(protectedApp, config.JWT_SECRET, pool);
|
||||
registerRunbookRoutes(protectedApp);
|
||||
registerApprovalRoutes(protectedApp);
|
||||
});
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.PORT, host: '0.0.0.0' });
|
||||
logger.info({ port: config.PORT }, 'dd0c/run SaaS started');
|
||||
@@ -33,6 +42,3 @@ try {
|
||||
logger.fatal(err, 'Failed to start');
|
||||
process.exit(1);
|
||||
}
|
||||
// Build: 2026-03-01T06:43:58Z
|
||||
// CI: 2026-03-01T06:52:14Z
|
||||
// CI fix: 06:56
|
||||
|
||||
@@ -48,8 +48,8 @@ export function registerSlackRoutes(app: FastifyInstance) {
|
||||
|
||||
if (actionType === 'approve_step' && stepId) {
|
||||
// Look up the audit entry to get tenant + execution context
|
||||
const { pool } = await import('../data/db.js');
|
||||
const entry = await pool.query(
|
||||
const { systemQuery } = await import('../data/db.js');
|
||||
const entry = await systemQuery(
|
||||
`SELECT ae.id, ae.execution_id, e.tenant_id
|
||||
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
|
||||
WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`,
|
||||
@@ -71,8 +71,8 @@ export function registerSlackRoutes(app: FastifyInstance) {
|
||||
logger.info({ stepId, user: slackUserId }, 'Step approved via Slack');
|
||||
}
|
||||
} else if (actionType === 'reject_step' && stepId) {
|
||||
const { pool } = await import('../data/db.js');
|
||||
const entry = await pool.query(
|
||||
const { systemQuery } = await import('../data/db.js');
|
||||
const entry = await systemQuery(
|
||||
`SELECT ae.id, ae.execution_id, e.tenant_id
|
||||
FROM audit_entries ae JOIN executions e ON ae.execution_id = e.id
|
||||
WHERE ae.id = $1 AND ae.status = 'awaiting_approval'`,
|
||||
|
||||
97
products/SECURITY-ARCHITECTURE-PLAN.md
Normal file
97
products/SECURITY-ARCHITECTURE-PLAN.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# ARCHITECTURE SPEC & IMPLEMENTATION PLAN
|
||||
**Project:** dd0c DevOps SaaS Platform
|
||||
**Author:** Dr. Quinn, BMad Master Problem Solver
|
||||
**Date:** 2026-03-02
|
||||
|
||||
## 1. ROOT CAUSE ANALYSIS
|
||||
|
||||
We have systematically analyzed the 10 adversarial review findings and grouped them by their structural root causes.
|
||||
|
||||
### Group A: Middleware & Request Pipeline Integrity (Issues 1, 7, 8, 9, 10)
|
||||
* **Root Cause:** The authentication middleware is implemented as a global `app.addHook('onRequest')` using brittle string manipulation (`.startsWith()`) to bypass public routes. Fastify’s strength lies in its plugin encapsulation model, which is completely bypassed here. Furthermore, foundational web security controls (CORS scoping, Rate Limiting, Request Validation via schema) are missing, and JWT verification lacks algorithm strictness, opening the door to algorithm confusion.
|
||||
|
||||
### Group B: Tenant Isolation & Data Security (Issues 2, 3)
|
||||
* **Root Cause:** The system's multi-tenancy model is incomplete.
|
||||
* **Data Leakage Risk:** The Postgres `pool` object is exported directly from `db.ts`. Even though `withTenant()` exists to enforce Row-Level Security (RLS) via `SET LOCAL`, exporting `pool` means developers can inadvertently bypass RLS by calling `pool.query()` directly.
|
||||
* **Product Gap:** The data model strictly creates a 1:1 mapping between a new signup and a new tenant. There is no relational entity (e.g., `tenant_invites`) to securely map a new user to an existing tenant, breaking the "built for teams" promise.
|
||||
|
||||
### Group C: Infrastructure Configuration Secrets (Issues 5, 6)
|
||||
* **Root Cause:** Infrastructure-as-Code (Docker Compose) is being used as a secrets manager. All 5 services authenticate to Postgres using the root `dd0c` superuser, and secrets (`JWT_SECRET`, passwords) are hardcoded into the compose YAML. If one service is compromised (e.g., via SQLi), the attacker gains root access to all databases.
|
||||
|
||||
### Group D: Architectural Reliability Constraints (Issue 4)
|
||||
* **Root Cause:** Webhooks operate synchronously on containers configured to scale-to-zero. External providers (PagerDuty, Datadog) have strict timeout thresholds (typically 5-10s). Fly.io container cold-starts often exceed these thresholds, causing providers to drop payloads before the container can awaken and process the request.
|
||||
|
||||
---
|
||||
|
||||
## 2. ARCHITECTURE DECISIONS
|
||||
|
||||
Considering the constraints of a solo founder, a current NAS deployment with a path to Fly.io, and the need to preserve existing tests, we will adopt the following architectural standards:
|
||||
|
||||
1. **Fastify Plugin Encapsulation (Fixes #1, #10):** We will stop using global hooks. Public routes (health, webhooks) will be registered on the main `app` instance. Authenticated routes will be registered inside an encapsulated Fastify plugin where the auth hook is applied safely without string checking. We will use `@fastify/type-provider-zod` for built-in, strict request schema validation.
|
||||
2. **Strict Data Access Module (Fixes #3):** The `db.ts` file will no longer export the raw `pool`. It will export a `db` object containing exactly two methods: `withTenant<T>(tenantId, callback)` for business logic, and `systemQuery<T>(query)` explicitly marked and audited for system-level background tasks.
|
||||
3. **Config-Driven Secrets & Least Privilege DB Roles (Fixes #5, #6):** `docker-compose.yml` will be scrubbed of secrets. Secrets will be loaded via a `.env` file. We will update `01-init-db.sh` to create service-specific PostgreSQL users with access *only* to their respective databases.
|
||||
4. **Invite-Based Onboarding (Fixes #2):** We will introduce a `tenant_invites` table. The signup flow will be modified: if a user signs up with a valid invite token, they bypass tenant creation and are appended to the existing tenant with the defined role.
|
||||
5. **Decoupled Webhook Ingestion (Fixes #4):** To support scale-to-zero without losing webhooks, we will leverage the already-running Upstash Redis instance. Webhooks will hit a lightweight, non-scaling ingestion function (or the Rust `route-proxy` which is always on) that simply `LPUSH`es payloads to Redis. The main Node services can safely wake up, `BRPOP` from the queue, and process asynchronously.
|
||||
|
||||
---
|
||||
|
||||
## 3. IMPLEMENTATION PLAN
|
||||
|
||||
This plan is phased. Existing tests MUST be run (`./integration-test.sh`) after each phase.
|
||||
|
||||
### Phase 1: Security Critical (Do this before production)
|
||||
|
||||
**Task 1.1: Fastify Auth Encapsulation**
|
||||
* **Change:** Modify `shared/auth.ts` and `03-alert-intelligence/src/auth/middleware.ts`. Remove `app.addHook('onRequest')`. Export a Fastify plugin `export const requireAuth = fp(async (fastify) => { fastify.addHook(...) })`.
|
||||
* **Change:** In `index.ts`, register public routes first. Then register the `requireAuth` plugin, then register protected routes.
|
||||
* **Effort:** Medium
|
||||
* **Risk if deferred:** High (Auth bypass easily discoverable by automated scanners).
|
||||
|
||||
**Task 1.2: Hardened JWT Validation**
|
||||
* **Change:** In `auth/middleware.ts`, update `jwt.verify(token, jwtSecret)` to `jwt.verify(token, jwtSecret, { algorithms: ['HS256'] })`.
|
||||
* **Effort:** Small
|
||||
|
||||
**Task 1.3: Restrict Database Pool Access**
|
||||
* **Change:** In `saas/src/data/db.ts` (and shared equivalents), remove `export const pool`. Wrap queries in a strictly exported `systemQuery` function, and enforce `withTenant` for the rest. Update all services relying on `pool.query()` to use the new paradigm.
|
||||
* **Effort:** Medium
|
||||
* **Risk if deferred:** Critical (Any developer mistake results in massive cross-tenant data leakage).
|
||||
|
||||
### Phase 2: Architecture (Structural Readiness)
|
||||
|
||||
**Task 2.1: Secrets Management & DB Roles**
|
||||
* **Change:** Update `docker-compose.yml` to use variable substitution (e.g., `${POSTGRES_PASSWORD}`). Provide an `.env.example`.
|
||||
* **Change:** Update `docker-init-db.sh` to execute `CREATE USER dd0c_alert WITH PASSWORD '...'; GRANT ALL ON DATABASE dd0c_alert TO dd0c_alert;`. Update services to use their designated credentials.
|
||||
* **Effort:** Medium
|
||||
* **Dependencies:** None.
|
||||
|
||||
**Task 2.2: Rate Limiting, CORS, and Zod Integration**
|
||||
* **Change:** Install `@fastify/rate-limit`. Apply a strict limit (e.g., 5 requests/min) to `/api/v1/auth/*`.
|
||||
* **Change:** In `config/index.ts`, enforce `CORS_ORIGIN` using Zod regex (e.g., `^https?://.*\.dd0c\.localhost$`).
|
||||
* **Change:** Integrate `@fastify/type-provider-zod` into route definitions to reject bad payloads at the Fastify schema level.
|
||||
* **Effort:** Medium
|
||||
|
||||
### Phase 3: Product (Feature Blockers)
|
||||
|
||||
**Task 3.1: Team Invite Flow**
|
||||
* **Change:** Create a new migration for `tenant_invites (id, tenant_id, email, token, role, expires_at)`.
|
||||
* **Change:** Add `POST /api/v1/auth/invite` (generates token) and update `POST /api/v1/auth/signup` to accept an optional `invite_token`.
|
||||
* **Effort:** Large
|
||||
* **Dependencies:** Database migrations.
|
||||
|
||||
**Task 3.2: Async Webhook Ingestion**
|
||||
* **Change:** Shift webhook endpoints to simply validate signatures and `LPUSH` the raw payload to Redis.
|
||||
* **Change:** Create a background worker loop in the Node service that uses `BRPOP` to pull and process webhooks. (Alternatively, route webhooks through the constantly-running Rust proxy).
|
||||
* **Effort:** Large
|
||||
* **Risk if deferred:** Medium (External webhook timeouts on Fly.io scale-to-zero).
|
||||
|
||||
---
|
||||
|
||||
## 4. TESTING STRATEGY
|
||||
|
||||
To verify fixes without breaking the 27 integration tests:
|
||||
|
||||
1. **Auth Bypass:** Write a new test in the smoke suite that attempts to hit `/api/v1/auth/login-hack` and `/webhooks/../api/protected`. Expect `404 Not Found` or `401 Unauthorized`.
|
||||
2. **RLS Protection:** After restricting `pool`, run `./integration-test.sh`. Any query that was improperly bypassing `withTenant` will cause TypeScript compilation to fail (since `pool.query` is no longer available), ensuring safe refactoring.
|
||||
3. **DB Roles:** Spin up a clean docker-compose environment. Use a Postgres client to verify that `dd0c_alert` user cannot run `SELECT * FROM dd0c_route.users`.
|
||||
4. **Webhooks:** Simulate a Fly.io cold start by pausing the `dd0c-alert` container, firing a webhook to the ingestion endpoint, and verifying the payload is queued in Redis and processed upon container resume.
|
||||
5. **Invite Flow:** Add a multi-user flow to `integration-test.sh` asserting User B can be invited by User A and both can query the same `tenant_id` records.
|
||||
@@ -43,7 +43,7 @@ else
|
||||
fi
|
||||
|
||||
# Login to registry
|
||||
echo "secret" | docker login "$REGISTRY" --username dd0c --password-stdin 2>/dev/null || true
|
||||
echo "${REGISTRY_PASSWORD:-secret}" | docker login "$REGISTRY" --username dd0c --password-stdin 2>/dev/null || true
|
||||
|
||||
echo -e "${YELLOW}dd0c Build & Push — $(date -u '+%Y-%m-%d %H:%M UTC')${NC}"
|
||||
echo -e "Registry: ${REGISTRY}\n"
|
||||
|
||||
205
products/console/src/modules/alert/AlertDashboard.tsx
Normal file
205
products/console/src/modules/alert/AlertDashboard.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import {
|
||||
fetchIncidentSummary,
|
||||
fetchIncidents,
|
||||
type Incident,
|
||||
type IncidentSummary,
|
||||
type Severity,
|
||||
type IncidentStatus,
|
||||
} from './api';
|
||||
|
||||
const severityColor: Record<Severity, 'red' | 'yellow' | 'blue' | 'cyan'> = {
|
||||
critical: 'red',
|
||||
high: 'yellow',
|
||||
warning: 'yellow',
|
||||
info: 'blue',
|
||||
};
|
||||
|
||||
// Use orange-ish for 'high' — override via custom class since Badge only has named colors
|
||||
// We'll map high→yellow and warning→yellow but label them differently
|
||||
const severityBadgeColor = (s: Severity): 'red' | 'yellow' | 'blue' => {
|
||||
if (s === 'critical') return 'red';
|
||||
if (s === 'high' || s === 'warning') return 'yellow';
|
||||
return 'blue';
|
||||
};
|
||||
|
||||
const statusBadgeColor = (s: IncidentStatus): 'red' | 'cyan' | 'green' => {
|
||||
if (s === 'open') return 'red';
|
||||
if (s === 'acknowledged') return 'cyan';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function AlertDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<IncidentSummary | null>(null);
|
||||
const [incidents, setIncidents] = useState<Incident[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([fetchIncidentSummary(), fetchIncidents()])
|
||||
.then(([sum, inc]) => {
|
||||
if (!cancelled) {
|
||||
setSummary(sum);
|
||||
setIncidents(inc);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
sortable: true,
|
||||
render: (row: Incident) => (
|
||||
<Badge color={severityBadgeColor(row.severity)}>
|
||||
{row.severity}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
header: 'Incident',
|
||||
sortable: true,
|
||||
render: (row: Incident) => (
|
||||
<span className="text-white font-medium">{row.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
render: (row: Incident) => (
|
||||
<Badge color={statusBadgeColor(row.status)}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
header: 'Source',
|
||||
sortable: true,
|
||||
render: (row: Incident) => (
|
||||
<span className="text-gray-400">{row.source}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'count',
|
||||
header: 'Count',
|
||||
sortable: true,
|
||||
render: (row: Incident) => (
|
||||
<span className="text-gray-400 tabular-nums">{row.count}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSeen',
|
||||
header: 'Last Seen',
|
||||
sortable: true,
|
||||
render: (row: Incident) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.lastSeen)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Alert Intelligence"
|
||||
description="Incident management and alert correlation"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/alert/notifications')}>
|
||||
Notifications
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/alert/webhooks')}>
|
||||
Webhooks
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{/* Summary stats */}
|
||||
{!loading && !error && summary && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Total Incidents', value: summary.total, color: 'text-white' },
|
||||
{ label: 'Open', value: summary.open, color: 'text-red-400' },
|
||||
{ label: 'Acknowledged', value: summary.acknowledged, color: 'text-cyan-400' },
|
||||
{ label: 'Resolved', value: summary.resolved, color: 'text-emerald-400' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading incidents…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
Failed to load incidents: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && incidents.length === 0 && (
|
||||
<EmptyState
|
||||
icon="🔔"
|
||||
title="No incidents"
|
||||
description="No incidents have been reported yet. Configure webhook integrations to start receiving alerts."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && incidents.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={incidents}
|
||||
rowKey={(row) => row.id}
|
||||
onRowClick={(row) => navigate(`/alert/incidents/${row.id}`)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
products/console/src/modules/alert/AlertDetail.tsx
Normal file
229
products/console/src/modules/alert/AlertDetail.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import {
|
||||
fetchIncident,
|
||||
updateIncidentStatus,
|
||||
type IncidentDetail as IncidentDetailType,
|
||||
type Severity,
|
||||
type IncidentStatus,
|
||||
} from './api';
|
||||
|
||||
const severityBadgeColor = (s: Severity): 'red' | 'yellow' | 'blue' => {
|
||||
if (s === 'critical') return 'red';
|
||||
if (s === 'high' || s === 'warning') return 'yellow';
|
||||
return 'blue';
|
||||
};
|
||||
|
||||
const statusBadgeColor = (s: IncidentStatus): 'red' | 'cyan' | 'green' => {
|
||||
if (s === 'open') return 'red';
|
||||
if (s === 'acknowledged') return 'cyan';
|
||||
return 'green';
|
||||
};
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
export function AlertDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [incident, setIncident] = useState<IncidentDetailType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
fetchIncident(id)
|
||||
.then((data) => {
|
||||
if (!cancelled) setIncident(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [id]);
|
||||
|
||||
const handleStatusChange = async (status: IncidentStatus) => {
|
||||
if (!id || !incident) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
await updateIncidentStatus(id, status);
|
||||
setIncident({ ...incident, status });
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading incident…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
Failed to load incident: {error}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" className="mt-4" onClick={() => navigate('/alert')}>
|
||||
← Back to incidents
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!incident) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon="🔍"
|
||||
title="Incident not found"
|
||||
description="This incident may have been deleted or does not exist."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={incident.title}
|
||||
description={`Incident ${incident.id}`}
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/alert')}>
|
||||
← Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{/* Metadata */}
|
||||
<Card>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Severity</p>
|
||||
<div className="mt-1">
|
||||
<Badge color={severityBadgeColor(incident.severity)}>{incident.severity}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Status</p>
|
||||
<div className="mt-1">
|
||||
<Badge color={statusBadgeColor(incident.status)}>{incident.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Source</p>
|
||||
<p className="text-white text-sm mt-1">{incident.source}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Alert Count</p>
|
||||
<p className="text-white text-sm mt-1 tabular-nums">{incident.count}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Fingerprint</p>
|
||||
<p className="text-gray-400 text-xs mt-1 font-mono break-all">{incident.fingerprint}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">First Seen</p>
|
||||
<p className="text-gray-400 text-sm mt-1">{formatTimestamp(incident.firstSeen)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">Last Seen</p>
|
||||
<p className="text-gray-400 text-sm mt-1">{formatTimestamp(incident.lastSeen)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Status actions */}
|
||||
<div className="flex gap-2 mt-4 mb-6">
|
||||
{incident.status !== 'acknowledged' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={updating}
|
||||
onClick={() => handleStatusChange('acknowledged')}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
{incident.status !== 'resolved' && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={updating}
|
||||
onClick={() => handleStatusChange('resolved')}
|
||||
>
|
||||
Resolve
|
||||
</Button>
|
||||
)}
|
||||
{incident.status === 'resolved' && (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={updating}
|
||||
onClick={() => handleStatusChange('open')}
|
||||
>
|
||||
Reopen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alert timeline */}
|
||||
<h3 className="text-white font-semibold text-sm mb-3">Alert Timeline</h3>
|
||||
<Card noPadding>
|
||||
{incident.alerts.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📭"
|
||||
title="No alerts"
|
||||
description="No correlated alerts found for this incident."
|
||||
/>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{incident.alerts.map((alert) => (
|
||||
<div key={alert.id} className="px-4 py-3 flex items-start gap-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
<div className="w-2 h-2 rounded-full bg-indigo-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm">{alert.message}</p>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="text-gray-500 text-xs">{formatTimestamp(alert.timestamp)}</span>
|
||||
<span className="text-gray-600 text-xs">via {alert.source}</span>
|
||||
</div>
|
||||
{alert.labels && Object.keys(alert.labels).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{Object.entries(alert.labels).map(([k, v]) => (
|
||||
<span key={k} className="text-xs bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded">
|
||||
{k}={v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
products/console/src/modules/alert/NotificationConfig.tsx
Normal file
185
products/console/src/modules/alert/NotificationConfig.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Button } from '../../shared/Button';
|
||||
import {
|
||||
fetchNotificationConfig,
|
||||
updateNotificationConfig,
|
||||
type NotificationSettings,
|
||||
type Severity,
|
||||
} from './api';
|
||||
|
||||
const severityOptions: Severity[] = ['critical', 'high', 'warning', 'info'];
|
||||
|
||||
export function NotificationConfig() {
|
||||
const navigate = useNavigate();
|
||||
const [config, setConfig] = useState<NotificationSettings>({
|
||||
webhook_url: '',
|
||||
slack_channel: '',
|
||||
email: '',
|
||||
pagerduty_key: '',
|
||||
severity_threshold: 'warning',
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchNotificationConfig()
|
||||
.then((data) => {
|
||||
if (!cancelled) setConfig(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
await updateNotificationConfig(config);
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateField = (field: keyof NotificationSettings, value: string) => {
|
||||
setConfig((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading configuration…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Notification Settings"
|
||||
description="Configure how you receive incident notifications"
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/alert')}>
|
||||
← Back
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="bg-emerald-500/10 border border-emerald-500/20 rounded-lg px-4 py-3 text-emerald-400 text-sm mb-4">
|
||||
Configuration saved successfully.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
Webhook URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={config.webhook_url}
|
||||
onChange={(e) => updateField('webhook_url', e.target.value)}
|
||||
placeholder="https://hooks.example.com/alerts"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
Slack Channel
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.slack_channel}
|
||||
onChange={(e) => updateField('slack_channel', e.target.value)}
|
||||
placeholder="#incidents"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={config.email}
|
||||
onChange={(e) => updateField('email', e.target.value)}
|
||||
placeholder="oncall@example.com"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
PagerDuty Integration Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.pagerduty_key}
|
||||
onChange={(e) => updateField('pagerduty_key', e.target.value)}
|
||||
placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
Severity Threshold
|
||||
</label>
|
||||
<p className="text-gray-600 text-xs mb-2">Only notify for incidents at or above this severity.</p>
|
||||
<div className="flex gap-2">
|
||||
{severityOptions.map((sev) => (
|
||||
<button
|
||||
key={sev}
|
||||
onClick={() => updateField('severity_threshold', sev)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
config.severity_threshold === sev
|
||||
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{sev}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
products/console/src/modules/alert/WebhookSecrets.tsx
Normal file
254
products/console/src/modules/alert/WebhookSecrets.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { Modal } from '../../shared/Modal';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import {
|
||||
fetchWebhookSecrets,
|
||||
upsertWebhookSecret,
|
||||
deleteWebhookSecret,
|
||||
type WebhookSecret,
|
||||
} from './api';
|
||||
|
||||
const knownProviders = ['Datadog', 'PagerDuty', 'OpsGenie', 'Grafana'];
|
||||
|
||||
function maskSecret(secret: string): string {
|
||||
if (secret.length <= 8) return '••••••••';
|
||||
return secret.slice(0, 4) + '••••••••' + secret.slice(-4);
|
||||
}
|
||||
|
||||
function formatTimestamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
export function WebhookSecrets() {
|
||||
const navigate = useNavigate();
|
||||
const [secrets, setSecrets] = useState<WebhookSecret[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Modal state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editProvider, setEditProvider] = useState('');
|
||||
const [editSecret, setEditSecret] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const loadSecrets = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchWebhookSecrets();
|
||||
setSecrets(data);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSecrets();
|
||||
}, []);
|
||||
|
||||
const openAdd = () => {
|
||||
setEditProvider('');
|
||||
setEditSecret('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (ws: WebhookSecret) => {
|
||||
setEditProvider(ws.provider);
|
||||
setEditSecret('');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editProvider.trim() || !editSecret.trim()) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await upsertWebhookSecret(editProvider.trim(), editSecret.trim());
|
||||
setModalOpen(false);
|
||||
await loadSecrets();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (provider: string) => {
|
||||
setDeleting(provider);
|
||||
try {
|
||||
await deleteWebhookSecret(provider);
|
||||
await loadSecrets();
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'provider',
|
||||
header: 'Provider',
|
||||
sortable: true,
|
||||
render: (row: WebhookSecret) => (
|
||||
<span className="text-white font-medium">{row.provider}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'secret',
|
||||
header: 'Secret',
|
||||
render: (row: WebhookSecret) => (
|
||||
<span className="text-gray-400 font-mono text-xs">{maskSecret(row.secret)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'updated_at',
|
||||
header: 'Updated',
|
||||
sortable: true,
|
||||
render: (row: WebhookSecret) => (
|
||||
<span className="text-gray-500 text-xs">{formatTimestamp(row.updated_at)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (row: WebhookSecret) => (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={deleting === row.provider}
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(row.provider); }}
|
||||
>
|
||||
{deleting === row.provider ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Webhook Secrets"
|
||||
description="Manage signing secrets for monitoring integrations"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/alert')}>
|
||||
← Back
|
||||
</Button>
|
||||
<Button size="sm" onClick={openAdd}>
|
||||
Add Secret
|
||||
</Button>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm mb-4">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading secrets…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && secrets.length === 0 && (
|
||||
<EmptyState
|
||||
icon="🔑"
|
||||
title="No webhook secrets"
|
||||
description="Add signing secrets for your monitoring providers to verify incoming webhooks."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && secrets.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={secrets}
|
||||
rowKey={(row) => row.provider}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
<Modal open={modalOpen} onClose={() => setModalOpen(false)} title={editProvider ? `Edit ${editProvider} Secret` : 'Add Webhook Secret'}>
|
||||
<div className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
Provider
|
||||
</label>
|
||||
{editProvider ? (
|
||||
<p className="text-white text-sm">{editProvider}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{knownProviders.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setEditProvider(p)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||
editProvider === p
|
||||
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
|
||||
: 'bg-gray-800 border-gray-700 text-gray-400 hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={editProvider}
|
||||
onChange={(e) => setEditProvider(e.target.value)}
|
||||
placeholder="Or type a custom provider name"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 uppercase tracking-wider mb-1.5">
|
||||
Signing Secret
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={editSecret}
|
||||
onChange={(e) => setEditSecret(e.target.value)}
|
||||
placeholder="Enter signing secret"
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button onClick={handleSave} disabled={saving || !editProvider.trim() || !editSecret.trim()}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
products/console/src/modules/alert/api.ts
Normal file
101
products/console/src/modules/alert/api.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { apiFetch } from '../../shell/api';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface IncidentSummary {
|
||||
total: number;
|
||||
open: number;
|
||||
acknowledged: number;
|
||||
resolved: number;
|
||||
}
|
||||
|
||||
export type Severity = 'critical' | 'high' | 'warning' | 'info';
|
||||
export type IncidentStatus = 'open' | 'acknowledged' | 'resolved';
|
||||
|
||||
export interface Incident {
|
||||
id: string;
|
||||
title: string;
|
||||
severity: Severity;
|
||||
status: IncidentStatus;
|
||||
source: string;
|
||||
fingerprint: string;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface IncidentDetail extends Incident {
|
||||
alerts: AlertEvent[];
|
||||
}
|
||||
|
||||
export interface AlertEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
message: string;
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
webhook_url: string;
|
||||
slack_channel: string;
|
||||
email: string;
|
||||
pagerduty_key: string;
|
||||
severity_threshold: Severity;
|
||||
}
|
||||
|
||||
export interface WebhookSecret {
|
||||
provider: string;
|
||||
secret: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// --- API calls ---
|
||||
|
||||
export async function fetchIncidentSummary(): Promise<IncidentSummary> {
|
||||
return apiFetch<IncidentSummary>('/api/v1/incidents/summary');
|
||||
}
|
||||
|
||||
export async function fetchIncidents(): Promise<Incident[]> {
|
||||
return apiFetch<Incident[]>('/api/v1/incidents');
|
||||
}
|
||||
|
||||
export async function fetchIncident(id: string): Promise<IncidentDetail> {
|
||||
return apiFetch<IncidentDetail>(`/api/v1/incidents/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function updateIncidentStatus(id: string, status: IncidentStatus): Promise<void> {
|
||||
await apiFetch(`/api/v1/incidents/${encodeURIComponent(id)}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchNotificationConfig(): Promise<NotificationSettings> {
|
||||
return apiFetch<NotificationSettings>('/api/v1/notifications/config');
|
||||
}
|
||||
|
||||
export async function updateNotificationConfig(config: NotificationSettings): Promise<void> {
|
||||
await apiFetch('/api/v1/notifications/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWebhookSecrets(): Promise<WebhookSecret[]> {
|
||||
return apiFetch<WebhookSecret[]>('/api/v1/webhooks/secrets');
|
||||
}
|
||||
|
||||
export async function upsertWebhookSecret(provider: string, secret: string): Promise<void> {
|
||||
await apiFetch('/api/v1/webhooks/secrets', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ provider, secret }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWebhookSecret(provider: string): Promise<void> {
|
||||
await apiFetch(`/api/v1/webhooks/secrets/${encodeURIComponent(provider)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
20
products/console/src/modules/alert/manifest.tsx
Normal file
20
products/console/src/modules/alert/manifest.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import type { ModuleManifest } from '../drift/manifest.js';
|
||||
import { AlertDashboard } from './AlertDashboard';
|
||||
import { AlertDetail } from './AlertDetail';
|
||||
import { NotificationConfig } from './NotificationConfig';
|
||||
import { WebhookSecrets } from './WebhookSecrets';
|
||||
|
||||
export const alertManifest: ModuleManifest = {
|
||||
id: 'alert',
|
||||
name: 'Alert Intelligence',
|
||||
icon: '🔔',
|
||||
path: '/alert',
|
||||
entitlement: 'alert',
|
||||
routes: [
|
||||
{ path: 'alert', element: <AlertDashboard /> },
|
||||
{ path: 'alert/incidents/:id', element: <AlertDetail /> },
|
||||
{ path: 'alert/notifications', element: <NotificationConfig /> },
|
||||
{ path: 'alert/webhooks', element: <WebhookSecrets /> },
|
||||
],
|
||||
};
|
||||
206
products/console/src/modules/cost/CostBaselines.tsx
Normal file
206
products/console/src/modules/cost/CostBaselines.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { Modal } from '../../shared/Modal';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchBaselines, updateBaseline, type Baseline } from './api';
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
export function CostBaselines() {
|
||||
const navigate = useNavigate();
|
||||
const [baselines, setBaselines] = useState<Baseline[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editItem, setEditItem] = useState<Baseline | null>(null);
|
||||
const [editForm, setEditForm] = useState({ monthly_budget: '', alert_threshold_pct: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchBaselines()
|
||||
.then((data) => {
|
||||
if (!cancelled) setBaselines(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
function openEdit(b: Baseline) {
|
||||
setEditItem(b);
|
||||
setEditForm({
|
||||
monthly_budget: String(b.monthly_budget),
|
||||
alert_threshold_pct: String(b.alert_threshold_pct),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editItem) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateBaseline(editItem.id, {
|
||||
monthly_budget: Number(editForm.monthly_budget),
|
||||
alert_threshold_pct: Number(editForm.alert_threshold_pct),
|
||||
});
|
||||
setBaselines((prev) => prev.map((b) => (b.id === editItem.id ? updated : b)));
|
||||
setEditItem(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const spendRatio = (b: Baseline) => {
|
||||
if (b.monthly_budget === 0) return 0;
|
||||
return Math.round((b.current_spend / b.monthly_budget) * 100);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'service',
|
||||
header: 'Service',
|
||||
sortable: true,
|
||||
render: (row: Baseline) => (
|
||||
<span className="text-white font-medium">{row.service}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'account_id',
|
||||
header: 'Account',
|
||||
sortable: true,
|
||||
render: (row: Baseline) => (
|
||||
<span className="text-gray-400 text-xs font-mono">{row.account_id}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'monthly_budget',
|
||||
header: 'Budget',
|
||||
sortable: true,
|
||||
render: (row: Baseline) => (
|
||||
<span className="text-gray-300 tabular-nums">{formatCurrency(row.monthly_budget)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'current_spend',
|
||||
header: 'Current Spend',
|
||||
sortable: true,
|
||||
render: (row: Baseline) => {
|
||||
const pct = spendRatio(row);
|
||||
const color = pct > 100 ? 'text-red-400' : pct > 80 ? 'text-yellow-400' : 'text-cyan-400';
|
||||
return (
|
||||
<span className={`tabular-nums font-medium ${color}`}>
|
||||
{formatCurrency(row.current_spend)} ({pct}%)
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'alert_threshold_pct',
|
||||
header: 'Alert At',
|
||||
sortable: true,
|
||||
render: (row: Baseline) => (
|
||||
<span className="text-gray-400 tabular-nums">{row.alert_threshold_pct}%</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (row: Baseline) => (
|
||||
<Button size="sm" variant="secondary" onClick={(e) => { e.stopPropagation(); openEdit(row); }}>
|
||||
Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Cost Baselines"
|
||||
description="Manage monthly budgets and alert thresholds"
|
||||
action={<Button variant="secondary" onClick={() => navigate('/cost')}>← Back</Button>}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading baselines…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && baselines.length === 0 && (
|
||||
<EmptyState
|
||||
icon="📊"
|
||||
title="No baselines configured"
|
||||
description="Baselines are auto-created when services report cost data."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && baselines.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={baselines}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Modal open={!!editItem} onClose={() => setEditItem(null)} title={`Edit Baseline: ${editItem?.service ?? ''}`}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Monthly Budget ($)</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={editForm.monthly_budget}
|
||||
onChange={(e) => setEditForm({ ...editForm, monthly_budget: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Alert Threshold (%)</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min="0"
|
||||
max="200"
|
||||
value={editForm.alert_threshold_pct}
|
||||
onChange={(e) => setEditForm({ ...editForm, alert_threshold_pct: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setEditItem(null)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
205
products/console/src/modules/cost/CostDashboard.tsx
Normal file
205
products/console/src/modules/cost/CostDashboard.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchCostSummary, fetchAnomalies, acknowledgeAnomaly, type Anomaly, type CostSummary } from './api';
|
||||
|
||||
function severityColor(severity: string): 'green' | 'yellow' | 'red' | 'cyan' {
|
||||
if (severity === 'critical') return 'red';
|
||||
if (severity === 'high') return 'red';
|
||||
if (severity === 'medium') return 'yellow';
|
||||
return 'green';
|
||||
}
|
||||
|
||||
function statusColor(status: string): 'green' | 'yellow' | 'gray' {
|
||||
if (status === 'resolved') return 'green';
|
||||
if (status === 'acknowledged') return 'yellow';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function CostDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<CostSummary | null>(null);
|
||||
const [anomalies, setAnomalies] = useState<Anomaly[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([fetchCostSummary(), fetchAnomalies()])
|
||||
.then(([sum, anoms]) => {
|
||||
if (!cancelled) {
|
||||
setSummary(sum);
|
||||
setAnomalies(anoms);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function handleAcknowledge(id: string) {
|
||||
try {
|
||||
const updated = await acknowledgeAnomaly(id);
|
||||
setAnomalies((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'service',
|
||||
header: 'Service',
|
||||
sortable: true,
|
||||
render: (row: Anomaly) => (
|
||||
<span className="text-white font-medium">{row.service}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'severity',
|
||||
header: 'Severity',
|
||||
sortable: true,
|
||||
render: (row: Anomaly) => (
|
||||
<Badge color={severityColor(row.severity)}>{row.severity}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'delta',
|
||||
header: 'Overspend',
|
||||
sortable: true,
|
||||
render: (row: Anomaly) => (
|
||||
<span className="text-red-400 tabular-nums font-medium">
|
||||
+{formatCurrency(row.delta)} ({row.delta_pct > 0 ? '+' : ''}{row.delta_pct}%)
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actual_cost',
|
||||
header: 'Actual',
|
||||
sortable: true,
|
||||
render: (row: Anomaly) => (
|
||||
<span className="text-gray-300 tabular-nums">{formatCurrency(row.actual_cost)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
render: (row: Anomaly) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge color={statusColor(row.status)}>{row.status}</Badge>
|
||||
{row.status === 'open' && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAcknowledge(row.id); }}
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
>
|
||||
Ack
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'detected_at',
|
||||
header: 'Detected',
|
||||
sortable: true,
|
||||
render: (row: Anomaly) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.detected_at)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Cost Anomaly Detection"
|
||||
description="Monitor AWS spend and detect cost anomalies"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => navigate('/cost/baselines')}>Baselines</Button>
|
||||
<Button variant="secondary" onClick={() => navigate('/cost/governance')}>Governance</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{!loading && !error && summary && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Total Spend', value: formatCurrency(summary.total_spend), color: 'text-white' },
|
||||
{ label: 'Anomalies', value: summary.anomaly_count, color: 'text-red-400' },
|
||||
{ label: 'Potential Savings', value: formatCurrency(summary.potential_savings), color: 'text-cyan-400' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading cost data…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
Failed to load cost data: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && anomalies.length === 0 && (
|
||||
<EmptyState
|
||||
icon="✅"
|
||||
title="No anomalies detected"
|
||||
description="Your spend is within expected baselines. Nice."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && anomalies.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={anomalies}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
207
products/console/src/modules/cost/GovernanceRules.tsx
Normal file
207
products/console/src/modules/cost/GovernanceRules.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchGovernanceRules, createGovernanceRule, deleteGovernanceRule, type GovernanceRule } from './api';
|
||||
|
||||
function actionColor(action: string): 'red' | 'yellow' | 'blue' {
|
||||
if (action === 'block') return 'red';
|
||||
if (action === 'alert') return 'yellow';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
export function GovernanceRules() {
|
||||
const navigate = useNavigate();
|
||||
const [rules, setRules] = useState<GovernanceRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', description: '', condition: '', action: 'alert' as 'alert' | 'block' | 'tag', enabled: true });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchGovernanceRules()
|
||||
.then((data) => {
|
||||
if (!cancelled) setRules(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createGovernanceRule(form);
|
||||
setRules((prev) => [...prev, created]);
|
||||
setShowCreate(false);
|
||||
setForm({ name: '', description: '', condition: '', action: 'alert', enabled: true });
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Delete this governance rule?')) return;
|
||||
try {
|
||||
await deleteGovernanceRule(id);
|
||||
setRules((prev) => prev.filter((r) => r.id !== id));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Rule',
|
||||
sortable: true,
|
||||
render: (row: GovernanceRule) => (
|
||||
<div>
|
||||
<span className="text-white font-medium">{row.name}</span>
|
||||
<p className="text-gray-500 text-xs mt-0.5">{row.description}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'condition',
|
||||
header: 'Condition',
|
||||
render: (row: GovernanceRule) => (
|
||||
<code className="text-xs text-cyan-400 bg-gray-800 px-2 py-0.5 rounded font-mono">{row.condition}</code>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Action',
|
||||
sortable: true,
|
||||
render: (row: GovernanceRule) => (
|
||||
<Badge color={actionColor(row.action)}>{row.action}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
header: 'Status',
|
||||
render: (row: GovernanceRule) => (
|
||||
<Badge color={row.enabled ? 'green' : 'gray'}>{row.enabled ? 'Active' : 'Disabled'}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (row: GovernanceRule) => (
|
||||
<Button size="sm" variant="danger" onClick={(e) => { e.stopPropagation(); handleDelete(row.id); }}>
|
||||
Delete
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Governance Rules"
|
||||
description="Define cost governance policies and automated actions"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => navigate('/cost')}>← Back</Button>
|
||||
<Button onClick={() => setShowCreate(!showCreate)}>+ New Rule</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<Card className="mb-6">
|
||||
<form onSubmit={handleCreate} className="space-y-4 max-w-xl">
|
||||
<div>
|
||||
<label className={labelClass}>Rule Name *</label>
|
||||
<input className={inputClass} required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="max-ec2-spend" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Description *</label>
|
||||
<input className={inputClass} required value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Block EC2 spend over $10k/month" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Condition *</label>
|
||||
<input className={inputClass} required value={form.condition} onChange={(e) => setForm({ ...form, condition: e.target.value })} placeholder="service.spend > 10000 AND service.type == 'ec2'" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Action *</label>
|
||||
<select className={inputClass} value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value as 'alert' | 'block' | 'tag' })}>
|
||||
<option value="alert">Alert</option>
|
||||
<option value="block">Block</option>
|
||||
<option value="tag">Tag</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Enabled</label>
|
||||
<select className={inputClass} value={form.enabled ? 'true' : 'false'} onChange={(e) => setForm({ ...form, enabled: e.target.value === 'true' })}>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Creating…' : 'Create Rule'}</Button>
|
||||
<Button variant="secondary" type="button" onClick={() => setShowCreate(false)}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading rules…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rules.length === 0 && (
|
||||
<EmptyState
|
||||
icon="📋"
|
||||
title="No governance rules"
|
||||
description="Create rules to automate cost governance policies."
|
||||
actionLabel="+ New Rule"
|
||||
onAction={() => setShowCreate(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && rules.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={rules}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
products/console/src/modules/cost/api.ts
Normal file
79
products/console/src/modules/cost/api.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { apiFetch } from '../../shell/api';
|
||||
|
||||
export interface Anomaly {
|
||||
id: string;
|
||||
service: string;
|
||||
account_id: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
expected_cost: number;
|
||||
actual_cost: number;
|
||||
delta: number;
|
||||
delta_pct: number;
|
||||
detected_at: string;
|
||||
status: 'open' | 'acknowledged' | 'resolved';
|
||||
}
|
||||
|
||||
export interface Baseline {
|
||||
id: string;
|
||||
service: string;
|
||||
account_id: string;
|
||||
monthly_budget: number;
|
||||
alert_threshold_pct: number;
|
||||
current_spend: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GovernanceRule {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
condition: string;
|
||||
action: 'alert' | 'block' | 'tag';
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CostSummary {
|
||||
total_spend: number;
|
||||
anomaly_count: number;
|
||||
potential_savings: number;
|
||||
month: string;
|
||||
}
|
||||
|
||||
export async function fetchCostSummary(): Promise<CostSummary> {
|
||||
return apiFetch<CostSummary>('/api/v1/cost/summary');
|
||||
}
|
||||
|
||||
export async function fetchAnomalies(): Promise<Anomaly[]> {
|
||||
return apiFetch<Anomaly[]>('/api/v1/cost/anomalies');
|
||||
}
|
||||
|
||||
export async function acknowledgeAnomaly(id: string): Promise<Anomaly> {
|
||||
return apiFetch<Anomaly>(`/api/v1/cost/anomalies/${encodeURIComponent(id)}/acknowledge`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function fetchBaselines(): Promise<Baseline[]> {
|
||||
return apiFetch<Baseline[]>('/api/v1/cost/baselines');
|
||||
}
|
||||
|
||||
export async function updateBaseline(id: string, payload: { monthly_budget?: number; alert_threshold_pct?: number }): Promise<Baseline> {
|
||||
return apiFetch<Baseline>(`/api/v1/cost/baselines/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGovernanceRules(): Promise<GovernanceRule[]> {
|
||||
return apiFetch<GovernanceRule[]>('/api/v1/cost/governance');
|
||||
}
|
||||
|
||||
export async function createGovernanceRule(payload: Omit<GovernanceRule, 'id' | 'created_at'>): Promise<GovernanceRule> {
|
||||
return apiFetch<GovernanceRule>('/api/v1/cost/governance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteGovernanceRule(id: string): Promise<void> {
|
||||
await apiFetch<void>(`/api/v1/cost/governance/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
19
products/console/src/modules/cost/manifest.tsx
Normal file
19
products/console/src/modules/cost/manifest.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { CostDashboard } from './CostDashboard';
|
||||
import { CostBaselines } from './CostBaselines';
|
||||
import { GovernanceRules } from './GovernanceRules';
|
||||
import type { ModuleManifest } from '../drift/manifest';
|
||||
|
||||
export const costManifest: ModuleManifest = {
|
||||
id: 'cost',
|
||||
name: 'Cost Anomaly',
|
||||
icon: '💰',
|
||||
path: '/cost',
|
||||
entitlement: 'cost',
|
||||
routes: [
|
||||
{ path: 'cost', element: <CostDashboard /> },
|
||||
{ path: 'cost/baselines', element: <CostBaselines /> },
|
||||
{ path: 'cost/governance', element: <GovernanceRules /> },
|
||||
],
|
||||
};
|
||||
194
products/console/src/modules/portal/PortalDashboard.tsx
Normal file
194
products/console/src/modules/portal/PortalDashboard.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchServices, type Service } from './api';
|
||||
|
||||
function tierColor(tier: string): 'red' | 'yellow' | 'cyan' {
|
||||
if (tier === 'critical') return 'red';
|
||||
if (tier === 'standard') return 'yellow';
|
||||
return 'cyan';
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function PortalDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchServices()
|
||||
.then((data) => {
|
||||
if (!cancelled) setServices(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const filtered = services.filter((s) =>
|
||||
s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.owner.toLowerCase().includes(search.toLowerCase()) ||
|
||||
s.language.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
const totalServices = services.length;
|
||||
const criticalCount = services.filter((s) => s.tier === 'critical').length;
|
||||
const standardCount = services.filter((s) => s.tier === 'standard').length;
|
||||
const languages = new Set(services.map((s) => s.language)).size;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Service',
|
||||
sortable: true,
|
||||
render: (row: Service) => (
|
||||
<span className="text-white font-medium">{row.name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
header: 'Owner',
|
||||
sortable: true,
|
||||
render: (row: Service) => (
|
||||
<span className="text-gray-400">{row.owner}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tier',
|
||||
header: 'Tier',
|
||||
sortable: true,
|
||||
render: (row: Service) => (
|
||||
<Badge color={tierColor(row.tier)}>{row.tier}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'language',
|
||||
header: 'Language',
|
||||
sortable: true,
|
||||
render: (row: Service) => (
|
||||
<Badge color="blue">{row.language}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'last_updated',
|
||||
header: 'Last Updated',
|
||||
sortable: true,
|
||||
render: (row: Service) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.last_updated)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Service Portal"
|
||||
description="Browse and manage your service catalog"
|
||||
action={
|
||||
<Button onClick={() => navigate('/portal/create')}>+ New Service</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!loading && !error && services.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Total Services', value: totalServices, color: 'text-white' },
|
||||
{ label: 'Critical', value: criticalCount, color: 'text-red-400' },
|
||||
{ label: 'Standard', value: standardCount, color: 'text-yellow-400' },
|
||||
{ label: 'Languages', value: languages, color: 'text-cyan-400' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && services.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search services by name, owner, or language…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-800 rounded-lg px-4 py-2 text-sm text-gray-300 placeholder-gray-600 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading services…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
Failed to load services: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && services.length === 0 && (
|
||||
<EmptyState
|
||||
icon="🏗️"
|
||||
title="No services registered"
|
||||
description="Add your first service to the catalog to get started."
|
||||
actionLabel="+ New Service"
|
||||
onAction={() => navigate('/portal/create')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={filtered}
|
||||
rowKey={(row) => row.id}
|
||||
onRowClick={(row) => navigate(`/portal/${row.id}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && services.length > 0 && filtered.length === 0 && (
|
||||
<EmptyState
|
||||
icon="🔍"
|
||||
title="No matching services"
|
||||
description="Try adjusting your search query."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
products/console/src/modules/portal/ServiceCreate.tsx
Normal file
111
products/console/src/modules/portal/ServiceCreate.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { createService } from './api';
|
||||
|
||||
export function ServiceCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
owner: '',
|
||||
tier: 'standard',
|
||||
language: '',
|
||||
repo_url: '',
|
||||
docs_url: '',
|
||||
tags: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createService({
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
owner: form.owner,
|
||||
tier: form.tier,
|
||||
language: form.language,
|
||||
repo_url: form.repo_url || undefined,
|
||||
docs_url: form.docs_url || undefined,
|
||||
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
navigate(`/portal/${created.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Register Service"
|
||||
description="Add a new service to the catalog"
|
||||
action={<Button variant="secondary" onClick={() => navigate('/portal')}>← Back</Button>}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-xl">
|
||||
<div>
|
||||
<label className={labelClass}>Name *</label>
|
||||
<input className={inputClass} required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="my-service" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Description *</label>
|
||||
<textarea className={`${inputClass} resize-none`} rows={3} required value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="What does this service do?" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Owner *</label>
|
||||
<input className={inputClass} required value={form.owner} onChange={(e) => setForm({ ...form, owner: e.target.value })} placeholder="team-platform" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Tier *</label>
|
||||
<select className={inputClass} value={form.tier} onChange={(e) => setForm({ ...form, tier: e.target.value })}>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="experimental">Experimental</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Language *</label>
|
||||
<input className={inputClass} required value={form.language} onChange={(e) => setForm({ ...form, language: e.target.value })} placeholder="TypeScript" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Repository URL</label>
|
||||
<input className={inputClass} value={form.repo_url} onChange={(e) => setForm({ ...form, repo_url: e.target.value })} placeholder="https://github.com/org/repo" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Documentation URL</label>
|
||||
<input className={inputClass} value={form.docs_url} onChange={(e) => setForm({ ...form, docs_url: e.target.value })} placeholder="https://docs.example.com/my-service" />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Tags (comma-separated)</label>
|
||||
<input className={inputClass} value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} placeholder="backend, api, grpc" />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Creating…' : 'Create Service'}</Button>
|
||||
<Button variant="secondary" type="button" onClick={() => navigate('/portal')}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
238
products/console/src/modules/portal/ServiceDetail.tsx
Normal file
238
products/console/src/modules/portal/ServiceDetail.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { Modal } from '../../shared/Modal';
|
||||
import { fetchService, updateService, deleteService, type Service } from './api';
|
||||
|
||||
function tierColor(tier: string): 'red' | 'yellow' | 'cyan' {
|
||||
if (tier === 'critical') return 'red';
|
||||
if (tier === 'standard') return 'yellow';
|
||||
return 'cyan';
|
||||
}
|
||||
|
||||
export function ServiceDetail() {
|
||||
const { serviceId } = useParams<{ serviceId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [service, setService] = useState<Service | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState({ name: '', description: '', owner: '', tier: '', language: '', repo_url: '', docs_url: '', tags: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serviceId) return;
|
||||
let cancelled = false;
|
||||
fetchService(serviceId)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setService(data);
|
||||
setEditForm({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
owner: data.owner,
|
||||
tier: data.tier,
|
||||
language: data.language,
|
||||
repo_url: data.repo_url || '',
|
||||
docs_url: data.docs_url || '',
|
||||
tags: data.tags.join(', '),
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [serviceId]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!serviceId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateService(serviceId, {
|
||||
name: editForm.name,
|
||||
description: editForm.description,
|
||||
owner: editForm.owner,
|
||||
tier: editForm.tier,
|
||||
language: editForm.language,
|
||||
repo_url: editForm.repo_url || undefined,
|
||||
docs_url: editForm.docs_url || undefined,
|
||||
tags: editForm.tags.split(',').map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
setService(updated);
|
||||
setEditOpen(false);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!serviceId || !confirm('Delete this service?')) return;
|
||||
try {
|
||||
await deleteService(serviceId);
|
||||
navigate('/portal');
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading service…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !service) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
Failed to load service: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!service) return null;
|
||||
|
||||
const metaFields = [
|
||||
{ label: 'Owner', value: service.owner },
|
||||
{ label: 'Tier', value: <Badge color={tierColor(service.tier)}>{service.tier}</Badge> },
|
||||
{ label: 'Language', value: <Badge color="blue">{service.language}</Badge> },
|
||||
{ label: 'Created', value: new Date(service.created_at).toLocaleDateString() },
|
||||
{ label: 'Last Updated', value: new Date(service.last_updated).toLocaleDateString() },
|
||||
];
|
||||
|
||||
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={service.name}
|
||||
description={service.description}
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => navigate('/portal')}>← Back</Button>
|
||||
<Button onClick={() => setEditOpen(true)}>Edit</Button>
|
||||
<Button variant="danger" onClick={handleDelete}>Delete</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card header={<span className="text-sm font-semibold text-white">Metadata</span>}>
|
||||
<dl className="space-y-3">
|
||||
{metaFields.map((f) => (
|
||||
<div key={f.label} className="flex items-center justify-between">
|
||||
<dt className="text-xs text-gray-500 uppercase tracking-wider">{f.label}</dt>
|
||||
<dd className="text-sm text-gray-300">{f.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card header={<span className="text-sm font-semibold text-white">Links & Tags</span>}>
|
||||
<div className="space-y-3">
|
||||
{service.repo_url && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Repository</p>
|
||||
<a href={service.repo_url} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 text-sm break-all">
|
||||
{service.repo_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{service.docs_url && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Documentation</p>
|
||||
<a href={service.docs_url} target="_blank" rel="noopener noreferrer" className="text-indigo-400 hover:text-indigo-300 text-sm break-all">
|
||||
{service.docs_url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider mb-2">Tags</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{service.tags.length > 0 ? (
|
||||
service.tags.map((tag) => (
|
||||
<Badge key={tag} color="gray">{tag}</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-600 text-sm">No tags</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Modal open={editOpen} onClose={() => setEditOpen(false)} title="Edit Service">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input className={inputClass} value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Description</label>
|
||||
<textarea className={`${inputClass} resize-none`} rows={3} value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>Owner</label>
|
||||
<input className={inputClass} value={editForm.owner} onChange={(e) => setEditForm({ ...editForm, owner: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Tier</label>
|
||||
<select className={inputClass} value={editForm.tier} onChange={(e) => setEditForm({ ...editForm, tier: e.target.value })}>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="experimental">Experimental</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Language</label>
|
||||
<input className={inputClass} value={editForm.language} onChange={(e) => setEditForm({ ...editForm, language: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Repository URL</label>
|
||||
<input className={inputClass} value={editForm.repo_url} onChange={(e) => setEditForm({ ...editForm, repo_url: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Docs URL</label>
|
||||
<input className={inputClass} value={editForm.docs_url} onChange={(e) => setEditForm({ ...editForm, docs_url: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Tags (comma-separated)</label>
|
||||
<input className={inputClass} value={editForm.tags} onChange={(e) => setEditForm({ ...editForm, tags: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={() => setEditOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? 'Saving…' : 'Save'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
products/console/src/modules/portal/api.ts
Normal file
54
products/console/src/modules/portal/api.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { apiFetch } from '../../shell/api';
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
owner: string;
|
||||
tier: 'critical' | 'standard' | 'experimental';
|
||||
language: string;
|
||||
repo_url?: string;
|
||||
docs_url?: string;
|
||||
tags: string[];
|
||||
last_updated: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServiceCreatePayload {
|
||||
name: string;
|
||||
description: string;
|
||||
owner: string;
|
||||
tier: string;
|
||||
language: string;
|
||||
repo_url?: string;
|
||||
docs_url?: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export async function fetchServices(): Promise<Service[]> {
|
||||
return apiFetch<Service[]>('/api/v1/services');
|
||||
}
|
||||
|
||||
export async function fetchService(id: string): Promise<Service> {
|
||||
return apiFetch<Service>(`/api/v1/services/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function createService(payload: ServiceCreatePayload): Promise<Service> {
|
||||
return apiFetch<Service>('/api/v1/services', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateService(id: string, payload: Partial<ServiceCreatePayload>): Promise<Service> {
|
||||
return apiFetch<Service>(`/api/v1/services/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteService(id: string): Promise<void> {
|
||||
await apiFetch<void>(`/api/v1/services/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
19
products/console/src/modules/portal/manifest.tsx
Normal file
19
products/console/src/modules/portal/manifest.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { PortalDashboard } from './PortalDashboard';
|
||||
import { ServiceDetail } from './ServiceDetail';
|
||||
import { ServiceCreate } from './ServiceCreate';
|
||||
import type { ModuleManifest } from '../drift/manifest';
|
||||
|
||||
export const portalManifest: ModuleManifest = {
|
||||
id: 'portal',
|
||||
name: 'Service Portal',
|
||||
icon: '🏗️',
|
||||
path: '/portal',
|
||||
entitlement: 'portal',
|
||||
routes: [
|
||||
{ path: 'portal', element: <PortalDashboard /> },
|
||||
{ path: 'portal/:serviceId', element: <ServiceDetail /> },
|
||||
{ path: 'portal/create', element: <ServiceCreate /> },
|
||||
],
|
||||
};
|
||||
174
products/console/src/modules/run/ApprovalQueue.tsx
Normal file
174
products/console/src/modules/run/ApprovalQueue.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchApprovals, approveExecution, rejectExecution, type Approval } from './api';
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function statusColor(status: string): 'green' | 'yellow' | 'red' {
|
||||
if (status === 'approved') return 'green';
|
||||
if (status === 'pending') return 'yellow';
|
||||
return 'red';
|
||||
}
|
||||
|
||||
export function ApprovalQueue() {
|
||||
const navigate = useNavigate();
|
||||
const [approvals, setApprovals] = useState<Approval[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchApprovals()
|
||||
.then((data) => {
|
||||
if (!cancelled) setApprovals(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function handleApprove(id: string) {
|
||||
try {
|
||||
const updated = await approveExecution(id);
|
||||
setApprovals((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReject(id: string) {
|
||||
try {
|
||||
const updated = await rejectExecution(id);
|
||||
setApprovals((prev) => prev.map((a) => (a.id === id ? updated : a)));
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = approvals.filter((a) => a.status === 'pending').length;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'runbook_name',
|
||||
header: 'Runbook',
|
||||
sortable: true,
|
||||
render: (row: Approval) => (
|
||||
<span className="text-white font-medium">{row.runbook_name}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requested_by',
|
||||
header: 'Requested By',
|
||||
sortable: true,
|
||||
render: (row: Approval) => (
|
||||
<span className="text-gray-400">{row.requested_by}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mode',
|
||||
header: 'Mode',
|
||||
render: (row: Approval) => (
|
||||
<Badge color="red">{row.mode}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
render: (row: Approval) => (
|
||||
<Badge color={statusColor(row.status)}>{row.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requested_at',
|
||||
header: 'Requested',
|
||||
sortable: true,
|
||||
render: (row: Approval) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.requested_at)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (row: Approval) => (
|
||||
row.status === 'pending' ? (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={(e) => { e.stopPropagation(); handleApprove(row.id); }}>
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onClick={(e) => { e.stopPropagation(); handleReject(row.id); }}>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Approval Queue"
|
||||
description={pendingCount > 0 ? `${pendingCount} pending approval${pendingCount > 1 ? 's' : ''}` : 'Review and approve runbook executions'}
|
||||
action={<Button variant="secondary" onClick={() => navigate('/run')}>← Back</Button>}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading approvals…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && approvals.length === 0 && (
|
||||
<EmptyState
|
||||
icon="✅"
|
||||
title="No pending approvals"
|
||||
description="All clear. No runbook executions waiting for review."
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && approvals.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={approvals}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
products/console/src/modules/run/RunDashboard.tsx
Normal file
179
products/console/src/modules/run/RunDashboard.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchRunbooks, type Runbook } from './api';
|
||||
|
||||
function statusColor(status?: string): 'green' | 'yellow' | 'red' | 'cyan' | 'gray' {
|
||||
if (status === 'success') return 'green';
|
||||
if (status === 'running') return 'cyan';
|
||||
if (status === 'failed') return 'red';
|
||||
if (status === 'pending_approval') return 'yellow';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) return 'Never';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function RunDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [runbooks, setRunbooks] = useState<Runbook[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchRunbooks()
|
||||
.then((data) => {
|
||||
if (!cancelled) setRunbooks(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const totalRunbooks = runbooks.length;
|
||||
const successCount = runbooks.filter((r) => r.last_status === 'success').length;
|
||||
const failedCount = runbooks.filter((r) => r.last_status === 'failed').length;
|
||||
const pendingCount = runbooks.filter((r) => r.last_status === 'pending_approval').length;
|
||||
|
||||
const columns = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Runbook',
|
||||
sortable: true,
|
||||
render: (row: Runbook) => (
|
||||
<div>
|
||||
<span className="text-white font-medium">{row.name}</span>
|
||||
<p className="text-gray-500 text-xs mt-0.5 max-w-xs truncate">{row.description}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'author',
|
||||
header: 'Author',
|
||||
sortable: true,
|
||||
render: (row: Runbook) => (
|
||||
<span className="text-gray-400">{row.author}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'requires_approval',
|
||||
header: 'Approval',
|
||||
render: (row: Runbook) => (
|
||||
<Badge color={row.requires_approval ? 'yellow' : 'gray'}>
|
||||
{row.requires_approval ? 'Required' : 'Auto'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'last_status',
|
||||
header: 'Last Status',
|
||||
sortable: true,
|
||||
render: (row: Runbook) => (
|
||||
row.last_status
|
||||
? <Badge color={statusColor(row.last_status)}>{row.last_status.replace('_', ' ')}</Badge>
|
||||
: <span className="text-gray-600 text-xs">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'last_run',
|
||||
header: 'Last Run',
|
||||
sortable: true,
|
||||
render: (row: Runbook) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.last_run)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Runbook Automation"
|
||||
description="Manage and execute operational runbooks"
|
||||
action={
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => navigate('/run/approvals')}>Approvals</Button>
|
||||
<Button onClick={() => navigate('/run/create')}>+ New Runbook</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{!loading && !error && runbooks.length > 0 && (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ label: 'Total Runbooks', value: totalRunbooks, color: 'text-white' },
|
||||
{ label: 'Succeeded', value: successCount, color: 'text-green-400' },
|
||||
{ label: 'Failed', value: failedCount, color: 'text-red-400' },
|
||||
{ label: 'Pending', value: pendingCount, color: 'text-yellow-400' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-gray-900 border border-gray-800 rounded-xl p-4">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wider">{stat.label}</p>
|
||||
<p className={`text-2xl font-bold mt-1 tabular-nums ${stat.color}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card noPadding>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading runbooks…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
Failed to load runbooks: {error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && runbooks.length === 0 && (
|
||||
<EmptyState
|
||||
icon="⚡"
|
||||
title="No runbooks yet"
|
||||
description="Create your first runbook to automate operational tasks."
|
||||
actionLabel="+ New Runbook"
|
||||
onAction={() => navigate('/run/create')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && runbooks.length > 0 && (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={runbooks}
|
||||
rowKey={(row) => row.id}
|
||||
onRowClick={(row) => navigate(`/run/${row.id}`)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
104
products/console/src/modules/run/RunbookCreate.tsx
Normal file
104
products/console/src/modules/run/RunbookCreate.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { createRunbook } from './api';
|
||||
|
||||
export function RunbookCreate() {
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
yaml_content: '',
|
||||
requires_approval: false,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await createRunbook(form);
|
||||
navigate(`/run/${created.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const inputClass = 'w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:outline-none focus:border-indigo-500 transition-colors';
|
||||
const labelClass = 'block text-xs font-medium text-gray-400 mb-1';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Create Runbook"
|
||||
description="Define a new operational runbook"
|
||||
action={<Button variant="secondary" onClick={() => navigate('/run')}>← Back</Button>}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||
<div>
|
||||
<label className={labelClass}>Name *</label>
|
||||
<input
|
||||
className={inputClass}
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="restart-service"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Description *</label>
|
||||
<textarea
|
||||
className={`${inputClass} resize-none`}
|
||||
rows={2}
|
||||
required
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder="Gracefully restart a service with health checks"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>YAML Definition *</label>
|
||||
<textarea
|
||||
className={`${inputClass} resize-y font-mono text-cyan-400`}
|
||||
rows={16}
|
||||
required
|
||||
value={form.yaml_content}
|
||||
onChange={(e) => setForm({ ...form, yaml_content: e.target.value })}
|
||||
placeholder={`steps:\n - name: drain-connections\n action: exec\n command: kubectl drain ...\n - name: restart-pods\n action: exec\n command: kubectl rollout restart ...`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="requires_approval"
|
||||
checked={form.requires_approval}
|
||||
onChange={(e) => setForm({ ...form, requires_approval: e.target.checked })}
|
||||
className="rounded border-gray-700 bg-gray-800 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-gray-950"
|
||||
/>
|
||||
<label htmlFor="requires_approval" className="text-sm text-gray-400">
|
||||
Require approval before live execution
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit" disabled={saving}>{saving ? 'Creating…' : 'Create Runbook'}</Button>
|
||||
<Button variant="secondary" type="button" onClick={() => navigate('/run')}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
products/console/src/modules/run/RunbookDetail.tsx
Normal file
217
products/console/src/modules/run/RunbookDetail.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { PageHeader } from '../../shared/PageHeader';
|
||||
import { Card } from '../../shared/Card';
|
||||
import { Table } from '../../shared/Table';
|
||||
import { Badge } from '../../shared/Badge';
|
||||
import { Button } from '../../shared/Button';
|
||||
import { EmptyState } from '../../shared/EmptyState';
|
||||
import { fetchRunbook, fetchExecutions, executeRunbook, type Runbook, type Execution } from './api';
|
||||
|
||||
function statusColor(status: string): 'green' | 'yellow' | 'red' | 'cyan' | 'gray' {
|
||||
if (status === 'success') return 'green';
|
||||
if (status === 'running' || status === 'queued') return 'cyan';
|
||||
if (status === 'failed') return 'red';
|
||||
if (status === 'pending_approval') return 'yellow';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function RunbookDetail() {
|
||||
const { runbookId } = useParams<{ runbookId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [runbook, setRunbook] = useState<Runbook | null>(null);
|
||||
const [executions, setExecutions] = useState<Execution[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [execMode, setExecMode] = useState<'dry_run' | 'live'>('dry_run');
|
||||
const [executing, setExecuting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!runbookId) return;
|
||||
let cancelled = false;
|
||||
Promise.all([fetchRunbook(runbookId), fetchExecutions(runbookId)])
|
||||
.then(([rb, execs]) => {
|
||||
if (!cancelled) {
|
||||
setRunbook(rb);
|
||||
setExecutions(execs);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [runbookId]);
|
||||
|
||||
async function handleExecute() {
|
||||
if (!runbookId) return;
|
||||
setExecuting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const exec = await executeRunbook(runbookId, execMode);
|
||||
setExecutions((prev) => [exec, ...prev]);
|
||||
} catch (err: any) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const execColumns = [
|
||||
{
|
||||
key: 'id',
|
||||
header: 'Execution',
|
||||
render: (row: Execution) => (
|
||||
<span className="text-gray-400 text-xs font-mono">{row.id.slice(0, 8)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'mode',
|
||||
header: 'Mode',
|
||||
render: (row: Execution) => (
|
||||
<Badge color={row.mode === 'live' ? 'red' : 'cyan'}>{row.mode}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
render: (row: Execution) => (
|
||||
<Badge color={statusColor(row.status)}>{row.status.replace('_', ' ')}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'triggered_by',
|
||||
header: 'Triggered By',
|
||||
render: (row: Execution) => (
|
||||
<span className="text-gray-400 text-sm">{row.triggered_by}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'started_at',
|
||||
header: 'Started',
|
||||
sortable: true,
|
||||
render: (row: Execution) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.started_at)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'finished_at',
|
||||
header: 'Finished',
|
||||
render: (row: Execution) => (
|
||||
<span className="text-gray-500 text-xs">{formatTime(row.finished_at)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center gap-3 text-gray-400">
|
||||
<svg className="animate-spin h-5 w-5 text-indigo-500" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span className="text-sm">Loading runbook…</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error && !runbook) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
Failed to load runbook: {error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!runbook) return null;
|
||||
|
||||
const selectClass = 'bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-200 focus:outline-none focus:border-indigo-500 transition-colors';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title={runbook.name}
|
||||
description={runbook.description}
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={() => navigate('/run')}>← Back</Button>
|
||||
<select className={selectClass} value={execMode} onChange={(e) => setExecMode(e.target.value as 'dry_run' | 'live')}>
|
||||
<option value="dry_run">Dry Run</option>
|
||||
<option value="live">Live</option>
|
||||
</select>
|
||||
<Button onClick={handleExecute} disabled={executing}>
|
||||
{executing ? 'Executing…' : '▶ Execute'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3 text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<Card header={<span className="text-sm font-semibold text-white">Details</span>}>
|
||||
<dl className="space-y-3">
|
||||
{[
|
||||
{ label: 'Author', value: runbook.author },
|
||||
{ label: 'Approval', value: runbook.requires_approval ? 'Required' : 'Auto' },
|
||||
{ label: 'Created', value: new Date(runbook.created_at).toLocaleDateString() },
|
||||
{ label: 'Updated', value: new Date(runbook.updated_at).toLocaleDateString() },
|
||||
].map((f) => (
|
||||
<div key={f.label} className="flex items-center justify-between">
|
||||
<dt className="text-xs text-gray-500 uppercase tracking-wider">{f.label}</dt>
|
||||
<dd className="text-sm text-gray-300">{f.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2" header={<span className="text-sm font-semibold text-white">YAML Definition</span>}>
|
||||
<pre className="bg-gray-950 border border-gray-800 rounded-lg p-4 overflow-x-auto text-xs text-cyan-400 font-mono leading-relaxed max-h-80 overflow-y-auto">
|
||||
<code>{runbook.yaml_content}</code>
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card noPadding header={<span className="text-sm font-semibold text-white">Execution History</span>}>
|
||||
{executions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📜"
|
||||
title="No executions yet"
|
||||
description="Run this runbook to see execution history."
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={execColumns}
|
||||
data={executions}
|
||||
rowKey={(row) => row.id}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
products/console/src/modules/run/api.ts
Normal file
80
products/console/src/modules/run/api.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { apiFetch } from '../../shell/api';
|
||||
|
||||
export interface Runbook {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
yaml_content: string;
|
||||
author: string;
|
||||
requires_approval: boolean;
|
||||
last_run?: string;
|
||||
last_status?: 'success' | 'failed' | 'running' | 'pending_approval';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Execution {
|
||||
id: string;
|
||||
runbook_id: string;
|
||||
mode: 'dry_run' | 'live';
|
||||
status: 'queued' | 'running' | 'success' | 'failed' | 'pending_approval' | 'cancelled';
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
output?: string;
|
||||
triggered_by: string;
|
||||
}
|
||||
|
||||
export interface Approval {
|
||||
id: string;
|
||||
execution_id: string;
|
||||
runbook_name: string;
|
||||
requested_by: string;
|
||||
requested_at: string;
|
||||
mode: 'live';
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
}
|
||||
|
||||
export interface RunbookCreatePayload {
|
||||
name: string;
|
||||
description: string;
|
||||
yaml_content: string;
|
||||
requires_approval?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchRunbooks(): Promise<Runbook[]> {
|
||||
return apiFetch<Runbook[]>('/api/v1/runbooks');
|
||||
}
|
||||
|
||||
export async function fetchRunbook(id: string): Promise<Runbook> {
|
||||
return apiFetch<Runbook>(`/api/v1/runbooks/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export async function createRunbook(payload: RunbookCreatePayload): Promise<Runbook> {
|
||||
return apiFetch<Runbook>('/api/v1/runbooks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeRunbook(id: string, mode: 'dry_run' | 'live'): Promise<Execution> {
|
||||
return apiFetch<Execution>(`/api/v1/runbooks/${encodeURIComponent(id)}/execute`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchExecutions(runbookId: string): Promise<Execution[]> {
|
||||
return apiFetch<Execution[]>(`/api/v1/runbooks/${encodeURIComponent(runbookId)}/executions`);
|
||||
}
|
||||
|
||||
export async function fetchApprovals(): Promise<Approval[]> {
|
||||
return apiFetch<Approval[]>('/api/v1/approvals');
|
||||
}
|
||||
|
||||
export async function approveExecution(approvalId: string): Promise<Approval> {
|
||||
return apiFetch<Approval>(`/api/v1/approvals/${encodeURIComponent(approvalId)}/approve`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function rejectExecution(approvalId: string): Promise<Approval> {
|
||||
return apiFetch<Approval>(`/api/v1/approvals/${encodeURIComponent(approvalId)}/reject`, { method: 'POST' });
|
||||
}
|
||||
21
products/console/src/modules/run/manifest.tsx
Normal file
21
products/console/src/modules/run/manifest.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import type { RouteObject } from 'react-router-dom';
|
||||
import { RunDashboard } from './RunDashboard';
|
||||
import { RunbookDetail } from './RunbookDetail';
|
||||
import { RunbookCreate } from './RunbookCreate';
|
||||
import { ApprovalQueue } from './ApprovalQueue';
|
||||
import type { ModuleManifest } from '../drift/manifest';
|
||||
|
||||
export const runManifest: ModuleManifest = {
|
||||
id: 'run',
|
||||
name: 'Runbook Automation',
|
||||
icon: '⚡',
|
||||
path: '/run',
|
||||
entitlement: 'run',
|
||||
routes: [
|
||||
{ path: 'run', element: <RunDashboard /> },
|
||||
{ path: 'run/create', element: <RunbookCreate /> },
|
||||
{ path: 'run/approvals', element: <ApprovalQueue /> },
|
||||
{ path: 'run/:runbookId', element: <RunbookDetail /> },
|
||||
],
|
||||
};
|
||||
@@ -4,14 +4,16 @@ interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, action }: PageHeaderProps) {
|
||||
export function PageHeader({ title, description, action, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight">{title}</h1>
|
||||
{description && <p className="text-gray-500 text-sm mt-1">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,13 @@ import { useAuth } from './AuthProvider';
|
||||
import { LoginPage } from './LoginPage';
|
||||
import { Layout } from './Layout';
|
||||
import { driftManifest } from '../modules/drift/manifest.js';
|
||||
import { alertManifest } from '../modules/alert/manifest.js';
|
||||
import { portalManifest } from '../modules/portal/manifest.js';
|
||||
import { costManifest } from '../modules/cost/manifest.js';
|
||||
import { runManifest } from '../modules/run/manifest.js';
|
||||
import type { ModuleManifest } from '../modules/drift/manifest.js';
|
||||
|
||||
const allModules: ModuleManifest[] = [driftManifest];
|
||||
const allModules: ModuleManifest[] = [driftManifest, alertManifest, portalManifest, costManifest, runManifest];
|
||||
|
||||
function OverviewPage() {
|
||||
return (
|
||||
|
||||
@@ -9,8 +9,13 @@ services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: dd0c
|
||||
POSTGRES_PASSWORD: dd0c-dev
|
||||
POSTGRES_USER: ${POSTGRES_USER:-dd0c}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dd0c-dev}
|
||||
DB_DRIFT_PASSWORD: ${DB_DRIFT_PASSWORD:-dd0c-dev}
|
||||
DB_ALERT_PASSWORD: ${DB_ALERT_PASSWORD:-dd0c-dev}
|
||||
DB_PORTAL_PASSWORD: ${DB_PORTAL_PASSWORD:-dd0c-dev}
|
||||
DB_COST_PASSWORD: ${DB_COST_PASSWORD:-dd0c-dev}
|
||||
DB_RUN_PASSWORD: ${DB_RUN_PASSWORD:-dd0c-dev}
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
@@ -64,9 +69,9 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_route
|
||||
DATABASE_URL: postgresql://dd0c:${POSTGRES_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_route
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-in-production!!}
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
@@ -82,7 +87,7 @@ services:
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_route
|
||||
DATABASE_URL: postgresql://dd0c:${POSTGRES_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_route
|
||||
REDIS_URL: redis://redis:6379
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
@@ -97,7 +102,7 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
command: ["dd0c-worker"]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_route
|
||||
DATABASE_URL: postgresql://dd0c:${POSTGRES_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_route
|
||||
REDIS_URL: redis://redis:6379
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
@@ -115,9 +120,9 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_drift
|
||||
DATABASE_URL: postgresql://dd0c_drift:${DB_DRIFT_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_drift
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-in-production!!}
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
@@ -134,9 +139,9 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_alert
|
||||
DATABASE_URL: postgresql://dd0c_alert:${DB_ALERT_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_alert
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-in-production!!}
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
@@ -153,10 +158,10 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_portal
|
||||
DATABASE_URL: postgresql://dd0c_portal:${DB_PORTAL_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_portal
|
||||
REDIS_URL: redis://redis:6379
|
||||
MEILI_URL: http://meilisearch:7700
|
||||
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-in-production!!}
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
@@ -174,9 +179,9 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_cost
|
||||
DATABASE_URL: postgresql://dd0c_cost:${DB_COST_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_cost
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-in-production!!}
|
||||
ANOMALY_THRESHOLD: "50"
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
@@ -194,9 +199,9 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://dd0c:dd0c-dev@postgres:5432/dd0c_run
|
||||
DATABASE_URL: postgresql://dd0c_run:${DB_RUN_PASSWORD:-dd0c-dev}@postgres:5432/dd0c_run
|
||||
REDIS_URL: redis://redis:6379
|
||||
JWT_SECRET: dev-secret-change-me-in-production!!
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me-in-production!!}
|
||||
LOG_LEVEL: info
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
|
||||
@@ -7,7 +7,26 @@ for db in dd0c_route dd0c_drift dd0c_alert dd0c_portal dd0c_cost dd0c_run; do
|
||||
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname postgres -c "CREATE DATABASE $db;" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# Run migrations for each product
|
||||
# Create per-service DB users with least-privilege access
|
||||
create_service_user() {
|
||||
local db=$1
|
||||
local user=$2
|
||||
local pass_var=$3
|
||||
local pass="${!pass_var:-dd0c-dev}"
|
||||
echo "Creating user $user for $db"
|
||||
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname postgres -c "CREATE USER $user WITH PASSWORD '$pass';" 2>/dev/null || true
|
||||
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "GRANT ALL PRIVILEGES ON DATABASE $db TO $user;" 2>/dev/null || true
|
||||
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "GRANT ALL ON SCHEMA public TO $user;" 2>/dev/null || true
|
||||
psql -v ON_ERROR_STOP=0 --username "$POSTGRES_USER" --dbname "$db" -c "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $user;" 2>/dev/null || true
|
||||
}
|
||||
|
||||
create_service_user dd0c_drift dd0c_drift DB_DRIFT_PASSWORD
|
||||
create_service_user dd0c_alert dd0c_alert DB_ALERT_PASSWORD
|
||||
create_service_user dd0c_portal dd0c_portal DB_PORTAL_PASSWORD
|
||||
create_service_user dd0c_cost dd0c_cost DB_COST_PASSWORD
|
||||
create_service_user dd0c_run dd0c_run DB_RUN_PASSWORD
|
||||
|
||||
# Run migrations for each product (as superuser so tables are created correctly)
|
||||
run_migrations() {
|
||||
local db=$1
|
||||
local dir=$2
|
||||
|
||||
13
products/shared/003_invites.sql
Normal file
13
products/shared/003_invites.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS tenant_invites (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member', 'viewer')),
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
invited_by UUID NOT NULL REFERENCES users(id),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days',
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_token ON tenant_invites(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_tenant_invites_email ON tenant_invites(email);
|
||||
@@ -15,21 +15,11 @@ export interface AuthPayload {
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT auth middleware. Extracts tenant context from Bearer token.
|
||||
* Also supports API key auth via `X-API-Key` header (dd0c_ prefix).
|
||||
* Returns an onRequest hook that validates JWT or API key auth.
|
||||
* No URL matching — only register this hook inside a protected plugin scope.
|
||||
*/
|
||||
export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.decorateRequest('tenantId', '');
|
||||
app.decorateRequest('userId', '');
|
||||
app.decorateRequest('userRole', 'viewer');
|
||||
|
||||
app.addHook('onRequest', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (req.url === '/health') return;
|
||||
if (req.url.startsWith('/webhooks/')) return;
|
||||
if (req.url.startsWith('/slack/')) return;
|
||||
const path = req.url.split('?')[0];
|
||||
if (path === '/api/v1/auth/login' || path === '/api/v1/auth/signup') return;
|
||||
|
||||
export function authHook(jwtSecret: string, pool: Pool) {
|
||||
return async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
const apiKey = req.headers['x-api-key'] as string | undefined;
|
||||
const authHeader = req.headers['authorization'];
|
||||
|
||||
@@ -38,7 +28,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
return reply.status(401).send({ error: 'Invalid API key format' });
|
||||
}
|
||||
|
||||
const prefix = apiKey.slice(0, 13); // dd0c_ + 8 hex chars
|
||||
const prefix = apiKey.slice(0, 13);
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -61,7 +51,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, jwtSecret) as AuthPayload;
|
||||
const payload = jwt.verify(token, jwtSecret, { algorithms: ['HS256'] }) as AuthPayload;
|
||||
(req as any).tenantId = payload.tenantId;
|
||||
(req as any).userId = payload.userId;
|
||||
(req as any).userRole = payload.role;
|
||||
@@ -72,7 +62,7 @@ export function registerAuth(app: FastifyInstance, jwtSecret: string, pool: Pool
|
||||
}
|
||||
|
||||
return reply.status(401).send({ error: 'Missing authentication' });
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: AuthPayload['role']): boolean {
|
||||
@@ -88,7 +78,7 @@ export function requireRole(req: FastifyRequest, reply: FastifyReply, minRole: A
|
||||
}
|
||||
|
||||
export function signToken(payload: AuthPayload, secret: string, expiresIn = '24h'): string {
|
||||
return jwt.sign(payload, secret, { expiresIn });
|
||||
return jwt.sign(payload, secret, { expiresIn } as jwt.SignOptions);
|
||||
}
|
||||
|
||||
// --- Password hashing (scrypt — no native bcrypt dep needed) ---
|
||||
@@ -123,11 +113,21 @@ const loginSchema = z.object({
|
||||
const signupSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
tenant_name: z.string().min(1).max(100),
|
||||
tenant_name: z.string().min(1).max(100).optional(),
|
||||
invite_token: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => data.invite_token || data.tenant_name,
|
||||
{ message: 'Either tenant_name or invite_token is required', path: ['tenant_name'] },
|
||||
);
|
||||
|
||||
const inviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.enum(['admin', 'member', 'viewer']).default('member'),
|
||||
});
|
||||
|
||||
/** Public auth routes — login/signup. No auth required. */
|
||||
export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.post('/api/v1/auth/login', async (req, reply) => {
|
||||
app.post('/api/v1/auth/login', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = loginSchema.parse(req.body);
|
||||
|
||||
const result = await pool.query(
|
||||
@@ -152,29 +152,66 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
return { token, expires_in: '24h' };
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/signup', async (req, reply) => {
|
||||
app.post('/api/v1/auth/signup', { config: { rateLimit: { max: 3, timeWindow: '1 minute' } } }, async (req, reply) => {
|
||||
const body = signupSchema.parse(req.body);
|
||||
|
||||
// Check if email already exists
|
||||
const existing = await pool.query('SELECT id FROM users WHERE email = $1', [body.email]);
|
||||
if (existing.rows[0]) return reply.status(409).send({ error: 'Email already registered' });
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 50);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
const tenantId = tenant.rows[0].id;
|
||||
let tenantId: string;
|
||||
let role: string;
|
||||
|
||||
if (body.invite_token) {
|
||||
// Invite-based signup
|
||||
const invite = await client.query(
|
||||
`SELECT id, tenant_id, role, expires_at, accepted_at FROM tenant_invites WHERE token = $1`,
|
||||
[body.invite_token],
|
||||
);
|
||||
if (!invite.rows[0]) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invalid invite token' });
|
||||
}
|
||||
const inv = invite.rows[0];
|
||||
if (inv.accepted_at) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite already accepted' });
|
||||
}
|
||||
if (new Date(inv.expires_at) < new Date()) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'Invite expired' });
|
||||
}
|
||||
|
||||
tenantId = inv.tenant_id;
|
||||
role = inv.role;
|
||||
|
||||
await client.query(
|
||||
`UPDATE tenant_invites SET accepted_at = NOW() WHERE id = $1`,
|
||||
[inv.id],
|
||||
);
|
||||
} else {
|
||||
// Normal signup — create new tenant
|
||||
if (!body.tenant_name) {
|
||||
await client.query('ROLLBACK');
|
||||
return reply.status(400).send({ error: 'tenant_name is required for new signups' });
|
||||
}
|
||||
const slug = body.tenant_name.toLowerCase().replace(/[^a-z0-9]+/g, '-').slice(0, 42) + '-' + crypto.randomBytes(3).toString('hex');
|
||||
const tenant = await client.query(
|
||||
`INSERT INTO tenants (name, slug) VALUES ($1, $2) RETURNING id`,
|
||||
[body.tenant_name, slug],
|
||||
);
|
||||
tenantId = tenant.rows[0].id;
|
||||
role = 'owner';
|
||||
}
|
||||
|
||||
const user = await client.query(
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, 'owner') RETURNING id`,
|
||||
[tenantId, body.email, passwordHash],
|
||||
`INSERT INTO users (tenant_id, email, password_hash, role) VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||
[tenantId, body.email, passwordHash, role],
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
@@ -183,7 +220,7 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
tenantId,
|
||||
userId: user.rows[0].id,
|
||||
email: body.email,
|
||||
role: 'owner',
|
||||
role: role as AuthPayload['role'],
|
||||
}, jwtSecret);
|
||||
|
||||
return reply.status(201).send({ token, tenant_id: tenantId, expires_in: '24h' });
|
||||
@@ -194,7 +231,10 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Protected auth routes — me, api-keys, invites. Must be registered inside an auth-protected plugin scope. */
|
||||
export function registerProtectedAuthRoutes(app: FastifyInstance, jwtSecret: string, pool: Pool) {
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
return {
|
||||
tenant_id: (req as any).tenantId,
|
||||
@@ -203,7 +243,6 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
};
|
||||
});
|
||||
|
||||
// Generate API key
|
||||
app.post('/api/v1/auth/api-keys', async (req, reply) => {
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
@@ -219,7 +258,56 @@ export function registerAuthRoutes(app: FastifyInstance, jwtSecret: string, pool
|
||||
[tenantId, userId, prefix, keyHash],
|
||||
);
|
||||
|
||||
// Return the raw key ONCE — it's never stored or retrievable again
|
||||
return reply.status(201).send({ api_key: rawKey, prefix });
|
||||
});
|
||||
|
||||
// --- Invite endpoints ---
|
||||
|
||||
// Create invite (admin+)
|
||||
app.post('/api/v1/auth/invite', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const userId = (req as any).userId;
|
||||
const body = inviteSchema.parse(req.body);
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const result = await pool.query(
|
||||
`INSERT INTO tenant_invites (tenant_id, email, role, token, invited_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING expires_at`,
|
||||
[tenantId, body.email, body.role, token, userId],
|
||||
);
|
||||
|
||||
return reply.status(201).send({ invite_token: token, expires_at: result.rows[0].expires_at });
|
||||
});
|
||||
|
||||
// List pending invites (admin+)
|
||||
app.get('/api/v1/auth/invites', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, role, expires_at, created_at FROM tenant_invites
|
||||
WHERE tenant_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
|
||||
ORDER BY created_at DESC`,
|
||||
[tenantId],
|
||||
);
|
||||
|
||||
return { invites: result.rows };
|
||||
});
|
||||
|
||||
// Revoke invite (admin+)
|
||||
app.delete('/api/v1/auth/invites/:id', async (req, reply) => {
|
||||
if (!requireRole(req, reply, 'admin')) return;
|
||||
const tenantId = (req as any).tenantId;
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM tenant_invites WHERE id = $1 AND tenant_id = $2 AND accepted_at IS NULL RETURNING id`,
|
||||
[id, tenantId],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) return reply.status(404).send({ error: 'Invite not found' });
|
||||
return { deleted: true };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user