mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-14 11:30:11 -05:00
7d77ed04de
Remove sentiment, emotion, and the TopicsUnnested join from the Cube schema, web registry, AI prompt, audit-allowlist, and i18n keys — these required metadata enrichment that is no longer planned. Tests swap the removed string dimensions for sourceType to keep coverage intact. Add csatDissatisfiedCount and csatNeutralCount so dashboards can render the standard satisfied/neutral/dissatisfied distribution alongside the existing top-2-box metric. Expose field_label, field_group_label, language, value_boolean, value_date, created_at, and updated_at as Cube dimensions. fieldLabel and fieldGroupLabel unlock "group by question" and the matrix/ranking aggregations enabled by the recent composite-question PR; the others round out coverage of the underlying feedback_records columns. Extend FieldDefinition with a boolean type and matching filter operators.
201 lines
5.7 KiB
JavaScript
201 lines
5.7 KiB
JavaScript
/* eslint-env es2022 */
|
|
|
|
const TENANT_MEMBERS = ["FeedbackRecords.tenantId"];
|
|
const REQUIRED_SCOPE = "xm:cube:query";
|
|
|
|
function assertRequiredEnvironmentVariable(name) {
|
|
const value = process.env[name];
|
|
|
|
if (typeof value !== "string" || value.trim().length === 0) {
|
|
throw new Error(`${name} is required to run Cube`);
|
|
}
|
|
}
|
|
|
|
assertRequiredEnvironmentVariable("CUBEJS_API_SECRET");
|
|
|
|
function getStringClaim(securityContext, claim) {
|
|
const value = securityContext?.[claim];
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const trimmedValue = value.trim();
|
|
return trimmedValue.length > 0 ? trimmedValue : null;
|
|
}
|
|
|
|
function getRequiredStringClaim(securityContext, claim) {
|
|
const value = getStringClaim(securityContext, claim);
|
|
|
|
if (!value) {
|
|
throw new Error(`Cube query rejected: missing ${claim} security context`);
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function collectFilterMembers(filters) {
|
|
if (!Array.isArray(filters)) {
|
|
return [];
|
|
}
|
|
|
|
return filters.flatMap((filter) => [
|
|
...(typeof filter?.member === "string" ? [filter.member] : []),
|
|
...(typeof filter?.dimension === "string" ? [filter.dimension] : []),
|
|
...collectFilterMembers(filter?.and),
|
|
...collectFilterMembers(filter?.or),
|
|
]);
|
|
}
|
|
|
|
function collectOrderMembers(order) {
|
|
if (!order) {
|
|
return [];
|
|
}
|
|
|
|
if (Array.isArray(order)) {
|
|
return order
|
|
.map((orderEntry) => (Array.isArray(orderEntry) ? orderEntry[0] : null))
|
|
.filter((member) => typeof member === "string");
|
|
}
|
|
|
|
if (typeof order === "object") {
|
|
return Object.keys(order);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
function collectTimeDimensionMembers(timeDimensions) {
|
|
if (!Array.isArray(timeDimensions)) {
|
|
return [];
|
|
}
|
|
|
|
return timeDimensions
|
|
.map((timeDimension) => timeDimension?.dimension)
|
|
.filter((dimension) => typeof dimension === "string");
|
|
}
|
|
|
|
function collectQueryMembers(query) {
|
|
const cubeQuery = query ?? {};
|
|
const members = [
|
|
...(Array.isArray(cubeQuery.measures) ? cubeQuery.measures : []),
|
|
...(Array.isArray(cubeQuery.dimensions) ? cubeQuery.dimensions : []),
|
|
...(Array.isArray(cubeQuery.segments) ? cubeQuery.segments : []),
|
|
...collectTimeDimensionMembers(cubeQuery.timeDimensions),
|
|
...collectFilterMembers(cubeQuery.filters),
|
|
...collectOrderMembers(cubeQuery.order),
|
|
].filter((member) => typeof member === "string");
|
|
|
|
return Array.from(new Set(members)).sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
function assertValidSecurityContext(securityContext) {
|
|
const tenantId = getRequiredStringClaim(securityContext, "tenantId");
|
|
const feedbackDirectoryId = getRequiredStringClaim(securityContext, "feedbackDirectoryId");
|
|
const workspaceId = getRequiredStringClaim(securityContext, "workspaceId");
|
|
const scope = getRequiredStringClaim(securityContext, "scope");
|
|
|
|
if (scope !== REQUIRED_SCOPE) {
|
|
throw new Error("Cube query rejected: invalid Cube query scope");
|
|
}
|
|
if (tenantId !== feedbackDirectoryId) {
|
|
throw new Error("Cube query rejected: tenantId/feedbackDirectoryId mismatch");
|
|
}
|
|
|
|
return {
|
|
tenantId,
|
|
feedbackDirectoryId,
|
|
workspaceId,
|
|
organizationId: getRequiredStringClaim(securityContext, "organizationId"),
|
|
userId: getRequiredStringClaim(securityContext, "userId"),
|
|
requestId: getRequiredStringClaim(securityContext, "jti"),
|
|
source: getRequiredStringClaim(securityContext, "source"),
|
|
};
|
|
}
|
|
|
|
function assertNoCallerTenantMember(query) {
|
|
for (const member of collectQueryMembers(query)) {
|
|
if (TENANT_MEMBERS.includes(member)) {
|
|
throw new Error("Cube query rejected: tenant filters are enforced by Cube");
|
|
}
|
|
}
|
|
}
|
|
|
|
function logCubeQueryAuditEvent(context, query, { error, status = "success" } = {}) {
|
|
const errorName = error instanceof Error ? error.name : undefined;
|
|
const errorMessage = error instanceof Error ? error.message : error ? String(error) : undefined;
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
type: "audit",
|
|
event: "cube.query",
|
|
status,
|
|
timestamp: new Date().toISOString(),
|
|
tenantId: context.tenantId,
|
|
feedbackDirectoryId: context.feedbackDirectoryId,
|
|
workspaceId: context.workspaceId,
|
|
organizationId: context.organizationId,
|
|
userId: context.userId,
|
|
requestId: context.requestId,
|
|
source: context.source,
|
|
members: collectQueryMembers(query),
|
|
...(errorName ? { errorName } : {}),
|
|
...(errorMessage ? { errorMessage } : {}),
|
|
})
|
|
);
|
|
}
|
|
|
|
function logCubeQuerySecurityContextFailure(query, error) {
|
|
console.log(
|
|
JSON.stringify({
|
|
type: "audit",
|
|
event: "cube.query",
|
|
status: "failure",
|
|
timestamp: new Date().toISOString(),
|
|
members: collectQueryMembers(query),
|
|
errorName: error instanceof Error ? error.name : undefined,
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
})
|
|
);
|
|
}
|
|
|
|
function queryRewrite(query, rewriteContext) {
|
|
const cubeQuery = query ?? {};
|
|
let context;
|
|
|
|
try {
|
|
context = assertValidSecurityContext(rewriteContext?.securityContext);
|
|
} catch (error) {
|
|
logCubeQuerySecurityContextFailure(cubeQuery, error);
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
assertNoCallerTenantMember(cubeQuery);
|
|
} catch (error) {
|
|
logCubeQueryAuditEvent(context, cubeQuery, { error, status: "failure" });
|
|
throw error;
|
|
}
|
|
|
|
const queriedCubePrefixes = new Set(collectQueryMembers(cubeQuery).map((member) => member.split(".")[0]));
|
|
const rewrittenQuery = {
|
|
...cubeQuery,
|
|
filters: [
|
|
...(Array.isArray(cubeQuery.filters) ? cubeQuery.filters : []),
|
|
...TENANT_MEMBERS.filter((member) => queriedCubePrefixes.has(member.split(".")[0])).map(
|
|
(member) => ({
|
|
member,
|
|
operator: "equals",
|
|
values: [context.tenantId],
|
|
})
|
|
),
|
|
],
|
|
};
|
|
|
|
logCubeQueryAuditEvent(context, rewrittenQuery);
|
|
return rewrittenQuery;
|
|
}
|
|
|
|
module.exports = {
|
|
queryRewrite,
|
|
};
|