Compare commits

..

3 Commits

Author SHA1 Message Date
Bhagya Amarasinghe e6b6f5e6d3 feat(helm): add Hub worker and embeddings runtime 2026-05-07 01:45:45 +05:30
Dhruwang Jariwala ed42df34c4 feat(ai): support Vertex AI ADC credentials (#7938) 2026-05-06 12:37:24 +05:30
Bhagya Amarasinghe c5d629ef25 feat(ai): support Vertex AI ADC credentials 2026-05-05 18:04:30 +05:30
52 changed files with 1942 additions and 189 deletions
+3 -2
View File
@@ -183,11 +183,12 @@ AZUREAD_TENANT_ID=
# Configure Formbricks AI at the instance level
# Set the provider used for AI features on this instance.
# Accepted values for AI_PROVIDER: aws, google, azure
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching credentials below.
# Set AI_MODEL to the provider-specific model or deployment name and configure the matching provider settings below.
# AI_PROVIDER=google
# AI_MODEL=gemini-2.5-flash
# Google Cloud credentials for Gemini models
# Google Cloud settings for Gemini models
# Credentials are optional when Application Default Credentials are available.
# AI_GOOGLE_CLOUD_PROJECT=
# AI_GOOGLE_CLOUD_LOCATION=
# AI_GOOGLE_CLOUD_CREDENTIALS_JSON=
+20
View File
@@ -123,6 +123,7 @@ describe("authenticateRequest", () => {
workspaceName: "Workspace 1",
},
],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
@@ -161,6 +162,7 @@ describe("authenticateRequest", () => {
lastUsedAt: null,
label: "Test API Key",
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [],
};
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
@@ -169,6 +171,7 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
@@ -189,6 +192,16 @@ describe("authenticateRequest", () => {
lastUsedAt: null,
label: "Test API Key",
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
permission: "write" as const,
feedbackDirectory: {
id: "clxx1234567890123456789012",
name: "Directory 1",
},
},
],
} as any);
const result = await authenticateRequest(request);
@@ -196,6 +209,13 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
feedbackDirectoryName: "Directory 1",
permission: "write",
},
],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: "all",
@@ -221,6 +221,7 @@ describe("withV3ApiWrapper", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const handler = vi.fn(async ({ authentication }) => {
@@ -259,6 +260,7 @@ describe("withV3ApiWrapper", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const wrapped = withV3ApiWrapper({
@@ -98,6 +98,7 @@ function createMockRequest({ method = "GET", url = "https://api.test/endpoint",
const mockApiAuthentication = {
type: "apiKey" as const,
workspacePermissions: [],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-1",
organizationId: "org-1",
organizationAccess: "all" as const,
+4
View File
@@ -1784,7 +1784,10 @@ checksums:
workspace/api_keys/api_key_updated: 0e03754eb33742b4ee8d5fdad64c9b3f
workspace/api_keys/delete_api_key_confirmation: b2f0342d4e55f0cb244fe121eeeb10a3
workspace/api_keys/duplicate_access: 7ac7ac5ba755ce94e6fc81afa5a21997
workspace/api_keys/duplicate_directory_access: 4230950c0bf6ebf23410b04627fb7bd3
workspace/api_keys/feedback_directory_access: 6de029dfe5192496d06a7bbf0f52effd
workspace/api_keys/no_api_keys_yet: 58593ed9f7e507dcd7ca7fe069add599
workspace/api_keys/no_directory_permissions_found: 8296e96142af0f8b98ff62535f42fc5c
workspace/api_keys/no_workspace_permissions_found: 1d719624828a9d3e433cdf6b387549f3
workspace/api_keys/organization_access: 96a92fa907b15e0c0e47e33cac15be88
workspace/api_keys/organization_access_description: 773dfeaf6ffbf34dd9a0a3d656a6d83c
@@ -1792,6 +1795,7 @@ checksums:
workspace/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
workspace/api_keys/unable_to_copy_api_key: 148506832e31d033fa3569ce292d2120
workspace/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
workspace/api_keys/unknown_directory: ed07f55f5dba1f451a45f2cf6e01c9a9
workspace/api_keys/unknown_workspace: 4b0df2d07ebc9ab084158b1b9525ae5e
workspace/api_keys/workspace_access: b38cb73197ef5f5fa6653b88c68aa0bd
workspace/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
+29
View File
@@ -79,6 +79,35 @@ describe("env", () => {
expect(env.DEBUG_SHOW_RESET_LINK).toBe("1");
});
test("allows Google Cloud AI configuration to rely on ADC credentials", async () => {
setTestEnv({
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: undefined,
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: undefined,
});
const { env } = await import("./env");
expect(env.AI_PROVIDER).toBe("google");
expect(env.AI_GOOGLE_CLOUD_PROJECT).toBe("test-project");
expect(env.AI_GOOGLE_CLOUD_LOCATION).toBe("us-central1");
});
test("fails to load when Google Cloud credentials JSON is invalid", async () => {
setTestEnv({
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_CREDENTIALS_JSON: "{not-json}",
});
await expect(import("./env")).rejects.toThrow("AI_GOOGLE_CLOUD_CREDENTIALS_JSON");
});
test("uses the configured Cube environment variables", async () => {
setTestEnv();
const { env } = await import("./env");
-8
View File
@@ -68,14 +68,6 @@ const validateGoogleAIConfiguration = (values: TAIConfigurationEnv, ctx: z.Refin
);
}
if (!values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON && !values.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS) {
addEnvIssue(
ctx,
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON",
"AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS is required when AI_PROVIDER=google"
);
}
if (values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) {
try {
const parsedCredentials = JSON.parse(values.AI_GOOGLE_CLOUD_CREDENTIALS_JSON) as unknown;
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API-Schlüssel aktualisiert",
"delete_api_key_confirmation": "Alle Anwendungen, die diesen Schlüssel verwenden, können nicht mehr auf deine Formbricks-Daten zugreifen.",
"duplicate_access": "Doppelter Workspace-Zugriff ist nicht erlaubt",
"duplicate_directory_access": "Doppelter Zugriff auf Feedback-Verzeichnis nicht erlaubt",
"feedback_directory_access": "Feedback-Verzeichnis-Zugriff",
"no_api_keys_yet": "Du hast noch keine API-Schlüssel",
"no_directory_permissions_found": "Keine Berechtigungen für Feedback-Verzeichnisse gefunden",
"no_workspace_permissions_found": "Keine Workspace-Berechtigungen gefunden",
"organization_access": "Organisations-Zugriff",
"organization_access_description": "Wähle Lese- oder Schreibrechte für organisationsweite Ressourcen aus.",
@@ -1862,6 +1865,7 @@
"secret": "Geheimnis",
"unable_to_copy_api_key": "API-Schlüssel konnte nicht kopiert werden",
"unable_to_delete_api_key": "API-Schlüssel konnte nicht gelöscht werden",
"unknown_directory": "Unbekanntes Verzeichnis",
"unknown_workspace": "Unbekannter Arbeitsbereich",
"workspace_access": "Workspace-Zugriff"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API Key updated",
"delete_api_key_confirmation": "Any applications using this key will no longer be able to access your Formbricks data.",
"duplicate_access": "Duplicate workspace access not allowed",
"duplicate_directory_access": "Duplicate feedback directory access not allowed",
"feedback_directory_access": "Feedback Directory Access",
"no_api_keys_yet": "You do not have any API keys yet",
"no_directory_permissions_found": "No feedback directory permissions found",
"no_workspace_permissions_found": "No Workspace permissions found",
"organization_access": "Organization Access",
"organization_access_description": "Select read or write privileges for organization-wide resources.",
@@ -1862,6 +1865,7 @@
"secret": "Secret",
"unable_to_copy_api_key": "Unable to copy API key",
"unable_to_delete_api_key": "Unable to delete API Key",
"unknown_directory": "Unknown directory",
"unknown_workspace": "Unknown workspace",
"workspace_access": "Workspace Access"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "Clave API actualizada",
"delete_api_key_confirmation": "Cualquier aplicación que use esta clave ya no podrá acceder a tus datos de Formbricks.",
"duplicate_access": "No se permite el acceso duplicado al espacio de trabajo",
"duplicate_directory_access": "No se permite el acceso duplicado al directorio de feedback",
"feedback_directory_access": "Acceso al directorio de feedback",
"no_api_keys_yet": "Aún no tienes ninguna clave API",
"no_directory_permissions_found": "No se encontraron permisos para el directorio de feedback",
"no_workspace_permissions_found": "No se encontraron permisos del espacio de trabajo",
"organization_access": "Acceso a la organización",
"organization_access_description": "Selecciona privilegios de lectura o escritura para recursos de toda la organización.",
@@ -1862,6 +1865,7 @@
"secret": "Secreto",
"unable_to_copy_api_key": "No se puede copiar la clave de API",
"unable_to_delete_api_key": "No se puede eliminar la clave API",
"unknown_directory": "Directorio desconocido",
"unknown_workspace": "Espacio de trabajo desconocido",
"workspace_access": "Acceso al espacio de trabajo"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "Clé API mise à jour",
"delete_api_key_confirmation": "Toute application utilisant cette clé ne pourra plus accéder à vos données Formbricks.",
"duplicate_access": "Accès en double à l'espace de travail non autorisé",
"duplicate_directory_access": "L'accès en double au répertoire de retours n'est pas autorisé",
"feedback_directory_access": "Accès au répertoire de retours",
"no_api_keys_yet": "Vous n'avez pas encore de clés API",
"no_directory_permissions_found": "Aucune autorisation de répertoire de retours trouvée",
"no_workspace_permissions_found": "Aucune autorisation d'espace de travail trouvée",
"organization_access": "Accès à l'organisation",
"organization_access_description": "Sélectionnez les privilèges de lecture ou d'écriture pour les ressources à l'échelle de l'organisation.",
@@ -1862,6 +1865,7 @@
"secret": "Secret",
"unable_to_copy_api_key": "Impossible de copier la clé API",
"unable_to_delete_api_key": "Impossible de supprimer la clé API",
"unknown_directory": "Répertoire inconnu",
"unknown_workspace": "Espace de travail inconnu",
"workspace_access": "Accès à l'espace de travail"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API-kulcs frissítve",
"delete_api_key_confirmation": "Az ezt a kulcsot használó bármely alkalmazás többé nem fog tudni hozzáférni a Formbricks adataihoz.",
"duplicate_access": "A kettőzött munkaterület-hozzáférés nem engedélyezett",
"duplicate_directory_access": "Duplikált visszajelzési könyvtár hozzáférés nem engedélyezett",
"feedback_directory_access": "Visszajelzési könyvtár hozzáférés",
"no_api_keys_yet": "Még nincs semmilyen API-kulcsa",
"no_directory_permissions_found": "Nem találhatók visszajelzési könyvtár jogosultságok",
"no_workspace_permissions_found": "Nem találhatók munkaterület-jogosultságok",
"organization_access": "Szervezeti hozzáférés",
"organization_access_description": "Olvasási vagy írási jogosultságok kiválasztása a teljes szervezetre vonatkozó erőforrásokhoz.",
@@ -1862,6 +1865,7 @@
"secret": "Titok",
"unable_to_copy_api_key": "Az API kulcs másolása nem lehetséges",
"unable_to_delete_api_key": "Nem lehet törölni az API-kulcsot",
"unknown_directory": "Ismeretlen könyvtár",
"unknown_workspace": "Ismeretlen munkaterület",
"workspace_access": "Munkaterület-hozzáférés"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "APIキーを更新しました",
"delete_api_key_confirmation": "このキーを使用しているアプリケーションは、Formbricksデータにアクセスできなくなります。",
"duplicate_access": "ワークスペースへの重複アクセスは許可されていません",
"duplicate_directory_access": "フィードバックディレクトリへの重複アクセスは許可されていません",
"feedback_directory_access": "フィードバックディレクトリアクセス",
"no_api_keys_yet": "まだAPIキーがありません",
"no_directory_permissions_found": "フィードバックディレクトリの権限が見つかりません",
"no_workspace_permissions_found": "ワークスペースの権限が見つかりません",
"organization_access": "組織アクセス",
"organization_access_description": "組織全体のリソースに対する読み取りまたは書き込み権限を選択してください。",
@@ -1862,6 +1865,7 @@
"secret": "シークレット",
"unable_to_copy_api_key": "APIキーをコピーできません",
"unable_to_delete_api_key": "APIキーを削除できません",
"unknown_directory": "不明なディレクトリ",
"unknown_workspace": "不明なワークスペース",
"workspace_access": "ワークスペースアクセス"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API-sleutel bijgewerkt",
"delete_api_key_confirmation": "Alle applicaties die deze sleutel gebruiken, hebben geen toegang meer tot uw Formbricks-gegevens.",
"duplicate_access": "Dubbele workspace-toegang niet toegestaan",
"duplicate_directory_access": "Dubbele feedbackmaptoegang niet toegestaan",
"feedback_directory_access": "Feedbackmaptoegang",
"no_api_keys_yet": "U heeft nog geen API-sleutels",
"no_directory_permissions_found": "Geen feedbackmaprechten gevonden",
"no_workspace_permissions_found": "Geen Workspace-rechten gevonden",
"organization_access": "Organisatietoegang",
"organization_access_description": "Selecteer lees- of schrijfrechten voor organisatiebrede bronnen.",
@@ -1862,6 +1865,7 @@
"secret": "Geheim",
"unable_to_copy_api_key": "Kan API-sleutel niet kopiëren",
"unable_to_delete_api_key": "Kan API-sleutel niet verwijderen",
"unknown_directory": "Onbekende map",
"unknown_workspace": "Onbekende workspace",
"workspace_access": "Workspace-toegang"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "Chave de API atualizada",
"delete_api_key_confirmation": "Quaisquer aplicativos que usem esta chave não poderão mais acessar seus dados do Formbricks.",
"duplicate_access": "Acesso duplicado ao workspace não permitido",
"duplicate_directory_access": "Acesso duplicado ao diretório de feedback não permitido",
"feedback_directory_access": "Acesso ao Diretório de Feedback",
"no_api_keys_yet": "Você ainda não tem nenhuma chave de API",
"no_directory_permissions_found": "Nenhuma permissão de diretório de feedback encontrada",
"no_workspace_permissions_found": "Nenhuma permissão de Workspace encontrada",
"organization_access": "Acesso à organização",
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
@@ -1862,6 +1865,7 @@
"secret": "Segredo",
"unable_to_copy_api_key": "Não foi possível copiar a chave de API",
"unable_to_delete_api_key": "Não foi possível excluir a chave de API",
"unknown_directory": "Diretório desconhecido",
"unknown_workspace": "Workspace desconhecido",
"workspace_access": "Acesso ao workspace"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "Chave API atualizada",
"delete_api_key_confirmation": "Quaisquer aplicações que utilizem esta chave deixarão de poder aceder aos seus dados Formbricks.",
"duplicate_access": "Acesso duplicado ao workspace não permitido",
"duplicate_directory_access": "Não é permitido acesso duplicado ao diretório de feedback",
"feedback_directory_access": "Acesso ao Diretório de Feedback",
"no_api_keys_yet": "Ainda não tem nenhuma chave de API",
"no_directory_permissions_found": "Nenhuma permissão de diretório de feedback encontrada",
"no_workspace_permissions_found": "Não foram encontradas permissões de Espaço de Trabalho",
"organization_access": "Acesso à organização",
"organization_access_description": "Selecione privilégios de leitura ou escrita para recursos de toda a organização.",
@@ -1862,6 +1865,7 @@
"secret": "Segredo",
"unable_to_copy_api_key": "Não foi possível copiar a chave API",
"unable_to_delete_api_key": "Não foi possível eliminar a chave de API",
"unknown_directory": "Diretório desconhecido",
"unknown_workspace": "Área de trabalho desconhecida",
"workspace_access": "Acesso ao workspace"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "Cheie API actualizată",
"delete_api_key_confirmation": "Orice aplicație care folosește această cheie nu va mai putea accesa datele dumneavoastră Formbricks.",
"duplicate_access": "Acces duplicat la spațiul de lucru nu este permis",
"duplicate_directory_access": "Accesul duplicat la directorul de feedback nu este permis",
"feedback_directory_access": "Acces la Directorul de Feedback",
"no_api_keys_yet": "Nu ai încă nicio cheie API",
"no_directory_permissions_found": "Nu au fost găsite permisiuni pentru directoare de feedback",
"no_workspace_permissions_found": "Nu s-au găsit permisiuni pentru Workspace",
"organization_access": "Acces organizație",
"organization_access_description": "Selectează privilegii de citire sau scriere pentru resursele la nivel de organizație.",
@@ -1862,6 +1865,7 @@
"secret": "Secret",
"unable_to_copy_api_key": "Nu se poate copia cheia API",
"unable_to_delete_api_key": "Nu se poate șterge cheia API",
"unknown_directory": "Director necunoscut",
"unknown_workspace": "Spațiu de lucru necunoscut",
"workspace_access": "Acces spațiu de lucru"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API-ключ обновлён",
"delete_api_key_confirmation": "Любые приложения, использующие этот ключ, больше не смогут получить доступ к вашим данным Formbricks.",
"duplicate_access": "Дублированный доступ к рабочему пространству не разрешён",
"duplicate_directory_access": "Дублирование доступа к директории обратной связи запрещено",
"feedback_directory_access": "Доступ к директории обратной связи",
"no_api_keys_yet": "У вас ещё нет API-ключей",
"no_directory_permissions_found": "Разрешения для директорий обратной связи не найдены",
"no_workspace_permissions_found": "Разрешения для рабочего пространства не найдены",
"organization_access": "Доступ к организации",
"organization_access_description": "Выберите права на чтение или запись для ресурсов всей организации.",
@@ -1862,6 +1865,7 @@
"secret": "Секрет",
"unable_to_copy_api_key": "Не удалось скопировать API-ключ",
"unable_to_delete_api_key": "Не удалось удалить API-ключ",
"unknown_directory": "Неизвестный каталог",
"unknown_workspace": "Неизвестное рабочее пространство",
"workspace_access": "Доступ к рабочему пространству"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API-nyckel uppdaterad",
"delete_api_key_confirmation": "Alla applikationer som använder denna nyckel kommer inte längre att kunna komma åt din Formbricks-data.",
"duplicate_access": "Duplicerad arbetsyteåtkomst är inte tillåten",
"duplicate_directory_access": "Duplicerad åtkomst till feedback-katalog är inte tillåten",
"feedback_directory_access": "Åtkomst till feedback-katalog",
"no_api_keys_yet": "Du har inga API-nycklar ännu",
"no_directory_permissions_found": "Inga behörigheter för feedback-katalog hittades",
"no_workspace_permissions_found": "Inga behörigheter för arbetsytan hittades",
"organization_access": "Organisationsåtkomst",
"organization_access_description": "Välj läs- eller skrivbehörighet för resurser på organisationsnivå.",
@@ -1862,6 +1865,7 @@
"secret": "Hemlig",
"unable_to_copy_api_key": "Kunde inte kopiera API-nyckel",
"unable_to_delete_api_key": "Det gick inte att radera API-nyckeln",
"unknown_directory": "Okänd katalog",
"unknown_workspace": "Okänd arbetsyta",
"workspace_access": "Arbetsyteåtkomst"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API anahtarı güncellendi",
"delete_api_key_confirmation": "Bu anahtarı kullanan tüm uygulamalar artık Formbricks verilerine erişemeyecek.",
"duplicate_access": "Yinelenen çalışma alanı erişimine izin verilmiyor",
"duplicate_directory_access": "Yinelenen geri bildirim dizini erişimine izin verilmiyor",
"feedback_directory_access": "Geri Bildirim Dizini Erişimi",
"no_api_keys_yet": "Henüz hiç API anahtarınız yok",
"no_directory_permissions_found": "Geri bildirim dizini izni bulunamadı",
"no_workspace_permissions_found": "Çalışma Alanı izni bulunamadı",
"organization_access": "Organizasyon Erişimi",
"organization_access_description": "Organizasyon genelindeki kaynaklar için okuma veya yazma yetkilerini seçin.",
@@ -1862,6 +1865,7 @@
"secret": "Gizli Anahtar",
"unable_to_copy_api_key": "API anahtarı kopyalanamıyor",
"unable_to_delete_api_key": "API anahtarı silinemiyor",
"unknown_directory": "Bilinmeyen dizin",
"unknown_workspace": "Bilinmeyen çalışma alanı",
"workspace_access": "Çalışma Alanı Erişimi"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API 密钥已更新",
"delete_api_key_confirmation": "使用此密钥的任何应用将无法再访问您的 Formbricks 数据。",
"duplicate_access": "不允许重复的工作区访问权限",
"duplicate_directory_access": "不允许重复的反馈目录访问权限",
"feedback_directory_access": "反馈目录访问权限",
"no_api_keys_yet": "您还没有任何 API 密钥",
"no_directory_permissions_found": "未找到反馈目录权限",
"no_workspace_permissions_found": "未找到工作区权限",
"organization_access": "组织访问权限",
"organization_access_description": "为组织范围的资源选择读取或写入权限。",
@@ -1862,6 +1865,7 @@
"secret": "密钥",
"unable_to_copy_api_key": "无法复制 API 密钥",
"unable_to_delete_api_key": "无法删除 API 密钥",
"unknown_directory": "未知目录",
"unknown_workspace": "未知工作区",
"workspace_access": "工作区访问权限"
},
+4
View File
@@ -1854,7 +1854,10 @@
"api_key_updated": "API 金鑰已更新",
"delete_api_key_confirmation": "使用此金鑰的任何應用程式將無法再存取您的 Formbricks 資料。",
"duplicate_access": "不允許重複工作區存取",
"duplicate_directory_access": "不允許重複的意見回饋目錄存取權限",
"feedback_directory_access": "意見回饋目錄存取權限",
"no_api_keys_yet": "您目前尚未有任何 API 金鑰",
"no_directory_permissions_found": "找不到意見回饋目錄權限",
"no_workspace_permissions_found": "找不到工作區權限",
"organization_access": "組織存取",
"organization_access_description": "請選擇組織層級資源的讀取或寫入權限。",
@@ -1862,6 +1865,7 @@
"secret": "密鑰",
"unable_to_copy_api_key": "無法複製 API 金鑰",
"unable_to_delete_api_key": "無法刪除 API 金鑰",
"unknown_directory": "未知目錄",
"unknown_workspace": "未知工作區",
"workspace_access": "工作區存取"
},
+5
View File
@@ -54,6 +54,11 @@ export const authenticateApiKeyFromHeaders = async (
workspaceId: workspacePermission.workspaceId,
workspaceName: workspacePermission.workspace.name,
})),
feedbackDirectoryPermissions: (apiKeyData.apiKeyFeedbackDirectories ?? []).map((directoryPermission) => ({
permission: directoryPermission.permission,
feedbackDirectoryId: directoryPermission.feedbackDirectoryId,
feedbackDirectoryName: directoryPermission.feedbackDirectory.name,
})),
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
@@ -74,6 +74,7 @@ describe("authenticateRequest", () => {
workspaceName: "Workspace 2",
},
],
feedbackDirectoryPermissions: [],
apiKeyId: "api-key-id",
organizationId: "org-id",
organizationAccess: {
@@ -119,6 +120,7 @@ describe("authenticateRequest", () => {
expect(result.data).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [],
apiKeyId: "org-api-key-id",
organizationId: "org-id",
organizationAccess: {
@@ -199,6 +201,17 @@ describe("authenticateRequest", () => {
},
},
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
apiKeyId: "bearer-api-key-id",
permission: "read",
feedbackDirectory: {
id: "clxx1234567890123456789012",
name: "Directory 1",
},
},
],
} as unknown as TApiKeyWithEnvironmentAndWorkspace);
const result = await authenticateRequest(request);
@@ -208,6 +221,13 @@ describe("authenticateRequest", () => {
expect(result.data).toEqual({
type: "apiKey",
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId: "clxx1234567890123456789012",
feedbackDirectoryName: "Directory 1",
permission: "read",
},
],
apiKeyId: "bearer-api-key-id",
organizationId: "org-id",
organizationAccess: {
+23 -120
View File
@@ -129,6 +129,13 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId,
feedbackDirectoryName: "Directory 1",
permission: "write",
},
],
});
const response = await authorizeEnvoyRequest(
@@ -155,6 +162,7 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -185,6 +193,7 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
mockGetFeedbackRecordTenant.mockResolvedValue({
data: null,
@@ -214,6 +223,7 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
mockGetFeedbackRecordTenant.mockResolvedValue({
data: null,
@@ -316,14 +326,15 @@ describe("authorizeEnvoyRequest", () => {
});
});
test("returns 403 when an API key has no matching workspace or org-level access", async () => {
test("returns 403 when an API key lacks directory permission", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: false, write: false } },
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -338,124 +349,6 @@ describe("authorizeEnvoyRequest", () => {
expect(response.status).toBe(403);
});
test("allows API key with workspace write permission on a linked workspace", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: false, write: false } },
workspacePermissions: [
{
workspaceId: "workspace_1",
workspaceName: "Workspace 1",
permission: "write",
},
],
});
const response = await authorizeEnvoyRequest(
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
method: "POST",
headers: {
"x-api-key": "fbk_test",
"content-type": "application/json",
},
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
})
);
expect(response.status).toBe(200);
});
test("returns 403 when API key has read-only workspace permission for a write op", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: false, write: false } },
workspacePermissions: [
{
workspaceId: "workspace_1",
workspaceName: "Workspace 1",
permission: "read",
},
],
});
const response = await authorizeEnvoyRequest(
createRequest("http://localhost/api/envoy-auth/api/v3/feedbackRecords", {
method: "POST",
headers: {
"x-api-key": "fbk_test",
"content-type": "application/json",
},
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
})
);
expect(response.status).toBe(403);
});
test("returns 403 when FRD has no workspace links and API key has no org-level access", async () => {
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
organizationId: "org_1",
workspaceIds: [],
isArchived: false,
});
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: false, write: false } },
workspacePermissions: [
{
workspaceId: "workspace_1",
workspaceName: "Workspace 1",
permission: "manage",
},
],
});
const response = await authorizeEnvoyRequest(
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
headers: {
"x-api-key": "fbk_test",
},
})
);
expect(response.status).toBe(403);
});
test("allows API key with org-level read access for a read op even without workspace match", async () => {
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
organizationId: "org_1",
workspaceIds: [],
isArchived: false,
});
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: false } },
workspacePermissions: [],
});
const response = await authorizeEnvoyRequest(
createRequest(`http://localhost/api/envoy-auth/v1/feedback-records?tenant_id=${feedbackDirectoryId}`, {
headers: {
"x-api-key": "fbk_test",
},
})
);
expect(response.status).toBe(200);
});
test("returns 403 when unify feedback entitlement is disabled", async () => {
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(false);
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
@@ -465,6 +358,13 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [
{
feedbackDirectoryId,
feedbackDirectoryName: "Directory 1",
permission: "write",
},
],
});
const response = await authorizeEnvoyRequest(
@@ -507,6 +407,7 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -531,6 +432,7 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -553,6 +455,7 @@ describe("authorizeEnvoyRequest", () => {
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
feedbackDirectoryPermissions: [],
});
const response = await authorizeEnvoyRequest(
@@ -165,29 +165,23 @@ const getFeedbackRecordsGatewayJwtFromHeaders = (headers: Headers): string | nul
return getBearerTokenFromHeaders(headers);
};
const hasApiKeyImplicitFeedbackDirectoryAccess = (
const hasFeedbackDirectoryPermission = (
authentication: TAuthenticationApiKey,
workspaceIds: string[],
feedbackDirectoryId: string,
requiredPermission: TFeedbackRecordsGatewayPermission
): boolean => {
const orgAccessControl = authentication.organizationAccess?.accessControl;
if (orgAccessControl?.write) {
return true;
}
if (orgAccessControl?.read && requiredPermission === "read") {
return true;
}
const feedbackDirectoryPermission = authentication.feedbackDirectoryPermissions.find(
(permission) => permission.feedbackDirectoryId === feedbackDirectoryId
);
const matchingWeights = authentication.workspacePermissions
.filter((permission) => workspaceIds.includes(permission.workspaceId))
.map((permission) => apiKeyPermissionWeight[permission.permission]);
if (matchingWeights.length === 0) {
if (!feedbackDirectoryPermission) {
return false;
}
const maxWeight = Math.max(...matchingWeights);
return maxWeight >= gatewayPermissionToApiKeyPermissionWeight[requiredPermission];
return (
apiKeyPermissionWeight[feedbackDirectoryPermission.permission] >=
gatewayPermissionToApiKeyPermissionWeight[requiredPermission]
);
};
const resolveTenantId = async (
@@ -270,11 +264,7 @@ const authorizeGatewayRequest = async (
}
if (principal.type === "apiKey") {
return hasApiKeyImplicitFeedbackDirectoryAccess(
principal.authentication,
feedbackDirectory.workspaceIds,
requiredPermission
)
return hasFeedbackDirectoryPermission(principal.authentication, feedbackDirectoryId, requiredPermission)
? { allowed: true }
: { allowed: false };
}
@@ -7,7 +7,10 @@ import { useForm } from "react-hook-form";
import { toast } from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TOrganizationAccess } from "@formbricks/types/api-key";
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
import {
TOrganizationFeedbackDirectory,
TOrganizationWorkspace,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { Alert, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import {
@@ -37,9 +40,14 @@ interface AddApiKeyModalProps {
permission: ApiKeyPermission;
workspaceId: string;
}>;
feedbackDirectoryPermissions: Array<{
permission: ApiKeyPermission;
feedbackDirectoryId: string;
}>;
organizationAccess: TOrganizationAccess;
}) => Promise<void>;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
isCreatingAPIKey: boolean;
}
@@ -48,12 +56,23 @@ interface WorkspaceOption {
name: string;
}
interface FeedbackDirectoryOption {
id: string;
name: string;
}
interface PermissionRecord {
workspaceId: string;
permission: ApiKeyPermission;
workspaceName: string;
}
interface DirectoryPermissionRecord {
feedbackDirectoryId: string;
permission: ApiKeyPermission;
feedbackDirectoryName: string;
}
const permissionOptions = [ApiKeyPermission.read, ApiKeyPermission.write, ApiKeyPermission.manage];
export const AddApiKeyModal = ({
@@ -61,6 +80,7 @@ export const AddApiKeyModal = ({
setOpen,
onSubmit,
workspaces,
feedbackDirectories,
isCreatingAPIKey,
}: AddApiKeyModalProps) => {
const { t } = useTranslation();
@@ -89,14 +109,34 @@ export const AddApiKeyModal = ({
return {};
};
const getInitialDirectoryPermission = (): DirectoryPermissionRecord | null => {
if (feedbackDirectories.length > 0) {
return {
feedbackDirectoryId: feedbackDirectories[0].id,
permission: ApiKeyPermission.read,
feedbackDirectoryName: feedbackDirectories[0].name,
};
}
return null;
};
// Initialize with one permission by default
const [selectedPermissions, setSelectedPermissions] = useState<Record<string, PermissionRecord>>({});
const [selectedDirectoryPermissions, setSelectedDirectoryPermissions] = useState<
Record<string, DirectoryPermissionRecord>
>({});
const [nextDirectoryPermissionId, setNextDirectoryPermissionId] = useState(0);
const workspaceOptions: WorkspaceOption[] = workspaces.map((workspace) => ({
id: workspace.id,
name: workspace.name,
}));
const directoryOptions: FeedbackDirectoryOption[] = feedbackDirectories.map((directory) => ({
id: directory.id,
name: directory.name,
}));
const removePermission = (index: number) => {
const updatedPermissions = { ...selectedPermissions };
delete updatedPermissions[`permission-${index}`];
@@ -139,12 +179,59 @@ export const AddApiKeyModal = ({
}
};
const removeDirectoryPermission = (key: string) => {
setSelectedDirectoryPermissions((prev) =>
Object.fromEntries(Object.entries(prev).filter(([k]) => k !== key))
);
};
const addDirectoryPermission = () => {
const initial = getInitialDirectoryPermission();
if (initial) {
setSelectedDirectoryPermissions({
...selectedDirectoryPermissions,
[`directory-permission-${nextDirectoryPermissionId}`]: initial,
});
setNextDirectoryPermissionId((id) => id + 1);
}
};
const updateDirectoryPermissionLevel = (key: string, permission: ApiKeyPermission) => {
setSelectedDirectoryPermissions({
...selectedDirectoryPermissions,
[key]: {
...selectedDirectoryPermissions[key],
permission,
},
});
};
const updateDirectorySelection = (key: string, directoryId: string) => {
const directory = feedbackDirectories.find((d) => d.id === directoryId);
if (directory) {
setSelectedDirectoryPermissions({
...selectedDirectoryPermissions,
[key]: {
...selectedDirectoryPermissions[key],
feedbackDirectoryId: directoryId,
feedbackDirectoryName: directory.name,
},
});
}
};
const checkForDuplicatePermissions = () => {
const permissions = Object.values(selectedPermissions);
const uniquePermissions = new Set(permissions.map((p) => p.workspaceId));
return uniquePermissions.size !== permissions.length;
};
const checkForDuplicateDirectoryPermissions = () => {
const permissions = Object.values(selectedDirectoryPermissions);
const unique = new Set(permissions.map((p) => p.feedbackDirectoryId));
return unique.size !== permissions.length;
};
const submitAPIKey = async () => {
const data = getValues();
@@ -153,20 +240,33 @@ export const AddApiKeyModal = ({
return;
}
if (checkForDuplicateDirectoryPermissions()) {
toast.error(t("workspace.api_keys.duplicate_directory_access"));
return;
}
// Convert permissions to the format expected by the API
const workspacePermissions = Object.values(selectedPermissions).map((permission) => ({
permission: permission.permission,
workspaceId: permission.workspaceId,
}));
const feedbackDirectoryPermissions = Object.values(selectedDirectoryPermissions).map((p) => ({
permission: p.permission,
feedbackDirectoryId: p.feedbackDirectoryId,
}));
await onSubmit({
label: data.label,
workspacePermissions,
feedbackDirectoryPermissions,
organizationAccess: selectedOrganizationAccess,
});
reset();
setSelectedPermissions({});
setSelectedDirectoryPermissions({});
setNextDirectoryPermissionId(0);
setSelectedOrganizationAccess(defaultOrganizationAccess);
};
@@ -178,13 +278,14 @@ export const AddApiKeyModal = ({
// Check if at least one workspace permission is set or one organization access toggle is ON
const hasWorkspaceAccess = Object.keys(selectedPermissions).length > 0;
const hasDirectoryAccess = Object.keys(selectedDirectoryPermissions).length > 0;
const hasOrganizationAccess = Object.values(selectedOrganizationAccess).some((accessGroup) =>
Object.values(accessGroup).some((value) => value === true)
);
// Disable submit if no access rights are granted
return !(hasWorkspaceAccess || hasOrganizationAccess);
return !(hasWorkspaceAccess || hasDirectoryAccess || hasOrganizationAccess);
};
const setSelectedOrganizationAccessValue = (key: string, accessType: string, value: boolean) => {
@@ -302,6 +403,95 @@ export const AddApiKeyModal = ({
</div>
</div>
<div className="space-y-2">
<Label>{t("workspace.api_keys.feedback_directory_access")}</Label>
<div className="space-y-2">
{Object.keys(selectedDirectoryPermissions).map((key) => {
const permission = selectedDirectoryPermissions[key];
return (
<div key={key} className="flex items-center gap-2">
{/* Directory dropdown */}
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{permission.feedbackDirectoryName}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
{directoryOptions.map((option) => (
<DropdownMenuItem
key={option.id}
onClick={() => {
updateDirectorySelection(key, option.id);
}}>
{option.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Permission level dropdown */}
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
<span className="flex h-full items-center border-l pl-3">
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem] capitalize">
{permissionOptions.map((option) => (
<DropdownMenuItem
key={option}
onClick={() => {
updateDirectoryPermissionLevel(key, option);
}}>
{option}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Delete button */}
<button type="button" className="p-2" onClick={() => removeDirectoryPermission(key)}>
<Trash2Icon className={"h-5 w-5 text-slate-500 hover:text-red-500"} />
</button>
</div>
);
})}
<Button
id="add_directory_permission__button"
type="button"
variant="outline"
onClick={addDirectoryPermission}
disabled={feedbackDirectories.length === 0}
data-testid="add_directory_permission__button__test">
<span className="mr-2">+</span> {t("workspace.settings.api_keys.add_permission")}
</Button>
</div>
</div>
<div className="space-y-4">
<Label>{t("workspace.api_keys.organization_access")}</Label>
{Object.keys(selectedOrganizationAccess).map((key) => (
@@ -341,6 +531,8 @@ export const AddApiKeyModal = ({
setOpen(false);
reset();
setSelectedPermissions({});
setSelectedDirectoryPermissions({});
setNextDirectoryPermissionId(0);
}}>
{t("common.cancel")}
</Button>
@@ -1,6 +1,9 @@
import { TUserLocale } from "@formbricks/types/user";
import { getApiKeysWithEnvironmentPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
import { TOrganizationWorkspace } from "@/modules/organization/settings/api-keys/types/api-keys";
import {
TOrganizationFeedbackDirectory,
TOrganizationWorkspace,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { EditAPIKeys } from "./edit-api-keys";
interface ApiKeyListProps {
@@ -8,9 +11,16 @@ interface ApiKeyListProps {
locale: TUserLocale;
isReadOnly: boolean;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
}
export const ApiKeyList = async ({ organizationId, locale, isReadOnly, workspaces }: ApiKeyListProps) => {
export const ApiKeyList = async ({
organizationId,
locale,
isReadOnly,
workspaces,
feedbackDirectories,
}: ApiKeyListProps) => {
const apiKeys = await getApiKeysWithEnvironmentPermissions(organizationId);
return (
@@ -20,6 +30,7 @@ export const ApiKeyList = async ({ organizationId, locale, isReadOnly, workspace
locale={locale}
isReadOnly={isReadOnly}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
/>
);
};
@@ -13,6 +13,7 @@ import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/co
import {
TApiKeyUpdateInput,
TApiKeyWithEnvironmentPermission,
TOrganizationFeedbackDirectory,
TOrganizationWorkspace,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { Button } from "@/modules/ui/components/button";
@@ -26,6 +27,7 @@ interface EditAPIKeysProps {
locale: TUserLocale;
isReadOnly: boolean;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
}
export const EditAPIKeys = ({
@@ -34,6 +36,7 @@ export const EditAPIKeys = ({
locale,
isReadOnly,
workspaces,
feedbackDirectories,
}: EditAPIKeysProps) => {
const { t } = useTranslation();
const [isAddAPIKeyModalOpen, setIsAddAPIKeyModalOpen] = useState(false);
@@ -73,6 +76,10 @@ export const EditAPIKeys = ({
permission: ApiKeyPermission;
workspaceId: string;
}>;
feedbackDirectoryPermissions: Array<{
permission: ApiKeyPermission;
feedbackDirectoryId: string;
}>;
organizationAccess: TOrganizationAccess;
}): Promise<void> => {
setIsLoading(true);
@@ -81,6 +88,7 @@ export const EditAPIKeys = ({
apiKeyData: {
label: data.label,
workspacePermissions: data.workspacePermissions,
feedbackDirectoryPermissions: data.feedbackDirectoryPermissions,
organizationAccess: data.organizationAccess,
},
});
@@ -237,6 +245,7 @@ export const EditAPIKeys = ({
setOpen={setIsAddAPIKeyModalOpen}
onSubmit={handleAddAPIKey}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
isCreatingAPIKey={isLoading}
/>
{activeKey && (
@@ -246,6 +255,7 @@ export const EditAPIKeys = ({
onSubmit={handleUpdateAPIKey}
apiKey={activeKey}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
isUpdating={isLoading}
/>
)}
@@ -9,6 +9,7 @@ import { type TOrganizationWorkspace } from "@/modules/ee/teams/team-list/types/
import {
type TApiKeyUpdateInput,
type TApiKeyWithEnvironmentPermission,
TOrganizationFeedbackDirectory,
ZApiKeyUpdateInput,
} from "@/modules/organization/settings/api-keys/types/api-keys";
import { Button } from "@/modules/ui/components/button";
@@ -31,6 +32,7 @@ interface ViewPermissionModalProps {
onSubmit: (data: TApiKeyUpdateInput) => Promise<void>;
apiKey: TApiKeyWithEnvironmentPermission;
workspaces: TOrganizationWorkspace[];
feedbackDirectories: TOrganizationFeedbackDirectory[];
isUpdating: boolean;
}
@@ -40,6 +42,7 @@ export const ViewPermissionModal = ({
onSubmit,
apiKey,
workspaces,
feedbackDirectories,
isUpdating,
}: ViewPermissionModalProps) => {
const { register, getValues, handleSubmit, reset, watch } = useForm<TApiKeyUpdateInput>({
@@ -73,6 +76,11 @@ export const ViewPermissionModal = ({
return name ?? `${t("workspace.api_keys.unknown_workspace")} (${workspaceId})`;
};
const getDirectoryName = (directoryId: string) => {
const name = feedbackDirectories.find((d) => d.id === directoryId)?.name;
return name ?? `${t("workspace.api_keys.unknown_directory")} (${directoryId})`;
};
const updateApiKey = async () => {
const data = getValues();
await onSubmit(data);
@@ -146,6 +154,50 @@ export const ViewPermissionModal = ({
})}
</div>
</div>
<div className="space-y-2">
<Label>{t("workspace.api_keys.feedback_directory_access")}</Label>
{apiKey.apiKeyFeedbackDirectories?.length === 0 && (
<div className="text-center text-sm">
{t("workspace.api_keys.no_directory_permissions_found")}
</div>
)}
<div className="space-y-2">
{apiKey.apiKeyFeedbackDirectories?.map((permission) => (
<div key={permission.feedbackDirectoryId} className="flex items-center gap-2">
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left">
{getDirectoryName(permission.feedbackDirectoryId)}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
<div className="w-1/2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-10 w-full rounded-md border border-slate-300 bg-transparent px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none">
<span className="flex w-4/5 flex-1">
<span className="w-full truncate text-left capitalize">
{permission.permission}
</span>
</span>
</button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
</div>
))}
</div>
</div>
<div className="space-y-4">
<Label>{t("workspace.api_keys.organization_access")}</Label>
{Object.keys(organizationAccess).map((key) => (
@@ -6,7 +6,7 @@ import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TOrganizationAccess } from "@formbricks/types/api-key";
import { ZId } from "@formbricks/types/common";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { CONTROL_HASH } from "@/lib/constants";
import { hashSecret, hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
@@ -38,6 +38,12 @@ export const getApiKeysWithEnvironmentPermissions = reactCache(
workspaceId: true,
},
},
apiKeyFeedbackDirectories: {
select: {
permission: true,
feedbackDirectoryId: true,
},
},
},
});
return apiKeys;
@@ -65,6 +71,16 @@ export const getApiKeyWithPermissions = reactCache(
},
},
},
apiKeyFeedbackDirectories: {
include: {
feedbackDirectory: {
select: {
id: true,
name: true,
},
},
},
},
};
// Try v2 format first (fbk_{secret})
@@ -156,6 +172,10 @@ export const createApiKey = async (
workspaceId: string;
permission: ApiKeyPermission;
}>;
feedbackDirectoryPermissions?: Array<{
feedbackDirectoryId: string;
permission: ApiKeyPermission;
}>;
organizationAccess: TOrganizationAccess;
}
): Promise<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
@@ -171,7 +191,22 @@ export const createApiKey = async (
// 2. bcrypt hash
const hashedKey = await hashSecret(secret, 12);
const { workspacePermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData;
const {
workspacePermissions,
feedbackDirectoryPermissions,
organizationAccess,
...apiKeyDataWithoutPermissions
} = apiKeyData;
if (feedbackDirectoryPermissions && feedbackDirectoryPermissions.length > 0) {
const directoryIds = feedbackDirectoryPermissions.map((p) => p.feedbackDirectoryId);
const ownedCount = await prisma.feedbackDirectory.count({
where: { id: { in: directoryIds }, organizationId, isArchived: false },
});
if (ownedCount !== directoryIds.length) {
throw new ResourceNotFoundError("FeedbackDirectory", null);
}
}
// Create the API key
const result = await prisma.apiKey.create({
@@ -192,9 +227,20 @@ export const createApiKey = async (
},
}
: {}),
...(feedbackDirectoryPermissions && feedbackDirectoryPermissions.length > 0
? {
apiKeyFeedbackDirectories: {
create: feedbackDirectoryPermissions.map((dirPerm) => ({
permission: dirPerm.permission,
feedbackDirectoryId: dirPerm.feedbackDirectoryId,
})),
},
}
: {}),
},
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
@@ -1,7 +1,7 @@
import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TApiKeyWithEnvironmentPermission } from "../types/api-keys";
import {
createApiKey,
@@ -36,6 +36,7 @@ const mockApiKeyWithEnvironments: TApiKeyWithEnvironmentPermission = {
permission: ApiKeyPermission.manage,
},
],
apiKeyFeedbackDirectories: [],
};
// Mock modules before tests
@@ -49,6 +50,9 @@ vi.mock("@formbricks/database", () => ({
create: vi.fn(),
update: vi.fn(),
},
feedbackDirectory: {
count: vi.fn(),
},
},
}));
@@ -115,6 +119,12 @@ describe("API Key Management", () => {
workspaceId: true,
},
},
apiKeyFeedbackDirectories: {
select: {
permission: true,
feedbackDirectoryId: true,
},
},
createdAt: true,
id: true,
label: true,
@@ -330,7 +340,7 @@ describe("API Key Management", () => {
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(errToThrow);
});
test("uses workspace include without feedback directory relations in v2 lookup", async () => {
test("includes apiKeyFeedbackDirectories with nested directory in v2 lookup", async () => {
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
...mockApiKey,
lastUsedAt: new Date(Date.now() - 1000 * 10),
@@ -348,11 +358,18 @@ describe("API Key Management", () => {
},
},
},
apiKeyFeedbackDirectories: {
include: {
feedbackDirectory: {
select: { id: true, name: true },
},
},
},
},
});
});
test("uses workspace include without feedback directory relations in legacy lookup", async () => {
test("includes apiKeyFeedbackDirectories with nested directory in legacy lookup", async () => {
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
...mockApiKey,
lastUsedAt: new Date(Date.now() - 1000 * 10),
@@ -370,9 +387,40 @@ describe("API Key Management", () => {
},
},
},
apiKeyFeedbackDirectories: {
include: {
feedbackDirectory: {
select: { id: true, name: true },
},
},
},
},
});
});
test("returns directory permissions on the api key payload", async () => {
const payload = {
...mockApiKey,
lastUsedAt: new Date(Date.now() - 1000 * 10),
apiKeyWorkspaces: [],
apiKeyFeedbackDirectories: [
{
id: "dir-perm-1",
apiKeyId: "apikey123",
feedbackDirectoryId: "dir1",
permission: ApiKeyPermission.read,
createdAt: new Date(),
updatedAt: new Date(),
feedbackDirectory: { id: "dir1", name: "Directory 1" },
},
],
};
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce(payload as any);
const result = await getApiKeyWithPermissions("fbk_testSecret123");
expect(result?.apiKeyFeedbackDirectories).toEqual(payload.apiKeyFeedbackDirectories);
});
});
describe("deleteApiKey", () => {
@@ -447,6 +495,7 @@ describe("API Key Management", () => {
}),
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
});
@@ -463,6 +512,108 @@ describe("API Key Management", () => {
expect(prisma.apiKey.create).toHaveBeenCalled();
});
test("creates an API key with feedback directory permissions", async () => {
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(2);
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
await createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "dir2", permission: ApiKeyPermission.write },
],
});
expect(prisma.feedbackDirectory.count).toHaveBeenCalledWith({
where: { id: { in: ["dir1", "dir2"] }, organizationId: "org123", isArchived: false },
});
expect(prisma.apiKey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
apiKeyFeedbackDirectories: {
create: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "dir2", permission: ApiKeyPermission.write },
],
},
}),
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
});
test("omits apiKeyFeedbackDirectories when feedbackDirectoryPermissions is empty", async () => {
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
await createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [],
});
const callArg = vi.mocked(prisma.apiKey.create).mock.calls[0][0] as { data: Record<string, unknown> };
expect(callArg.data.apiKeyFeedbackDirectories).toBeUndefined();
});
test("creates an API key with both workspace and directory permissions", async () => {
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(1);
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
await createApiKey("org123", "user123", {
...mockApiKeyData,
workspacePermissions: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
feedbackDirectoryPermissions: [{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage }],
});
expect(prisma.apiKey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
apiKeyWorkspaces: {
create: [{ workspaceId: "workspace123", permission: ApiKeyPermission.manage }],
},
apiKeyFeedbackDirectories: {
create: [{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage }],
},
}),
include: {
apiKeyWorkspaces: true,
apiKeyFeedbackDirectories: true,
},
});
});
test("rejects when a feedbackDirectoryId is not owned by the organization", async () => {
vi.mocked(prisma.feedbackDirectory.count).mockResolvedValueOnce(1);
await expect(
createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "foreign-dir", permission: ApiKeyPermission.read },
],
})
).rejects.toThrow(ResourceNotFoundError);
expect(prisma.feedbackDirectory.count).toHaveBeenCalledWith({
where: { id: { in: ["dir1", "foreign-dir"] }, organizationId: "org123", isArchived: false },
});
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
test("rejects create input with duplicate feedbackDirectoryId", async () => {
await expect(
createApiKey("org123", "user123", {
...mockApiKeyData,
feedbackDirectoryPermissions: [
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.read },
{ feedbackDirectoryId: "dir1", permission: ApiKeyPermission.manage },
],
})
).rejects.toThrow();
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
test("rejects create input with duplicate workspaceId", async () => {
await expect(
createApiKey("org123", "user123", {
@@ -0,0 +1,94 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TOrganizationFeedbackDirectory } from "../types/api-keys";
import { getFeedbackDirectoriesByOrganizationId } from "./feedback-directories";
const mockDirectories: TOrganizationFeedbackDirectory[] = [
{
id: "dir1",
name: "Directory 1",
},
{
id: "dir2",
name: "Directory 2",
},
];
vi.mock("@formbricks/database", () => ({
prisma: {
feedbackDirectory: {
findMany: vi.fn(),
},
},
}));
describe("Feedback Directories Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getFeedbackDirectoriesByOrganizationId", () => {
test("retrieves non-archived directories by organization ID successfully", async () => {
vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValueOnce(
mockDirectories as unknown as Awaited<ReturnType<typeof prisma.feedbackDirectory.findMany>>
);
const result = await getFeedbackDirectoriesByOrganizationId("org123");
expect(result).toEqual(mockDirectories);
expect(prisma.feedbackDirectory.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org123",
isArchived: false,
},
select: {
id: true,
name: true,
},
orderBy: {
createdAt: "desc",
},
});
});
test("returns empty array when no directories exist", async () => {
vi.mocked(prisma.feedbackDirectory.findMany).mockResolvedValueOnce([]);
const result = await getFeedbackDirectoriesByOrganizationId("org123");
expect(result).toEqual([]);
expect(prisma.feedbackDirectory.findMany).toHaveBeenCalledWith({
where: {
organizationId: "org123",
isArchived: false,
},
select: {
id: true,
name: true,
},
orderBy: {
createdAt: "desc",
},
});
});
test("throws DatabaseError on prisma known request error", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: "P2002",
clientVersion: "0.0.1",
});
vi.mocked(prisma.feedbackDirectory.findMany).mockRejectedValueOnce(errToThrow);
await expect(getFeedbackDirectoriesByOrganizationId("org123")).rejects.toThrow(DatabaseError);
});
test("bubbles up unexpected errors", async () => {
const unexpectedError = new Error("Unexpected error");
vi.mocked(prisma.feedbackDirectory.findMany).mockRejectedValueOnce(unexpectedError);
await expect(getFeedbackDirectoriesByOrganizationId("org123")).rejects.toThrow(unexpectedError);
});
});
});
@@ -0,0 +1,34 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/errors";
import { TOrganizationFeedbackDirectory } from "@/modules/organization/settings/api-keys/types/api-keys";
export const getFeedbackDirectoriesByOrganizationId = reactCache(
async (organizationId: string): Promise<TOrganizationFeedbackDirectory[]> => {
try {
const directories = await prisma.feedbackDirectory.findMany({
where: {
organizationId,
isArchived: false,
},
select: {
id: true,
name: true,
},
orderBy: {
createdAt: "desc",
},
});
return directories;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
}
);
@@ -2,6 +2,7 @@ import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/comp
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getUserLocale } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getFeedbackDirectoriesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/feedback-directories";
import { getWorkspacesByOrganizationId } from "@/modules/organization/settings/api-keys/lib/workspaces";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
@@ -14,8 +15,9 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
const { currentUserMembership, organization, session } = await getWorkspaceAuth(params.workspaceId);
const [workspaces, locale] = await Promise.all([
const [workspaces, feedbackDirectories, locale] = await Promise.all([
getWorkspacesByOrganizationId(organization.id),
getFeedbackDirectoriesByOrganizationId(organization.id),
getUserLocale(session.user.id),
]);
@@ -34,6 +36,7 @@ export const APIKeysPage = async (props: { params: Promise<{ workspaceId: string
locale={locale ?? DEFAULT_LOCALE}
isReadOnly={!canAccessApiKeys}
workspaces={workspaces}
feedbackDirectories={feedbackDirectories}
/>
</SettingsCard>
</PageContentWrapper>
@@ -8,10 +8,16 @@ export const ZApiKeyWorkspacePermission = z.object({
permission: z.enum(ApiKeyPermission),
});
export const ZApiKeyFeedbackDirectoryPermission = z.object({
feedbackDirectoryId: z.string(),
permission: z.enum(ApiKeyPermission),
});
export const ZApiKeyCreateInput = z
.object({
label: z.string(),
workspacePermissions: z.array(ZApiKeyWorkspacePermission).optional(),
feedbackDirectoryPermissions: z.array(ZApiKeyFeedbackDirectoryPermission).optional(),
organizationAccess: ZOrganizationAccess,
})
.refine(
@@ -21,6 +27,17 @@ export const ZApiKeyCreateInput = z
return new Set(ids).size === ids.length;
},
{ message: "Duplicate workspace permissions are not allowed", path: ["workspacePermissions"] }
)
.refine(
(data) => {
if (!data.feedbackDirectoryPermissions) return true;
const ids = data.feedbackDirectoryPermissions.map((p) => p.feedbackDirectoryId);
return new Set(ids).size === ids.length;
},
{
message: "Duplicate feedback directory permissions are not allowed",
path: ["feedbackDirectoryPermissions"],
}
);
export type TApiKeyCreateInput = z.infer<typeof ZApiKeyCreateInput>;
@@ -44,13 +61,23 @@ export type TOrganizationWorkspace = z.infer<typeof OrganizationWorkspace>;
export type TApiKeyWorkspacePermission = z.infer<typeof ZApiKeyWorkspacePermission>;
export type TApiKeyFeedbackDirectoryPermission = z.infer<typeof ZApiKeyFeedbackDirectoryPermission>;
export interface TApiKeyWithEnvironmentPermission extends Pick<
ApiKey,
"id" | "label" | "createdAt" | "organizationAccess"
> {
apiKeyWorkspaces: TApiKeyWorkspacePermission[];
apiKeyFeedbackDirectories: TApiKeyFeedbackDirectoryPermission[];
}
export const OrganizationFeedbackDirectory = z.object({
id: z.string(),
name: z.string(),
});
export type TOrganizationFeedbackDirectory = z.infer<typeof OrganizationFeedbackDirectory>;
const ZApiKeyWorkspaceWithWorkspace = z.object({
id: z.string(),
createdAt: z.date(),
@@ -61,6 +88,19 @@ const ZApiKeyWorkspaceWithWorkspace = z.object({
workspace: ZWorkspace.pick({ id: true, name: true }),
});
const ZApiKeyFeedbackDirectoryWithDirectory = z.object({
id: z.string(),
createdAt: z.date(),
updatedAt: z.date(),
apiKeyId: z.string(),
feedbackDirectoryId: z.string(),
permission: z.enum(ApiKeyPermission),
feedbackDirectory: z.object({
id: z.string(),
name: z.string(),
}),
});
const ZApiKey = z.object({
id: z.string(),
createdAt: z.date(),
@@ -76,6 +116,7 @@ const ZApiKey = z.object({
export const ZApiKeyWithEnvironmentAndWorkspace = ZApiKey.extend({
apiKeyWorkspaces: z.array(ZApiKeyWorkspaceWithWorkspace),
apiKeyFeedbackDirectories: z.array(ZApiKeyFeedbackDirectoryWithDirectory),
});
export type TApiKeyWithEnvironmentAndWorkspace = z.infer<typeof ZApiKeyWithEnvironmentAndWorkspace>;
+76
View File
@@ -54,6 +54,38 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
- Provide `CUBEJS_API_SECRET` through your existing secret management flow, such as the generated app secret override or `deployment.envFrom`.
- Keep Hub enabled. Cube should point at the same feedback records database that Hub writes to, unless you intentionally split that storage.
## Hub worker and self-hosted embeddings
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
```yaml
hub:
worker:
enabled: true
embeddings:
enabled: true
model: google/embeddinggemma-300m
servedModelName: google/embeddinggemma-300m
huggingFace:
token: hf_...
```
The generated Hub embedding configuration is:
- `EMBEDDING_PROVIDER=openai`
- `EMBEDDING_MODEL=<hub.embeddings.servedModelName or hub.embeddings.model>`
- `EMBEDDING_BASE_URL=http://<release>-hub-embeddings:8080/v1`
- `EMBEDDING_PROVIDER_API_KEY` from a dedicated embeddings Secret
The TEI service is internal-only (`ClusterIP`) and not exposed through ingress. For gated models such as `google/embeddinggemma-300m`, provide `hub.embeddings.huggingFace.token` or set `hub.embeddings.huggingFace.existingSecret`.
When TEI auth is enabled, configure the shared key through `hub.embeddings.auth.apiKey` or `hub.embeddings.auth.existingSecret`; the chart manages both TEI `API_KEY` and Hub `EMBEDDING_PROVIDER_API_KEY` from that source.
Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If you scale the embeddings runtime above one replica while persistence is enabled, the cache PVC must support `ReadWriteMany`; otherwise set `hub.embeddings.persistence.enabled=false` or provide a compatible `existingClaim`.
## Values
| Key | Type | Default | Description |
@@ -139,7 +171,40 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
| externalSecret.secretStore.name | string | `"aws-secrets-manager"` | |
| formbricks.publicUrl | string | `""` | |
| formbricks.webappUrl | string | `""` | |
| hub.autoscaling.enabled | bool | `false` | |
| hub.autoscaling.maxReplicas | int | `3` | |
| hub.autoscaling.minReplicas | int | `1` | |
| hub.enabled | bool | `true` | |
| hub.embeddings.auth.enabled | bool | `true` | |
| hub.embeddings.auth.existingSecret | string | `""` | |
| hub.embeddings.auth.secretKey | string | `"EMBEDDING_PROVIDER_API_KEY"` | |
| hub.embeddings.autoscaling.enabled | bool | `false` | |
| hub.embeddings.autoscaling.maxReplicas | int | `2` | |
| hub.embeddings.autoscaling.minReplicas | int | `1` | |
| hub.embeddings.baseUrl | string | `""` | Defaults to the internal TEI service URL ending in `/v1`. |
| hub.embeddings.enabled | bool | `false` | |
| hub.embeddings.huggingFace.existingSecret | string | `""` | |
| hub.embeddings.huggingFace.token | string | `""` | |
| hub.embeddings.huggingFace.tokenKey | string | `"HF_TOKEN"` | |
| hub.embeddings.image.pullPolicy | string | `"IfNotPresent"` | |
| hub.embeddings.image.repository | string | `"ghcr.io/huggingface/text-embeddings-inference"` | |
| hub.embeddings.image.tag | string | `"cpu-1.9"` | |
| hub.embeddings.maxConcurrent | string | `"5"` | |
| hub.embeddings.model | string | `"google/embeddinggemma-300m"` | |
| hub.embeddings.persistence.enabled | bool | `true` | |
| hub.embeddings.persistence.mountPath | string | `"/data"` | |
| hub.embeddings.persistence.size | string | `"10Gi"` | |
| hub.embeddings.pdb.enabled | bool | `false` | |
| hub.embeddings.port | int | `8080` | |
| hub.embeddings.prometheusPort | int | `9000` | |
| hub.embeddings.replicas | int | `1` | |
| hub.embeddings.resources.limits.memory | string | `"8Gi"` | |
| hub.embeddings.resources.requests.cpu | string | `"4"` | |
| hub.embeddings.resources.requests.memory | string | `"8Gi"` | |
| hub.embeddings.runtime | string | `"tei"` | |
| hub.embeddings.servedModelName | string | `""` | Defaults to `hub.embeddings.model`. |
| hub.embeddings.service.port | int | `8080` | |
| hub.embeddings.service.type | string | `"ClusterIP"` | |
| hub.env | object | `{}` | |
| hub.existingSecret | string | `""` | |
| hub.image.digest | string | `"sha256:14db7b3d285b6e9165b55693f9b83d08beff840a255fd77dd12882ee0a62f5cb"` | When set, takes precedence over tag (immutable pin). |
@@ -149,10 +214,21 @@ This chart does not deploy Cube.js. XM Suite v5 dashboard and analysis features
| hub.migration.activeDeadlineSeconds | int | `900` | |
| hub.migration.backoffLimit | int | `3` | |
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
| hub.pdb.enabled | bool | `false` | |
| hub.replicas | int | `1` | |
| hub.resources.limits.memory | string | `"512Mi"` | |
| hub.resources.requests.cpu | string | `"100m"` | |
| hub.resources.requests.memory | string | `"256Mi"` | |
| hub.worker.autoscaling.enabled | bool | `false` | |
| hub.worker.autoscaling.maxReplicas | int | `5` | |
| hub.worker.autoscaling.minReplicas | int | `1` | |
| hub.worker.enabled | bool | `true` | |
| hub.worker.env | object | `{}` | |
| hub.worker.pdb.enabled | bool | `false` | |
| hub.worker.replicas | int | `1` | |
| hub.worker.resources.limits.memory | string | `"512Mi"` | |
| hub.worker.resources.requests.cpu | string | `"100m"` | |
| hub.worker.resources.requests.memory | string | `"256Mi"` | |
| ingress.annotations | object | `{}` | |
| ingress.enabled | bool | `false` | |
| ingress.hosts[0].host | string | `"k8s.formbricks.com"` | |
+106 -7
View File
@@ -114,6 +114,105 @@ hub-worker) must use this helper so they cannot drift apart.
{{- end -}}
{{- end }}
{{/*
Hub worker resource name.
*/}}
{{- define "formbricks.hubWorkerName" -}}
{{- $base := include "formbricks.name" . | trunc 52 | trimSuffix "-" }}
{{- printf "%s-hub-worker" $base | trimSuffix "-" }}
{{- end }}
{{/*
Hub embeddings runtime resource name.
*/}}
{{- define "formbricks.hubEmbeddingsName" -}}
{{- $base := include "formbricks.name" . | trunc 48 | trimSuffix "-" }}
{{- printf "%s-hub-embeddings" $base | trimSuffix "-" }}
{{- end }}
{{/*
Secret used by Hub and the embeddings runtime for the embeddings API key.
*/}}
{{- define "formbricks.hubEmbeddingsSecretName" -}}
{{- default (printf "%s-secret" (include "formbricks.hubEmbeddingsName" .)) .Values.hub.embeddings.auth.existingSecret -}}
{{- end }}
{{/*
Secret used by the embeddings runtime for Hugging Face access.
*/}}
{{- define "formbricks.hubEmbeddingsHuggingFaceSecretName" -}}
{{- default (include "formbricks.hubEmbeddingsSecretName" .) .Values.hub.embeddings.huggingFace.existingSecret -}}
{{- end }}
{{/*
Model name Hub sends to the OpenAI-compatible embeddings endpoint.
*/}}
{{- define "formbricks.hubEmbeddingsServedModelName" -}}
{{- default .Values.hub.embeddings.model .Values.hub.embeddings.servedModelName -}}
{{- end }}
{{/*
OpenAI-compatible embeddings base URL used by Hub.
*/}}
{{- define "formbricks.hubEmbeddingsBaseURL" -}}
{{- if .Values.hub.embeddings.baseUrl -}}
{{- .Values.hub.embeddings.baseUrl -}}
{{- else -}}
{{- printf "http://%s:%v/v1" (include "formbricks.hubEmbeddingsName" .) (.Values.hub.embeddings.service.port | default .Values.hub.embeddings.port) -}}
{{- end -}}
{{- end }}
{{/*
Embedding API key value for the generated embeddings secret.
*/}}
{{- define "formbricks.hubEmbeddingsApiKey" -}}
{{- $secretName := include "formbricks.hubEmbeddingsSecretName" . }}
{{- $secretKey := .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace $secretName) }}
{{- if and $secret (index $secret.data $secretKey) }}
{{- index $secret.data $secretKey | b64dec -}}
{{- else if .Values.hub.embeddings.auth.apiKey }}
{{- .Values.hub.embeddings.auth.apiKey -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{/*
Shared Hub embedding env. These values are managed from hub.embeddings when the
self-hosted runtime is enabled so Hub API and Hub worker cannot drift.
*/}}
{{- define "formbricks.hubEmbeddingEnv" -}}
{{- $root := .root -}}
{{- if $root.Values.hub.embeddings.enabled }}
- name: EMBEDDING_PROVIDER
value: "openai"
- name: EMBEDDING_MODEL
value: {{ include "formbricks.hubEmbeddingsServedModelName" $root | quote }}
- name: EMBEDDING_BASE_URL
value: {{ include "formbricks.hubEmbeddingsBaseURL" $root | quote }}
- name: EMBEDDING_PROVIDER_API_KEY
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsSecretName" $root }}
key: {{ $root.Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
- name: EMBEDDING_MAX_CONCURRENT
value: {{ $root.Values.hub.embeddings.maxConcurrent | quote }}
- name: EMBEDDING_NORMALIZE
value: {{ $root.Values.hub.embeddings.normalize | quote }}
{{- end }}
{{- end }}
{{/*
Returns true when an env var is managed by hub.embeddings and should not be rendered from hub.env/worker.env.
*/}}
{{- define "formbricks.hubEmbeddingEnvManaged" -}}
{{- $key := .key -}}
{{- if has $key (list "EMBEDDING_PROVIDER" "EMBEDDING_MODEL" "EMBEDDING_BASE_URL" "EMBEDDING_PROVIDER_API_KEY" "EMBEDDING_MAX_CONCURRENT" "EMBEDDING_NORMALIZE") -}}
true
{{- end -}}
{{- end }}
{{- define "formbricks.postgresAdminPassword" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
@@ -142,13 +241,13 @@ hub-worker) must use this helper so they cannot drift apart.
{{- end -}}
{{- end }}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if $secret }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- define "formbricks.cronSecret" -}}
{{- $secret := (lookup "v1" "Secret" .Release.Namespace (include "formbricks.appSecretName" .)) }}
{{- if $secret }}
{{- index $secret.data "CRON_SECRET" | b64dec -}}
{{- else }}
{{- randAlphaNum 32 -}}
{{- end -}}
{{- end }}
{{- define "formbricks.encryptionKey" -}}
@@ -14,7 +14,9 @@ metadata:
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if not .Values.hub.autoscaling.enabled }}
replicas: {{ .Values.hub.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
@@ -66,10 +68,13 @@ spec:
secretKeyRef:
name: {{ include "formbricks.hubSecretName" . }}
key: HUB_API_KEY
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" .Values.hub.env) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- if .Values.hub.resources }}
resources:
{{- toYaml .Values.hub.resources | nindent 12 }}
@@ -0,0 +1,158 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
{{- $embeddingsReplicas := int (.Values.hub.embeddings.replicas | default 1) -}}
{{- $embeddingsMaxReplicas := int (.Values.hub.embeddings.autoscaling.maxReplicas | default 1) -}}
{{- if and .Values.hub.embeddings.persistence.enabled (not (has "ReadWriteMany" .Values.hub.embeddings.persistence.accessModes)) (or (gt $embeddingsReplicas 1) (and .Values.hub.embeddings.autoscaling.enabled (gt $embeddingsMaxReplicas 1))) }}
{{- fail "hub.embeddings persistence with multiple replicas requires persistence.accessModes to include ReadWriteMany, or set hub.embeddings.persistence.enabled=false/use a ReadWriteMany existingClaim" }}
{{- end }}
{{- if and .Values.hub.embeddings.auth.existingSecret .Values.hub.embeddings.huggingFace.token (not .Values.hub.embeddings.huggingFace.existingSecret) }}
{{- fail "hub.embeddings.huggingFace.token cannot be stored when hub.embeddings.auth.existingSecret is set; put HF_TOKEN in the existing auth secret or set hub.embeddings.huggingFace.existingSecret" }}
{{- end }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if not .Values.hub.embeddings.autoscaling.enabled }}
replicas: {{ .Values.hub.embeddings.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
{{- with .Values.hub.embeddings.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.hub.embeddings.podSecurityContext }}
securityContext:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.embeddings.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
containers:
- name: hub-embeddings
image: "{{ .Values.hub.embeddings.image.repository }}:{{ .Values.hub.embeddings.image.tag }}"
imagePullPolicy: {{ .Values.hub.embeddings.image.pullPolicy }}
{{- with .Values.hub.embeddings.command }}
command:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.hub.embeddings.args }}
args:
{{- toYaml .Values.hub.embeddings.args | nindent 12 }}
{{- else }}
args:
- --model-id
- {{ .Values.hub.embeddings.model | quote }}
- --port
- {{ .Values.hub.embeddings.port | quote }}
- --huggingface-hub-cache
- {{ .Values.hub.embeddings.persistence.mountPath | quote }}
- --served-model-name
- {{ include "formbricks.hubEmbeddingsServedModelName" . | quote }}
{{- with .Values.hub.embeddings.revision }}
- --revision
- {{ . | quote }}
{{- end }}
{{- with .Values.hub.embeddings.extraArgs }}
{{- toYaml . | nindent 12 }}
{{- end }}
{{- end }}
ports:
- name: http
containerPort: {{ .Values.hub.embeddings.port }}
protocol: TCP
- name: metrics
containerPort: {{ .Values.hub.embeddings.prometheusPort }}
protocol: TCP
{{- if or .Values.hub.embeddings.auth.enabled .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token (gt (len .Values.hub.embeddings.env) 0) }}
env:
{{- if .Values.hub.embeddings.auth.enabled }}
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
key: {{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}
{{- end }}
{{- if or .Values.hub.embeddings.huggingFace.existingSecret .Values.hub.embeddings.huggingFace.token }}
- name: HF_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "formbricks.hubEmbeddingsHuggingFaceSecretName" . }}
key: {{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" }}
{{- end }}
{{- range $key, $value := .Values.hub.embeddings.env }}
{{- if not (or (and $.Values.hub.embeddings.auth.enabled (eq $key "API_KEY")) (and (or $.Values.hub.embeddings.huggingFace.existingSecret $.Values.hub.embeddings.huggingFace.token) (eq $key "HF_TOKEN"))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- with .Values.hub.embeddings.probes.startupProbe }}
startupProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.probes.readinessProbe }}
readinessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.probes.livenessProbe }}
livenessProbe:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.resources }}
resources:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- with .Values.hub.embeddings.securityContext }}
securityContext:
{{- toYaml . | nindent 12 }}
{{- end }}
{{- if .Values.hub.embeddings.persistence.enabled }}
volumeMounts:
- name: model-cache
mountPath: {{ .Values.hub.embeddings.persistence.mountPath }}
{{- end }}
{{- if .Values.hub.embeddings.persistence.enabled }}
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: {{ default (include "formbricks.hubEmbeddingsName" .) .Values.hub.embeddings.persistence.existingClaim }}
{{- end }}
{{- end }}
@@ -0,0 +1,23 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.persistence.enabled (not .Values.hub.embeddings.persistence.existingClaim) }}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
accessModes:
{{- toYaml .Values.hub.embeddings.persistence.accessModes | nindent 4 }}
resources:
requests:
storage: {{ .Values.hub.embeddings.persistence.size }}
{{- with .Values.hub.embeddings.persistence.storageClass }}
storageClassName: {{ . | quote }}
{{- end }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled (not .Values.hub.embeddings.auth.existingSecret) }}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "formbricks.hubEmbeddingsSecretName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
type: Opaque
data:
{{ .Values.hub.embeddings.auth.secretKey | default "EMBEDDING_PROVIDER_API_KEY" }}: {{ include "formbricks.hubEmbeddingsApiKey" . | b64enc }}
{{- if and (not .Values.hub.embeddings.huggingFace.existingSecret) .Values.hub.embeddings.huggingFace.token }}
{{ .Values.hub.embeddings.huggingFace.tokenKey | default "HF_TOKEN" }}: {{ .Values.hub.embeddings.huggingFace.token | b64enc }}
{{- end }}
{{- end }}
@@ -0,0 +1,35 @@
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled }}
---
apiVersion: v1
kind: Service
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.service.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.hub.embeddings.service.type }}
selector:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
ports:
- name: http
port: {{ .Values.hub.embeddings.service.port }}
targetPort: http
protocol: TCP
- name: metrics
port: {{ .Values.hub.embeddings.prometheusPort }}
targetPort: metrics
protocol: TCP
{{- end }}
+102
View File
@@ -0,0 +1,102 @@
{{- if and .Values.hub.enabled .Values.hub.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubname" . }}
minReplicas: {{ .Values.hub.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.worker.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.worker.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubWorkerName" . }}
minReplicas: {{ .Values.hub.worker.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.worker.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.worker.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.worker.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.autoscaling.enabled }}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.autoscaling.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.autoscaling.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "formbricks.hubEmbeddingsName" . }}
minReplicas: {{ .Values.hub.embeddings.autoscaling.minReplicas }}
maxReplicas: {{ .Values.hub.embeddings.autoscaling.maxReplicas }}
metrics:
{{- toYaml .Values.hub.embeddings.autoscaling.metrics | nindent 4 }}
{{- with .Values.hub.embeddings.autoscaling.behavior }}
behavior:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
+129
View File
@@ -0,0 +1,129 @@
{{- if and .Values.hub.enabled .Values.hub.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.pdb.minAvailable and hub.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.pdb.enabled is true but neither hub.pdb.minAvailable nor hub.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubname" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.worker.enabled .Values.hub.worker.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.worker.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.worker.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.worker.pdb.minAvailable and hub.worker.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.worker.pdb.enabled is true but neither hub.worker.pdb.minAvailable nor hub.worker.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.worker.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.worker.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.worker.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.worker.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.worker.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{- if and .Values.hub.enabled .Values.hub.embeddings.enabled .Values.hub.embeddings.pdb.enabled }}
{{- $hasMinAvailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.minAvailable) -}}
{{- $hasMaxUnavailable := not (kindIs "invalid" .Values.hub.embeddings.pdb.maxUnavailable) -}}
{{- if and $hasMinAvailable $hasMaxUnavailable }}
{{- fail "hub.embeddings.pdb.minAvailable and hub.embeddings.pdb.maxUnavailable are mutually exclusive; set only one" }}
{{- end }}
{{- if not (or $hasMinAvailable $hasMaxUnavailable) }}
{{- fail "hub.embeddings.pdb.enabled is true but neither hub.embeddings.pdb.minAvailable nor hub.embeddings.pdb.maxUnavailable is set; set exactly one" }}
{{- end }}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "formbricks.hubEmbeddingsName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-embeddings
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
{{- with .Values.hub.embeddings.pdb.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
{{- with .Values.hub.embeddings.pdb.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if $hasMinAvailable }}
minAvailable: {{ .Values.hub.embeddings.pdb.minAvailable }}
{{- end }}
{{- if $hasMaxUnavailable }}
maxUnavailable: {{ .Values.hub.embeddings.pdb.maxUnavailable }}
{{- end }}
{{- with .Values.hub.embeddings.pdb.unhealthyPodEvictionPolicy }}
unhealthyPodEvictionPolicy: {{ . }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubEmbeddingsName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
@@ -0,0 +1,97 @@
{{- if and .Values.hub.enabled .Values.hub.worker.enabled }}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "formbricks.hubWorkerName" . }}
labels:
helm.sh/chart: {{ include "formbricks.chart" . }}
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
spec:
{{- if not .Values.hub.worker.autoscaling.enabled }}
replicas: {{ .Values.hub.worker.replicas | default 1 }}
{{- end }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "formbricks.hubWorkerName" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: hub-worker
spec:
{{- with .Values.hub.worker.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.hub.worker.topologySpreadConstraints }}
topologySpreadConstraints:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if .Values.deployment.imagePullSecrets }}
imagePullSecrets:
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
{{- end }}
initContainers:
- name: wait-for-hub-api
image: {{ include "formbricks.hubImage" . }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
command:
- sh
- -c
- |
until wget --no-verbose --tries=1 --spider http://{{ include "formbricks.hubname" . }}:8080/health; do
echo "Waiting for Hub API migrations and health check..."
sleep 5
done
containers:
- name: hub-worker
image: {{ include "formbricks.hubImage" . }}
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
command:
- /app/hub-worker
securityContext:
readOnlyRootFilesystem: true
runAsNonRoot: true
envFrom:
- secretRef:
name: {{ include "formbricks.hubSecretName" . }}
{{- if or .Values.hub.embeddings.enabled (gt (len .Values.hub.env) 0) (gt (len .Values.hub.worker.env) 0) }}
env:
{{- $workerEnv := merge (dict) .Values.hub.env .Values.hub.worker.env }}
{{- include "formbricks.hubEmbeddingEnv" (dict "root" $ "env" $workerEnv) | nindent 12 }}
{{- range $key, $value := .Values.hub.env }}
{{- if and (not (hasKey $.Values.hub.worker.env $key)) (not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key)))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- range $key, $value := .Values.hub.worker.env }}
{{- if not (and $.Values.hub.embeddings.enabled (include "formbricks.hubEmbeddingEnvManaged" (dict "key" $key))) }}
- name: {{ $key }}
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
{{- if .Values.hub.worker.resources }}
resources:
{{- toYaml .Values.hub.worker.resources | nindent 12 }}
{{- end }}
{{- end }}
+235
View File
@@ -588,6 +588,241 @@ hub:
# Optional env vars (non-secret). Use existingSecret for secret values such as DATABASE_URL and HUB_API_KEY.
env: {}
# Optional autoscaling for the Hub API deployment.
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior: {}
# Optional PDB for the Hub API deployment. Disabled by default because a single
# Hub replica with minAvailable: 1 blocks voluntary node drains.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
worker:
# Hub async jobs (webhook dispatch, embeddings) run in hub-worker. Keep this
# enabled unless another worker deployment processes the same River queues.
enabled: true
replicas: 1
# Optional env vars (non-secret) added only to hub-worker.
env: {}
resources:
limits:
memory: 512Mi
requests:
memory: 256Mi
cpu: "100m"
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 5
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 120
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 2
periodSeconds: 60
# Disabled by default because the default worker replica count is 1.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
embeddings:
# Optional self-hosted OpenAI-compatible embeddings runtime for Hub.
enabled: false
runtime: tei
model: google/embeddinggemma-300m
revision: ""
# Defaults to `model` when empty. Used by TEI OpenAI-compatible responses
# and as Hub's EMBEDDING_MODEL.
servedModelName: ""
# Defaults to http://<release>-hub-embeddings:<service.port>/v1 when empty.
baseUrl: ""
maxConcurrent: "5"
normalize: "false"
replicas: 1
image:
repository: "ghcr.io/huggingface/text-embeddings-inference"
tag: "cpu-1.9"
pullPolicy: IfNotPresent
command: []
# When empty, the chart renders TEI args from model, servedModelName, port,
# revision, and persistence.mountPath. Set this to fully override args.
args: []
extraArgs: []
env: {}
port: 8080
prometheusPort: 9000
service:
type: ClusterIP
port: 8080
annotations: {}
additionalLabels: {}
auth:
# TEI can enforce bearer-token auth with API_KEY. Hub always receives the
# same key as EMBEDDING_PROVIDER_API_KEY because the OpenAI-compatible Hub
# provider requires an API key.
enabled: true
existingSecret: ""
secretKey: EMBEDDING_PROVIDER_API_KEY
apiKey: ""
huggingFace:
# Required for gated models such as google/embeddinggemma-300m unless the
# model is pre-cached or a different ungated model is configured.
existingSecret: ""
tokenKey: HF_TOKEN
token: ""
persistence:
enabled: true
existingClaim: ""
storageClass: ""
accessModes:
- ReadWriteOnce
size: 10Gi
mountPath: /data
resources:
requests:
cpu: "4"
memory: 8Gi
limits:
memory: 8Gi
autoscaling:
enabled: false
additionalLabels: {}
annotations: {}
minReplicas: 1
maxReplicas: 2
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 300
scaleUp:
stabilizationWindowSeconds: 120
policies:
- type: Pods
value: 1
periodSeconds: 120
# Disabled by default because the default embeddings replica count is 1.
pdb:
enabled: false
additionalLabels: {}
annotations: {}
minAvailable: 1
# maxUnavailable: 1
# unhealthyPodEvictionPolicy: AlwaysAllow
probes:
startupProbe:
failureThreshold: 60
periodSeconds: 10
tcpSocket:
port: http
readinessProbe:
failureThreshold: 6
periodSeconds: 10
timeoutSeconds: 5
tcpSocket:
port: http
livenessProbe:
failureThreshold: 6
periodSeconds: 20
timeoutSeconds: 5
tcpSocket:
port: http
podAnnotations: {}
podLabels: {}
podSecurityContext: {}
# Keep empty by default because upstream model-serving images may define
# their own user and need write access to the model cache path.
securityContext: {}
nodeSelector: {}
tolerations: []
affinity: {}
topologySpreadConstraints: []
# Helm does not deploy Cube. XM Suite v5 analytics requires operators to provide an external Cube instance,
# set deployment.env.CUBEJS_API_URL, and supply CUBEJS_API_SECRET via an existing secret.
+66
View File
@@ -83,6 +83,30 @@ describe("packages/ai provider helpers", () => {
});
});
test("reports a fully configured Google Cloud instance when ADC provides credentials", () => {
expect(
getAiConfigurationStatus({
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
})
).toEqual({
provider: "google",
model: "gemini-2.5-flash",
isConfigured: true,
missingFields: [],
invalidFields: [],
providerStatus: {
provider: "google",
model: "gemini-2.5-flash",
isConfigured: true,
missingFields: [],
invalidFields: [],
},
});
});
test("treats the instance as not configured when AI_PROVIDER is missing", () => {
expect(
isAiConfigured({
@@ -207,6 +231,48 @@ describe("packages/ai provider helpers", () => {
expect(mocks.createVertex).toHaveBeenCalledTimes(1);
});
test("creates a Google Cloud model with application credentials file", () => {
const vertexProvider = createMockProvider("google");
mocks.createVertex.mockReturnValue(vertexProvider);
const model = getAiModel({
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS: "/tmp/google-cloud.json",
});
expect(model).toEqual({ providerName: "google", modelName: "gemini-2.5-flash" });
expect(mocks.createVertex).toHaveBeenCalledWith({
project: "test-project",
location: "us-central1",
googleAuthOptions: {
keyFilename: "/tmp/google-cloud.json",
},
});
expect(vertexProvider).toHaveBeenCalledWith("gemini-2.5-flash");
});
test("creates a Google Cloud model using ADC when no credential override is configured", () => {
const vertexProvider = createMockProvider("google");
mocks.createVertex.mockReturnValue(vertexProvider);
const model = getAiModel({
AI_PROVIDER: "google",
AI_MODEL: "gemini-2.5-flash",
AI_GOOGLE_CLOUD_PROJECT: "test-project",
AI_GOOGLE_CLOUD_LOCATION: "us-central1",
});
expect(model).toEqual({ providerName: "google", modelName: "gemini-2.5-flash" });
expect(mocks.createVertex).toHaveBeenCalledWith({
project: "test-project",
location: "us-central1",
});
expect(vertexProvider).toHaveBeenCalledWith("gemini-2.5-flash");
});
test("creates an AWS model with explicit AWS credentials", () => {
const bedrockProvider = createMockProvider("aws");
mocks.createAmazonBedrock.mockReturnValue(bedrockProvider);
+2 -10
View File
@@ -39,11 +39,6 @@ export const googleProviderAdapter: AIProviderAdapter = {
}
const credentialsJson = normalizeValue(environment.AI_GOOGLE_CLOUD_CREDENTIALS_JSON);
const applicationCredentials = normalizeValue(environment.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS);
if (!credentialsJson && !applicationCredentials) {
missingFields.push("AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS");
}
if (credentialsJson) {
try {
@@ -73,15 +68,12 @@ export const googleProviderAdapter: AIProviderAdapter = {
const credentialsJson = normalizeValue(environment.AI_GOOGLE_CLOUD_CREDENTIALS_JSON);
const applicationCredentials = normalizeValue(environment.AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS);
if (!project || !location || (!credentialsJson && !applicationCredentials)) {
throw new AIConfigurationError("providerNotConfigured", "Google Cloud AI credentials are incomplete", {
if (!project || !location) {
throw new AIConfigurationError("providerNotConfigured", "Google Cloud AI configuration is incomplete", {
provider: "google",
missingFields: [
...(!project ? ["AI_GOOGLE_CLOUD_PROJECT"] : []),
...(!location ? ["AI_GOOGLE_CLOUD_LOCATION"] : []),
...(!credentialsJson && !applicationCredentials
? ["AI_GOOGLE_CLOUD_CREDENTIALS_JSON or AI_GOOGLE_CLOUD_APPLICATION_CREDENTIALS"]
: []),
],
});
}
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "ApiKeyFeedbackDirectory" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"apiKeyId" TEXT NOT NULL,
"feedbackDirectoryId" TEXT NOT NULL,
"permission" "ApiKeyPermission" NOT NULL,
CONSTRAINT "ApiKeyFeedbackDirectory_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ApiKeyFeedbackDirectory_apiKeyId_feedbackDirectoryId_key" ON "ApiKeyFeedbackDirectory"("apiKeyId", "feedbackDirectoryId");
-- CreateIndex
CREATE INDEX "ApiKeyFeedbackDirectory_feedbackDirectoryId_idx" ON "ApiKeyFeedbackDirectory"("feedbackDirectoryId");
-- AddForeignKey
ALTER TABLE "ApiKeyFeedbackDirectory" ADD CONSTRAINT "ApiKeyFeedbackDirectory_apiKeyId_fkey" FOREIGN KEY ("apiKeyId") REFERENCES "ApiKey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ApiKeyFeedbackDirectory" ADD CONSTRAINT "ApiKeyFeedbackDirectory_feedbackDirectoryId_fkey" FOREIGN KEY ("feedbackDirectoryId") REFERENCES "FeedbackDirectory"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+34 -11
View File
@@ -757,18 +757,19 @@ model Invite {
/// @property lastUsedAt - Timestamp of last usage
/// @property apiKeyWorkspaces - Workspaces this key has access to
model ApiKey {
id String @id @default(cuid())
createdAt DateTime @default(now())
createdBy String?
lastUsedAt DateTime?
label String
hashedKey String
lookupHash String? @unique
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
apiKeyWorkspaces ApiKeyWorkspace[]
id String @id @default(cuid())
createdAt DateTime @default(now())
createdBy String?
lastUsedAt DateTime?
label String
hashedKey String
lookupHash String? @unique
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
apiKeyWorkspaces ApiKeyWorkspace[]
apiKeyFeedbackDirectories ApiKeyFeedbackDirectory[]
/// [OrganizationAccess]
organizationAccess Json @default("{}")
organizationAccess Json @default("{}")
@@index([organizationId])
}
@@ -802,6 +803,27 @@ model ApiKeyWorkspace {
@@index([workspaceId])
}
/// Links API keys to feedback directories with specific permissions.
/// Enables granular access control for API keys across feedback directories (Hub API).
///
/// @property id - Unique identifier for the directory access entry
/// @property apiKey - The associated API key
/// @property feedbackDirectory - The directory being accessed
/// @property permission - Level of access granted
model ApiKeyFeedbackDirectory {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
apiKeyId String
apiKey ApiKey @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
feedbackDirectoryId String
feedbackDirectory FeedbackDirectory @relation(fields: [feedbackDirectoryId], references: [id], onDelete: Cascade)
permission ApiKeyPermission
@@unique([apiKeyId, feedbackDirectoryId])
@@index([feedbackDirectoryId])
}
enum IdentityProvider {
email
github
@@ -1270,6 +1292,7 @@ model FeedbackDirectory {
workspaces FeedbackDirectoryWorkspace[]
connectors Connector[]
charts Chart[]
apiKeys ApiKeyFeedbackDirectory[]
@@unique([organizationId, name])
}
+9
View File
@@ -15,9 +15,18 @@ export const ZAPIKeyWorkspacePermission = z.object({
export type TAPIKeyWorkspacePermission = z.infer<typeof ZAPIKeyWorkspacePermission>;
export const ZAPIKeyFeedbackDirectoryPermission = z.object({
feedbackDirectoryId: z.cuid2(),
feedbackDirectoryName: z.string(),
permission: z.enum(ApiKeyPermission),
});
export type TAPIKeyFeedbackDirectoryPermission = z.infer<typeof ZAPIKeyFeedbackDirectoryPermission>;
export const ZAuthenticationApiKey = z.object({
type: z.literal("apiKey"),
workspacePermissions: z.array(ZAPIKeyWorkspacePermission),
feedbackDirectoryPermissions: z.array(ZAPIKeyFeedbackDirectoryPermission),
apiKeyId: z.string(),
organizationId: z.string(),
organizationAccess: ZOrganizationAccess,