feat: complete admin phase C and add safe mutation pilot
This commit is contained in:
@@ -11,6 +11,7 @@ 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';
|
||||
import { adminOpsRoutes } from './routes/admin-ops.js';
|
||||
|
||||
function parseAllowedOrigins(rawOrigins: string) {
|
||||
return rawOrigins
|
||||
@@ -60,6 +61,7 @@ export async function buildApp() {
|
||||
await app.register(analyticsRoutes, { prefix: '/api' });
|
||||
await app.register(adminBootstrapRoutes, { prefix: '/api' });
|
||||
await app.register(adminAccessRoutes, { prefix: '/api' });
|
||||
await app.register(adminOpsRoutes, { prefix: '/api' });
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
+151
-1
@@ -6,6 +6,24 @@ import { getDbPool } from '../db/pool.js';
|
||||
|
||||
type DbClient = Pool | PoolClient;
|
||||
|
||||
export const ADMIN_AUDIT_ACTIONS = {
|
||||
ADMIN_ACCESS_LIST: 'admin_access_list',
|
||||
ADMIN_ACCESS_UPSERT: 'admin_access_upsert',
|
||||
ADMIN_ACCESS_STATUS_CHANGED: 'admin_access_status_changed',
|
||||
ANALYTICS_SUMMARY: 'analytics_summary',
|
||||
BILLING_WORKSPACES_LIST: 'billing_workspaces_list',
|
||||
BILLING_WORKSPACE_DETAIL: 'billing_workspace_detail',
|
||||
BOOTSTRAP_ADMIN_CLAIMED: 'bootstrap_admin_claimed',
|
||||
ADMIN_OPS_AUDIT_LIST: 'admin_ops_audit_list',
|
||||
ADMIN_OPS_SECURITY_POSTURE: 'admin_ops_security_posture',
|
||||
ADMIN_OPS_DIAGNOSTICS: 'admin_ops_diagnostics',
|
||||
ADMIN_MUTATION_BILLING_RESYNC_REQUESTED: 'admin_mutation_billing_resync_requested',
|
||||
} as const;
|
||||
|
||||
export type AdminAuditAction = (typeof ADMIN_AUDIT_ACTIONS)[keyof typeof ADMIN_AUDIT_ACTIONS];
|
||||
|
||||
export const ADMIN_AUDIT_ACTION_VALUES = Object.values(ADMIN_AUDIT_ACTIONS) as [AdminAuditAction, ...AdminAuditAction[]];
|
||||
|
||||
type ApplicationAdminRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -17,6 +35,40 @@ type ApplicationAdminRow = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type AdminAccessAuditRow = {
|
||||
id: string;
|
||||
actor_user_id: string | null;
|
||||
actor_email: string | null;
|
||||
route: string;
|
||||
action: string;
|
||||
target_workspace_id: string | null;
|
||||
target_workspace_name: string | null;
|
||||
metadata_json: Record<string, unknown>;
|
||||
occurred_at: string;
|
||||
};
|
||||
|
||||
export type AdminAccessAuditSummary = {
|
||||
id: string;
|
||||
actorUserId: string | null;
|
||||
actorEmail: string | null;
|
||||
route: string;
|
||||
action: AdminAuditAction;
|
||||
targetWorkspaceId: string | null;
|
||||
targetWorkspaceName: string | null;
|
||||
metadataJson: Record<string, unknown>;
|
||||
occurredAt: string;
|
||||
};
|
||||
|
||||
export type AdminAccessAuditListFilters = {
|
||||
actorEmail?: string;
|
||||
action?: AdminAuditAction;
|
||||
workspaceId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
};
|
||||
|
||||
function toApplicationAdminSummary(row: ApplicationAdminRow): ApplicationAdminSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -33,7 +85,7 @@ type AdminAccessAuditPayload = {
|
||||
actorUserId?: string | null;
|
||||
actorEmail?: string | null;
|
||||
route: string;
|
||||
action: string;
|
||||
action: AdminAuditAction;
|
||||
targetWorkspaceId?: string | null;
|
||||
metadataJson?: Record<string, unknown>;
|
||||
occurredAt?: string;
|
||||
@@ -222,3 +274,101 @@ export async function recordAdminAccessAudit(db: DbClient, payload: AdminAccessA
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
function buildAdminAccessAuditWhere(filters: Omit<AdminAccessAuditListFilters, 'limit' | 'offset'>) {
|
||||
const clauses: string[] = [];
|
||||
const params: Array<string> = [];
|
||||
|
||||
if (filters.actorEmail?.trim()) {
|
||||
params.push(`%${filters.actorEmail.trim().toLowerCase()}%`);
|
||||
clauses.push(`lower(audit.actor_email) like $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.action?.trim()) {
|
||||
params.push(filters.action.trim());
|
||||
clauses.push(`audit.action = $${params.length}`);
|
||||
}
|
||||
|
||||
if (filters.workspaceId?.trim()) {
|
||||
params.push(filters.workspaceId.trim());
|
||||
clauses.push(`audit.target_workspace_id = $${params.length}::uuid`);
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
params.push(filters.from);
|
||||
clauses.push(`audit.occurred_at >= $${params.length}::timestamptz`);
|
||||
}
|
||||
|
||||
if (filters.to) {
|
||||
params.push(filters.to);
|
||||
clauses.push(`audit.occurred_at <= $${params.length}::timestamptz`);
|
||||
}
|
||||
|
||||
return {
|
||||
whereSql: clauses.length > 0 ? `where ${clauses.join(' and ')}` : '',
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAdminAccessAuditRow(row: AdminAccessAuditRow): AdminAccessAuditSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
actorUserId: row.actor_user_id,
|
||||
actorEmail: row.actor_email,
|
||||
route: row.route,
|
||||
action: row.action as AdminAuditAction,
|
||||
targetWorkspaceId: row.target_workspace_id,
|
||||
targetWorkspaceName: row.target_workspace_name,
|
||||
metadataJson: row.metadata_json,
|
||||
occurredAt: row.occurred_at,
|
||||
};
|
||||
}
|
||||
|
||||
export async function countAdminAccessAudit(
|
||||
db: DbClient,
|
||||
filters: Omit<AdminAccessAuditListFilters, 'limit' | 'offset'>,
|
||||
): Promise<number> {
|
||||
const built = buildAdminAccessAuditWhere(filters);
|
||||
const result = await db.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.admin_access_audit audit
|
||||
${built.whereSql}
|
||||
`,
|
||||
built.params,
|
||||
);
|
||||
|
||||
return Number(result.rows[0]?.count ?? '0');
|
||||
}
|
||||
|
||||
export async function listAdminAccessAudit(db: DbClient, filters: AdminAccessAuditListFilters): Promise<AdminAccessAuditSummary[]> {
|
||||
const built = buildAdminAccessAuditWhere(filters);
|
||||
const params = [...built.params, filters.limit, filters.offset];
|
||||
const limitParam = `$${built.params.length + 1}`;
|
||||
const offsetParam = `$${built.params.length + 2}`;
|
||||
|
||||
const result = await db.query<AdminAccessAuditRow>(
|
||||
`
|
||||
select
|
||||
audit.id,
|
||||
audit.actor_user_id,
|
||||
audit.actor_email,
|
||||
audit.route,
|
||||
audit.action,
|
||||
audit.target_workspace_id,
|
||||
workspace.name as target_workspace_name,
|
||||
audit.metadata_json,
|
||||
audit.occurred_at
|
||||
from public.admin_access_audit audit
|
||||
left join public.workspaces workspace
|
||||
on workspace.id = audit.target_workspace_id
|
||||
${built.whereSql}
|
||||
order by audit.occurred_at desc
|
||||
limit ${limitParam}
|
||||
offset ${offsetParam}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return result.rows.map(mapAdminAccessAuditRow);
|
||||
}
|
||||
|
||||
@@ -133,6 +133,17 @@ type BillingAdminWorkspaceSummaryRow = {
|
||||
external_subscription_ref: string | null;
|
||||
};
|
||||
|
||||
type BillingStaleSyncWorkspaceRow = {
|
||||
workspace_id: string;
|
||||
workspace_name: string;
|
||||
billing_sync_status: BillingSyncStatus;
|
||||
last_stripe_sync_at: string | null;
|
||||
status: AccountBillingStatus;
|
||||
plan_code: string | null;
|
||||
pending_plan_code: string | null;
|
||||
pending_plan_effective_at: string | null;
|
||||
};
|
||||
|
||||
type UsagePeriodRow = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -174,6 +185,23 @@ type AddonPurchaseRow = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type BillingStaleSyncWorkspaceSummary = {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
billingSyncStatus: BillingSyncStatus;
|
||||
lastStripeSyncAt: string | null;
|
||||
status: AccountBillingStatus;
|
||||
planCode: PlanCode | null;
|
||||
pendingPlanCode: PlanCode | null;
|
||||
pendingPlanEffectiveAt: string | null;
|
||||
};
|
||||
|
||||
export type BillingTimelineAnomalySummary = {
|
||||
repeatedPaymentFailedCount: number;
|
||||
pendingPlanPastEffectiveCount: number;
|
||||
staleSyncThresholdCount: number;
|
||||
};
|
||||
|
||||
export async function getBillingAccountForWorkspace(db: DbClient, workspaceId: string): Promise<BillingAccountRecord | null> {
|
||||
const result = await db.query<BillingAccountRow>(
|
||||
`
|
||||
@@ -509,6 +537,118 @@ export async function listRecentBillingTimelineEventsForWorkspace(db: DbClient,
|
||||
return result.rows.map(mapBillingTimelineEventRow);
|
||||
}
|
||||
|
||||
export async function listStaleBillingSyncWorkspaces(
|
||||
db: DbClient,
|
||||
input: { staleThresholdHours?: number; limit?: number } = {},
|
||||
): Promise<BillingStaleSyncWorkspaceSummary[]> {
|
||||
const staleThresholdHours = input.staleThresholdHours ?? 24;
|
||||
const limit = input.limit ?? 10;
|
||||
|
||||
const result = await db.query<BillingStaleSyncWorkspaceRow>(
|
||||
`
|
||||
select
|
||||
w.id as workspace_id,
|
||||
w.name as workspace_name,
|
||||
billing.billing_sync_status,
|
||||
billing.last_stripe_sync_at,
|
||||
billing.status,
|
||||
billing.plan_code,
|
||||
billing.pending_plan_code,
|
||||
billing.pending_plan_effective_at
|
||||
from public.workspace_billing_accounts billing
|
||||
join public.workspaces w on w.id = billing.workspace_id
|
||||
where
|
||||
billing.billing_sync_status in ('stale', 'error')
|
||||
or billing.last_stripe_sync_at is null
|
||||
or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int)
|
||||
order by
|
||||
case when billing.billing_sync_status = 'error' then 0 else 1 end asc,
|
||||
billing.last_stripe_sync_at asc nulls first
|
||||
limit $2
|
||||
`,
|
||||
[staleThresholdHours, limit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
workspaceId: row.workspace_id,
|
||||
workspaceName: row.workspace_name,
|
||||
billingSyncStatus: row.billing_sync_status,
|
||||
lastStripeSyncAt: row.last_stripe_sync_at,
|
||||
status: row.status,
|
||||
planCode: row.plan_code as PlanCode | null,
|
||||
pendingPlanCode: row.pending_plan_code as PlanCode | null,
|
||||
pendingPlanEffectiveAt: row.pending_plan_effective_at,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function countStaleBillingSyncWorkspaces(db: DbClient, staleThresholdHours = 24): Promise<number> {
|
||||
const result = await db.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.workspace_billing_accounts billing
|
||||
where
|
||||
billing.billing_sync_status in ('stale', 'error')
|
||||
or billing.last_stripe_sync_at is null
|
||||
or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int)
|
||||
`,
|
||||
[staleThresholdHours],
|
||||
);
|
||||
|
||||
return Number(result.rows[0]?.count ?? '0');
|
||||
}
|
||||
|
||||
export async function getBillingTimelineAnomalySummary(
|
||||
db: DbClient,
|
||||
input: { windowDays?: number; paymentFailedThreshold?: number; staleThresholdHours?: number } = {},
|
||||
): Promise<BillingTimelineAnomalySummary> {
|
||||
const windowDays = input.windowDays ?? 7;
|
||||
const paymentFailedThreshold = input.paymentFailedThreshold ?? 2;
|
||||
const staleThresholdHours = input.staleThresholdHours ?? 24;
|
||||
|
||||
const repeatedPaymentFailedResult = await db.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from (
|
||||
select event.workspace_id
|
||||
from public.workspace_billing_timeline_events event
|
||||
where event.event_type = 'invoice_payment_failed'
|
||||
and event.occurred_at >= now() - make_interval(days => $1::int)
|
||||
group by event.workspace_id
|
||||
having count(*) >= $2
|
||||
) repeated_failure_workspaces
|
||||
`,
|
||||
[windowDays, paymentFailedThreshold],
|
||||
);
|
||||
|
||||
const pendingPlanPastEffectiveResult = await db.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.workspace_billing_accounts billing
|
||||
where billing.pending_plan_code is not null
|
||||
and billing.pending_plan_effective_at is not null
|
||||
and billing.pending_plan_effective_at < now()
|
||||
`,
|
||||
);
|
||||
|
||||
const staleSyncThresholdResult = await db.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.workspace_billing_accounts billing
|
||||
where
|
||||
billing.billing_sync_status in ('stale', 'error')
|
||||
or billing.last_stripe_sync_at is null
|
||||
or billing.last_stripe_sync_at < now() - make_interval(hours => $1::int)
|
||||
`,
|
||||
[staleThresholdHours],
|
||||
);
|
||||
|
||||
return {
|
||||
repeatedPaymentFailedCount: Number(repeatedPaymentFailedResult.rows[0]?.count ?? '0'),
|
||||
pendingPlanPastEffectiveCount: Number(pendingPlanPastEffectiveResult.rows[0]?.count ?? '0'),
|
||||
staleSyncThresholdCount: Number(staleSyncThresholdResult.rows[0]?.count ?? '0'),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listBillingAdminWorkspaceSummaries(db: DbClient, search: string | null, limit = 50) {
|
||||
const result = await db.query<BillingAdminWorkspaceSummaryRow>(
|
||||
`
|
||||
|
||||
@@ -68,11 +68,24 @@ function parseAdminEmailAllowlist(allowlist: string | undefined): string[] {
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasEntries(raw: string | undefined) {
|
||||
return parseAdminEmailAllowlist(raw).length > 0;
|
||||
}
|
||||
|
||||
export function getAdminEmailAllowlist() {
|
||||
const env = getEnv();
|
||||
return parseAdminEmailAllowlist(env.ADMIN_EMAILS || env.BILLING_ADMIN_EMAILS);
|
||||
}
|
||||
|
||||
export function hasAdminEmailAllowlistConfigured() {
|
||||
return hasEntries(getEnv().ADMIN_EMAILS);
|
||||
}
|
||||
|
||||
export function isUsingDeprecatedBillingAdminFallback() {
|
||||
const env = getEnv();
|
||||
return !hasEntries(env.ADMIN_EMAILS) && hasEntries(env.BILLING_ADMIN_EMAILS);
|
||||
}
|
||||
|
||||
export function isBillingAdminEmail(email: string) {
|
||||
const allowlist = getAdminEmailAllowlist();
|
||||
|
||||
|
||||
@@ -38,6 +38,14 @@ type BillingWebhookEventRow = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type BillingFailedWebhookEventRow = BillingWebhookEventRow & {
|
||||
workspace_name: string | null;
|
||||
};
|
||||
|
||||
export type BillingFailedWebhookEventSummary = BillingWebhookEventRecord & {
|
||||
workspaceName: string | null;
|
||||
};
|
||||
|
||||
export async function recordIncomingWebhookEvent(
|
||||
db: DbClient,
|
||||
input: {
|
||||
@@ -146,6 +154,62 @@ export async function listRecentWebhookEventsForWorkspace(db: DbClient, workspac
|
||||
return result.rows.map(mapBillingWebhookEventRow);
|
||||
}
|
||||
|
||||
export async function listRecentFailedWebhookEvents(
|
||||
db: DbClient,
|
||||
input: { sinceDays?: number; limit?: number } = {},
|
||||
): Promise<BillingFailedWebhookEventSummary[]> {
|
||||
const sinceDays = input.sinceDays ?? 7;
|
||||
const limit = input.limit ?? 10;
|
||||
|
||||
const result = await db.query<BillingFailedWebhookEventRow>(
|
||||
`
|
||||
select
|
||||
event.id,
|
||||
event.provider,
|
||||
event.external_event_id,
|
||||
event.event_type,
|
||||
event.status,
|
||||
event.workspace_id,
|
||||
event.external_customer_ref,
|
||||
event.external_subscription_ref,
|
||||
event.payload_json,
|
||||
event.error_message,
|
||||
event.received_at,
|
||||
event.processed_at,
|
||||
event.created_at,
|
||||
event.updated_at,
|
||||
workspace.name as workspace_name
|
||||
from public.billing_webhook_events event
|
||||
left join public.workspaces workspace
|
||||
on workspace.id = event.workspace_id
|
||||
where event.status = 'failed'
|
||||
and event.received_at >= now() - make_interval(days => $1::int)
|
||||
order by event.received_at desc
|
||||
limit $2
|
||||
`,
|
||||
[sinceDays, limit],
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
...mapBillingWebhookEventRow(row),
|
||||
workspaceName: row.workspace_name,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function countRecentFailedWebhookEvents(db: DbClient, sinceDays = 7): Promise<number> {
|
||||
const result = await db.query<{ count: string }>(
|
||||
`
|
||||
select count(*)::text as count
|
||||
from public.billing_webhook_events event
|
||||
where event.status = 'failed'
|
||||
and event.received_at >= now() - make_interval(days => $1::int)
|
||||
`,
|
||||
[sinceDays],
|
||||
);
|
||||
|
||||
return Number(result.rows[0]?.count ?? '0');
|
||||
}
|
||||
|
||||
function mapBillingWebhookEventRow(row: BillingWebhookEventRow): BillingWebhookEventRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
|
||||
@@ -5,7 +5,14 @@ import { getAddonByCode } from '../../../shared/billing/addons.js';
|
||||
import type { AddonCode, ActivePlanCode } from '../../../shared/billing/plans.js';
|
||||
import { getPlanByCode } from '../../../shared/billing/plans.js';
|
||||
import { ensureWorkspaceForUser } from '../account/repository.js';
|
||||
import { ensureBillingAccountForWorkspace, listAddonBalancesForWorkspace, recordAddonPurchase, updateBillingAccountState, upsertAddonBalance } from '../billing/repository.js';
|
||||
import {
|
||||
ensureBillingAccountForWorkspace,
|
||||
getBillingAccountForWorkspace,
|
||||
listAddonBalancesForWorkspace,
|
||||
recordAddonPurchase,
|
||||
updateBillingAccountState,
|
||||
upsertAddonBalance,
|
||||
} from '../billing/repository.js';
|
||||
import { getEnv } from '../config/env.js';
|
||||
import {
|
||||
assertAddonSupportsStripeCheckout,
|
||||
@@ -336,6 +343,28 @@ export async function retrieveStripeSubscription(subscriptionId: string) {
|
||||
return stripe.subscriptions.retrieve(subscriptionId);
|
||||
}
|
||||
|
||||
export async function requestWorkspaceBillingResyncByAdmin(db: DbClient, input: { workspaceId: string }) {
|
||||
ensureStripeReady('resync');
|
||||
|
||||
const billingAccount = await getBillingAccountForWorkspace(db, input.workspaceId);
|
||||
|
||||
if (!billingAccount) {
|
||||
throw new Error('Billing workspace not found.');
|
||||
}
|
||||
|
||||
if (!billingAccount.externalSubscriptionRef) {
|
||||
throw new Error('Workspace does not have a Stripe subscription reference.');
|
||||
}
|
||||
|
||||
const subscription = await retrieveStripeSubscription(billingAccount.externalSubscriptionRef);
|
||||
await syncWorkspaceBillingFromStripeSubscription(db, subscription, input.workspaceId);
|
||||
|
||||
return {
|
||||
workspaceId: input.workspaceId,
|
||||
externalSubscriptionRef: billingAccount.externalSubscriptionRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapStripeSubscriptionStatus(status: Stripe.Subscription.Status): 'active' | 'inactive' | 'past_due' | 'canceled' {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AdminApplicationAdminUpsertRequest,
|
||||
} from '../../../shared/types.js';
|
||||
import {
|
||||
ADMIN_AUDIT_ACTIONS,
|
||||
getActiveApplicationAdminCountExcludingId,
|
||||
listApplicationAdmins,
|
||||
recordAdminAccessAudit,
|
||||
@@ -37,7 +38,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'admin_access_list',
|
||||
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_LIST,
|
||||
});
|
||||
const admins = await listApplicationAdmins(db);
|
||||
const response: AdminApplicationAdminsListResponse = { admins };
|
||||
@@ -60,7 +61,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'admin_access_upsert',
|
||||
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_UPSERT,
|
||||
metadataJson: {
|
||||
targetEmail: payload.email.trim(),
|
||||
},
|
||||
@@ -103,7 +104,7 @@ export const adminAccessRoutes: FastifyPluginAsync = async (app) => {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'admin_access_status_changed',
|
||||
action: ADMIN_AUDIT_ACTIONS.ADMIN_ACCESS_STATUS_CHANGED,
|
||||
metadataJson: {
|
||||
targetAdminId: adminId,
|
||||
nextStatus: payload.status,
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
|
||||
import { ZodError, z } from 'zod';
|
||||
import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js';
|
||||
import { createDefaultWorkspaceForUser } from '../account/repository.js';
|
||||
import { createApplicationAdmin, isApplicationAdmin, isBootstrapRequired, recordAdminAccessAudit } from '../auth/admin.js';
|
||||
import {
|
||||
ADMIN_AUDIT_ACTIONS,
|
||||
createApplicationAdmin,
|
||||
isApplicationAdmin,
|
||||
isBootstrapRequired,
|
||||
recordAdminAccessAudit,
|
||||
} from '../auth/admin.js';
|
||||
import { hashPassword } from '../auth/passwords.js';
|
||||
import { createSession, setSessionCookie } from '../auth/sessions.js';
|
||||
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
||||
@@ -91,7 +97,7 @@ export const adminBootstrapRoutes: FastifyPluginAsync = async (app) => {
|
||||
actorUserId: user.id,
|
||||
actorEmail: user.email,
|
||||
route: '/api/admin/bootstrap/claim',
|
||||
action: 'bootstrap_admin_claimed',
|
||||
action: ADMIN_AUDIT_ACTIONS.BOOTSTRAP_ADMIN_CLAIMED,
|
||||
metadataJson: {
|
||||
bootstrapRequiredAtClaim: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import type { FastifyPluginAsync } from 'fastify';
|
||||
import { ZodError, z } from 'zod';
|
||||
import type {
|
||||
AdminAuditLogListResponse,
|
||||
AdminBillingResyncRequest,
|
||||
AdminBillingResyncResponse,
|
||||
AdminSecurityPostureResponse,
|
||||
AdminSupportDiagnosticsResponse,
|
||||
} from '../../../shared/types.js';
|
||||
import {
|
||||
ADMIN_AUDIT_ACTIONS,
|
||||
ADMIN_AUDIT_ACTION_VALUES,
|
||||
countAdminAccessAudit,
|
||||
getActiveApplicationAdminCount,
|
||||
isBootstrapRequired,
|
||||
listAdminAccessAudit,
|
||||
recordAdminAccessAudit,
|
||||
requireAdmin,
|
||||
} from '../auth/admin.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import {
|
||||
countStaleBillingSyncWorkspaces,
|
||||
getBillingTimelineAnomalySummary,
|
||||
listStaleBillingSyncWorkspaces,
|
||||
} from '../billing/repository.js';
|
||||
import {
|
||||
hasAdminEmailAllowlistConfigured,
|
||||
isAdminBootstrapEnabled,
|
||||
isUsingDeprecatedBillingAdminFallback,
|
||||
} from '../config/env.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
import {
|
||||
countRecentFailedWebhookEvents,
|
||||
listRecentFailedWebhookEvents,
|
||||
} from '../payments/repository.js';
|
||||
import { requestWorkspaceBillingResyncByAdmin } from '../payments/service.js';
|
||||
|
||||
const auditListQuerySchema = z.object({
|
||||
actorEmail: z.string().trim().min(1).max(255).optional(),
|
||||
action: z.enum(ADMIN_AUDIT_ACTION_VALUES).optional(),
|
||||
workspaceId: z.string().uuid().optional(),
|
||||
from: z.string().datetime().optional(),
|
||||
to: z.string().datetime().optional(),
|
||||
page: z.coerce.number().int().min(1).max(10_000).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
const diagnosticsQuerySchema = z.object({
|
||||
windowDays: z.coerce.number().int().min(1).max(90).optional(),
|
||||
staleSyncThresholdHours: z.coerce.number().int().min(1).max(720).optional(),
|
||||
sampleLimit: z.coerce.number().int().min(1).max(50).optional(),
|
||||
});
|
||||
|
||||
const billingResyncBodySchema = z.object({
|
||||
workspaceId: z.string().uuid(),
|
||||
reason: z.string().trim().min(10).max(500),
|
||||
confirmationText: z.literal('RESYNC'),
|
||||
ticketRef: z.string().trim().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
export const adminOpsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/admin/ops/audit', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const query = auditListQuerySchema.parse(request.query ?? {});
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 25;
|
||||
const offset = (page - 1) * pageSize;
|
||||
const db = getDbPool();
|
||||
|
||||
await recordAdminAccessAudit(db, {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_AUDIT_LIST,
|
||||
metadataJson: {
|
||||
filters: {
|
||||
actorEmail: query.actorEmail ?? null,
|
||||
action: query.action ?? null,
|
||||
workspaceId: query.workspaceId ?? null,
|
||||
from: query.from ?? null,
|
||||
to: query.to ?? null,
|
||||
},
|
||||
page,
|
||||
pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
listAdminAccessAudit(db, {
|
||||
actorEmail: query.actorEmail,
|
||||
action: query.action,
|
||||
workspaceId: query.workspaceId,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
}),
|
||||
countAdminAccessAudit(db, {
|
||||
actorEmail: query.actorEmail,
|
||||
action: query.action,
|
||||
workspaceId: query.workspaceId,
|
||||
from: query.from,
|
||||
to: query.to,
|
||||
}),
|
||||
]);
|
||||
|
||||
const response: AdminAuditLogListResponse = {
|
||||
items,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
};
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin audit query.' });
|
||||
}
|
||||
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to load admin audit logs.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/ops/security-posture', { 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_AUDIT_ACTIONS.ADMIN_OPS_SECURITY_POSTURE,
|
||||
});
|
||||
|
||||
const [bootstrapRequired, activeApplicationAdminCount] = await Promise.all([
|
||||
isBootstrapRequired(db),
|
||||
getActiveApplicationAdminCount(db),
|
||||
]);
|
||||
|
||||
const response: AdminSecurityPostureResponse = {
|
||||
bootstrapRequired,
|
||||
bootstrapEnabled: isAdminBootstrapEnabled(),
|
||||
activeApplicationAdminCount,
|
||||
adminAllowlistConfigured: hasAdminEmailAllowlistConfigured(),
|
||||
usingDeprecatedBillingAdminFallback: isUsingDeprecatedBillingAdminFallback(),
|
||||
};
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to load admin security posture.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/admin/ops/diagnostics', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const query = diagnosticsQuerySchema.parse(request.query ?? {});
|
||||
const windowDays = query.windowDays ?? 7;
|
||||
const staleSyncThresholdHours = query.staleSyncThresholdHours ?? 24;
|
||||
const sampleLimit = query.sampleLimit ?? 10;
|
||||
const db = getDbPool();
|
||||
|
||||
await recordAdminAccessAudit(db, {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: ADMIN_AUDIT_ACTIONS.ADMIN_OPS_DIAGNOSTICS,
|
||||
metadataJson: {
|
||||
windowDays,
|
||||
staleSyncThresholdHours,
|
||||
sampleLimit,
|
||||
},
|
||||
});
|
||||
|
||||
const [failedWebhooksCount, staleBillingSyncCount, failedWebhooks, staleBillingSync, timelineAnomalies] = await Promise.all([
|
||||
countRecentFailedWebhookEvents(db, windowDays),
|
||||
countStaleBillingSyncWorkspaces(db, staleSyncThresholdHours),
|
||||
listRecentFailedWebhookEvents(db, { sinceDays: windowDays, limit: sampleLimit }),
|
||||
listStaleBillingSyncWorkspaces(db, { staleThresholdHours: staleSyncThresholdHours, limit: sampleLimit }),
|
||||
getBillingTimelineAnomalySummary(db, {
|
||||
windowDays,
|
||||
staleThresholdHours: staleSyncThresholdHours,
|
||||
paymentFailedThreshold: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
const response: AdminSupportDiagnosticsResponse = {
|
||||
windowDays,
|
||||
staleSyncThresholdHours,
|
||||
failedWebhooks: {
|
||||
count: failedWebhooksCount,
|
||||
items: failedWebhooks.map((issue) => ({
|
||||
id: issue.id,
|
||||
eventType: issue.eventType,
|
||||
workspaceId: issue.workspaceId,
|
||||
workspaceName: issue.workspaceName,
|
||||
errorMessage: issue.errorMessage,
|
||||
receivedAt: issue.receivedAt,
|
||||
externalEventId: issue.externalEventId,
|
||||
})),
|
||||
},
|
||||
staleBillingSync: {
|
||||
count: staleBillingSyncCount,
|
||||
items: staleBillingSync,
|
||||
},
|
||||
timelineAnomalies,
|
||||
};
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid admin diagnostics query.' });
|
||||
}
|
||||
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: 'Failed to load admin diagnostics.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/admin/mutations/billing/resync', { preHandler: [requireAuth, requireAdmin] }, async (request, reply) => {
|
||||
try {
|
||||
const payload = billingResyncBodySchema.parse(request.body ?? {}) as AdminBillingResyncRequest;
|
||||
const db = getDbPool();
|
||||
|
||||
const result = await requestWorkspaceBillingResyncByAdmin(db, {
|
||||
workspaceId: payload.workspaceId,
|
||||
});
|
||||
|
||||
await recordAdminAccessAudit(db, {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: ADMIN_AUDIT_ACTIONS.ADMIN_MUTATION_BILLING_RESYNC_REQUESTED,
|
||||
targetWorkspaceId: payload.workspaceId,
|
||||
metadataJson: {
|
||||
reason: payload.reason,
|
||||
ticketRef: payload.ticketRef?.trim() || null,
|
||||
confirmationText: payload.confirmationText,
|
||||
},
|
||||
});
|
||||
|
||||
const response: AdminBillingResyncResponse = {
|
||||
accepted: true,
|
||||
message: 'Billing resync was requested successfully.',
|
||||
workspaceId: result.workspaceId,
|
||||
externalSubscriptionRef: result.externalSubscriptionRef,
|
||||
};
|
||||
|
||||
return reply.code(202).send(response);
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid billing resync payload.' });
|
||||
}
|
||||
|
||||
request.log.error(error);
|
||||
return reply.code(500).send({ error: error instanceof Error ? error.message : 'Failed to request billing resync.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import type { FastifyPluginAsync } from 'fastify';
|
||||
import { z, ZodError } from 'zod';
|
||||
import { ensureWorkspaceForUser } from '../account/repository.js';
|
||||
import { hydrateAuthUser, requireAuth } from '../auth/middleware.js';
|
||||
import { recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
|
||||
import { ADMIN_AUDIT_ACTIONS, recordAdminAccessAudit, requireAdmin } from '../auth/admin.js';
|
||||
import { getDbPool } from '../db/pool.js';
|
||||
import { getAdminAnalyticsSummary, recordAnalyticsEvent } from '../analytics/service.js';
|
||||
|
||||
@@ -75,7 +75,7 @@ export const analyticsRoutes: FastifyPluginAsync = async (app) => {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'analytics_summary',
|
||||
action: ADMIN_AUDIT_ACTIONS.ANALYTICS_SUMMARY,
|
||||
metadataJson: {
|
||||
days: query.days ?? 30,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { ADMIN_AUDIT_ACTIONS, 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';
|
||||
@@ -121,7 +121,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'billing_workspaces_list',
|
||||
action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACES_LIST,
|
||||
metadataJson: {
|
||||
query: query.query ?? null,
|
||||
limit: query.limit ?? 50,
|
||||
@@ -151,7 +151,7 @@ export const billingRoutes: FastifyPluginAsync = async (app) => {
|
||||
actorUserId: request.authUser?.id,
|
||||
actorEmail: request.authUser?.email,
|
||||
route: request.routeOptions.url ?? request.url,
|
||||
action: 'billing_workspace_detail',
|
||||
action: ADMIN_AUDIT_ACTIONS.BILLING_WORKSPACE_DETAIL,
|
||||
targetWorkspaceId: workspaceId,
|
||||
});
|
||||
const summary = await getBillingAdminWorkspaceSummaryByWorkspaceId(db, workspaceId);
|
||||
|
||||
Reference in New Issue
Block a user