diff --git a/products/02-iac-drift-detection/saas/migrations/001_init.sql b/products/02-iac-drift-detection/saas/migrations/001_init.sql new file mode 100644 index 0000000..bbf8f45 --- /dev/null +++ b/products/02-iac-drift-detection/saas/migrations/001_init.sql @@ -0,0 +1,115 @@ +-- dd0c/drift V1 schema — PostgreSQL with RLS + +-- Enable RLS +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Tenants +CREATE TABLE tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + tier TEXT NOT NULL DEFAULT 'free' CHECK (tier IN ('free', 'pro', 'enterprise')), + stripe_customer_id TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Users +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + email TEXT NOT NULL, + name TEXT, + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'member', 'viewer')), + github_id BIGINT UNIQUE, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_users_tenant ON users(tenant_id); + +-- Agent API keys (mTLS + bearer) +CREATE TABLE agent_keys ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + key_prefix CHAR(8) NOT NULL, + key_hash TEXT NOT NULL, + name TEXT NOT NULL DEFAULT 'Default Agent', + cert_fingerprint TEXT, -- mTLS cert SHA256 + revoked_at TIMESTAMPTZ, + last_seen_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX idx_agent_keys_prefix ON agent_keys(key_prefix) WHERE revoked_at IS NULL; + +-- Drift reports (core table) +CREATE TABLE drift_reports ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + stack_name TEXT NOT NULL, + stack_fingerprint TEXT NOT NULL, + agent_version TEXT NOT NULL, + scanned_at TIMESTAMPTZ NOT NULL, + state_serial BIGINT NOT NULL, + lineage TEXT NOT NULL, + total_resources INT NOT NULL, + drift_score NUMERIC(5,2) NOT NULL DEFAULT 0, + nonce TEXT NOT NULL UNIQUE, + raw_report JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_drift_reports_tenant_stack ON drift_reports(tenant_id, stack_name, scanned_at DESC); +CREATE INDEX idx_drift_reports_score ON drift_reports(tenant_id, drift_score) WHERE drift_score > 0; + +-- Remediation actions +CREATE TABLE remediation_actions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + report_id UUID NOT NULL REFERENCES drift_reports(id) ON DELETE CASCADE, + stack_name TEXT NOT NULL, + action_type TEXT NOT NULL CHECK (action_type IN ('plan', 'apply', 'accept', 'ignore')), + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'executing', 'completed', 'failed', 'aborted')), + plan_output TEXT, + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_remediation_tenant ON remediation_actions(tenant_id, stack_name); + +-- Notification preferences +CREATE TABLE notification_configs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + channel TEXT NOT NULL CHECK (channel IN ('slack', 'email', 'webhook', 'pagerduty')), + config JSONB NOT NULL DEFAULT '{}', + min_severity TEXT NOT NULL DEFAULT 'medium' CHECK (min_severity IN ('critical', 'high', 'medium', 'low')), + enabled BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, channel) +); + +-- Feature flags +CREATE TABLE feature_flags ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + flag_key TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + rollout_pct INT NOT NULL DEFAULT 0 CHECK (rollout_pct BETWEEN 0 AND 100), + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(tenant_id, flag_key) +); + +-- Row Level Security +ALTER TABLE drift_reports ENABLE ROW LEVEL SECURITY; +ALTER TABLE remediation_actions ENABLE ROW LEVEL SECURITY; +ALTER TABLE notification_configs ENABLE ROW LEVEL SECURITY; + +CREATE POLICY tenant_isolation_drift ON drift_reports + USING (tenant_id::text = current_setting('app.tenant_id', true)); + +CREATE POLICY tenant_isolation_remediation ON remediation_actions + USING (tenant_id::text = current_setting('app.tenant_id', true)); + +CREATE POLICY tenant_isolation_notifications ON notification_configs + USING (tenant_id::text = current_setting('app.tenant_id', true)); diff --git a/products/02-iac-drift-detection/saas/package.json b/products/02-iac-drift-detection/saas/package.json new file mode 100644 index 0000000..16e9b63 --- /dev/null +++ b/products/02-iac-drift-detection/saas/package.json @@ -0,0 +1,41 @@ +{ + "name": "dd0c-drift-saas", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src/ tests/" + }, + "dependencies": { + "fastify": "^4.28.0", + "@fastify/cors": "^9.0.0", + "@fastify/helmet": "^11.1.0", + "pg": "^8.12.0", + "drizzle-orm": "^0.31.0", + "ioredis": "^5.4.0", + "zod": "^3.23.0", + "jsonwebtoken": "^9.0.2", + "bcryptjs": "^2.4.3", + "pino": "^9.1.0", + "uuid": "^9.0.1", + "@aws-sdk/client-sqs": "^3.600.0", + "@aws-sdk/client-s3": "^3.600.0" + }, + "devDependencies": { + "typescript": "^5.5.0", + "tsx": "^4.15.0", + "vitest": "^1.6.0", + "@types/node": "^20.14.0", + "@types/pg": "^8.11.0", + "@types/jsonwebtoken": "^9.0.6", + "@types/bcryptjs": "^2.4.6", + "@types/uuid": "^9.0.8", + "drizzle-kit": "^0.22.0", + "eslint": "^9.5.0" + } +} diff --git a/products/02-iac-drift-detection/saas/src/api/routes.ts b/products/02-iac-drift-detection/saas/src/api/routes.ts new file mode 100644 index 0000000..0d305a3 --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/api/routes.ts @@ -0,0 +1,102 @@ +import { FastifyInstance } from 'fastify'; +import { withTenant } from '../data/db.js'; + +export async function registerApiRoutes(app: FastifyInstance) { + // List stacks + app.get('/api/v1/stacks', async (request, reply) => { + const tenantId = (request as any).tenantId; + const pool = (app as any).pool; + + const result = await withTenant(pool, tenantId, async (client) => { + const { rows } = await client.query( + `SELECT DISTINCT ON (stack_name) + stack_name, stack_fingerprint, drift_score, total_resources, scanned_at, state_serial + FROM drift_reports + WHERE tenant_id = $1 + ORDER BY stack_name, scanned_at DESC`, + [tenantId] + ); + return rows; + }); + + return reply.send(result); + }); + + // Get stack drift history + app.get('/api/v1/stacks/:stackName/history', async (request, reply) => { + const tenantId = (request as any).tenantId; + const { stackName } = request.params as { stackName: string }; + const pool = (app as any).pool; + + const result = await withTenant(pool, tenantId, async (client) => { + const { rows } = await client.query( + `SELECT id, drift_score, total_resources, scanned_at, state_serial, + (raw_report->'drifted_resources') as drifted_resources + FROM drift_reports + WHERE tenant_id = $1 AND stack_name = $2 + ORDER BY scanned_at DESC + LIMIT 50`, + [tenantId, stackName] + ); + return rows; + }); + + return reply.send(result); + }); + + // Get single drift report + app.get('/api/v1/reports/:reportId', async (request, reply) => { + const tenantId = (request as any).tenantId; + const { reportId } = request.params as { reportId: string }; + const pool = (app as any).pool; + + const result = await withTenant(pool, tenantId, async (client) => { + const { rows } = await client.query( + `SELECT * FROM drift_reports WHERE tenant_id = $1 AND id = $2`, + [tenantId, reportId] + ); + return rows[0] ?? null; + }); + + if (!result) { + return reply.status(404).send({ error: 'Not found' }); + } + + return reply.send(result); + }); + + // Dashboard summary + app.get('/api/v1/dashboard', async (request, reply) => { + const tenantId = (request as any).tenantId; + const pool = (app as any).pool; + + const result = await withTenant(pool, tenantId, async (client) => { + const stacks = await client.query( + `SELECT COUNT(DISTINCT stack_name) as stack_count FROM drift_reports WHERE tenant_id = $1`, + [tenantId] + ); + const drifted = await client.query( + `SELECT COUNT(DISTINCT stack_name) as drifted_count + FROM drift_reports + WHERE tenant_id = $1 AND drift_score > 0 + AND scanned_at = (SELECT MAX(scanned_at) FROM drift_reports r2 WHERE r2.stack_name = drift_reports.stack_name AND r2.tenant_id = $1)`, + [tenantId] + ); + const critical = await client.query( + `SELECT COUNT(*) as critical_count + FROM drift_reports + WHERE tenant_id = $1 AND drift_score >= 80 + AND scanned_at >= NOW() - INTERVAL '24 hours'`, + [tenantId] + ); + + return { + total_stacks: parseInt(stacks.rows[0]?.stack_count ?? '0'), + drifted_stacks: parseInt(drifted.rows[0]?.drifted_count ?? '0'), + critical_last_24h: parseInt(critical.rows[0]?.critical_count ?? '0'), + }; + }); + + return reply.send(result); + }); +} diff --git a/products/02-iac-drift-detection/saas/src/config/index.ts b/products/02-iac-drift-detection/saas/src/config/index.ts new file mode 100644 index 0000000..239ec52 --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/config/index.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +const envSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().default(3000), + 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('*'), + LOG_LEVEL: z.string().default('info'), + SQS_QUEUE_URL: z.string().optional(), + S3_BUCKET: z.string().default('dd0c-drift-snapshots'), + SLACK_WEBHOOK_URL: z.string().optional(), +}); + +const parsed = envSchema.safeParse(process.env); +if (!parsed.success) { + console.error('Invalid environment:', parsed.error.flatten()); + process.exit(1); +} + +export const config = { + nodeEnv: parsed.data.NODE_ENV, + port: parsed.data.PORT, + databaseUrl: parsed.data.DATABASE_URL, + redisUrl: parsed.data.REDIS_URL, + jwtSecret: parsed.data.JWT_SECRET, + corsOrigin: parsed.data.CORS_ORIGIN, + logLevel: parsed.data.LOG_LEVEL, + sqsQueueUrl: parsed.data.SQS_QUEUE_URL, + s3Bucket: parsed.data.S3_BUCKET, + slackWebhookUrl: parsed.data.SLACK_WEBHOOK_URL, +}; + +export type Config = typeof config; diff --git a/products/02-iac-drift-detection/saas/src/data/db.ts b/products/02-iac-drift-detection/saas/src/data/db.ts new file mode 100644 index 0000000..c5852d7 --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/data/db.ts @@ -0,0 +1,51 @@ +import pg from 'pg'; + +export function createPool(connectionString: string): pg.Pool { + return new pg.Pool({ + connectionString, + max: 20, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000, + }); +} + +/** + * Set tenant context on a pooled connection for RLS. + * MUST be called before any query in a multi-tenant context. + * MUST be cleared when returning the connection to the pool. + */ +export async function setTenantContext(client: pg.PoolClient, tenantId: string): Promise { + await client.query('SET LOCAL app.tenant_id = $1', [tenantId]); +} + +/** + * Clear tenant context — call this in a finally block before releasing the client. + */ +export async function clearTenantContext(client: pg.PoolClient): Promise { + await client.query('RESET app.tenant_id'); +} + +/** + * Execute a query within a tenant-scoped transaction. + * Handles SET LOCAL + RESET automatically to prevent RLS leakage via connection pooling. + */ +export async function withTenant( + pool: pg.Pool, + tenantId: string, + fn: (client: pg.PoolClient) => Promise, +): Promise { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await setTenantContext(client, tenantId); + const result = await fn(client); + await client.query('COMMIT'); + return result; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + await clearTenantContext(client); + client.release(); + } +} diff --git a/products/02-iac-drift-detection/saas/src/data/redis.ts b/products/02-iac-drift-detection/saas/src/data/redis.ts new file mode 100644 index 0000000..bf29b87 --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/data/redis.ts @@ -0,0 +1,10 @@ +import Redis from 'ioredis'; + +export function createRedis(url: string): Redis { + return new Redis(url, { + maxRetriesPerRequest: 3, + retryStrategy(times) { + return Math.min(times * 200, 3000); + }, + }); +} diff --git a/products/02-iac-drift-detection/saas/src/index.ts b/products/02-iac-drift-detection/saas/src/index.ts new file mode 100644 index 0000000..92e1794 --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/index.ts @@ -0,0 +1,48 @@ +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 { createPool } from './data/db.js'; +import { createRedis } from './data/redis.js'; + +const app = Fastify({ + logger: { + level: config.logLevel, + transport: config.nodeEnv === 'development' + ? { target: 'pino-pretty' } + : undefined, + }, +}); + +async function start() { + await app.register(cors, { origin: config.corsOrigin }); + await app.register(helmet); + + // Data layer + const pool = createPool(config.databaseUrl); + const redis = createRedis(config.redisUrl); + + // Decorate fastify instance + app.decorate('pool', pool); + app.decorate('redis', redis); + app.decorate('config', config); + + // Routes + await registerProcessorRoutes(app); + await registerApiRoutes(app); + + // Health + app.get('/health', async () => ({ status: 'ok' })); + + await app.listen({ port: config.port, host: '0.0.0.0' }); + app.log.info(`dd0c/drift SaaS listening on :${config.port}`); +} + +start().catch((err) => { + console.error('Failed to start:', err); + process.exit(1); +}); + +export { app }; diff --git a/products/02-iac-drift-detection/saas/src/processor/routes.ts b/products/02-iac-drift-detection/saas/src/processor/routes.ts new file mode 100644 index 0000000..858a1f9 --- /dev/null +++ b/products/02-iac-drift-detection/saas/src/processor/routes.ts @@ -0,0 +1,64 @@ +import { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { withTenant } from '../data/db.js'; + +const driftReportSchema = z.object({ + stack_name: z.string(), + stack_fingerprint: z.string(), + agent_version: z.string(), + scanned_at: z.string(), + state_serial: z.number(), + lineage: z.string(), + total_resources: z.number(), + drifted_resources: z.array(z.object({ + resource_type: z.string(), + resource_address: z.string(), + module: z.string().optional(), + provider: z.string(), + severity: z.enum(['critical', 'high', 'medium', 'low']), + diffs: z.array(z.object({ + attribute_name: z.string(), + old_value: z.string(), + new_value: z.string(), + sensitive: z.boolean(), + })), + })), + drift_score: z.number(), + nonce: z.string(), +}); + +export async function registerProcessorRoutes(app: FastifyInstance) { + app.post('/v1/ingest/drift', async (request, reply) => { + const parsed = driftReportSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ error: 'Invalid drift report', details: parsed.error.flatten() }); + } + + const report = parsed.data; + const tenantId = (request as any).tenantId; + + // Nonce replay prevention + const redis = (app as any).redis; + const nonceKey = `nonce:${report.nonce}`; + const exists = await redis.exists(nonceKey); + if (exists) { + return reply.status(409).send({ error: 'Nonce already used (replay rejected)' }); + } + await redis.setex(nonceKey, 86400, '1'); // 24h TTL + + // Persist + const pool = (app as any).pool; + await withTenant(pool, tenantId, async (client) => { + await client.query( + `INSERT INTO drift_reports (tenant_id, stack_name, stack_fingerprint, agent_version, scanned_at, state_serial, lineage, total_resources, drift_score, nonce, raw_report) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, + [tenantId, report.stack_name, report.stack_fingerprint, report.agent_version, report.scanned_at, report.state_serial, report.lineage, report.total_resources, report.drift_score, report.nonce, JSON.stringify(report)] + ); + }); + + // TODO: Trigger notification if drift_score > threshold + // TODO: Trigger remediation workflow if auto-remediate enabled + + return reply.status(201).send({ status: 'accepted' }); + }); +} diff --git a/products/02-iac-drift-detection/saas/tsconfig.json b/products/02-iac-drift-detection/saas/tsconfig.json new file mode 100644 index 0000000..eec8948 --- /dev/null +++ b/products/02-iac-drift-detection/saas/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +}