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:
@@ -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'`,
|
||||
|
||||
Reference in New Issue
Block a user