281 lines
11 KiB
TypeScript
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);
|
|
}
|