Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/services/apiService.ts
T

281 lines
11 KiB
TypeScript

import { timingSafeEqual } from "node:crypto";
import { createUniqueLegacyTenantIpAddress, findLegacyTenantByIdOrSlug } from "@hivegrid/db/accounts.server";
import {
createLegacyApiKeyLog,
findLegacyApiKeyByKeyWithRlsBootstrap,
setLegacyApiKeyLogStatus as setApiKeyLogStatus,
type LegacyApiKeySimple,
} from "@hivegrid/db/api-keys.server";
import type { ApiKeyLog } from "@hivegrid/db/prisma-browser";
import { runWithRlsContext } from "@hivegrid/db/rls-context.server";
import { Params } from "react-router";
import { DefaultFeatures } from "~/application/dtos/shared/DefaultFeatures";
import { getActiveTenantSubscriptions, getPlanFeatureUsage } from "./.server/subscriptionService";
import { PlanFeatureUsageDto } from "~/application/dtos/subscriptions/PlanFeatureUsageDto";
import { getTrustedClientIpOrNull } from "../server/IpUtils";
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
import { getTranslations } from "~/locale/i18next.server";
import { rateLimitByKey } from "./rateLimitService";
import { loadApiConfig, loadRequiredWebConfig } from "@hivegrid/config";
import { absoluteAppPath } from "../app/CrossHostUrls";
import { getApiKeyLookupCacheKey, serializeApiLogSearchParams } from "../api/apiKeySecurity.server";
import { loadCachedApiKeySnapshot } from "../api/api-key-auth-cache.server";
import { loadTenantSecuritySnapshot } from "../db/tenant-cache.server";
type ApiKeyLogScope = { id: string; tenantId: string };
async function setApiError(_request: Request, _params: Params, error: string, status: number, apiKeyLogScope?: ApiKeyLogScope) {
if (apiKeyLogScope) {
await setApiKeyLogStatus(apiKeyLogScope, {
error,
status,
startTime: null,
});
}
throw Object.assign(new Error(error), {
status,
apiKeyLog: apiKeyLogScope,
});
}
async function createApiKeyLog(
request: Request,
data: {
tenantId: string | null;
apiKeyId: string | null;
endpoint: string;
error?: string;
status?: number;
alias?: string;
tenantName?: string;
}
) {
return 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),
});
}
async function getApiKeyFromHeader(key: string) {
return loadCachedApiKeySnapshot(getApiKeyLookupCacheKey(key), () => {
const { config } = loadApiConfig();
return findLegacyApiKeyByKeyWithRlsBootstrap(key, undefined, config.jwtSecret, {
allowPlaintextFallback: config.apiKeys.allowLegacyPlaintextFallback,
});
});
}
export function getServiceAccessTokenFromRequest(request: Request) {
const authorization = request.headers.get("Authorization")?.trim() ?? "";
if (/^Bearer\s+/i.test(authorization)) {
return authorization.replace(/^Bearer\s+/i, "").trim();
}
return request.headers.get("X-Api-Key")?.trim() ?? "";
}
export function hasValidServiceAccessToken(request: Request) {
const apiAccessToken = loadRequiredWebConfig().apiAccessToken;
return typeof apiAccessToken === "string" && apiAccessToken.length > 0 && timingSafeStringEqual(getServiceAccessTokenFromRequest(request), apiAccessToken);
}
function timingSafeStringEqual(left: string, right: string) {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.byteLength !== rightBuffer.byteLength) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
}
function getServiceTenantScopeFromRequest(request: Request) {
const scopes = getTenantScopeOverridesFromRequest(request);
const distinctValues = new Set(scopes);
if (distinctValues.size > 1) {
throw Error("Conflicting tenant scope values");
}
return scopes[0] ?? null;
}
function getTenantScopeOverridesFromRequest(request: Request) {
const searchParams = new URL(request.url).searchParams;
return [searchParams.get("tenantId"), request.headers.get("X-Tenant-Id"), request.headers.get("X-Account-Id")]
.map((value) => value?.trim() ?? "")
.filter((value) => value.length > 0);
}
export type ApiAccessValidation = {
userId?: string;
tenant: {
id: string;
name: string;
slug: string;
deactivatedReason: string | null;
} | null;
tenantApiKey?: {
apiKey: LegacyApiKeySimple;
apiKeyLog: ApiKeyLog;
usage: PlanFeatureUsageDto | undefined;
};
};
export async function validateApiKey(request: Request, params: Params): Promise<ApiAccessValidation> {
const authorization = request.headers.get("Authorization")?.trim() ?? "";
const hasBearerCredential = /^Bearer\s+/i.test(authorization);
if (hasBearerCredential && !hasValidServiceAccessToken(request)) {
// User/API JWTs belong to the dedicated API runtime. The web runtime only
// accepts its exact service credential as Bearer auth; stored tenant keys
// must use X-Api-Key so an HS256 token can never be mistaken for a session.
throw Error("Bearer authentication is only supported for service access tokens");
}
const apiKeyFromHeaders = request.headers.get("X-Api-Key") ?? "";
if (!apiKeyFromHeaders && !hasBearerCredential) {
throw Error("No X-API-Key header or Authorization token provided");
}
if (hasValidServiceAccessToken(request)) {
const tenantId = getServiceTenantScopeFromRequest(request);
if (!tenantId) {
return { tenant: null };
}
const tenant = await runWithRlsContext({ tenantId }, () =>
loadTenantSecuritySnapshot(tenantId, async () => {
const current = await findLegacyTenantByIdOrSlug(tenantId);
return current ? { id: current.id, name: current.name, slug: current.slug, deactivatedReason: current.deactivatedReason } : null;
})
);
if (!tenant) {
throw Error("Account not found: " + tenantId);
}
return { tenant };
}
if (getTenantScopeOverridesFromRequest(request).length > 0) {
throw Error("You cannot use tenantId, X-Tenant-Id, or X-Account-Id with a stored API key");
}
return validateTenantApiKey(request, params);
}
export async function validateTenantApiKey(request: Request, params: Params): Promise<ApiAccessValidation> {
const { t } = await getTranslations(request);
const apiKeyFromHeaders = request.headers.get("X-Api-Key") ?? "";
const apiKey = await getApiKeyFromHeader(apiKeyFromHeaders);
if (!apiKey) {
throw await setApiError(request, params, "Invalid API Key", 401);
}
return runWithRlsContext({ tenantId: apiKey.tenantId, userId: apiKey.createdByUserId }, async () => {
const tenant = await loadTenantSecuritySnapshot(apiKey.tenantId, async () => {
const current = await findLegacyTenantByIdOrSlug(apiKey.tenantId);
return current ? { id: current.id, name: current.name, slug: current.slug, deactivatedReason: current.deactivatedReason } : null;
});
if (!tenant) {
throw await setApiError(request, params, "Account not found", 401);
}
const tenantSubscription = await getActiveTenantSubscriptions(apiKey.tenantId);
const rateLimitResult = await rateLimitByKey({
apiKey: apiKey.id,
tenantId: apiKey.tenantId,
tenantSubscription,
});
const apiKeyLog = await createApiKeyLog(request, {
apiKeyId: apiKey.id,
tenantId: apiKey.tenantId,
endpoint: new URL(request.url).pathname,
});
if (rateLimitResult.error) {
throw await setApiError(request, params, "Rate limit exceeded: " + rateLimitResult.error, 429, { id: apiKeyLog.id, tenantId: apiKey.tenantId });
}
createUniqueLegacyTenantIpAddress({
ip: getTrustedClientIpOrNull(request) ?? "Unknown",
fromUrl: new URL(request.url).pathname,
tenantId: apiKey.tenantId,
apiKeyId: apiKey.id,
});
if (tenant.deactivatedReason) {
throw await setApiError(request, params, "Account deactivated: " + tenant.deactivatedReason, 403, { id: apiKeyLog.id, tenantId: apiKey.tenantId });
}
if (!apiKey.active) {
throw await setApiError(request, params, "Inactive API Key", 400, { id: apiKeyLog.id, tenantId: apiKey.tenantId });
}
if (apiKey.expires && apiKey?.expires < new Date()) {
throw await setApiError(request, params, "Expired API Key", 400, { id: apiKeyLog.id, tenantId: apiKey.tenantId });
}
const usage = await getPlanFeatureUsage(apiKey.tenantId, DefaultFeatures.API, tenantSubscription);
if (usage && !usage.enabled) {
await NotificationService.sendToRoles({
channel: "admin-users",
tenantId: null,
notification: {
message: `${tenant.name} (${tenant.slug}) API error. ${t(usage.message)}`,
action: {
url: absoluteAppPath(loadRequiredWebConfig().routing, tenant.slug, "api/keys"),
},
},
});
throw await setApiError(request, params, t(usage.message), 402, { id: apiKeyLog.id, tenantId: apiKey.tenantId });
}
return {
userId: apiKey.createdByUserId,
tenant,
tenantApiKey: {
apiKey: { ...apiKey, tenant },
apiKeyLog,
usage,
},
};
});
}
export type WorkflowApiAccessValidation = {
tenant: ApiAccessValidation["tenant"];
userId?: string;
apiKeyLog?: ApiKeyLog | ApiKeyLogScope;
usage?: PlanFeatureUsageDto;
};
export async function getWorkflowApiKeyFromRequest(request: Request, params: Params): Promise<WorkflowApiAccessValidation> {
const apiAccessValidation = await validateApiKey(request, params);
return {
tenant: apiAccessValidation.tenant,
userId: apiAccessValidation.userId,
apiKeyLog: apiAccessValidation.tenantApiKey?.apiKeyLog,
usage: apiAccessValidation.tenantApiKey?.usage,
};
}
export async function setWorkflowApiKeyLogStatus(
apiAccessValidation: WorkflowApiAccessValidation | undefined,
data: { error?: string; status: number; startTime: number }
) {
if (!apiAccessValidation?.apiKeyLog) {
return;
}
const tenantId = apiAccessValidation.apiKeyLog.tenantId ?? apiAccessValidation.tenant?.id;
if (!tenantId) {
throw new Error(`API key log ${apiAccessValidation.apiKeyLog.id} cannot be updated without tenant scope`);
}
await runWithRlsContext({ tenantId, userId: apiAccessValidation.userId }, () =>
setApiKeyLogStatus({ id: apiAccessValidation.apiKeyLog!.id, tenantId }, data)
);
}
export function runWithWorkflowApiRlsContext<T>(apiAccessValidation: WorkflowApiAccessValidation, callback: () => T): T {
const tenantId = apiAccessValidation.tenant?.id;
if (!tenantId) {
throw new Error("Workflow API tenant context is required");
}
return runWithRlsContext({ tenantId, userId: apiAccessValidation.userId }, callback);
}