Domain Admin

This commit is contained in:
2026-04-27 16:57:08 -05:00
parent 31b3fd8c9f
commit 85b608e3d4
10 changed files with 903 additions and 61 deletions

View File

@@ -0,0 +1,26 @@
-- ============================================================
-- 002_admin_users_self_service.sql
-- Phase 2: Domain-Admin support.
--
-- The admin_users table already has all required columns from 001_init.sql:
-- - role TEXT NOT NULL DEFAULT 'super_admin'
-- - allowed_domains TEXT[] NOT NULL DEFAULT '{}'
-- - active BOOLEAN NOT NULL DEFAULT true
--
-- This migration just adds a check constraint on role and an index
-- for fast role/active lookups.
-- ============================================================
-- Drop any old/legacy variants of the constraint to make this migration
-- idempotent across environments.
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_chk;
ALTER TABLE admin_users DROP CONSTRAINT IF EXISTS admin_users_role_check;
-- Reapply the canonical check constraint.
ALTER TABLE admin_users
ADD CONSTRAINT admin_users_role_check
CHECK (role IN ('super_admin', 'domain_admin'));
-- Useful index for role-based filtering.
CREATE INDEX IF NOT EXISTS idx_admin_users_role_active
ON admin_users(role, active);

View File

@@ -33,6 +33,18 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
}
export function requireSuperAdmin(req: Request, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: 'Not authenticated' });
return;
}
if (req.user.role !== 'super_admin') {
res.status(403).json({ error: 'Forbidden: super_admin role required' });
return;
}
next();
}
export function canAccessDomain(user: AuthUser, domain: string): boolean {
return user.role === 'super_admin' || user.allowed_domains.includes(domain.toLowerCase());
}

View File

@@ -0,0 +1,158 @@
import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { z } from 'zod';
import { pool } from '../db.js';
import { requireAuth, requireSuperAdmin } from '../middleware/auth.js';
import { audit } from '../services/audit.js';
export const adminsRouter = Router();
adminsRouter.use(requireAuth);
adminsRouter.use(requireSuperAdmin);
// All routes here are super_admin only — no exceptions.
adminsRouter.get('/', async (_req, res) => {
const result = await pool.query(
`SELECT id, email, role, allowed_domains, active, created_at, updated_at
FROM admin_users
ORDER BY role, email`
);
res.json(result.rows);
});
const createSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
role: z.enum(['super_admin', 'domain_admin']),
// For domain_admin this should not be empty; we enforce that below.
allowed_domains: z.array(z.string().min(1)).optional(),
});
adminsRouter.post('/', async (req, res) => {
const body = createSchema.parse(req.body);
const email = body.email.toLowerCase();
const allowed = (body.allowed_domains ?? []).map((d) => d.trim().toLowerCase()).filter(Boolean);
if (body.role === 'domain_admin' && allowed.length === 0) {
res.status(400).json({ error: 'A domain_admin must have at least one allowed domain' });
return;
}
if (body.role === 'super_admin' && allowed.length > 0) {
// super_admin implicitly has access to everything; reject ambiguous input.
res.status(400).json({ error: 'super_admin must not have allowed_domains set' });
return;
}
const existing = await pool.query(`SELECT id FROM admin_users WHERE email=$1`, [email]);
if ((existing.rowCount ?? 0) > 0) {
res.status(409).json({ error: 'An admin with this email already exists' });
return;
}
const passwordHash = await bcrypt.hash(body.password, 12);
const inserted = await pool.query(
`INSERT INTO admin_users (email, password_hash, role, allowed_domains, active)
VALUES ($1, $2, $3, $4, true)
RETURNING id, email, role, allowed_domains, active, created_at, updated_at`,
[email, passwordHash, body.role, allowed],
);
await audit(req.user!.email, 'admin.create', 'admin', email, { role: body.role, allowed_domains: allowed }, req.ip);
res.status(201).json(inserted.rows[0]);
});
const updateSchema = z.object({
// Email is the natural id but we allow editing the rest.
password: z.string().min(8).optional(),
role: z.enum(['super_admin', 'domain_admin']).optional(),
allowed_domains: z.array(z.string().min(1)).optional(),
active: z.boolean().optional(),
});
adminsRouter.put('/:email', async (req, res) => {
const targetEmail = String(req.params.email || '').toLowerCase();
const body = updateSchema.parse(req.body);
// Safety: don't let the super_admin demote or deactivate themselves and
// lock the system out.
if (targetEmail === req.user!.email.toLowerCase()) {
if (body.role && body.role !== 'super_admin') {
res.status(400).json({ error: 'You cannot change your own role' });
return;
}
if (body.active === false) {
res.status(400).json({ error: 'You cannot deactivate yourself' });
return;
}
}
const existing = await pool.query(`SELECT id, role FROM admin_users WHERE email=$1`, [targetEmail]);
const row = existing.rows[0];
if (!row) { res.status(404).json({ error: 'Admin not found' }); return; }
// Determine the effective new role and allowed_domains for validation.
const newRole = body.role ?? row.role;
const newAllowed = body.allowed_domains?.map((d) => d.trim().toLowerCase()).filter(Boolean);
if (newRole === 'domain_admin' && newAllowed !== undefined && newAllowed.length === 0) {
res.status(400).json({ error: 'A domain_admin must have at least one allowed domain' });
return;
}
if (newRole === 'super_admin' && newAllowed !== undefined && newAllowed.length > 0) {
res.status(400).json({ error: 'super_admin must not have allowed_domains set' });
return;
}
// Build dynamic SET clause.
const sets: string[] = [];
const params: unknown[] = [];
const push = (frag: string, val: unknown) => {
params.push(val);
sets.push(`${frag}=$${params.length}`);
};
if (body.password !== undefined) {
push('password_hash', await bcrypt.hash(body.password, 12));
}
if (body.role !== undefined) push('role', body.role);
if (newAllowed !== undefined) push('allowed_domains', newAllowed);
if (body.active !== undefined) push('active', body.active);
if (sets.length === 0) {
res.json({ ok: true, unchanged: true });
return;
}
sets.push(`updated_at=now()`);
params.push(targetEmail);
const updated = await pool.query(
`UPDATE admin_users SET ${sets.join(', ')} WHERE email=$${params.length}
RETURNING id, email, role, allowed_domains, active, created_at, updated_at`,
params,
);
// Build a sanitized audit detail (no password leakage).
const auditDetails: Record<string, unknown> = {};
if (body.role !== undefined) auditDetails.role = body.role;
if (newAllowed !== undefined) auditDetails.allowed_domains = newAllowed;
if (body.active !== undefined) auditDetails.active = body.active;
if (body.password !== undefined) auditDetails.password_changed = true;
await audit(req.user!.email, 'admin.update', 'admin', targetEmail, auditDetails, req.ip);
res.json(updated.rows[0]);
});
adminsRouter.delete('/:email', async (req, res) => {
const targetEmail = String(req.params.email || '').toLowerCase();
if (targetEmail === req.user!.email.toLowerCase()) {
res.status(400).json({ error: 'You cannot delete yourself' });
return;
}
const result = await pool.query(`DELETE FROM admin_users WHERE email=$1`, [targetEmail]);
if ((result.rowCount ?? 0) === 0) {
res.status(404).json({ error: 'Admin not found' });
return;
}
await audit(req.user!.email, 'admin.delete', 'admin', targetEmail, {}, req.ip);
res.json({ ok: true });
});

