Scaffold dd0c/drift SaaS backend: Fastify, RLS, ingestion, dashboard API
- Fastify server with Zod validation, pino logging, CORS/helmet - Drift report ingestion endpoint with nonce replay prevention - Dashboard API: stacks list, drift history, report detail, summary stats - PostgreSQL schema with RLS: tenants, users, agent_keys, drift_reports, remediation_actions - withTenant() helper for safe connection pool tenant context management - Config via Zod-validated env vars
This commit is contained in:
115
products/02-iac-drift-detection/saas/migrations/001_init.sql
Normal file
115
products/02-iac-drift-detection/saas/migrations/001_init.sql
Normal file
@@ -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));
|
||||||
41
products/02-iac-drift-detection/saas/package.json
Normal file
41
products/02-iac-drift-detection/saas/package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
products/02-iac-drift-detection/saas/src/api/routes.ts
Normal file
102
products/02-iac-drift-detection/saas/src/api/routes.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
35
products/02-iac-drift-detection/saas/src/config/index.ts
Normal file
35
products/02-iac-drift-detection/saas/src/config/index.ts
Normal file
@@ -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;
|
||||||
51
products/02-iac-drift-detection/saas/src/data/db.ts
Normal file
51
products/02-iac-drift-detection/saas/src/data/db.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<T>(
|
||||||
|
pool: pg.Pool,
|
||||||
|
tenantId: string,
|
||||||
|
fn: (client: pg.PoolClient) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
10
products/02-iac-drift-detection/saas/src/data/redis.ts
Normal file
10
products/02-iac-drift-detection/saas/src/data/redis.ts
Normal file
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
48
products/02-iac-drift-detection/saas/src/index.ts
Normal file
48
products/02-iac-drift-detection/saas/src/index.ts
Normal file
@@ -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 };
|
||||||
64
products/02-iac-drift-detection/saas/src/processor/routes.ts
Normal file
64
products/02-iac-drift-detection/saas/src/processor/routes.ts
Normal file
@@ -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' });
|
||||||
|
});
|
||||||
|
}
|
||||||
20
products/02-iac-drift-detection/saas/tsconfig.json
Normal file
20
products/02-iac-drift-detection/saas/tsconfig.json
Normal file
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user