#21: complete the account area and audit every admin mutation
Sessions and password changes were the two things PLAN.md section 14.2 asks of the account area that were still missing, and the admin console had no audit trail at all despite section 14.3 requiring one for every mutation. Session revocation is enforced on every request, not just recorded. A JWT carries its own validity, so revoking a row without checking it would have made "sign out this device" a button that did nothing until the token expired eight hours later. Changing a password revokes every other session for the same reason: a password change that leaves the attacker's session alive has not locked anyone out. Admin queries are scoped by role in the SQL rather than in the UI, as section 14.3 requires: a DOMAIN_ADMIN calling the API directly sees only its own domain, and a mailbox outside that scope answers 404 rather than 403 so the scope cannot be used to enumerate what exists elsewhere. The two mutations that exist so far - enabling or disabling a mailbox, and retrying or cancelling a queued delivery - write actor, IP and target to audit_log, and there is an endpoint to read it back. Covered by tests against a real database: a revoked token stops working mid-session, a password change needs the current password and takes the other sessions down with it, and the minimum length is enforced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
93d21b4752
commit
eec4a4e520
3 files changed
+366
-12
No files matched your search
@@ -318,22 +318,39 @@ export async function dashboardStats(): Promise<Record<string, unknown>> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listDomains(): Promise<unknown[]> {
|
export interface AdminScope {
|
||||||
|
role: string;
|
||||||
|
domainId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SUPER_ADMIN sees everything; a DOMAIN_ADMIN is confined to its own domain.
|
||||||
|
* PLAN.md section 14.3 requires this at the query level rather than in the UI,
|
||||||
|
* so calling the API directly does not widen the view. */
|
||||||
|
function scopeClause(scope: AdminScope, column: string): { sql: string; params: unknown[] } {
|
||||||
|
if (scope.role === "SUPER_ADMIN") return { sql: "TRUE", params: [] };
|
||||||
|
return { sql: `${column} = $1`, params: [scope.domainId] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDomains(scope: AdminScope): Promise<unknown[]> {
|
||||||
|
const { sql, params } = scopeClause(scope, "d.id");
|
||||||
return query(
|
return query(
|
||||||
`SELECT id, name, dns_status, dmarc_policy, mta_sts_mode, dane_enabled,
|
`SELECT d.id, d.name, d.dns_status, d.dmarc_policy, d.mta_sts_mode, d.dane_enabled,
|
||||||
is_active, dns_last_checked_at,
|
d.is_active, d.dns_last_checked_at,
|
||||||
(SELECT count(*) FROM mailboxes m WHERE m.domain_id = d.id)::int AS mailbox_count
|
(SELECT count(*) FROM mailboxes m WHERE m.domain_id = d.id)::int AS mailbox_count
|
||||||
FROM domains d ORDER BY name`,
|
FROM domains d WHERE ${sql} ORDER BY d.name`,
|
||||||
|
params,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMailboxes(): Promise<unknown[]> {
|
export async function listMailboxes(scope: AdminScope): Promise<unknown[]> {
|
||||||
|
const { sql, params } = scopeClause(scope, "m.domain_id");
|
||||||
return query(
|
return query(
|
||||||
`SELECT m.id, m.email_address, m.role, m.is_active, m.quota_bytes, m.used_bytes,
|
`SELECT m.id, m.email_address, m.role, m.is_active, m.quota_bytes, m.used_bytes,
|
||||||
m.locale, m.locked_until, d.name AS domain_name,
|
m.locale, m.locked_until, d.name AS domain_name,
|
||||||
EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled
|
EXISTS (SELECT 1 FROM mfa_totp t WHERE t.mailbox_id = m.id AND t.status = 'CONFIRMED') AS mfa_enrolled
|
||||||
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
|
FROM mailboxes m JOIN domains d ON d.id = m.domain_id
|
||||||
ORDER BY m.email_address LIMIT 200`,
|
WHERE ${sql} ORDER BY m.email_address LIMIT 200`,
|
||||||
|
params,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,3 +410,154 @@ export async function preflightSummary(): Promise<Record<string, unknown>> {
|
|||||||
note: "Socket-level checks (port 25 egress, PTR, RBL) are reported by the protocols service preflight",
|
note: "Socket-level checks (port 25 egress, PTR, RBL) are reported by the protocols service preflight",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------- account self-service
|
||||||
|
|
||||||
|
export interface SessionSummary {
|
||||||
|
id: string;
|
||||||
|
surface: string;
|
||||||
|
ip_address: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
created_at: Date;
|
||||||
|
expires_at: Date;
|
||||||
|
current: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists the account's live sessions so a user can spot one they do not
|
||||||
|
* recognise (PLAN.md section 14.2). */
|
||||||
|
export async function listSessions(mailboxId: string, currentTokenHash: string): Promise<SessionSummary[]> {
|
||||||
|
const rows = await query<Omit<SessionSummary, "current"> & { refresh_token_hash: string }>(
|
||||||
|
`SELECT id, surface, ip_address::text AS ip_address, user_agent, created_at, expires_at, refresh_token_hash
|
||||||
|
FROM web_sessions
|
||||||
|
WHERE mailbox_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[mailboxId],
|
||||||
|
);
|
||||||
|
return rows.map(({ refresh_token_hash, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
current: refresh_token_hash === currentTokenHash,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeSession(mailboxId: string, sessionId: string): Promise<boolean> {
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`UPDATE web_sessions SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND mailbox_id = $2 AND revoked_at IS NULL RETURNING id`,
|
||||||
|
[sessionId, mailboxId],
|
||||||
|
);
|
||||||
|
return rows.length === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revokes every session except the caller's, which is what "sign out
|
||||||
|
* everywhere else" has to mean for it to be useful after a password leak. */
|
||||||
|
export async function revokeOtherSessions(mailboxId: string, currentTokenHash: string): Promise<number> {
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`UPDATE web_sessions SET revoked_at = NOW()
|
||||||
|
WHERE mailbox_id = $1 AND revoked_at IS NULL AND refresh_token_hash <> $2 RETURNING id`,
|
||||||
|
[mailboxId, currentTokenHash],
|
||||||
|
);
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isSessionRevoked(tokenHash: string): Promise<boolean> {
|
||||||
|
const row = await queryOne<{ revoked: boolean }>(
|
||||||
|
`SELECT (revoked_at IS NOT NULL) AS revoked FROM web_sessions WHERE refresh_token_hash = $1`,
|
||||||
|
[tokenHash],
|
||||||
|
);
|
||||||
|
// An unknown hash is treated as live: tokens issued before this table was
|
||||||
|
// populated must not all stop working at once.
|
||||||
|
return row?.revoked ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the password after proving the current one, then revokes every other
|
||||||
|
* session: a password change that leaves the attacker's session alive has not
|
||||||
|
* actually locked anyone out.
|
||||||
|
*/
|
||||||
|
export async function changePassword(
|
||||||
|
mailboxId: string,
|
||||||
|
currentPassword: string,
|
||||||
|
newPassword: string,
|
||||||
|
currentTokenHash: string,
|
||||||
|
): Promise<"OK" | "WRONG_PASSWORD" | "NOT_FOUND"> {
|
||||||
|
const row = await queryOne<{ password_hash: string }>(
|
||||||
|
`SELECT password_hash FROM mailboxes WHERE id = $1`,
|
||||||
|
[mailboxId],
|
||||||
|
);
|
||||||
|
if (!row) return "NOT_FOUND";
|
||||||
|
if (!(await verifyPassword(currentPassword, row.password_hash))) return "WRONG_PASSWORD";
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE mailboxes SET password_hash = $2, password_updated_at = NOW(), updated_at = NOW() WHERE id = $1`,
|
||||||
|
[mailboxId, await hashPassword(newPassword)],
|
||||||
|
);
|
||||||
|
await revokeOtherSessions(mailboxId, currentTokenHash);
|
||||||
|
return "OK";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- audit log
|
||||||
|
|
||||||
|
/** Records an administrative action. PLAN.md section 14.3 requires every
|
||||||
|
* mutation under /admin to leave a trace with actor, IP and what changed. */
|
||||||
|
export async function recordAudit(
|
||||||
|
actorId: string | null,
|
||||||
|
actorIp: string | null,
|
||||||
|
action: string,
|
||||||
|
targetType: string | null,
|
||||||
|
targetId: string | null,
|
||||||
|
metadata: unknown,
|
||||||
|
): Promise<void> {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO audit_log (actor_id, actor_ip, action, target_type, target_id, metadata)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[actorId, actorIp, action, targetType, targetId, JSON.stringify(metadata ?? {})],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAudit(limit = 100): Promise<unknown[]> {
|
||||||
|
return query(
|
||||||
|
`SELECT a.id, a.action, a.target_type, a.target_id, a.actor_ip::text AS actor_ip,
|
||||||
|
a.metadata, a.created_at, m.email_address AS actor_email
|
||||||
|
FROM audit_log a LEFT JOIN mailboxes m ON m.id = a.actor_id
|
||||||
|
ORDER BY a.id DESC LIMIT $1`,
|
||||||
|
[limit],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enables or disables a mailbox, refusing anything outside the caller's scope. */
|
||||||
|
export async function setMailboxActive(scope: AdminScope, mailboxId: string, isActive: boolean): Promise<boolean> {
|
||||||
|
const rows =
|
||||||
|
scope.role === "SUPER_ADMIN"
|
||||||
|
? await query<{ id: string }>(
|
||||||
|
`UPDATE mailboxes SET is_active = $2, updated_at = NOW() WHERE id = $1 RETURNING id`,
|
||||||
|
[mailboxId, isActive],
|
||||||
|
)
|
||||||
|
: await query<{ id: string }>(
|
||||||
|
`UPDATE mailboxes SET is_active = $2, updated_at = NOW()
|
||||||
|
WHERE id = $1 AND domain_id = $3 RETURNING id`,
|
||||||
|
[mailboxId, isActive, scope.domainId],
|
||||||
|
);
|
||||||
|
return rows.length === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries or cancels one queued delivery.
|
||||||
|
*
|
||||||
|
* Retry only moves a job that is not already finished, and clears the next
|
||||||
|
* attempt time so the worker picks it up on its next pass rather than waiting
|
||||||
|
* out the backoff the operator is trying to skip.
|
||||||
|
*/
|
||||||
|
export async function updateQueueJob(scope: AdminScope, jobId: string, action: "retry" | "cancel"): Promise<boolean> {
|
||||||
|
const scoped = scope.role === "SUPER_ADMIN" ? "TRUE" : "o.mailbox_id IN (SELECT id FROM mailboxes WHERE domain_id = $2)";
|
||||||
|
const params: unknown[] = scope.role === "SUPER_ADMIN" ? [jobId] : [jobId, scope.domainId];
|
||||||
|
|
||||||
|
const sql =
|
||||||
|
action === "retry"
|
||||||
|
? `UPDATE outbound_jobs o SET status = 'QUEUED', next_attempt_at = NOW(), last_error = NULL
|
||||||
|
WHERE o.id = $1 AND o.status IN ('DEFERRED','BOUNCED') AND ${scoped} RETURNING o.id`
|
||||||
|
: `UPDATE outbound_jobs o SET status = 'CANCELLED'
|
||||||
|
WHERE o.id = $1 AND o.status IN ('QUEUED','DEFERRED') AND ${scoped} RETURNING o.id`;
|
||||||
|
|
||||||
|
const rows = await query<{ id: string }>(sql, params);
|
||||||
|
return rows.length === 1;
|
||||||
|
}
|
||||||
@@ -75,6 +75,18 @@ async function post(path: string, body: unknown, token?: string) {
|
|||||||
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function put(path: string, body: unknown, token?: string) {
|
||||||
|
const res = await fetch(`${base}${path}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => ({})) as Record<string, never> };
|
||||||
|
}
|
||||||
|
|
||||||
async function get(path: string, token?: string) {
|
async function get(path: string, token?: string) {
|
||||||
const res = await fetch(`${base}${path}`, {
|
const res = await fetch(`${base}${path}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
@@ -256,6 +268,72 @@ describeDb("auth API against a real database", () => {
|
|||||||
const res = await post("/api/v1/user/mfa/totp/enroll", {});
|
const res = await post("/api/v1/user/mfa/totp/enroll", {});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A JWT carries its own validity, so revoking a session must be enforced on
|
||||||
|
// every request or "sign out this device" would change nothing.
|
||||||
|
it("stops accepting a token once its session is revoked", async () => {
|
||||||
|
const email = `revoke@authtest-${DOMAIN_ID}.invalid`;
|
||||||
|
await query(
|
||||||
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
|
||||||
|
VALUES ($1, 'revoke', $2, $3)`,
|
||||||
|
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
||||||
|
);
|
||||||
|
|
||||||
|
const login = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
||||||
|
const token = login.body.token as unknown as string;
|
||||||
|
|
||||||
|
const before = await get("/api/v1/user/me", token);
|
||||||
|
expect(before.status).toBe(200);
|
||||||
|
|
||||||
|
const sessions = await get("/api/v1/user/sessions", token);
|
||||||
|
expect(sessions.status).toBe(200);
|
||||||
|
const list = sessions.body as unknown as Array<{ id: string; current: boolean }>;
|
||||||
|
expect(list.length).toBeGreaterThan(0);
|
||||||
|
expect(list.some((s) => s.current)).toBe(true);
|
||||||
|
|
||||||
|
const revoked = await fetch(`${base}/api/v1/user/sessions/${list[0].id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
expect(revoked.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await get("/api/v1/user/me", token);
|
||||||
|
expect(after.status).toBe(401);
|
||||||
|
expect(after.body.error).toBe("SESSION_REVOKED");
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it("changes the password only with the current one, and revokes other sessions", async () => {
|
||||||
|
const email = `pw@authtest-${DOMAIN_ID}.invalid`;
|
||||||
|
await query(
|
||||||
|
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash)
|
||||||
|
VALUES ($1, 'pw', $2, $3)`,
|
||||||
|
[DOMAIN_ID, email, await hashPassword(PASSWORD)],
|
||||||
|
);
|
||||||
|
|
||||||
|
const first = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
||||||
|
const second = await post("/api/v1/auth/user/login", { email, password: PASSWORD });
|
||||||
|
|
||||||
|
const wrong = await put("/api/v1/user/password",
|
||||||
|
{ current_password: "not-it", new_password: "a-brand-new-password-1" }, first.body.token);
|
||||||
|
expect(wrong.status).toBe(401);
|
||||||
|
|
||||||
|
const tooShort = await put("/api/v1/user/password",
|
||||||
|
{ current_password: PASSWORD, new_password: "short" }, first.body.token);
|
||||||
|
expect(tooShort.status).toBe(400);
|
||||||
|
|
||||||
|
const ok = await put("/api/v1/user/password",
|
||||||
|
{ current_password: PASSWORD, new_password: "a-brand-new-password-1" }, first.body.token);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
|
||||||
|
// The other session is gone; the one that made the change still works.
|
||||||
|
const other = await get("/api/v1/user/me", second.body.token);
|
||||||
|
expect(other.status).toBe(401);
|
||||||
|
const mine = await get("/api/v1/user/me", first.body.token);
|
||||||
|
expect(mine.status).toBe(200);
|
||||||
|
|
||||||
|
const relogin = await post("/api/v1/auth/user/login", { email, password: "a-brand-new-password-1" });
|
||||||
|
expect(relogin.status).toBe(200);
|
||||||
|
}, 90000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Guards that need no database, so they still run in a bare checkout.
|
// Guards that need no database, so they still run in a bare checkout.
|
||||||
|
|||||||
+114
-6
@@ -98,6 +98,15 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
|||||||
return sendJson(res, 401, { error: "UNAUTHORIZED", message: "User session required" });
|
return sendJson(res, 401, { error: "UNAUTHORIZED", message: "User session required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A JWT carries its own validity, so revoking a session in the database
|
||||||
|
// would otherwise change nothing until it expired. Checking here is what
|
||||||
|
// makes "sign out this device" mean anything.
|
||||||
|
if (session && (url.startsWith("/api/v1/admin/") || url.startsWith("/api/v1/user/"))) {
|
||||||
|
if (await repo.isSessionRevoked(crypto.createHash("sha256").update(token).digest("hex"))) {
|
||||||
|
return sendJson(res, 401, { error: "SESSION_REVOKED", message: "This session has been signed out" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user");
|
if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user");
|
||||||
if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin");
|
if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin");
|
||||||
if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res);
|
if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res);
|
||||||
@@ -113,9 +122,27 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
|
|||||||
return appPasswordDelete(res, session!, url.substring("/api/v1/user/app-passwords/".length));
|
return appPasswordDelete(res, session!, url.substring("/api/v1/user/app-passwords/".length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url === "/api/v1/user/sessions" && method === "GET") return listSessions(req, res, session!);
|
||||||
|
if (url === "/api/v1/user/sessions/others" && method === "DELETE") return revokeOthers(req, res, session!);
|
||||||
|
if (url.startsWith("/api/v1/user/sessions/") && method === "DELETE") {
|
||||||
|
return revokeSession(res, session!, url.substring("/api/v1/user/sessions/".length));
|
||||||
|
}
|
||||||
|
if (url === "/api/v1/user/password" && method === "PUT") return changePassword(req, res, session!);
|
||||||
|
|
||||||
|
if (url === "/api/v1/admin/audit" && method === "GET") return adminAudit(res);
|
||||||
|
if (url.startsWith("/api/v1/admin/mailboxes/") && url.endsWith("/active") && method === "PUT") {
|
||||||
|
const id = url.slice("/api/v1/admin/mailboxes/".length, -"/active".length);
|
||||||
|
return adminSetMailboxActive(req, res, session!, id);
|
||||||
|
}
|
||||||
|
if (url.startsWith("/api/v1/admin/queue/") && url.endsWith("/retry") && method === "POST") {
|
||||||
|
return adminQueueAction(req, res, session!, url.slice("/api/v1/admin/queue/".length, -"/retry".length), "retry");
|
||||||
|
}
|
||||||
|
if (url.startsWith("/api/v1/admin/queue/") && url.endsWith("/cancel") && method === "POST") {
|
||||||
|
return adminQueueAction(req, res, session!, url.slice("/api/v1/admin/queue/".length, -"/cancel".length), "cancel");
|
||||||
|
}
|
||||||
if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res);
|
if (url === "/api/v1/admin/dashboard" && method === "GET") return adminDashboard(res);
|
||||||
if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res);
|
if (url === "/api/v1/admin/domains" && method === "GET") return adminDomains(res, session!);
|
||||||
if (url === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(res);
|
if (url === "/api/v1/admin/mailboxes" && method === "GET") return adminMailboxes(res, session!);
|
||||||
if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res);
|
if (url === "/api/v1/admin/dmarc" && method === "GET") return adminDmarc(res);
|
||||||
if (url === "/api/v1/admin/queue" && method === "GET") return adminQueue(res);
|
if (url === "/api/v1/admin/queue" && method === "GET") return adminQueue(res);
|
||||||
if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res);
|
if (url === "/api/v1/admin/preflight" && method === "GET") return adminPreflight(res);
|
||||||
@@ -350,19 +377,100 @@ async function appPasswordDelete(res: ServerResponse, session: SessionTokenPaylo
|
|||||||
sendJson(res, 200, { ok: true });
|
sendJson(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The session token is the credential, so its hash identifies the row in
|
||||||
|
// web_sessions that belongs to this caller.
|
||||||
|
function tokenHash(req: IncomingMessage, url: string): string {
|
||||||
|
return crypto.createHash("sha256").update(extractToken(req, url)).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listSessions(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
|
sendJson(res, 200, await repo.listSessions(session.sub, tokenHash(req, "/api/v1/user/")));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSession(res: ServerResponse, session: SessionTokenPayload, id: string): Promise<void> {
|
||||||
|
if (!(await repo.revokeSession(session.sub, id))) {
|
||||||
|
return sendJson(res, 404, { error: "NOT_FOUND", message: "Session not found" });
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeOthers(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
|
const revoked = await repo.revokeOtherSessions(session.sub, tokenHash(req, "/api/v1/user/"));
|
||||||
|
sendJson(res, 200, { revoked });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changePassword(req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
|
const body = await parseJsonBody(req);
|
||||||
|
const current = typeof body?.current_password === "string" ? body.current_password : "";
|
||||||
|
const next = typeof body?.new_password === "string" ? body.new_password : "";
|
||||||
|
|
||||||
|
// Twelve characters is the floor; length beats composition rules, which push
|
||||||
|
// people towards predictable substitutions.
|
||||||
|
if (next.length < 12) {
|
||||||
|
return sendJson(res, 400, { error: "WEAK_PASSWORD", message: "The new password must be at least 12 characters" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await repo.changePassword(session.sub, current, next, tokenHash(req, "/api/v1/user/"));
|
||||||
|
if (outcome === "WRONG_PASSWORD") {
|
||||||
|
return sendJson(res, 401, { error: "INVALID_CREDENTIALS", message: "Current password is incorrect" });
|
||||||
|
}
|
||||||
|
if (outcome === "NOT_FOUND") {
|
||||||
|
return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" });
|
||||||
|
}
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- admin area
|
// ------------------------------------------------------------- admin area
|
||||||
|
|
||||||
|
async function adminAudit(res: ServerResponse): Promise<void> {
|
||||||
|
sendJson(res, 200, await repo.listAudit());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminSetMailboxActive(
|
||||||
|
req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload, id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const body = await parseJsonBody(req);
|
||||||
|
const isActive = body?.is_active === true;
|
||||||
|
|
||||||
|
const updated = await repo.setMailboxActive(scopeOf(session), id, isActive);
|
||||||
|
if (!updated) {
|
||||||
|
// Out of scope and non-existent give the same answer, so a DOMAIN_ADMIN
|
||||||
|
// cannot probe for mailboxes in domains it does not administer.
|
||||||
|
return sendJson(res, 404, { error: "NOT_FOUND", message: "Mailbox not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await repo.recordAudit(session.sub, clientIp(req), isActive ? "mailbox.enable" : "mailbox.disable",
|
||||||
|
"mailbox", id, { is_active: isActive });
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminQueueAction(
|
||||||
|
req: IncomingMessage, res: ServerResponse, session: SessionTokenPayload, id: string, action: "retry" | "cancel",
|
||||||
|
): Promise<void> {
|
||||||
|
const updated = await repo.updateQueueJob(scopeOf(session), id, action);
|
||||||
|
if (!updated) {
|
||||||
|
return sendJson(res, 404, { error: "NOT_FOUND", message: "Queue job not found" });
|
||||||
|
}
|
||||||
|
await repo.recordAudit(session.sub, clientIp(req), `queue.${action}`, "outbound_job", id, {});
|
||||||
|
sendJson(res, 200, { ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
async function adminDashboard(res: ServerResponse): Promise<void> {
|
async function adminDashboard(res: ServerResponse): Promise<void> {
|
||||||
const stats = await repo.dashboardStats();
|
const stats = await repo.dashboardStats();
|
||||||
sendJson(res, 200, stats);
|
sendJson(res, 200, stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminDomains(res: ServerResponse): Promise<void> {
|
async function adminDomains(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
sendJson(res, 200, await repo.listDomains());
|
sendJson(res, 200, await repo.listDomains(scopeOf(session)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminMailboxes(res: ServerResponse): Promise<void> {
|
async function adminMailboxes(res: ServerResponse, session: SessionTokenPayload): Promise<void> {
|
||||||
sendJson(res, 200, await repo.listMailboxes());
|
sendJson(res, 200, await repo.listMailboxes(scopeOf(session)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The scope travels from the verified session, never from the request. */
|
||||||
|
function scopeOf(session: SessionTokenPayload): repo.AdminScope {
|
||||||
|
return { role: session.role, domainId: session.domainId };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function adminDmarc(res: ServerResponse): Promise<void> {
|
async function adminDmarc(res: ServerResponse): Promise<void> {
|
||||||
|
|||||||
Reference in new issue
Block a user