From d29ce87d41e14645f7c786274fa83bdfe1e5e2bf Mon Sep 17 00:00:00 2001 From: Justin Tahara <105671973+justin-tahara@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:38:06 +0000 Subject: [PATCH] feat(connectors): data-access group picker for perm-synced connectors (#15033) --- backend/onyx/server/documents/cc_pair.py | 9 ++ backend/onyx/server/documents/connector.py | 14 +++ backend/onyx/server/documents/models.py | 4 + .../channels/SlackChannelConfigFormFields.tsx | 5 +- .../admin/connectors/AccessTypeForm.tsx | 6 + web/src/i18n/messages/ar.json | 19 ++- web/src/i18n/messages/de.json | 19 ++- web/src/i18n/messages/en.json | 19 ++- web/src/i18n/messages/es.json | 19 ++- web/src/i18n/messages/fr.json | 19 ++- web/src/i18n/messages/ja.json | 19 ++- web/src/i18n/messages/ko.json | 19 ++- web/src/i18n/messages/pt.json | 19 ++- web/src/i18n/messages/zh.json | 19 ++- web/src/lib/connectors/accessType.test.ts | 53 ++++++++ web/src/lib/connectors/accessType.ts | 38 ++++++ web/src/lib/connectors/hooks.ts | 17 +++ web/src/lib/connectors/utils.ts | 7 +- web/src/lib/swr-keys.ts | 1 + web/src/lib/types.ts | 2 +- .../ConnectorGroupRestrictionPicker.tsx | 116 ++++++++++++++++++ .../AddConnectorPage/AddConnector.tsx | 13 +- .../connectors/ConnectorDetailPage/index.tsx | 5 +- .../CCPairIndexingStatusTable.tsx | 3 +- 24 files changed, 447 insertions(+), 17 deletions(-) create mode 100644 web/src/lib/connectors/accessType.test.ts create mode 100644 web/src/lib/connectors/accessType.ts create mode 100644 web/src/sections/connectors/ConnectorGroupRestrictionPicker.tsx diff --git a/backend/onyx/server/documents/cc_pair.py b/backend/onyx/server/documents/cc_pair.py index b7b813e7f6..6df02da0c9 100644 --- a/backend/onyx/server/documents/cc_pair.py +++ b/backend/onyx/server/documents/cc_pair.py @@ -814,6 +814,15 @@ def associate_credential_to_connector( if metadata.access_type == AccessType.SYNC_RESTRICTED: # Becomes creatable in the same change that enforces its data-access # groups at query time, so no restricted pair exists without them. + # TODO(evan, ENG-4342): remove this rejection in the enforcement change, + # together with: + # - the allowed-connector query filter and the /chat/file check + # - the creation path: restriction_group_ids, validation, persistence + # (branch jtahara/connector-group-restrictions-creation-path) + # - SYNC-only checks in connector_credential_pair.py: listing + # visibility, tier/source validation, get_all_auto_sync_cc_pairs, + # get_cc_pairs_by_source + # - creating the pair and its data-access rows in one transaction raise OnyxError( OnyxErrorCode.FEATURE_NOT_AVAILABLE, "Restricted perm-synced connectors are not available yet.", diff --git a/backend/onyx/server/documents/connector.py b/backend/onyx/server/documents/connector.py index a6076588de..e34501a5b1 100644 --- a/backend/onyx/server/documents/connector.py +++ b/backend/onyx/server/documents/connector.py @@ -132,6 +132,7 @@ from onyx.server.documents.models import ( ConnectorCredentialPairIdentifier, ConnectorFileInfo, ConnectorFilesResponse, + ConnectorGroupRestrictionsStatus, ConnectorIndexingStatusLite, ConnectorIndexingStatusLiteResponse, ConnectorRequestSubmission, @@ -226,6 +227,19 @@ def upsert_gmail_service_account_credential( return ObjectCreationIdResponse(id=credential.id) +@router.get("/connector-group-restrictions") +def get_connector_group_restrictions_status( + _: User = Depends( + require_permission(Permission.MANAGE_CONNECTORS, allow_scope=True) + ), +) -> ConnectorGroupRestrictionsStatus: + """Whether connector forms offer the data-access group restriction. Scoped + managers read it here because the security settings API is admin-only.""" + return ConnectorGroupRestrictionsStatus( + enabled=get_security_settings().allow_connector_group_restrictions + ) + + @router.get("/admin/connector/google-drive/check-auth/{credential_id}") def check_drive_tokens( credential_id: int, diff --git a/backend/onyx/server/documents/models.py b/backend/onyx/server/documents/models.py index d6cae848bf..bc5d9be2ac 100644 --- a/backend/onyx/server/documents/models.py +++ b/backend/onyx/server/documents/models.py @@ -823,6 +823,10 @@ class AuthStatus(BaseModel): authenticated: bool +class ConnectorGroupRestrictionsStatus(BaseModel): + enabled: bool + + class AuthUrl(BaseModel): auth_url: str diff --git a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx index baa0de316c..d16bb2794c 100644 --- a/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx +++ b/web/src/app/admin/bots/[bot-id]/channels/SlackChannelConfigFormFields.tsx @@ -36,6 +36,7 @@ import { AccordionTrigger, } from "@/components/ui/accordion"; import { CheckboxField } from "@/refresh-components/form/LabeledCheckboxField"; +import { isPermSynced } from "@/lib/connectors/accessType"; export interface SlackChannelConfigFormFieldsProps { isUpdate: boolean; @@ -67,8 +68,8 @@ export function SlackChannelConfigFormFields({ // Helper function to check if a document set contains sync connectors const documentSetContainsSync = (documentSet: DocumentSetSummary) => { - return documentSet.cc_pair_summaries.some( - (summary) => summary.access_type === "sync" + return documentSet.cc_pair_summaries.some((summary) => + isPermSynced(summary.access_type) ); }; diff --git a/web/src/components/admin/connectors/AccessTypeForm.tsx b/web/src/components/admin/connectors/AccessTypeForm.tsx index 493e5715c4..44bc9b261a 100644 --- a/web/src/components/admin/connectors/AccessTypeForm.tsx +++ b/web/src/components/admin/connectors/AccessTypeForm.tsx @@ -8,6 +8,8 @@ import { import { useField } from "formik"; import { useTranslations } from "next-intl"; import { AutoSyncOptions } from "./AutoSyncOptions"; +import { ConnectorGroupRestrictionPicker } from "@/sections/connectors/ConnectorGroupRestrictionPicker"; +import { useConnectorGroupRestrictionsEnabled } from "@/lib/connectors/hooks"; import { useTierAtLeast } from "@/hooks/useTierAtLeast"; import { Tier } from "@/lib/settings/types"; import { useEffect, useMemo } from "react"; @@ -42,6 +44,7 @@ export function AccessTypeForm({ // both are Business+ features. const businessTier = useTierAtLeast(Tier.BUSINESS); const showAutoSync = businessTier && isValidAutoSyncSource(connector); + const groupRestrictionsEnabled = useConnectorGroupRestrictionsEnabled(); const selectedAuthMethod = currentCredential?.credential_json?.[ "authentication_method" @@ -151,6 +154,9 @@ export function AccessTypeForm({ } includeDefault={false} /> + {access_type.value === "sync" && + showAutoSync && + groupRestrictionsEnabled && } {access_type.value === "sync" && showAutoSync && ( )} diff --git a/web/src/i18n/messages/ar.json b/web/src/i18n/messages/ar.json index 54de0d0950..1e3c7ab9e2 100644 --- a/web/src/i18n/messages/ar.json +++ b/web/src/i18n/messages/ar.json @@ -2152,12 +2152,29 @@ "title": "خطأ في مزامنة عضويات المجموعات" } }, + "groupRestriction": { + "empty": { + "description": "يحتفظ الجميع بالوصول المتزامن من المصدر.", + "title": "لا توجد قيود حتى تتم إضافة مجموعات" + }, + "memberCount": "{count, plural, zero {لا أعضاء} one {عضو واحد} two {عضوان} few {# أعضاء} many {# عضوًا} other {# عضو}}", + "picker": { + "placeholder": "أضف مجموعات لتقييد الوصول إلى هذا الموصل" + }, + "remove": { + "tooltip": "إزالة {name}" + }, + "toggle": { + "description": "قصر الوصول على أعضاء المجموعات المحددة. تظل الأذونات المتزامنة سارية.", + "title": "تقييد الوصول بالمجموعات" + } + }, "groupSelector": { "assignGroupAccess": { "title": "إسناد وصول المجموعات لهذا الموصّل" }, "assignToGroup": { - "title": "إسناد هذا الموصّل إلى مجموعة" + "title": "المجموعات التي يمكنها إدارة هذا الموصل" }, "scopedManagerGroups": { "description": "حدد مجموعة أو أكثر من المجموعات التي تديرها لمنح حق الوصول إلى هذا الموصّل" diff --git a/web/src/i18n/messages/de.json b/web/src/i18n/messages/de.json index c8e6d89ae8..8f036c0758 100644 --- a/web/src/i18n/messages/de.json +++ b/web/src/i18n/messages/de.json @@ -2152,12 +2152,29 @@ "title": "Fehler bei der Synchronisierung der Gruppenmitgliedschaften" } }, + "groupRestriction": { + "empty": { + "description": "Alle behalten den aus der Quelle synchronisierten Zugriff.", + "title": "Keine Beschränkung, bis Gruppen hinzugefügt werden" + }, + "memberCount": "{count, plural, one {# Mitglied} other {# Mitglieder}}", + "picker": { + "placeholder": "Gruppen hinzufügen, um den Zugriff auf diesen Connector zu beschränken" + }, + "remove": { + "tooltip": "{name} entfernen" + }, + "toggle": { + "description": "Zugriff auf Mitglieder der ausgewählten Gruppen beschränken. Synchronisierte Berechtigungen gelten weiterhin.", + "title": "Zugriff auf Gruppen beschränken" + } + }, "groupSelector": { "assignGroupAccess": { "title": "Gruppenzugriff für diesen Konnektor zuweisen" }, "assignToGroup": { - "title": "Diesen Konnektor einer Gruppe zuweisen" + "title": "Gruppen, die diesen Connector verwalten können" }, "scopedManagerGroups": { "description": "Wähle eine oder mehrere der von dir verwalteten Gruppen aus, um Zugriff auf diesen Konnektor zu gewähren" diff --git a/web/src/i18n/messages/en.json b/web/src/i18n/messages/en.json index eee4a74a7c..29c48a65e7 100644 --- a/web/src/i18n/messages/en.json +++ b/web/src/i18n/messages/en.json @@ -2152,12 +2152,29 @@ "title": "Group Membership Sync Error" } }, + "groupRestriction": { + "empty": { + "description": "Everyone keeps the access synced from the source.", + "title": "No restriction until groups are added" + }, + "memberCount": "{count, plural, one {# member} other {# members}}", + "picker": { + "placeholder": "Add groups to restrict access to this connector" + }, + "remove": { + "tooltip": "Remove {name}" + }, + "toggle": { + "description": "Limit access to members of selected groups. Synced permissions still apply.", + "title": "Restrict Access to Groups" + } + }, "groupSelector": { "assignGroupAccess": { "title": "Assign group access for this Connector" }, "assignToGroup": { - "title": "Assign this Connector to a group" + "title": "Groups that can manage this Connector" }, "scopedManagerGroups": { "description": "Select one or more of the groups you manage to give access to this Connector" diff --git a/web/src/i18n/messages/es.json b/web/src/i18n/messages/es.json index 173865b1d3..fb65daac7a 100644 --- a/web/src/i18n/messages/es.json +++ b/web/src/i18n/messages/es.json @@ -2152,12 +2152,29 @@ "title": "Error de sincronización de pertenencia a grupos" } }, + "groupRestriction": { + "empty": { + "description": "Todos conservan el acceso sincronizado desde el origen.", + "title": "Sin restricción hasta que se añadan grupos" + }, + "memberCount": "{count, plural, one {# miembro} other {# miembros}}", + "picker": { + "placeholder": "Añade grupos para restringir el acceso a este conector" + }, + "remove": { + "tooltip": "Quitar {name}" + }, + "toggle": { + "description": "Limita el acceso a los miembros de los grupos seleccionados. Los permisos sincronizados siguen aplicándose.", + "title": "Restringir acceso a grupos" + } + }, "groupSelector": { "assignGroupAccess": { "title": "Asignar acceso de grupos a este conector" }, "assignToGroup": { - "title": "Asignar este conector a un grupo" + "title": "Grupos que pueden administrar este conector" }, "scopedManagerGroups": { "description": "Selecciona uno o más de los grupos que administras para dar acceso a este conector" diff --git a/web/src/i18n/messages/fr.json b/web/src/i18n/messages/fr.json index 98aa29ef88..aaae8a07d7 100644 --- a/web/src/i18n/messages/fr.json +++ b/web/src/i18n/messages/fr.json @@ -2152,12 +2152,29 @@ "title": "Erreur de synchronisation de l'appartenance aux groupes" } }, + "groupRestriction": { + "empty": { + "description": "Tout le monde conserve l'accès synchronisé depuis la source.", + "title": "Aucune restriction tant qu'aucun groupe n'est ajouté" + }, + "memberCount": "{count, plural, one {# membre} other {# membres}}", + "picker": { + "placeholder": "Ajouter des groupes pour restreindre l'accès à ce connecteur" + }, + "remove": { + "tooltip": "Retirer {name}" + }, + "toggle": { + "description": "Limite l'accès aux membres des groupes sélectionnés. Les permissions synchronisées s'appliquent toujours.", + "title": "Restreindre l'accès à des groupes" + } + }, "groupSelector": { "assignGroupAccess": { "title": "Attribuer l'accès des groupes à ce connecteur" }, "assignToGroup": { - "title": "Attribuer ce connecteur à un groupe" + "title": "Groupes pouvant gérer ce connecteur" }, "scopedManagerGroups": { "description": "Sélectionnez un ou plusieurs des groupes que vous gérez pour donner accès à ce connecteur" diff --git a/web/src/i18n/messages/ja.json b/web/src/i18n/messages/ja.json index b0d60f72f5..5541e13a6b 100644 --- a/web/src/i18n/messages/ja.json +++ b/web/src/i18n/messages/ja.json @@ -2152,12 +2152,29 @@ "title": "グループメンバーシップの同期エラー" } }, + "groupRestriction": { + "empty": { + "description": "全員がソースから同期されたアクセス権を保持します。", + "title": "グループを追加するまで制限はありません" + }, + "memberCount": "{count, plural, other {#人のメンバー}}", + "picker": { + "placeholder": "このコネクタへのアクセスを制限するグループを追加" + }, + "remove": { + "tooltip": "{name}を削除" + }, + "toggle": { + "description": "選択したグループのメンバーにアクセスを限定します。同期された権限も引き続き適用されます。", + "title": "グループへのアクセス制限" + } + }, "groupSelector": { "assignGroupAccess": { "title": "このコネクタへのグループアクセスを割り当てる" }, "assignToGroup": { - "title": "このコネクタをグループに割り当てる" + "title": "このコネクタを管理できるグループ" }, "scopedManagerGroups": { "description": "このコネクタへのアクセスを付与するには、管理しているグループを1つ以上選択してください" diff --git a/web/src/i18n/messages/ko.json b/web/src/i18n/messages/ko.json index 48df1055eb..7f05ee97ed 100644 --- a/web/src/i18n/messages/ko.json +++ b/web/src/i18n/messages/ko.json @@ -2152,12 +2152,29 @@ "title": "그룹 멤버십 동기화 오류" } }, + "groupRestriction": { + "empty": { + "description": "모든 사용자가 소스에서 동기화된 접근 권한을 유지합니다.", + "title": "그룹을 추가하기 전까지 제한 없음" + }, + "memberCount": "{count, plural, other {멤버 #명}}", + "picker": { + "placeholder": "이 커넥터에 대한 접근을 제한할 그룹 추가" + }, + "remove": { + "tooltip": "{name} 제거" + }, + "toggle": { + "description": "선택한 그룹의 멤버로 접근을 제한합니다. 동기화된 권한은 계속 적용됩니다.", + "title": "그룹으로 접근 제한" + } + }, "groupSelector": { "assignGroupAccess": { "title": "이 커넥터에 대한 그룹 액세스 할당" }, "assignToGroup": { - "title": "이 커넥터를 그룹에 할당" + "title": "이 커넥터를 관리할 수 있는 그룹" }, "scopedManagerGroups": { "description": "이 커넥터에 대한 액세스 권한을 부여하려면 관리 중인 그룹을 하나 이상 선택하세요" diff --git a/web/src/i18n/messages/pt.json b/web/src/i18n/messages/pt.json index 76e86a0afd..483bb90d4a 100644 --- a/web/src/i18n/messages/pt.json +++ b/web/src/i18n/messages/pt.json @@ -2152,12 +2152,29 @@ "title": "Erro na sincronização das associações de grupo" } }, + "groupRestriction": { + "empty": { + "description": "Todos mantêm o acesso sincronizado da origem.", + "title": "Sem restrição até que grupos sejam adicionados" + }, + "memberCount": "{count, plural, one {# membro} other {# membros}}", + "picker": { + "placeholder": "Adicione grupos para restringir o acesso a este conector" + }, + "remove": { + "tooltip": "Remover {name}" + }, + "toggle": { + "description": "Limite o acesso aos membros dos grupos selecionados. As permissões sincronizadas continuam valendo.", + "title": "Restringir acesso a grupos" + } + }, "groupSelector": { "assignGroupAccess": { "title": "Atribuir acesso de grupos a este conector" }, "assignToGroup": { - "title": "Atribuir este conector a um grupo" + "title": "Grupos que podem gerenciar este conector" }, "scopedManagerGroups": { "description": "Selecione um ou mais dos grupos que você gerencia para dar acesso a este conector" diff --git a/web/src/i18n/messages/zh.json b/web/src/i18n/messages/zh.json index 3dc85b56a1..ac80115e8a 100644 --- a/web/src/i18n/messages/zh.json +++ b/web/src/i18n/messages/zh.json @@ -2152,12 +2152,29 @@ "title": "用户组成员同步错误" } }, + "groupRestriction": { + "empty": { + "description": "所有人保留从源系统同步的访问权限。", + "title": "添加群组前不设限制" + }, + "memberCount": "{count, plural, other {# 名成员}}", + "picker": { + "placeholder": "添加群组以限制对此连接器的访问" + }, + "remove": { + "tooltip": "移除 {name}" + }, + "toggle": { + "description": "仅允许所选群组的成员访问。同步的权限仍然适用。", + "title": "限制为群组访问" + } + }, "groupSelector": { "assignGroupAccess": { "title": "为此连接器分配组访问权限" }, "assignToGroup": { - "title": "将此连接器分配给一个组" + "title": "可以管理此连接器的群组" }, "scopedManagerGroups": { "description": "选择一个或多个您管理的组以授予对此连接器的访问权限" diff --git a/web/src/lib/connectors/accessType.test.ts b/web/src/lib/connectors/accessType.test.ts new file mode 100644 index 0000000000..efa53721e3 --- /dev/null +++ b/web/src/lib/connectors/accessType.test.ts @@ -0,0 +1,53 @@ +import { isPermSynced, toWireAccess } from "@/lib/connectors/accessType"; + +describe("toWireAccess", () => { + it("restricts a synced connector when groups are chosen", () => { + expect( + toWireAccess("sync", { + restrict_access_to_groups: true, + restriction_group_ids: [3, 7], + }) + ).toEqual({ + access_type: "sync_restricted", + restriction_group_ids: [3, 7], + }); + }); + + it("keeps plain sync when the switch is on but no groups are chosen", () => { + expect( + toWireAccess("sync", { + restrict_access_to_groups: true, + restriction_group_ids: [], + }) + ).toEqual({ access_type: "sync", restriction_group_ids: [] }); + }); + + it("drops stale groups when the switch is off", () => { + expect( + toWireAccess("sync", { + restrict_access_to_groups: false, + restriction_group_ids: [3], + }) + ).toEqual({ access_type: "sync", restriction_group_ids: [] }); + }); + + it("never restricts non-synced access types", () => { + for (const accessType of ["public", "private"] as const) { + expect( + toWireAccess(accessType, { + restrict_access_to_groups: true, + restriction_group_ids: [3], + }) + ).toEqual({ access_type: accessType, restriction_group_ids: [] }); + } + }); +}); + +describe("isPermSynced", () => { + it("treats both synced types as synced", () => { + expect(isPermSynced("sync")).toBe(true); + expect(isPermSynced("sync_restricted")).toBe(true); + expect(isPermSynced("private")).toBe(false); + expect(isPermSynced("public")).toBe(false); + }); +}); diff --git a/web/src/lib/connectors/accessType.ts b/web/src/lib/connectors/accessType.ts new file mode 100644 index 0000000000..70f5a8cfcf --- /dev/null +++ b/web/src/lib/connectors/accessType.ts @@ -0,0 +1,38 @@ +import type { AccessType } from "@/lib/types"; + +// Perm sync plus a data-access group restriction (ENG-4342). The wire name is +// provisional until the connector editing initiative confirms it. +export const SYNC_RESTRICTED_ACCESS_TYPE = + "sync_restricted" satisfies AccessType; + +export function isPermSynced(accessType: AccessType): boolean { + return accessType === "sync" || accessType === SYNC_RESTRICTED_ACCESS_TYPE; +} + +export interface ConnectorGroupRestrictionFormValues { + restrict_access_to_groups: boolean; + restriction_group_ids: number[]; +} + +export interface WireAccess { + access_type: AccessType; + restriction_group_ids: number[]; +} + +// The form keeps "sync" plus a restriction flag, so the dropdown never sees the +// fourth value. A switched-on restriction with no groups restricts nobody. +export function toWireAccess( + accessType: AccessType, + restriction: ConnectorGroupRestrictionFormValues +): WireAccess { + const restricted = + accessType === "sync" && + restriction.restrict_access_to_groups && + restriction.restriction_group_ids.length > 0; + return restricted + ? { + access_type: SYNC_RESTRICTED_ACCESS_TYPE, + restriction_group_ids: restriction.restriction_group_ids, + } + : { access_type: accessType, restriction_group_ids: [] }; +} diff --git a/web/src/lib/connectors/hooks.ts b/web/src/lib/connectors/hooks.ts index d3b8506f94..aac770e75b 100644 --- a/web/src/lib/connectors/hooks.ts +++ b/web/src/lib/connectors/hooks.ts @@ -245,3 +245,20 @@ export function useFederatedConnector( error, }; } + +interface ConnectorGroupRestrictionsStatus { + enabled: boolean; +} + +/** + * Whether connector forms offer the data-access group restriction. Set by the + * workspace toggle in Security and Hardening. Fails closed: hidden until the + * setting loads, and hidden if the request fails. + */ +export function useConnectorGroupRestrictionsEnabled(): boolean { + const { data } = useSWR( + SWR_KEYS.connectorGroupRestrictions, + errorHandlingFetcher + ); + return data?.enabled ?? false; +} diff --git a/web/src/lib/connectors/utils.ts b/web/src/lib/connectors/utils.ts index cc7a2f8fc3..abfc17b97d 100644 --- a/web/src/lib/connectors/utils.ts +++ b/web/src/lib/connectors/utils.ts @@ -1,5 +1,6 @@ import * as Yup from "yup"; import type { AccessTypeGroupSelectorFormType } from "@/components/admin/connectors/AccessTypeGroupSelector"; +import type { ConnectorGroupRestrictionFormValues } from "@/lib/connectors/accessType"; import type { ConfigurableSources, IndexAttemptStage, @@ -60,13 +61,17 @@ const buildInitialValuesForFields = ( export function createConnectorInitialValues( connector: ConfigurableSources -): Record & AccessTypeGroupSelectorFormType { +): Record & + AccessTypeGroupSelectorFormType & + ConnectorGroupRestrictionFormValues { const configuration = connectorConfigs[connector]; return { name: "", groups: [], access_type: "public", + restrict_access_to_groups: false, + restriction_group_ids: [], ...buildInitialValuesForFields(configuration.values), ...buildInitialValuesForFields(configuration.advanced_values), }; diff --git a/web/src/lib/swr-keys.ts b/web/src/lib/swr-keys.ts index 0f8fcc4b7f..13901a64c5 100644 --- a/web/src/lib/swr-keys.ts +++ b/web/src/lib/swr-keys.ts @@ -71,6 +71,7 @@ export const SWR_KEYS = { indexingStatus: "/api/manage/admin/connector/indexing-status", adminConnectorStatus: "/api/manage/admin/connector/status", federatedConnectors: "/api/federated", + connectorGroupRestrictions: "/api/manage/connector-group-restrictions", // ── Google Connectors ───────────────────────────────────────────────────── googleConnectorCredentials: (service: "gmail" | "google-drive") => diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index f42e8ac365..a113027704 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -184,7 +184,7 @@ export type ValidStatuses = | "not_started"; export type TaskStatus = "PENDING" | "STARTED" | "SUCCESS" | "FAILURE"; export type Feedback = "like" | "dislike" | "mixed"; -export type AccessType = "public" | "private" | "sync"; +export type AccessType = "public" | "private" | "sync" | "sync_restricted"; export type ProcessingMode = "REGULAR"; export type SessionType = "Chat" | "Search" | "Slack"; diff --git a/web/src/sections/connectors/ConnectorGroupRestrictionPicker.tsx b/web/src/sections/connectors/ConnectorGroupRestrictionPicker.tsx new file mode 100644 index 0000000000..235e5ac545 --- /dev/null +++ b/web/src/sections/connectors/ConnectorGroupRestrictionPicker.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useField } from "formik"; +import { useTranslations } from "next-intl"; +import { + Button, + Card, + EmptyMessageCard, + InputSingleComboBox, + InputSwitch, +} from "@opal/components"; +import { ContentAction, InputHorizontal } from "@opal/layouts"; +import { SvgUsers, SvgX } from "@opal/icons"; +import { useUserGroups } from "@/lib/hooks"; +import type { ConnectorGroupRestrictionFormValues } from "@/lib/connectors/accessType"; + +type FieldName = keyof ConnectorGroupRestrictionFormValues; + +const RESTRICT_FIELD: FieldName = "restrict_access_to_groups"; +const GROUP_IDS_FIELD: FieldName = "restriction_group_ids"; + +/** + * Data-access restriction for a perm-synced connector: only members of the + * chosen groups can read its documents, on top of the source's permissions. + * Separate from the management-access groups, which decide who can configure + * the connector. + */ +export function ConnectorGroupRestrictionPicker() { + const t = useTranslations("admin.connector.groupRestriction"); + const { data: userGroups } = useUserGroups(); + const [restrict, , restrictHelpers] = useField(RESTRICT_FIELD); + const [groupIds, , groupIdsHelpers] = useField(GROUP_IDS_FIELD); + + const selectedIds = new Set(groupIds.value); + const selectedGroups = (userGroups ?? []).filter((group) => + selectedIds.has(group.id) + ); + const options = (userGroups ?? []) + .filter((group) => !selectedIds.has(group.id)) + .map((group) => ({ + value: String(group.id), + title: group.name, + description: t("memberCount", { count: group.users.length }), + })); + + function addGroup(value: string) { + const id = Number(value); + if (!Number.isInteger(id) || selectedIds.has(id)) return; + void groupIdsHelpers.setValue([...groupIds.value, id]); + } + + function removeGroup(id: number) { + void groupIdsHelpers.setValue( + groupIds.value.filter((groupId) => groupId !== id) + ); + } + + return ( +
+ + void restrictHelpers.setValue(checked)} + /> + + + {restrict.value && ( + <> + + + {selectedGroups.length === 0 ? ( + + ) : ( +
+ {selectedGroups.map((group) => ( + + removeGroup(group.id)} + /> + } + /> + + ))} +
+ )} + + )} +
+ ); +} diff --git a/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx b/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx index 35e574086b..aae76a3e77 100644 --- a/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx +++ b/web/src/views/admin/connectors/AddConnectorPage/AddConnector.tsx @@ -66,6 +66,7 @@ import { deleteConnector } from "@/lib/connector"; import ConnectorDocsLink from "@/components/admin/connectors/ConnectorDocsLink"; import { SvgArrowExchange, SvgKey, SvgSimpleLoader } from "@opal/icons"; import { useTranslations } from "next-intl"; +import { toWireAccess } from "@/lib/connectors/accessType"; export interface AdvancedConfig { refreshFreq: number; @@ -334,7 +335,9 @@ export default function AddConnector({ const { name, groups, - access_type, + access_type: formAccessType, + restrict_access_to_groups, + restriction_group_ids, pruneFreq, indexingStart, refreshFreq, @@ -342,6 +345,12 @@ export default function AddConnector({ ...connector_specific_config } = values; + const wireAccess = toWireAccess(formAccessType, { + restrict_access_to_groups, + restriction_group_ids, + }); + const access_type = wireAccess.access_type; + // Apply special transforms according to application logic const transformedConnectorSpecificConfig = Object.entries( connector_specific_config @@ -473,6 +482,8 @@ export default function AddConnector({ currentCredential || liveGDriveCredential || liveGmailCredential; + // TODO(evan, ENG-4342): send wireAccess.restriction_group_ids + // once the backend accepts them; this call creates the cc-pair. const linkCredentialResponse = await linkCredential( response.id, credential!.id, diff --git a/web/src/views/admin/connectors/ConnectorDetailPage/index.tsx b/web/src/views/admin/connectors/ConnectorDetailPage/index.tsx index d9f08721e7..60e4d8f2a2 100644 --- a/web/src/views/admin/connectors/ConnectorDetailPage/index.tsx +++ b/web/src/views/admin/connectors/ConnectorDetailPage/index.tsx @@ -69,6 +69,7 @@ import { useUser } from "@/providers/UserProvider"; import { resolveAllErrorsForCCPair } from "@/lib/targeted_reindex"; import { SWR_KEYS } from "@/lib/swr-keys"; import { can } from "@/lib/permissions/resource-actions"; +import { isPermSynced } from "@/lib/connectors/accessType"; // synchronize these validations with the SQLAlchemy connector class until we have a // centralized schema for both frontend and backend const RefreshFrequencySchema = Yup.object().shape({ @@ -666,7 +667,7 @@ function Main({ ccPairId }: { ccPairId: number }) { - {ccPair.access_type === "sync" && ( + {isPermSynced(ccPair.access_type) && ( <>
{/* TODO: Remove className and switch to text03 once Text is fully integrated across this page */} @@ -783,7 +784,7 @@ function Main({ ccPairId }: { ccPairId: number }) { )} {indexAttempts && - (ccPair.access_type === "sync" ? ( + (isPermSynced(ccPair.access_type) ? (
{t("status.access.organizationPublic.label")} - ) : ccPairsIndexingStatus.access_type === "sync" ? ( + ) : isPermSynced(ccPairsIndexingStatus.access_type) ? (