Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,328 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
createUniqueLegacyTenantIpAddress,
|
||||
findLegacyTenantByIdOrSlug,
|
||||
getLegacyTenantMembershipsForUser,
|
||||
} from "@toolctrl/db/accounts.server";
|
||||
import {
|
||||
createLegacyApiKeyLog,
|
||||
findLegacyApiKeyByKey,
|
||||
setLegacyApiKeyLogStatus as setApiKeyLogStatus,
|
||||
type LegacyApiKeySimple,
|
||||
} from "@toolctrl/db/api-keys.server";
|
||||
import type { ApiKeyLog } from "@toolctrl/db/prisma-browser";
|
||||
import { findLegacyUserWithoutPasswordById } from "@toolctrl/db/users.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 { getClientIPAddress } from "../server/IpUtils";
|
||||
import jwt from "jsonwebtoken";
|
||||
import NotificationService from "~/modules/notifications/services/.server/NotificationService";
|
||||
import { getOpenInvoices } from "../stripe.server";
|
||||
import { getTranslations } from "~/locale/i18next.server";
|
||||
import { rateLimitByKey } from "./rateLimitService";
|
||||
import { cachified } from "../cache.server";
|
||||
import { loadApiConfig, loadRequiredWebConfig } from "@toolctrl/config";
|
||||
import { absoluteAppPath } from "../app/CrossHostUrls";
|
||||
import { getApiKeyLookupCacheKey, serializeApiLogSearchParams } from "../api/apiKeySecurity.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,
|
||||
});
|
||||
} else {
|
||||
await createApiKeyLog(request, {
|
||||
apiKeyId: null,
|
||||
tenantId: null,
|
||||
endpoint: new URL(request.url).pathname,
|
||||
error,
|
||||
status,
|
||||
});
|
||||
}
|
||||
throw Object.assign(new Error(error), {
|
||||
status,
|
||||
apiKeyLog: apiKeyLogScope,
|
||||
});
|
||||
}
|
||||
|
||||
function getRequiredJwtSecret() {
|
||||
const jwtSecret = loadRequiredWebConfig().jwtSecret;
|
||||
if (!jwtSecret) {
|
||||
throw new Error("JWT_SECRET is required for API authorization.");
|
||||
}
|
||||
return jwtSecret;
|
||||
}
|
||||
|
||||
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: getClientIPAddress(request) ?? "",
|
||||
method: request.method,
|
||||
params: serializeApiLogSearchParams(request),
|
||||
});
|
||||
}
|
||||
|
||||
async function getApiKeyFromHeader(key: string) {
|
||||
return cachified({
|
||||
key: getApiKeyLookupCacheKey(key),
|
||||
ttl: 1000 * 60 * 60,
|
||||
getFreshValue: () => {
|
||||
const { config } = loadApiConfig();
|
||||
return findLegacyApiKeyByKey(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");
|
||||
if (authorization && authorization.startsWith("Bearer ")) {
|
||||
const token = authorization.split(" ")[1];
|
||||
let userId = "";
|
||||
try {
|
||||
const decoded: any = jwt.verify(token, getRequiredJwtSecret());
|
||||
userId = decoded.userId;
|
||||
} catch (e: any) {
|
||||
throw Error("Invalid authorization token: " + e.message);
|
||||
}
|
||||
const user = await findLegacyUserWithoutPasswordById(userId);
|
||||
if (!user) {
|
||||
throw Error("Unauthorized");
|
||||
}
|
||||
let tenantId = request.headers.get("X-Account-Id") ?? request.headers.get("X-Tenant-Id");
|
||||
if (!tenantId) {
|
||||
if (!user.admin) {
|
||||
throw Error("No X-Account-Id provided");
|
||||
} else {
|
||||
tenantId = null;
|
||||
}
|
||||
}
|
||||
if (tenantId !== null) {
|
||||
const userTenants = await getLegacyTenantMembershipsForUser(user.id);
|
||||
if (!userTenants.find((t) => t.id === tenantId || t.slug === tenantId)) {
|
||||
throw Error("Unauthorized");
|
||||
}
|
||||
}
|
||||
const tenant = await findLegacyTenantByIdOrSlug(tenantId ?? "");
|
||||
return {
|
||||
userId,
|
||||
tenant: tenantId === null ? null : tenant,
|
||||
};
|
||||
}
|
||||
const apiKeyFromHeaders = request.headers.get("X-Api-Key") ?? "";
|
||||
if (!apiKeyFromHeaders) {
|
||||
throw Error("No X-API-Key header or Authorization token provided");
|
||||
}
|
||||
const apiAccessToken = loadRequiredWebConfig().apiAccessToken;
|
||||
if (apiAccessToken && apiKeyFromHeaders === apiAccessToken) {
|
||||
const tenantId = getServiceTenantScopeFromRequest(request);
|
||||
if (!tenantId) {
|
||||
return { tenant: null };
|
||||
}
|
||||
const tenant = await findLegacyTenantByIdOrSlug(tenantId);
|
||||
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);
|
||||
}
|
||||
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: getClientIPAddress(request) ?? "Unknown",
|
||||
fromUrl: new URL(request.url).pathname,
|
||||
tenantId: apiKey.tenantId,
|
||||
apiKeyId: apiKey.id,
|
||||
});
|
||||
|
||||
if (apiKey.tenant.deactivatedReason) {
|
||||
throw await setApiError(request, params, "Account deactivated: " + apiKey.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: `${apiKey.tenant.name} (${apiKey.tenant.slug}) API error. ${t(usage.message)}`,
|
||||
action: {
|
||||
url: absoluteAppPath(loadRequiredWebConfig().routing, apiKey.tenant.slug, "settings/api/keys"),
|
||||
},
|
||||
},
|
||||
});
|
||||
throw await setApiError(request, params, t(usage.message), 402, { id: apiKeyLog.id, tenantId: apiKey.tenantId });
|
||||
}
|
||||
if (tenantSubscription && tenantSubscription.stripeCustomerId) {
|
||||
const openInvoices = await getOpenInvoices(tenantSubscription.stripeCustomerId);
|
||||
if (openInvoices.data.length > 0) {
|
||||
const invoicesDescription = openInvoices.data.map((invoice) => `Invoice #${invoice.number} - ${invoice.total} ${invoice.currency}`).join("\n");
|
||||
await NotificationService.sendToRoles({
|
||||
channel: "admin-users",
|
||||
tenantId: null,
|
||||
notification: {
|
||||
message: `${apiKey.tenant.name} (${apiKey.tenant.slug}) API error. You have open invoices: ${invoicesDescription}`,
|
||||
action: {
|
||||
url: absoluteAppPath(loadRequiredWebConfig().routing, apiKey.tenant.slug, "settings/api/keys"),
|
||||
},
|
||||
},
|
||||
});
|
||||
throw await setApiError(request, params, "You have open invoices: " + invoicesDescription, 402, { id: apiKeyLog.id, tenantId: apiKey.tenantId });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tenant: apiKey.tenant,
|
||||
tenantApiKey: {
|
||||
apiKey,
|
||||
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 setApiKeyLogStatus({ id: apiAccessValidation.apiKeyLog.id, tenantId }, data);
|
||||
}
|
||||
Reference in new issue
Block a user