View File

@@ -5,9 +5,45 @@ import { requireAuth } from '../middleware/auth.js';
export const auditRouter = Router();
auditRouter.use(requireAuth);
auditRouter.get('/', async (_req, res) => {
auditRouter.get('/', async (req, res) => {
const user = req.user!;
if (user.role === 'super_admin') {
// Super admin sees everything.
const result = await pool.query(
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
);
res.json(result.rows);
return;
}
// Domain admin: filter to entries that touch one of their domains.
// We fetch a slightly larger window because filtering happens after the LIMIT
// would skip relevant rows. 1000 should be plenty for a single domain.
const allowed = (user.allowed_domains ?? []).map((d) => d.toLowerCase());
if (allowed.length === 0) {
res.json([]);
return;
}
const result = await pool.query(
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 200`,
`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT 1000`,
);
res.json(result.rows);
const visible = result.rows
.filter((row) => {
const t = String(row.target_type ?? '').toLowerCase();
const id = String(row.target_id ?? '').toLowerCase();
if (t === 'domain') return allowed.includes(id);
if (t === 'mailbox') {
const at = id.lastIndexOf('@');
if (at < 0) return false;
return allowed.includes(id.slice(at + 1));
}
// 'node' and unknown types: globally scoped, hidden from domain admins.
return false;
})
.slice(0, 200);
res.json(visible);
});

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import { pool } from '../db.js';
import { config } from '../config.js';
import { requireAuth, signUser } from '../middleware/auth.js';
import { audit } from '../services/audit.js';
export const authRouter = Router();
@@ -38,3 +39,30 @@ authRouter.post('/logout', (_req, res) => {
authRouter.get('/me', requireAuth, (req, res) => {
res.json(req.user);
});
// Self-service password change. Requires the current password to prevent
// session hijacking from changing the password silently.
const changePwSchema = z.object({
current_password: z.string().min(1),
new_password: z.string().min(8),
});
authRouter.post('/change-password', requireAuth, async (req, res) => {
const body = changePwSchema.parse(req.body);
const result = await pool.query(
`SELECT id, password_hash FROM admin_users WHERE email=$1 AND active=true`,
[req.user!.email.toLowerCase()],
);
const row = result.rows[0];
if (!row || !(await bcrypt.compare(body.current_password, row.password_hash))) {
res.status(401).json({ error: 'Current password is incorrect' });
return;
}
const newHash = await bcrypt.hash(body.new_password, 12);
await pool.query(
`UPDATE admin_users SET password_hash=$1, updated_at=now() WHERE id=$2`,
[newHash, row.id],
);
await audit(req.user!.email, 'admin.self_password_change', 'admin', req.user!.email, {}, req.ip);
res.json({ ok: true });
});

View File

@@ -9,6 +9,7 @@ import { authRouter } from './routes/auth.js';
import { domainsRouter } from './routes/domains.js';
import { mailboxesRouter } from './routes/mailboxes.js';
import { auditRouter } from './routes/audit.js';
import { adminsRouter } from './routes/admins.js';
import { SyncService } from './services/sync.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -41,6 +42,7 @@ app.use('/api/auth', authRouter);
app.use('/api/domains', domainsRouter);
app.use('/api/mailboxes', mailboxesRouter);
app.use('/api/audit', auditRouter);
app.use('/api/admins', adminsRouter);
app.use((err: any, req: express.Request, res: express.Response, _next: express.NextFunction) => {
const status = err.status ?? err.statusCode ?? 500;
@@ -93,4 +95,4 @@ try {
app.listen(config.port, () => {
console.log(`mailadmin listening on ${config.port} for ${config.nodeName}`);
});
});