Domain Admin
This commit is contained in:
26
backend/migrations/002_admin_users_self_service.sql
Normal file
26
backend/migrations/002_admin_users_self_service.sql
Normal 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);
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
158
backend/src/routes/admins.ts
Normal file
158
backend/src/routes/admins.ts
Normal 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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user