254 lines
8.9 KiB
TypeScript
254 lines
8.9 KiB
TypeScript
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<LegacyApiKeyWithDetails>).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<RedactedApiKeyWithDetails[]> {
|
|
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<RedactedApiKeyWithDetails[]> {
|
|
return runWithRlsContext({ tenantId }, () => getRedactedLegacyApiKeysForTenant(tenantId));
|
|
}
|
|
|
|
export async function adminGetApiKeyById(id: string): Promise<RedactedApiKeyWithDetails | null> {
|
|
return findAdminApiKeyById(id);
|
|
}
|
|
|
|
export async function getTenantApiKeyById(id: string, tenantId: string): Promise<RedactedApiKeyWithDetails | null> {
|
|
return runWithRlsContext({ tenantId }, () => findRedactedLegacyApiKeyById({ id, tenantId }));
|
|
}
|
|
|
|
export async function getApiKey(key: string): Promise<ApiKeySimple | null> {
|
|
return loadCachedApiKeySnapshot(getApiKeyLookupCacheKey(key), () => findLegacyApiKeyByKey(key));
|
|
}
|
|
|
|
export async function getApiKeyByAlias(tenantId: string, alias: string): Promise<RedactedApiKeyWithDetails | null> {
|
|
return runWithRlsContext({ tenantId }, () => findRedactedLegacyApiKeyByAlias(tenantId, alias));
|
|
}
|
|
|
|
export async function getApiKeyLogs(id: string, tenantId: string): Promise<ApiKeyLogWithDetails[]> {
|
|
return runWithRlsContext({ tenantId }, () => getLegacyApiKeyLogsForKey({ id, tenantId }));
|
|
}
|
|
|
|
export async function countApiKeyLogsForKey(id: string, tenantId: string): Promise<number> {
|
|
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<RedactedApiKeyWithDetails | null> {
|
|
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<T>(operation: (tenantId: string) => Promise<T>): Promise<T[]> {
|
|
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<T, U>(items: readonly T[], concurrency: number, operation: (item: T) => Promise<U>): Promise<U[]> {
|
|
const results = new Array<U>(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;
|
|
}
|