Public Access
1
0

feat: add admin console and app-admin access management

This commit is contained in:
pguerrerox
2026-05-26 18:46:24 +00:00
parent f1c3e2db7d
commit bdeda4902e
13 changed files with 756 additions and 139 deletions
+2
View File
@@ -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;
}
+83
View File
@@ -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);
+124
View File
@@ -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.' });
}
});
};