import { adminGetAllLegacyTenantSummaries } from "@hivegrid/db/accounts.server"; import { createLegacyApiKeyLog, createLegacyApiKey, countLegacyApiKeyLogsForKey, deleteLegacyApiKey, findFirstLegacyActiveApiKey, findLegacyApiKeyById, findLegacyApiKeyByKey, findRedactedLegacyApiKeyByAlias, findRedactedLegacyApiKeyById, getRedactedLegacyApiKeysForTenant, rotateLegacyApiKey, getLegacyApiKeyLogsForKey, type LegacyApiKeyLogWithDetails, type LegacyRedactedApiKeyWithDetails, type LegacyApiKeySimple, type LegacyApiKeyWithDetails, updateLegacyApiKey, } from "@hivegrid/db/api-keys.server"; import { getRlsExecutionContext, runWithRlsContext } from "@hivegrid/db/rls-context.server"; import { db } from "../db.server"; import { getTrustedClientIpOrNull } from "~/utils/server/IpUtils"; import { PaginationDto } from "~/application/dtos/data/PaginationDto"; import { FiltersDto } from "~/application/dtos/data/FiltersDto"; import RowFiltersHelper from "../helpers/RowFiltersHelper"; import { loadRequiredWebConfig } from "@hivegrid/config"; import { getApiKeyLookupCacheKey, serializeApiLogSearchParams } from "../api/apiKeySecurity.server"; import { invalidateApiKeySnapshot, loadCachedApiKeySnapshot } from "../api/api-key-auth-cache.server"; export type ApiKeyWithDetails = LegacyApiKeyWithDetails; export type RedactedApiKeyWithDetails = LegacyRedactedApiKeyWithDetails; export function redactApiKeyForDisplay(apiKey: LegacyApiKeyWithDetails): RedactedApiKeyWithDetails { const redactedApiKey = { ...apiKey }; delete (redactedApiKey as Partial).key; return redactedApiKey; } export function redactApiKeysForDisplay(apiKeys: LegacyApiKeyWithDetails[]): RedactedApiKeyWithDetails[] { return apiKeys.map(redactApiKeyForDisplay); } export type ApiKeyLogWithDetails = LegacyApiKeyLogWithDetails; export type ApiKeySimple = LegacyApiKeySimple; export async function getAllApiKeys(): Promise { const groups = await mapAdminTenants((tenantId) => getRedactedLegacyApiKeysForTenant(tenantId)); return groups.flat().sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime()); } export async function getAllApiKeyLogs( pagination: { page: number; pageSize: number }, filters: FiltersDto ): Promise<{ items: ApiKeyLogWithDetails[]; pagination: PaginationDto }> { const where = RowFiltersHelper.getFiltersCondition(filters); const items = await db.apiKeyLog.findMany({ take: pagination.pageSize, skip: pagination.pageSize * (pagination.page - 1), where, include: { apiKey: { include: { tenant: true }, }, }, orderBy: { createdAt: "desc", }, }); const totalItems = await db.apiKeyLog.count({ where, }); return { items, pagination: { page: pagination.page, pageSize: pagination.pageSize, totalItems, totalPages: Math.ceil(totalItems / pagination.pageSize), }, }; } export async function getTenantApiKeyLogs( tenantId: string, pagination: { page: number; pageSize: number }, filters: FiltersDto ): Promise<{ items: ApiKeyLogWithDetails[]; pagination: PaginationDto }> { const where = { AND: [RowFiltersHelper.getFiltersCondition(filters), { apiKey: { tenantId } }], }; const items = await db.apiKeyLog.findMany({ take: pagination.pageSize, skip: pagination.pageSize * (pagination.page - 1), where, include: { apiKey: { include: { tenant: true } } }, orderBy: { createdAt: "desc" }, }); const totalItems = await db.apiKeyLog.count({ where }); return { items, pagination: { page: pagination.page, pageSize: pagination.pageSize, totalItems, totalPages: Math.ceil(totalItems / pagination.pageSize), }, }; } export async function getApiKeys(tenantId: string): Promise { return runWithRlsContext({ tenantId }, () => getRedactedLegacyApiKeysForTenant(tenantId)); } export async function adminGetApiKeyById(id: string): Promise { return findAdminApiKeyById(id); } export async function getTenantApiKeyById(id: string, tenantId: string): Promise { return runWithRlsContext({ tenantId }, () => findRedactedLegacyApiKeyById({ id, tenantId })); } export async function getApiKey(key: string): Promise { return loadCachedApiKeySnapshot(getApiKeyLookupCacheKey(key), () => findLegacyApiKeyByKey(key)); } export async function getApiKeyByAlias(tenantId: string, alias: string): Promise { return runWithRlsContext({ tenantId }, () => findRedactedLegacyApiKeyByAlias(tenantId, alias)); } export async function getApiKeyLogs(id: string, tenantId: string): Promise { return runWithRlsContext({ tenantId }, () => getLegacyApiKeyLogsForKey({ id, tenantId })); } export async function countApiKeyLogsForKey(id: string, tenantId: string): Promise { return runWithRlsContext({ tenantId }, () => countLegacyApiKeyLogsForKey({ id, tenantId })); } export async function getFirstApiKey(tenantId: string | null) { if (!tenantId) return null; return runWithRlsContext({ tenantId }, () => findFirstLegacyActiveApiKey(tenantId)); } export async function createApiKey(data: { tenantId: string; createdByUserId: string; alias: string; expires: Date | null; active: boolean }) { return runWithRlsContext({ tenantId: data.tenantId }, () => createLegacyApiKey(data, undefined, getApiKeyHashSecret())); } export async function createApiKeyLog( request: Request, data: { tenantId: string | null; apiKeyId: string | null; endpoint: string; error?: string; status?: number; alias?: string; tenantName?: string; } ) { return await createLegacyApiKeyLog({ tenantId: data.tenantId, apiKeyId: data.apiKeyId, endpoint: data.endpoint, error: data.error, status: data.status, alias: data.alias, tenantName: data.tenantName, ip: getTrustedClientIpOrNull(request) ?? "", method: request.method, params: serializeApiLogSearchParams(request), }); } export async function updateApiKey( id: string, data: { tenantId: string; alias: string; expires: Date | null; active: boolean; } ) { const apiKey = await runWithRlsContext({ tenantId: data.tenantId }, () => updateLegacyApiKey(id, data)); await invalidateApiKeySnapshot(getApiKeyLookupCacheKey(apiKey.key)); return apiKey; } export async function rotateApiKey(id: string, tenantId: string) { const previousApiKey = await runWithRlsContext({ tenantId }, () => findLegacyApiKeyById({ id, tenantId })); if (!previousApiKey) { throw new Error(`API key ${id} not found`); } const apiKey = await runWithRlsContext({ tenantId }, () => rotateLegacyApiKey({ id, tenantId: previousApiKey.tenantId }, undefined, getApiKeyHashSecret())); if (previousApiKey?.key) { await invalidateApiKeySnapshot(getApiKeyLookupCacheKey(previousApiKey.key)); } await invalidateApiKeySnapshot(getApiKeyLookupCacheKey(apiKey.key)); return apiKey; } export async function deleteApiKey(id: string, tenantId: string) { const previousApiKey = await runWithRlsContext({ tenantId }, () => findLegacyApiKeyById({ id, tenantId })); if (!previousApiKey) { throw new Error(`API key ${id} not found`); } const apiKey = await runWithRlsContext({ tenantId }, () => deleteLegacyApiKey({ id, tenantId: previousApiKey.tenantId })); await invalidateApiKeySnapshot(getApiKeyLookupCacheKey(apiKey.key)); return apiKey; } function getApiKeyHashSecret() { return loadRequiredWebConfig().jwtSecret; } async function findAdminApiKeyById(id: string): Promise { const normalizedId = id.trim(); if (!normalizedId) return null; const matches = (await mapAdminTenants((tenantId) => findRedactedLegacyApiKeyById({ id: normalizedId, tenantId }))).filter( (item): item is RedactedApiKeyWithDetails => item !== null ); if (matches.length > 1) { throw new Error(`API key ${normalizedId} resolved to more than one tenant`); } return matches[0] ?? null; } async function mapAdminTenants(operation: (tenantId: string) => Promise): Promise { const context = getRlsExecutionContext(); if (!context?.userId || context.tenantId) { throw new Error("Admin API key access requires an unscoped authenticated operator context"); } const tenants = await adminGetAllLegacyTenantSummaries(); return mapWithConcurrency(tenants, 8, ({ id }) => runWithRlsContext({ tenantId: id }, () => operation(id))); } async function mapWithConcurrency(items: readonly T[], concurrency: number, operation: (item: T) => Promise): Promise { const results = new Array(items.length); let cursor = 0; await Promise.all( Array.from({ length: Math.min(concurrency, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; results[index] = await operation(items[index]!); } }) ); return results; }