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

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:
2026-03-02 23:53:55 +00:00
parent be3f37cfdd
commit eb953cdea5
67 changed files with 4461 additions and 410 deletions

View 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);

View File

@@ -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",

View File

@@ -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 };
});
}

View File

@@ -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'),
});

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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'`,