Public Access
1
0

feat: introduce app-admin authorization and audit logging

- add migrations for owner/member workspace roles and application admins

- centralize /admin access checks with DB-backed admin resolution

- audit admin analytics/billing route access

- update account/admin UI typing and env/docs for ADMIN_EMAILS fallback behavior
This commit is contained in:
pguerrerox
2026-05-25 15:25:59 +00:00
parent 5508e15da1
commit f5e7e966e3
14 changed files with 269 additions and 302 deletions
+5 -3
View File
@@ -1,7 +1,7 @@
import type { Pool, PoolClient } from 'pg';
import type { AccountPageData, AccountWorkspace, AppUser, WorkspaceType, WorkspaceRole } from '../../../shared/types.js';
import { getWorkspaceBillingState } from '../billing/service.js';
import { isBillingAdminEmail } from '../config/env.js';
import { isApplicationAdmin } from '../auth/admin.js';
type DbClient = Pool | PoolClient;
@@ -155,6 +155,7 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
const summary = await getAccountSummaryForUser(db, user.id);
const billing = await getWorkspaceBillingState(db, workspace.id);
const isAdmin = await isApplicationAdmin(db, user.email);
return {
profile: user,
@@ -162,10 +163,11 @@ export async function buildAccountPageData(db: DbClient, user: AppUser): Promise
summary,
billing,
team: {
canManageMembers: workspace.role === 'owner' || workspace.role === 'admin',
canManageMembers: workspace.role === 'owner',
message: 'Workspace member management is coming soon.',
},
isBillingAdmin: isBillingAdminEmail(user.email),
isAdmin,
isBillingAdmin: isAdmin,
};
}
+105
View File
@@ -0,0 +1,105 @@
import type { FastifyReply, FastifyRequest } from 'fastify';
import type { Pool, PoolClient } from 'pg';
import { getAdminEmailAllowlist } from '../config/env.js';
import { getDbPool } from '../db/pool.js';
type DbClient = Pool | PoolClient;
type ApplicationAdminRow = {
id: string;
email: string;
email_normalized: string;
status: 'active' | 'disabled';
permissions_json: unknown;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
type AdminAccessAuditPayload = {
actorUserId?: string | null;
actorEmail?: string | null;
route: string;
action: string;
targetWorkspaceId?: string | null;
metadataJson?: Record<string, unknown>;
occurredAt?: string;
};
export function normalizeEmail(email: string) {
return email.trim().toLowerCase();
}
export function isAdminEmailAllowlisted(email: string) {
return getAdminEmailAllowlist().includes(normalizeEmail(email));
}
export async function getApplicationAdminByEmail(db: DbClient, email: string) {
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
where email_normalized = $1
limit 1
`,
[normalizeEmail(email)],
);
return result.rows[0] ?? null;
}
export async function isApplicationAdmin(db: DbClient, email: string) {
const admin = await getApplicationAdminByEmail(db, email);
if (admin) {
if (admin.status === 'disabled') {
return false;
}
if (admin.status === 'active') {
return true;
}
}
return isAdminEmailAllowlisted(email);
}
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
const user = request.authUser;
if (!user) {
return reply.code(403).send({ error: 'Admin access is required.' });
}
const hasAccess = await isApplicationAdmin(getDbPool(), user.email);
if (!hasAccess) {
return reply.code(403).send({ error: 'Admin access is required.' });
}
return undefined;
}
export async function recordAdminAccessAudit(db: DbClient, payload: AdminAccessAuditPayload) {
await db.query(
`
insert into public.admin_access_audit (
actor_user_id,
actor_email,
route,
action,
target_workspace_id,
metadata_json,
occurred_at
) values ($1, $2, $3, $4, $5, $6::jsonb, coalesce($7::timestamptz, now()))
`,
[
payload.actorUserId ?? null,
payload.actorEmail ?? null,
payload.route,
payload.action,
payload.targetWorkspaceId ?? null,
JSON.stringify(payload.metadataJson ?? {}),
payload.occurredAt ?? null,
],
);
}
+19 -6
View File
@@ -38,6 +38,7 @@ const envSchema = z.object({
STRIPE_PRICE_EXPORT_PACK_10K: z.string().optional(),
STRIPE_PRICE_EXPORT_PACK_50K: z.string().optional(),
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
ADMIN_EMAILS: z.string().optional(),
BILLING_ADMIN_EMAILS: z.string().optional(),
});
@@ -54,16 +55,28 @@ export function getEnv(): AppEnv {
return cachedEnv;
}
export function isBillingAdminEmail(email: string) {
const allowlist = getEnv().BILLING_ADMIN_EMAILS;
function parseAdminEmailAllowlist(allowlist: string | undefined): string[] {
if (!allowlist) {
return false;
return [];
}
return allowlist
.split(',')
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean)
.includes(email.trim().toLowerCase());
.filter(Boolean);
}
export function getAdminEmailAllowlist() {
const env = getEnv();
return parseAdminEmailAllowlist(env.ADMIN_EMAILS || env.BILLING_ADMIN_EMAILS);
}
export function isBillingAdminEmail(email: string) {
const allowlist = getAdminEmailAllowlist();
if (allowlist.length === 0) {
return false;
}
return allowlist.includes(email.trim().toLowerCase());
}
+1 -1
View File
@@ -31,7 +31,7 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
return reply.code(500).send({ error: 'Failed to load workspace.' });
}
if (payload.workspaceName && workspace.role !== 'owner' && workspace.role !== 'admin') {
if (payload.workspaceName && workspace.role !== 'owner') {
return reply.code(403).send({ error: 'You do not have permission to update this workspace.' });
}
+14 -12
View File
@@ -1,8 +1,8 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import type { FastifyPluginAsync } from 'fastify';
import { z, ZodError } from 'zod';
import { ensureWorkspaceForUser } from '../account/repository.js';
import { hydrateAuthUser, requireAuth } from '../auth/middleware.js';
import { isBillingAdminEmail } from '../config/env.js';
import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
import { getDbPool } from '../db/pool.js';
import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js';
@@ -37,14 +37,6 @@ const summaryQuerySchema = z.object({
days: z.coerce.number().int().min(7).max(90).optional(),
});
async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) {
if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) {
return reply.code(403).send({ error: 'Billing admin access is required.' });
}
return undefined;
}
export const analyticsRoutes: FastifyPluginAsync = async (app) => {
app.post('/analytics/events', async (request, reply) => {
try {
@@ -75,10 +67,20 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => {
}
});
app.get('/admin/analytics/summary', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => {
app.get('/admin/analytics/summary', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
try {
const query = summaryQuerySchema.parse(request.query ?? {});
const summary = await getAdminAnalyticsSummary(getDbPool(), query.days ?? 30);
const db = getDbPool();
await recordAdminAccessAudit(db, {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'analytics_summary',
metadataJson: {
days: query.days ?? 30,
},
});
const summary = await getAdminAnalyticsSummary(db, query.days ?? 30);
return { summary };
} catch (error) {
if (error instanceof ZodError) {
+23 -13
View File
@@ -1,13 +1,13 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import type { FastifyPluginAsync } from 'fastify';
import { z, ZodError } from 'zod';
import { getDbPool } from '../db/pool.js';
import { requireAuth } from '../auth/middleware.js';
import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
import { listRecentWebhookEventsForWorkspace } from '../payments/repository.js';
import { createAddonCheckoutSession, createBillingPortalSession, createSubscriptionCheckoutSession } from '../payments/service.js';
import { constructStripeWebhookEvent, processStripeWebhookEvent } from '../payments/webhooks.js';
import { ensureWorkspaceForUser } from '../account/repository.js';
import { isBillingAdminEmail } from '../config/env.js';
import {
ensureBillingAccountForWorkspace,
getBillingAdminWorkspaceSummaryByWorkspaceId,
@@ -41,14 +41,6 @@ function parseJsonBody<T>(body: unknown, schema: z.ZodSchema<T>) {
return schema.parse(JSON.parse(body) as unknown);
}
async function requireBillingAdmin(request: FastifyRequest, reply: FastifyReply) {
if (!request.authUser || !isBillingAdminEmail(request.authUser.email)) {
return reply.code(403).send({ error: 'Billing admin access is required.' });
}
return undefined;
}
export const billingRoutes: FastifyPluginAsync = async (app) => {
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_request, body, done) => {
done(null, body);
@@ -121,11 +113,22 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
}
});
app.get('/admin/billing/workspaces', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => {
app.get('/admin/billing/workspaces', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
try {
const query = adminWorkspaceQuerySchema.parse(request.query ?? {});
const db = getDbPool();
await recordAdminAccessAudit(db, {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'billing_workspaces_list',
metadataJson: {
query: query.query ?? null,
limit: query.limit ?? 50,
},
});
const workspaces = await listBillingAdminWorkspaceSummaries(
getDbPool(),
db,
query.query ?? null,
query.limit ?? 50,
);
@@ -140,10 +143,17 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
}
});
app.get('/admin/billing/workspaces/:workspaceId', { preHandler: [requireAuth, requireBillingAdmin] }, async (request, reply) => {
app.get('/admin/billing/workspaces/:workspaceId', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
try {
const { workspaceId } = adminWorkspaceParamsSchema.parse(request.params);
const db = getDbPool();
await recordAdminAccessAudit(db, {
actorUserId: request.authUser?.id,
actorEmail: request.authUser?.email,
route: request.routeOptions.url ?? request.url,
action: 'billing_workspace_detail',
targetWorkspaceId: workspaceId,
});
const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId);
if (!summary) {