feat: add admin console and app-admin access management
This commit is contained in:
@@ -10,6 +10,7 @@ import { healthRoutes } from './routes/health.js';
|
||||
import { searchJobRoutes } from './routes/search-jobs.js';
|
||||
import { analyticsRoutes } from './routes/analytics.js';
|
||||
import { adminBootstrapRoutes } from './routes/admin-bootstrap.js';
|
||||
import { adminAccessRoutes } from './routes/admin-access.js';
|
||||
|
||||
function parseAllowedOrigins(rawOrigins: string) {
|
||||
return rawOrigins
|
||||
@@ -58,6 +59,7 @@ export async function buildApp() {
|
||||
await app.register(deepResearchRoutes, { prefix: '/api' });
|
||||
await app.register(analyticsRoutes, { prefix: '/api' });
|
||||
await app.register(adminBootstrapRoutes, { prefix: '/api' });
|
||||
await app.register(adminAccessRoutes, { prefix: '/api' });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import type { Pool, PoolClient } from 'pg';
|
||||
import type { ApplicationAdminSummary, ApplicationAdminStatus } from '../../../shared/types.js';
|
||||
import { getAdminEmailAllowlist } from '../config/env.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
|
||||
@@ -16,6 +17,18 @@ type ApplicationAdminRow = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function toApplicationAdminSummary(row: ApplicationAdminRow): ApplicationAdminSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
emailNormalized: row.email_normalized,
|
||||
status: row.status,
|
||||
createdByUserId: row.created_by_user_id,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
type AdminAccessAuditPayload = {
|
||||
actorUserId?: string | null;
|
||||
actorEmail?: string | null;
|
||||
@@ -48,6 +61,76 @@ export async function getApplicationAdminByEmail(db: DbClient, email: string) {
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function listApplicationAdmins(db: DbClient): Promise<ApplicationAdminSummary[]> {
|
||||
const result = await db.query<ApplicationAdminRow>(
|
||||
`
|
||||
select id, email, email_normalized, status, permissions_json, created_by_user_id, created_at, updated_at
|
||||
from public.application_admins
|
||||
order by
|
||||
case when status = 'active' then 0 else 1 end asc,
|
||||
email_normalized asc
|
||||
`,
|
||||
);
|
||||
|
||||
return result.rows.map(toApplicationAdminSummary);
|
||||
}
|
||||
|
||||
export async function upsertApplicationAdminByEmail(
|
||||
db: DbClient,
|
||||
input: { email: string; actorUserId?: string | null },
|
||||
): Promise<ApplicationAdminSummary> {
|
||||
const normalizedEmail = normalizeEmail(input.email);
|
||||
const result = await db.query<ApplicationAdminRow>(
|
||||
`
|
||||
insert into public.application_admins (email, email_normalized, status, permissions_json, created_by_user_id)
|
||||
values ($1, $2, 'active', '[]'::jsonb, $3)
|
||||
on conflict (email_normalized)
|
||||
do update set
|
||||
email = excluded.email,
|
||||
status = 'active',
|
||||
updated_at = now()
|
||||
returning id, email, email_normalized, status, permissions_json, created_by_user_id, created_at, updated_at
|
||||
`,
|
||||
[input.email.trim(), normalizedEmail, input.actorUserId ?? null],
|
||||
);
|
||||
|
||||
return toApplicationAdminSummary(result.rows[0]!);
|
||||
}
|
||||
|
||||
export async function updateApplicationAdminStatusById(
|
||||
db: DbClient,
|
||||
input: { adminId: string; status: ApplicationAdminStatus },
|
||||
): Promise<ApplicationAdminSummary | null> {
|
||||
const result = await db.query<ApplicationAdminRow>(
|
||||
`
|
||||
update public.application_admins
|
||||
set status = $2, updated_at = now()
|
||||
where id = $1
|
||||
returning id, email, email_normalized, status, permissions_json, created_by_user_id, created_at, updated_at
|
||||
`,
|
||||
[input.adminId, input.status],
|
||||
);
|
||||
|
||||
if (!result.rows[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toApplicationAdminSummary(result.rows[0]);
|
||||
}
|
||||
|
||||
export async function getActiveApplicationAdminCountExcludingId(db: DbClient, adminId: string): Promise<number> {
|
||||
const result = await db.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.application_admins
|
||||
where status = 'active' and id <> $1
|
||||
`,
|
||||
[adminId],
|
||||
);
|
||||
|
||||
return Number(result.rows[0]?.count ?? '0');
|
||||
}
|
||||
|
||||
export async function isApplicationAdmin(db: DbClient, email: string) {
|
||||
const admin = await getApplicationAdminByEmail(db, email);
|
||||
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { ZodError, z } from 'zod';
|
||||
import type {
|
||||
AdminApplicationAdminResponse,
|
||||
AdminApplicationAdminsListResponse,
|
||||
AdminApplicationAdminStatusUpdateRequest,
|
||||
AdminApplicationAdminUpsertRequest,
|
||||
} from '../../../shared/types.js';
|
||||
import {
|
||||
getActiveApplicationAdminCountExcludingId,
|
||||
listApplicationAdmins,
|
||||
recordAdminAccessAudit,
|
||||
requireAdmin,
|
||||
updateApplicationAdminStatusById,
|
||||
upsertApplicationAdminByEmail,
|
||||
} from '../auth/admin.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
|
||||
const upsertAdminSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
const statusUpdateSchema = z.object({
|
||||
status: z.enum(['active', 'disabled']),
|
||||
});
|
||||
|
||||
const adminIdParamsSchema = z.object({
|
||||
adminId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/admin/access/admins', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const db = getDbPool();
|
||||
await recordAdminAccessAudit(db, {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'admin_access_list',
|
||||
});
|
||||
const admins = await listApplicationAdmins(db);
|
||||
const response: AdminApplicationAdminsListResponse = { admins };
|
||||
return response;
|
||||
} catch (error) {
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to list application admins.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/access/admins', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const payload = upsertAdminSchema.parse(request.body) as AdminApplicationAdminUpsertRequest;
|
||||
const db = getDbPool();
|
||||
const admin = await upsertApplicationAdminByEmail(db, {
|
||||
email: payload.email,
|
||||
actorUserId: request.authUser?.id,
|
||||
});
|
||||
await recordAdminAccessAudit(db, {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'admin_access_upsert',
|
||||
metadataJson: {
|
||||
targetEmail: payload.email.trim(),
|
||||
},
|
||||
});
|
||||
const response: AdminApplicationAdminResponse = { admin };
|
||||
return reply.code(201).send(response);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin upsert payload.' });
|
||||
}
|
||||
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to upsert application admin.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/admin/access/admins/:adminId', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const { adminId } = adminIdParamsSchema.parse(request.params);
|
||||
const payload = statusUpdateSchema.parse(request.body) as AdminApplicationAdminStatusUpdateRequest;
|
||||
const db = getDbPool();
|
||||
|
||||
if (payload.status === 'disabled') {
|
||||
const remainingActiveAdminCount = await getActiveApplicationAdminCountExcludingId(db, adminId);
|
||||
if (remainingActiveAdminCount < 1) {
|
||||
return reply.code(409).send({ error: 'Cannot disable the last active application admin.' });
|
||||
}
|
||||
}
|
||||
|
||||
const admin = await updateApplicationAdminStatusById(db, {
|
||||
adminId,
|
||||
status: payload.status,
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
return reply.code(404).send({ error: 'Application admin not found.' });
|
||||
}
|
||||
|
||||
await recordAdminAccessAudit(db, {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'admin_access_status_changed',
|
||||
metadataJson: {
|
||||
targetAdminId: adminId,
|
||||
nextStatus: payload.status,
|
||||
},
|
||||
});
|
||||
|
||||
const response: AdminApplicationAdminResponse = { admin };
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin status update payload.' });
|
||||
}
|
||||
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to update application admin status.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user