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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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.' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user