From 473a5a9b613b1a19bcf1d08f053219e5ceeba561 Mon Sep 17 00:00:00 2001 From: Rogger Vasquez Date: Fri, 24 Jul 2026 14:08:17 -0700 Subject: [PATCH 1/4] fe/shadowlink: add role sync options to create flow The core admin API now exposes RoleSyncOptions on shadow link configurations, but the create flow had no way to choose which RBAC roles a link shadows. Add a "Shadow roles" card above the consumer groups section, mirroring its all/specify filter pattern. An untouched form sends a single "*" include filter so all roles sync by default, matching the consumer groups behavior. Interval and paused are left unset so the backend applies its defaults. Filter tab labels were hardcoded to "topics"; they now follow the resource type, and the role name placeholder tracks the selected pattern tab. --- .../configuration/configuration-step.tsx | 2 + .../consumer-offset-step.test.tsx | 4 +- .../create/configuration/filter-item.tsx | 12 +- .../create/configuration/roles-step.test.tsx | 262 ++++++++++++++++++ .../create/configuration/roles-step.tsx | 189 +++++++++++++ .../pages/shadowlinks/create/model.ts | 12 + .../create/shadowlink-create-page.tsx | 17 +- .../pages/shadowlinks/mappers/controlplane.ts | 5 + .../pages/shadowlinks/mappers/dataplane.ts | 3 + .../pages/shadowlinks/shadowlink-helpers.ts | 10 +- 10 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/pages/shadowlinks/create/configuration/roles-step.test.tsx create mode 100644 frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx diff --git a/frontend/src/components/pages/shadowlinks/create/configuration/configuration-step.tsx b/frontend/src/components/pages/shadowlinks/create/configuration/configuration-step.tsx index db1513ffca..f58eb7d7d7 100644 --- a/frontend/src/components/pages/shadowlinks/create/configuration/configuration-step.tsx +++ b/frontend/src/components/pages/shadowlinks/create/configuration/configuration-step.tsx @@ -11,6 +11,7 @@ import { AclsStep } from './acls-step'; import { ConsumerOffsetStep } from './consumer-offset-step'; +import { RolesStep } from './roles-step'; import { SchemaRegistryStep } from './schema-registry-step'; import { TopicsStep } from './topics-step'; @@ -18,6 +19,7 @@ export const ConfigurationStep = () => (
+
diff --git a/frontend/src/components/pages/shadowlinks/create/configuration/consumer-offset-step.test.tsx b/frontend/src/components/pages/shadowlinks/create/configuration/consumer-offset-step.test.tsx index 8d63e8da99..a9de26e3a2 100644 --- a/frontend/src/components/pages/shadowlinks/create/configuration/consumer-offset-step.test.tsx +++ b/frontend/src/components/pages/shadowlinks/create/configuration/consumer-offset-step.test.tsx @@ -69,7 +69,9 @@ describe('ConsumerOffsetStep', () => { expect(screen.getByTestId('consumer-filter-0-exclude-prefix')).toBeInTheDocument(); // Verify the text content of the tabs - expect(screen.getByTestId('consumer-filter-0-include-specific')).toHaveTextContent('Include specific topics'); + expect(screen.getByTestId('consumer-filter-0-include-specific')).toHaveTextContent( + 'Include specific consumer groups' + ); expect(screen.getByTestId('consumer-filter-0-include-prefix')).toHaveTextContent('Include starting with'); expect(screen.getByTestId('consumer-filter-0-exclude-specific')).toHaveTextContent('Exclude specific'); expect(screen.getByTestId('consumer-filter-0-exclude-prefix')).toHaveTextContent('Exclude starting with'); diff --git a/frontend/src/components/pages/shadowlinks/create/configuration/filter-item.tsx b/frontend/src/components/pages/shadowlinks/create/configuration/filter-item.tsx index 5010042601..36808e630a 100644 --- a/frontend/src/components/pages/shadowlinks/create/configuration/filter-item.tsx +++ b/frontend/src/components/pages/shadowlinks/create/configuration/filter-item.tsx @@ -23,6 +23,12 @@ import { useWatch } from 'react-hook-form'; import { Item } from '../../../../redpanda-ui/components/item'; import { getFilterTypeLabel } from '../../shadowlink-helpers'; +const RESOURCE_TYPE_BY_PREFIX: Record = { + topics: 'topics', + roles: 'roles', + consumers: 'consumer groups', +}; + type FilterItemProps = { control: Control; index: number; @@ -55,11 +61,11 @@ export const FilterItem = ({ const showMatchAllMessage = patternValue === PatternType.LITERAL && filterValue === FilterType.INCLUDE && nameValue === '*'; - const resourceType = fieldNamePrefix === 'topics' ? 'topics' : 'consumer groups'; + const resourceType = RESOURCE_TYPE_BY_PREFIX[fieldNamePrefix] ?? 'consumer groups'; // Resume/summary view (non-editable) if (!viewType) { - const filterLabel = getFilterTypeLabel(patternValue, filterValue); + const filterLabel = getFilterTypeLabel(patternValue, filterValue, resourceType); return (
@@ -148,7 +154,7 @@ export const FilterItem = ({ data-testid={dataTestId ? `${dataTestId}-include-specific` : undefined} value="include-specific" > - Include specific topics + Include specific {resourceType} { + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues, + }); + + return ( +
+ + + + + ); +}; + +describe('RolesStep', () => { + describe('Filter type options', () => { + test('should show all filter type options when in specify roles mode', async () => { + const user = userEvent.setup(); + const customValues: FormValues = { + ...initialValues, + rolesMode: 'specify', + roles: [ + { + name: '', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + render(); + + // Need to open the collapsible to see the editable filters + const toggleButton = screen.getByTestId('roles-toggle-button'); + await user.click(toggleButton); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-0')).toBeInTheDocument(); + }); + + // Verify all filter type tabs are present + expect(screen.getByTestId('role-filter-0-include-specific')).toBeInTheDocument(); + expect(screen.getByTestId('role-filter-0-include-prefix')).toBeInTheDocument(); + expect(screen.getByTestId('role-filter-0-exclude-specific')).toBeInTheDocument(); + expect(screen.getByTestId('role-filter-0-exclude-prefix')).toBeInTheDocument(); + + // Verify the text content of the tabs + expect(screen.getByTestId('role-filter-0-include-specific')).toHaveTextContent('Include specific roles'); + expect(screen.getByTestId('role-filter-0-include-prefix')).toHaveTextContent('Include starting with'); + expect(screen.getByTestId('role-filter-0-exclude-specific')).toHaveTextContent('Exclude specific'); + expect(screen.getByTestId('role-filter-0-exclude-prefix')).toHaveTextContent('Exclude starting with'); + }); + + test('should switch the name placeholder between specific and prefix tabs', async () => { + const user = userEvent.setup(); + const customValues: FormValues = { + ...initialValues, + rolesMode: 'specify', + roles: [ + { + name: '', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + render(); + + // Need to open the collapsible to see the editable filters + const toggleButton = screen.getByTestId('roles-toggle-button'); + await user.click(toggleButton); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-0-name')).toHaveAttribute('placeholder', 'my-role'); + }); + + await user.click(screen.getByTestId('role-filter-0-include-prefix')); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-0-name')).toHaveAttribute('placeholder', 'prefix-'); + }); + + await user.click(screen.getByTestId('role-filter-0-exclude-specific')); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-0-name')).toHaveAttribute('placeholder', 'my-role'); + }); + }); + }); + + describe('Mode toggle', () => { + test('should seed one empty filter when switching from all to specify mode', async () => { + const user = userEvent.setup(); + + // initialValues default to rolesMode 'all' with no filters + render(); + + await user.click(screen.getByTestId('roles-specify-tab')); + + // Switching auto-expands the card and appends a single empty filter + await waitFor(() => { + expect(screen.getByTestId('role-filter-0-name')).toHaveValue(''); + }); + expect(screen.getAllByTestId(ROLE_FILTER_PATTERN)).toHaveLength(1); + }); + + test('should clear filters when switching to all mode and reseed on return', async () => { + const user = userEvent.setup(); + const customValues: FormValues = { + ...initialValues, + rolesMode: 'specify', + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'role-2', + patternType: PatternType.PREFIX, + filterType: FilterType.INCLUDE, + }, + ], + }; + + render(); + + const toggleButton = screen.getByTestId('roles-toggle-button'); + await user.click(toggleButton); + + await waitFor(() => { + expect(screen.getAllByTestId(ROLE_FILTER_PATTERN)).toHaveLength(2); + }); + + // Switching to all discards the filter list entirely + await user.click(screen.getByTestId('roles-all-tab')); + + await waitFor(() => { + expect(screen.queryAllByTestId(ROLE_FILTER_PATTERN)).toHaveLength(0); + }); + + // Coming back to specify seeds a single empty filter, not the old list + await user.click(screen.getByTestId('roles-specify-tab')); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-0-name')).toHaveValue(''); + }); + expect(screen.getAllByTestId(ROLE_FILTER_PATTERN)).toHaveLength(1); + }); + }); + + describe('Multiple filters', () => { + test('should create multiple role filters', async () => { + const user = userEvent.setup(); + const customValues: FormValues = { + ...initialValues, + rolesMode: 'specify', + roles: [ + { + name: '', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + render(); + + // Need to open the collapsible to see the editable filters + const toggleButton = screen.getByTestId('roles-toggle-button'); + await user.click(toggleButton); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-0')).toBeInTheDocument(); + }); + + // Add second filter + const addButton = screen.getByTestId('add-role-filter-button'); + await user.click(addButton); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-1')).toBeInTheDocument(); + }); + + // Verify both filters exist + expect(screen.getByTestId('role-filter-0')).toBeInTheDocument(); + expect(screen.getByTestId('role-filter-1')).toBeInTheDocument(); + }); + }); + + describe('Deleting filters', () => { + test('should delete role filters', async () => { + const user = userEvent.setup(); + const customValues: FormValues = { + ...initialValues, + rolesMode: 'specify', + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'role-2', + patternType: PatternType.PREFIX, + filterType: FilterType.INCLUDE, + }, + ], + }; + + render(); + + // Need to open the collapsible to see the editable filters + const toggleButton = screen.getByTestId('roles-toggle-button'); + await user.click(toggleButton); + + await waitFor(() => { + expect(screen.getByTestId('role-filter-0')).toBeInTheDocument(); + expect(screen.getByTestId('role-filter-1')).toBeInTheDocument(); + }); + + // Delete the first filter + const deleteButton = screen.getByTestId('role-filter-0-delete'); + await user.click(deleteButton); + + await waitFor(() => { + // The second filter should now be at index 0 + expect(screen.getByTestId('role-filter-0')).toBeInTheDocument(); + // The old filter at index 1 should not exist anymore + expect(screen.queryByTestId('role-filter-1')).not.toBeInTheDocument(); + }); + + // Verify only one filter remains + expect(screen.getAllByTestId(ROLE_FILTER_PATTERN)).toHaveLength(1); + }); + }); +}); diff --git a/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx b/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx new file mode 100644 index 0000000000..b478e0ef0c --- /dev/null +++ b/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx @@ -0,0 +1,189 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { Alert, AlertDescription } from 'components/redpanda-ui/components/alert'; +import { Button } from 'components/redpanda-ui/components/button'; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from 'components/redpanda-ui/components/collapsible'; +import { FormControl, FormField, FormItem, FormMessage } from 'components/redpanda-ui/components/form'; +import { Input } from 'components/redpanda-ui/components/input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs'; +import { ChevronDown } from 'lucide-react'; +import { FilterType, PatternType } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; +import { useState } from 'react'; +import { type Control, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; + +import { FilterItem } from './filter-item'; +import type { FormValues } from '../model'; + +const RoleFilterNameInput = ({ control, index }: { control: Control; index: number }) => { + const patternType = useWatch({ control, name: `roles.${index}.patternType` }); + + return ( + ( + + + + + + + )} + /> + ); +}; + +export const RolesStep = () => { + const { + control, + setValue, + formState: { errors }, + } = useFormContext(); + const [isOpen, setIsOpen] = useState(false); + + const rolesMode = useWatch({ control, name: 'rolesMode' }); + const { fields, append, remove, replace } = useFieldArray({ + control, + name: 'roles', + }); + + const handleModeChange = (mode: string) => { + setValue('rolesMode', mode as 'all' | 'specify'); + + // Auto-expand when switching to specify mode + if (mode === 'specify') { + setIsOpen(true); + } + + if (mode === 'specify' && fields.length === 0) { + // Add an empty filter when switching to specify mode + append({ + name: '', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }); + } + + if (mode === 'all') { + replace([]); + } + }; + + return ( + + + + Shadow roles + + + + + } + /> + + + + + + + All roles + + + Specify roles + + + + {/* Resume/summary view when collapsed */} + {!isOpen && rolesMode === 'specify' && fields.length > 0 && ( +
+ {fields.map((field, index) => { + const fieldError = errors.roles?.[index]; + const errorMessage = fieldError?.name?.message; + return ( + remove(index)} + viewType={false} + > + {null} + + ); + })} +
+ )} + + {/* Full editable view when expanded */} + + + + + All roles from the source cluster will be synchronized to the destination cluster. + + + + + +
+ {fields.map((field, index) => ( + remove(index)} + viewType={true} + > + + + ))} + + +
+
+
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/pages/shadowlinks/create/model.ts b/frontend/src/components/pages/shadowlinks/create/model.ts index 12fe620841..56446f013d 100644 --- a/frontend/src/components/pages/shadowlinks/create/model.ts +++ b/frontend/src/components/pages/shadowlinks/create/model.ts @@ -211,6 +211,16 @@ const formSchemaShape = z.object({ topicProperties: z.array(z.string()).optional(), excludeDefault: z.boolean(), + // Role sync (mirrors consumer offset sync; no interval/paused exposed) + rolesMode: z.enum(['all', 'specify']), + roles: z.array( + z.object({ + patternType: z.enum(PatternType), + filterType: z.enum(FilterType), + name: z.string().trim().min(1, 'name is required'), + }) + ), + // Consumer offset sync enableConsumerOffsetSync: z.boolean(), consumersMode: z.enum(['all', 'specify']), @@ -684,6 +694,8 @@ export const initialValues: FormValues = { topics: [], topicProperties: [], excludeDefault: false, + rolesMode: 'all', + roles: [], enableConsumerOffsetSync: false, consumersMode: 'all', consumers: [], diff --git a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx index 698c25259a..e5e496c2f6 100644 --- a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx +++ b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx @@ -22,7 +22,7 @@ import { CreateShadowLinkRequestSchema, FilterType, NameFilterSchema, - PatternType, + RoleSyncOptionsSchema, SecuritySettingsSyncOptionsSchema, ShadowLinkClientOptionsSchema, ShadowLinkConfigurationsSchema, @@ -150,6 +150,20 @@ const buildCreateShadowLinkRequest = (values: FormValues) => { ), }); + // Build role sync options (no interval/paused exposed) + const roleSyncOptions = create(RoleSyncOptionsSchema, { + roleNameFilters: + values.rolesMode === 'all' + ? allNameFilter + : values.roles.map((role) => + create(NameFilterSchema, { + patternType: role.patternType, + filterType: role.filterType, + name: role.name, + }) + ), + }); + // Build security sync options (ACL filters, ignore enabled field) const securitySyncOptions = create(SecuritySettingsSyncOptionsSchema, { aclFilters: values.aclsMode @@ -180,6 +194,7 @@ const buildCreateShadowLinkRequest = (values: FormValues) => { clientOptions, topicMetadataSyncOptions, consumerOffsetSyncOptions, + roleSyncOptions, securitySyncOptions, schemaRegistrySyncOptions, }); diff --git a/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts b/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts index 43a73c64c9..e621ac4fa0 100644 --- a/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts +++ b/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts @@ -445,6 +445,11 @@ export const buildDefaultFormValuesFromControlplane = (shadowLink: ControlplaneS return { name: shadowLink.name ?? '', + // The controlplane proto does not ship role_sync_options yet, so hydrate + // like the dataplane absent case: specify mode with no filters keeps role + // sync disabled on untouched edits + rolesMode: 'specify', + roles: [], ...connectionValues, ...authSettings, ...topicsValues, diff --git a/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts b/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts index 332638c32b..8739d59ac6 100644 --- a/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts +++ b/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts @@ -492,6 +492,9 @@ export const buildDefaultFormValues = (shadowLink: DataplaneShadowLink): FormVal return { name: shadowLink.name || '', + // Static defaults until role sync gets edit support + rolesMode: 'all', + roles: [], ...connectionValues, ...topicsValues, ...consumerGroupsValues, diff --git a/frontend/src/components/pages/shadowlinks/shadowlink-helpers.ts b/frontend/src/components/pages/shadowlinks/shadowlink-helpers.ts index 8284086f47..b9f2f2f445 100644 --- a/frontend/src/components/pages/shadowlinks/shadowlink-helpers.ts +++ b/frontend/src/components/pages/shadowlinks/shadowlink-helpers.ts @@ -13,9 +13,13 @@ import { FilterType, PatternType } from 'protogen/redpanda/core/admin/v2/shadow_ import { ACLOperation, ACLPattern, ACLPermissionType, ACLResource } from 'protogen/redpanda/core/common/v1/acl_pb'; // Helper function to get filter label from pattern and filter type -export const getFilterTypeLabel = (patternType: PatternType, filterType: FilterType): string => { +export const getFilterTypeLabel = ( + patternType: PatternType, + filterType: FilterType, + resourceType = 'topics' +): string => { if (patternType === PatternType.LITERAL && filterType === FilterType.INCLUDE) { - return 'Include specific topics'; + return `Include specific ${resourceType}`; } if (patternType === PatternType.PREFIX && filterType === FilterType.INCLUDE) { return 'Include starting with'; @@ -26,7 +30,7 @@ export const getFilterTypeLabel = (patternType: PatternType, filterType: FilterT if (patternType === PatternType.PREFIX && filterType === FilterType.EXCLUDE) { return 'Exclude starting with'; } - return 'Include specific topics'; + return `Include specific ${resourceType}`; }; // Helper functions to convert ACL enum values to readable labels From ad165062003d041f158d6f21758b90168e1102e6 Mon Sep 17 00:00:00 2001 From: Rogger Vasquez Date: Fri, 24 Jul 2026 14:25:24 -0700 Subject: [PATCH 2/4] fe/shadowlink: show role replication in details view Now the role filter section appears in the details view of the Shadow Link. Map roleNameFilters through the unified model from the dataplane API only; the controlplane proto does not expose role sync yet, so the section is hidden when the data is unavailable instead of showing a misleading "no filters" empty state. Name filter labels now take a resource noun, so role and consumer group rows no longer read "Include specific topics". --- .../config/configuration-shadowing.test.tsx | 60 +++++++++++++++++++ .../config/configuration-shadowing.tsx | 35 +++++++++-- .../pages/shadowlinks/mappers/controlplane.ts | 1 + .../shadowlinks/mappers/dataplane.test.ts | 40 +++++++++++++ .../pages/shadowlinks/mappers/dataplane.ts | 20 +++++++ .../src/components/pages/shadowlinks/model.ts | 9 +++ 6 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.test.tsx diff --git a/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.test.tsx b/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.test.tsx new file mode 100644 index 0000000000..2236d3c785 --- /dev/null +++ b/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.test.tsx @@ -0,0 +1,60 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { FilterType, PatternType } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; +import { render, screen } from 'test-utils'; + +import { ConfigurationShadowing } from './configuration-shadowing'; +import { type UnifiedShadowLink, UnifiedShadowLinkState } from '../../model'; + +const buildShadowLink = (configurations?: UnifiedShadowLink['configurations']): UnifiedShadowLink => ({ + name: 'test-link', + id: 'uid-1', + state: UnifiedShadowLinkState.ACTIVE, + configurations, + tasksStatus: [], + syncedShadowTopicProperties: [], +}); + +describe('ConfigurationShadowing', () => { + test('should render the role replication section with resource-aware filter labels', () => { + const shadowLink = buildShadowLink({ + roleSyncOptions: { + roleNameFilters: [{ name: 'my-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], + }, + }); + + render(); + + expect(screen.getByTestId('role-replication-card')).toBeInTheDocument(); + expect(screen.getByText('Role replication')).toBeInTheDocument(); + expect(screen.getByText('Include specific roles')).toBeInTheDocument(); + expect(screen.getByText('my-role')).toBeInTheDocument(); + }); + + test('should show the empty message when role sync has no filters', () => { + const shadowLink = buildShadowLink({ + roleSyncOptions: { roleNameFilters: [] }, + }); + + render(); + + expect(screen.getByTestId('no-role-replication')).toHaveTextContent('No role filters configured'); + }); + + test('should hide the role replication section when role sync options are unavailable', () => { + const shadowLink = buildShadowLink({}); + + render(); + + expect(screen.queryByTestId('role-replication-card')).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx b/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx index 5d7135ab85..72f0bbdacb 100644 --- a/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx +++ b/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx @@ -29,9 +29,19 @@ export type ConfigurationShadowingProps = { shadowLink: UnifiedShadowLink; }; -// Component to display a single name filter (topic or consumer group) -const NameFilterDisplay = ({ filter, index }: { filter: UnifiedNameFilter; index: number }) => { - const filterLabel = getFilterTypeLabel(filter.patternType, filter.filterType); +// Component to display a single name filter (topic, consumer group, or role) +const NameFilterDisplay = ({ + filter, + index, + resourceType, + testId, +}: { + filter: UnifiedNameFilter; + index: number; + resourceType: string; + testId: string; +}) => { + const filterLabel = getFilterTypeLabel(filter.patternType, filter.filterType, resourceType); return ( @@ -57,11 +67,13 @@ const NameFilterSection = ({ filters, testId, emptyMessage, + resourceType, }: { title: string; filters: UnifiedNameFilter[]; testId: string; emptyMessage: string; + resourceType: string; }) => ( @@ -74,7 +86,9 @@ const NameFilterSection = ({ ))} @@ -176,6 +190,7 @@ const ACLFilterSection = ({ filters }: { filters: UnifiedACLFilter[] }) => { export const ConfigurationShadowing = ({ shadowLink }: ConfigurationShadowingProps) => { const topicSyncOptions = shadowLink.configurations?.topicMetadataSyncOptions; const consumerSyncOptions = shadowLink.configurations?.consumerOffsetSyncOptions; + const roleSyncOptions = shadowLink.configurations?.roleSyncOptions; const securitySyncOptions = shadowLink.configurations?.securitySyncOptions; const schemaRegistrySyncOptions = shadowLink.configurations?.schemaRegistrySyncOptions; @@ -201,10 +216,22 @@ export const ConfigurationShadowing = ({ shadowLink }: ConfigurationShadowingPro {/* ACL Replication Section */} + {/* Role Replication Section (hidden when the source API does not expose role sync) */} + {roleSyncOptions && ( + + )} + {/* Consumer Group Replication Section */} diff --git a/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts b/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts index e621ac4fa0..f1a167e941 100644 --- a/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts +++ b/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts @@ -185,6 +185,7 @@ function mapControlplaneConfigurations(sl: ControlplaneShadowLink): UnifiedShado clientOptions: mapControlplaneClientOptions(sl.clientOptions), topicMetadataSyncOptions: mapControlplaneTopicMetadataSyncOptions(sl.topicMetadataSyncOptions), consumerOffsetSyncOptions: mapControlplaneConsumerOffsetSyncOptions(sl.consumerOffsetSyncOptions), + // roleSyncOptions stays undefined: the controlplane proto does not expose role sync yet securitySyncOptions: mapControlplaneSecuritySyncOptions(sl.securitySyncOptions), schemaRegistrySyncOptions: mapSchemaRegistrySyncOptions(sl.schemaRegistrySyncOptions), }; diff --git a/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts b/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts index c048c8a409..effb4a87f4 100644 --- a/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts +++ b/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts @@ -13,6 +13,8 @@ import { create, type MessageInitShape } from '@bufbuild/protobuf'; import { timestampFromDate } from '@bufbuild/protobuf/wkt'; import { ShadowLinkSchema } from 'protogen/redpanda/api/dataplane/v1/shadowlink_pb'; import { + FilterType, + PatternType, type SchemaRegistrySyncOptionsSchema, UnsupportedSchemaFeaturePolicy, } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; @@ -156,6 +158,44 @@ describe('fromDataplaneShadowLink schema registry sync options', () => { }); }); +describe('fromDataplaneShadowLink role sync options', () => { + test('should map role name filters', () => { + const shadowLink = create(ShadowLinkSchema, { + name: 'test-link', + uid: 'uid-1', + configurations: { + roleSyncOptions: { + roleNameFilters: [ + { name: 'my-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }, + { name: 'prefix-', patternType: PatternType.PREFIX, filterType: FilterType.EXCLUDE }, + ], + }, + }, + }); + + const result = fromDataplaneShadowLink(shadowLink); + + expect(result.configurations?.roleSyncOptions).toEqual({ + roleNameFilters: [ + { name: 'my-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }, + { name: 'prefix-', patternType: PatternType.PREFIX, filterType: FilterType.EXCLUDE }, + ], + }); + }); + + test('should map absent role sync options to undefined', () => { + const shadowLink = create(ShadowLinkSchema, { + name: 'test-link', + uid: 'uid-1', + configurations: {}, + }); + + const result = fromDataplaneShadowLink(shadowLink); + + expect(result.configurations?.roleSyncOptions).toBeUndefined(); + }); +}); + describe('buildDefaultFormValues schema registry hydration', () => { const buildLinkWithConnection = ( schemaRegistrySyncOptions?: MessageInitShape diff --git a/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts b/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts index 8739d59ac6..bc897c05cc 100644 --- a/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts +++ b/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts @@ -20,6 +20,7 @@ import type { AuthenticationConfiguration, ConsumerOffsetSyncOptions, NameFilter, + RoleSyncOptions, SecuritySettingsSyncOptions, ShadowLinkClientOptions, ShadowLinkConfigurations, @@ -41,6 +42,7 @@ import { type UnifiedAuthenticationConfiguration, type UnifiedClientOptions, type UnifiedConsumerOffsetSyncOptions, + type UnifiedRoleSyncOptions, type UnifiedSecuritySyncOptions, type UnifiedShadowLink, type UnifiedShadowLinkConfigurations, @@ -151,6 +153,23 @@ function mapDataplaneConsumerOffsetSyncOptions( }; } +/** + * Map dataplane role sync options to unified type + */ +function mapDataplaneRoleSyncOptions(options: RoleSyncOptions | undefined): UnifiedRoleSyncOptions | undefined { + if (!options) { + return; + } + + return { + roleNameFilters: (options.roleNameFilters ?? []).map((f: NameFilter) => ({ + name: f.name, + patternType: f.patternType, + filterType: f.filterType, + })), + }; +} + /** * Map dataplane security sync options to unified type */ @@ -196,6 +215,7 @@ function mapDataplaneConfigurations( clientOptions: mapDataplaneClientOptions(config.clientOptions), topicMetadataSyncOptions: mapDataplaneTopicMetadataSyncOptions(config.topicMetadataSyncOptions), consumerOffsetSyncOptions: mapDataplaneConsumerOffsetSyncOptions(config.consumerOffsetSyncOptions), + roleSyncOptions: mapDataplaneRoleSyncOptions(config.roleSyncOptions), securitySyncOptions: mapDataplaneSecuritySyncOptions(config.securitySyncOptions), schemaRegistrySyncOptions: mapSchemaRegistrySyncOptions(config.schemaRegistrySyncOptions), }; diff --git a/frontend/src/components/pages/shadowlinks/model.ts b/frontend/src/components/pages/shadowlinks/model.ts index 0903fea43e..ca2a8fe6cd 100644 --- a/frontend/src/components/pages/shadowlinks/model.ts +++ b/frontend/src/components/pages/shadowlinks/model.ts @@ -127,6 +127,13 @@ export type UnifiedConsumerOffsetSyncOptions = { groupFilters: UnifiedNameFilter[]; }; +/** + * Plain TypeScript role sync options interface + */ +export type UnifiedRoleSyncOptions = { + roleNameFilters: UnifiedNameFilter[]; +}; + /** * Plain TypeScript security sync options interface */ @@ -193,6 +200,8 @@ export type UnifiedShadowLinkConfigurations = { clientOptions?: UnifiedClientOptions; topicMetadataSyncOptions?: UnifiedTopicMetadataSyncOptions; consumerOffsetSyncOptions?: UnifiedConsumerOffsetSyncOptions; + /** undefined = not configured, or the source API does not expose role sync (controlplane) */ + roleSyncOptions?: UnifiedRoleSyncOptions; securitySyncOptions?: UnifiedSecuritySyncOptions; schemaRegistrySyncOptions?: UnifiedSchemaRegistrySyncOptions; }; From 5a79fdfc31155b0c5f45b676b099ca12aedfe745 Mon Sep 17 00:00:00 2001 From: Rogger Vasquez Date: Fri, 24 Jul 2026 14:36:50 -0700 Subject: [PATCH 3/4] fe/shadowlink: add role sync editing to edit flow Let users change which RBAC roles a shadow link syncs from the shadowing tab, reusing the create-flow roles card and the consumer groups diffing pattern: a single configurations.role_sync_options mask path is emitted only when the mode or filters changed. A link without role sync options hydrates to specify mode with no filters, so saving an untouched form never enables role syncing. The controlplane proto does not carry role sync yet; its update builder skips the category and the card is hidden in embedded mode so edits cannot be dropped silently. --- .../create/shadowlink-create-page.test.tsx | 44 ++ .../create/shadowlink-create-page.tsx | 46 +- .../config/configuration-shadowing.tsx | 3 +- .../edit/build-update-request.test.ts | 73 ++- .../edit/get-update-values-for-acls.test.ts | 2 + .../get-update-values-for-connection.test.ts | 2 + ...-update-values-for-consumer-groups.test.ts | 2 + .../edit/get-update-values-for-roles.test.ts | 451 ++++++++++++++++++ .../edit/get-update-values-for-topics.test.ts | 2 + .../pages/shadowlinks/edit/shadowing-tab.tsx | 5 + .../shadowlinks/edit/shadowlink-edit-page.tsx | 2 + .../shadowlinks/edit/shadowlink-edit-utils.ts | 88 +++- .../shadowlinks/mappers/dataplane.test.ts | 56 +++ .../pages/shadowlinks/mappers/dataplane.ts | 29 +- .../shadowlinks/shadowlink-test-helpers.ts | 34 +- 15 files changed, 786 insertions(+), 53 deletions(-) create mode 100644 frontend/src/components/pages/shadowlinks/edit/get-update-values-for-roles.test.ts diff --git a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx index 49ea32ac4f..a7a1df630f 100644 --- a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx +++ b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx @@ -71,6 +71,7 @@ import { addACLFilterCreate, addBootstrapServer, addConsumerFilterCreate, + addRoleFilterCreate, addTopicFilterCreate, enableTLS, navigateToConfigurationStep, @@ -105,6 +106,7 @@ type CreateAction = | { type: 'addTopicFilterCreate'; name: string; options?: { patternType?: PatternType; filterType?: FilterType } } | { type: 'addConsumerFilterCreate'; name: string } | { type: 'addACLFilterCreate'; principal: string } + | { type: 'addRoleFilterCreate'; name: string } | { type: 'enableSchemaRegistrySync' }; /** @@ -239,6 +241,9 @@ const performCreateAction = async ( case 'addACLFilterCreate': await addACLFilterCreate(user, scr, action.principal); break; + case 'addRoleFilterCreate': + await addRoleFilterCreate(user, scr, action.name); + break; case 'enableSchemaRegistrySync': { const schemaRegistrySwitch = scr.getByTestId('sr-enable-switch'); await user.click(schemaRegistrySwitch); @@ -282,6 +287,10 @@ const testCases: CreateTestCase[] = [ exp(scramConfig.username).toBe('admin'); exp(scramConfig.password).toBe('admin-secret'); exp(scramConfig.scramMechanism).toBeDefined(); + // Untouched roles default to all: a single wildcard include filter + exp(createRequest.shadowLink.configurations.roleSyncOptions?.roleNameFilters).toEqual([ + exp.objectContaining({ name: '*', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }), + ]); }, }, { @@ -326,6 +335,41 @@ const testCases: CreateTestCase[] = [ exp(topicFilter.filterType).toBe(FilterType.INCLUDE); }, }, + { + description: 'creates shadow link with role filters', + actions: [ + { type: 'fillName', value: 'test-shadow-link' }, + { type: 'fillBootstrapServer', index: 0, value: 'server1.example.com:9092' }, + { type: 'fillScramUsername', value: 'admin' }, + { type: 'fillScramPassword', value: 'admin-secret' }, + { type: 'navigateToConfiguration' }, + { type: 'addRoleFilterCreate', name: 'my-role' }, + ], + verify: (createRequest, exp) => { + exp(createRequest.shadowLink.configurations.roleSyncOptions?.roleNameFilters).toHaveLength(1); + const roleFilter = createRequest.shadowLink.configurations.roleSyncOptions?.roleNameFilters[0]; + exp(roleFilter.name).toBe('my-role'); + exp(roleFilter.patternType).toBe(PatternType.LITERAL); + exp(roleFilter.filterType).toBe(FilterType.INCLUDE); + }, + }, + { + description: 'creates shadow link with a specific ACL filter', + actions: [ + { type: 'fillName', value: 'test-shadow-link' }, + { type: 'fillBootstrapServer', index: 0, value: 'server1.example.com:9092' }, + { type: 'fillScramUsername', value: 'admin' }, + { type: 'fillScramPassword', value: 'admin-secret' }, + { type: 'navigateToConfiguration' }, + { type: 'addACLFilterCreate', principal: 'User:alice' }, + ], + verify: (createRequest, exp) => { + // Specify mode must send the user's filter, not the match-all one + exp(createRequest.shadowLink.configurations.securitySyncOptions?.aclFilters).toHaveLength(1); + const aclFilter = createRequest.shadowLink.configurations.securitySyncOptions?.aclFilters[0]; + exp(aclFilter.accessFilter?.principal).toBe('User:alice'); + }, + }, { description: 'creates shadow link with 2 servers and 1 topic filter', actions: [ diff --git a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx index e5e496c2f6..5a7d9c91f7 100644 --- a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx +++ b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx @@ -20,7 +20,6 @@ import { ACLFilterSchema, ConsumerOffsetSyncOptionsSchema, CreateShadowLinkRequestSchema, - FilterType, NameFilterSchema, RoleSyncOptionsSchema, SecuritySettingsSyncOptionsSchema, @@ -48,7 +47,7 @@ import { } from '../../../../protogen/redpanda/core/common/v1/acl_pb'; import { useCreateShadowLinkMutation } from '../../../../react-query/api/shadowlink'; import { getBasePath } from '../../../../utils/env'; -import { buildAuthenticationConfiguration, buildTLSSettings } from '../edit/shadowlink-edit-utils'; +import { allNameFilter, buildAuthenticationConfiguration, buildTLSSettings } from '../edit/shadowlink-edit-utils'; // Stepper definition const { Stepper } = defineStepper( @@ -97,14 +96,6 @@ const buildCreateShadowLinkRequest = (values: FormValues) => { fetchPartitionMaxBytes: values.advanceClientOptions.fetchPartitionMaxBytes, }); - const allNameFilter = [ - create(NameFilterSchema, { - patternType: PatternType.LITERAL, - filterType: FilterType.INCLUDE, - name: '*', - }), - ]; - const allACLs = [ create(ACLFilterSchema, { resourceFilter: { @@ -166,23 +157,24 @@ const buildCreateShadowLinkRequest = (values: FormValues) => { // Build security sync options (ACL filters, ignore enabled field) const securitySyncOptions = create(SecuritySettingsSyncOptionsSchema, { - aclFilters: values.aclsMode - ? allACLs - : values.aclFilters?.map((acl) => - create(ACLFilterSchema, { - resourceFilter: { - resourceType: acl.resourceType, - patternType: acl.resourcePattern, - name: acl.resourceName || '', - }, - accessFilter: { - principal: acl.principal || '', - operation: acl.operation, - permissionType: acl.permissionType, - host: acl.host || '', - }, - }) - ), + aclFilters: + values.aclsMode === 'all' + ? allACLs + : values.aclFilters?.map((acl) => + create(ACLFilterSchema, { + resourceFilter: { + resourceType: acl.resourceType, + patternType: acl.resourcePattern, + name: acl.resourceName || '', + }, + accessFilter: { + principal: acl.principal || '', + operation: acl.operation, + permissionType: acl.permissionType, + host: acl.host || '', + }, + }) + ), }); // Build schema registry sync options (api mode via the redesigned section, diff --git a/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx b/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx index 72f0bbdacb..ac37647614 100644 --- a/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx +++ b/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx @@ -48,7 +48,7 @@ const NameFilterDisplay = ({
{filterLabel}
{filter.name ? ( - + {filter.name} ) : ( @@ -209,6 +209,7 @@ export const ConfigurationShadowing = ({ shadowLink }: ConfigurationShadowingPro diff --git a/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts b/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts index d75c9709cf..5914ed2763 100644 --- a/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts +++ b/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts @@ -10,6 +10,7 @@ */ import { create } from '@bufbuild/protobuf'; +import { DurationSchema } from '@bufbuild/protobuf/wkt'; import { ShadowLinkSchema } from 'protogen/redpanda/api/dataplane/v1/shadowlink_pb'; import { ACLFilterSchema, @@ -17,6 +18,7 @@ import { FilterType, NameFilterSchema, PatternType, + RoleSyncOptionsSchema, ScramMechanism, SecuritySettingsSyncOptionsSchema, ShadowLinkConfigurationsSchema, @@ -59,6 +61,8 @@ const baseFormValues: FormValues = { enableConsumerOffsetSync: false, consumersMode: 'all', consumers: [], + rolesMode: 'all', + roles: [], aclsMode: 'specify', aclFilters: [ { @@ -114,6 +118,15 @@ const createBaseShadowLink = () => }), ], }), + roleSyncOptions: create(RoleSyncOptionsSchema, { + roleNameFilters: [ + create(NameFilterSchema, { + name: '*', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }), + ], + }), securitySyncOptions: create(SecuritySettingsSyncOptionsSchema, { aclFilters: [ create(ACLFilterSchema, { @@ -186,6 +199,22 @@ describe('buildControlplaneUpdateRequest', () => { }); }); + describe('role sync options are skipped', () => { + test('should not emit a role mask path or field even when roles change', () => { + const updatedValues = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [{ name: 'test-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], + }; + + const result = buildControlplaneUpdateRequest('test-id', updatedValues, baseFormValues); + + // The controlplane proto does not expose role sync yet, so the only + // changed category must produce no mask path at all + expect(result.updateMask?.paths).toEqual([]); + }); + }); + describe('request structure', () => { test('should include shadowLink ID in request', () => { const shadowLinkId = 'unique-shadow-link-id'; @@ -357,6 +386,14 @@ describe('buildDataplaneUpdateRequest', () => { }, expectedPath: 'configurations.consumer_offset_sync_options', }, + { + description: 'role filters change', + changes: { + rolesMode: 'specify' as const, + roles: [{ name: 'test-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], + }, + expectedPath: 'configurations.role_sync_options', + }, { description: 'schema registry change', changes: { enableSchemaRegistrySync: true }, @@ -394,6 +431,7 @@ describe('buildDataplaneUpdateRequest', () => { expect(result.shadowLink?.configurations?.clientOptions).toBeDefined(); expect(result.shadowLink?.configurations?.topicMetadataSyncOptions).toBeDefined(); expect(result.shadowLink?.configurations?.consumerOffsetSyncOptions).toBeDefined(); + expect(result.shadowLink?.configurations?.roleSyncOptions).toBeDefined(); expect(result.shadowLink?.configurations?.securitySyncOptions).toBeDefined(); }); @@ -430,13 +468,15 @@ describe('buildDataplaneUpdateRequest', () => { expectedPathCount: 2, }, { - description: 'all five categories', + description: 'all six categories', changes: { bootstrapServers: [{ value: 'new:9092' }], topicsMode: 'specify' as const, topics: [{ name: 'topic1', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], consumersMode: 'specify' as const, consumers: [{ name: 'group1', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], + rolesMode: 'specify' as const, + roles: [{ name: 'role1', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], aclsMode: 'specify' as const, aclFilters: [ { @@ -451,7 +491,7 @@ describe('buildDataplaneUpdateRequest', () => { ], enableSchemaRegistrySync: true, }, - expectedPathCount: 5, + expectedPathCount: 6, }, ])('should have $expectedPathCount paths when $description changed', ({ changes, expectedPathCount }) => { const baseShadowLink = createBaseShadowLink(); @@ -472,6 +512,35 @@ describe('buildDataplaneUpdateRequest', () => { expect(result.updateMask?.paths).toEqual([]); }); }); + + describe('role sync interval and paused round-trip', () => { + test('should preserve original interval and paused when role filters change', () => { + const baseShadowLink = createBaseShadowLink(); + const roleSyncOptions = baseShadowLink.configurations?.roleSyncOptions; + if (!roleSyncOptions) { + throw new Error('base shadow link must have role sync options'); + } + roleSyncOptions.paused = true; + roleSyncOptions.interval = create(DurationSchema, { seconds: BigInt(300) }); + + const updatedValues = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [{ name: 'test-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], + }; + + const result = buildDataplaneUpdateRequest('test-shadow-link', updatedValues, baseShadowLink); + + // The whole role_sync_options message is replaced by the mask path, so + // the fields the UI never exposes must carry over from the fetched link + expect(result.updateMask?.paths).toContain('configurations.role_sync_options'); + expect(result.shadowLink?.configurations?.roleSyncOptions?.paused).toBe(true); + expect(result.shadowLink?.configurations?.roleSyncOptions?.interval?.seconds).toBe(BigInt(300)); + expect(result.shadowLink?.configurations?.roleSyncOptions?.roleNameFilters).toEqual([ + expect.objectContaining({ name: 'test-role' }), + ]); + }); + }); }); describe('schema registry api mode through both builders', () => { diff --git a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-acls.test.ts b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-acls.test.ts index d9af4c2ecf..e1a359613b 100644 --- a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-acls.test.ts +++ b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-acls.test.ts @@ -50,6 +50,8 @@ const baseFormValues: FormValues = { enableConsumerOffsetSync: false, consumersMode: 'all', consumers: [], + rolesMode: 'all', + roles: [], aclsMode: 'all', aclFilters: [], excludeDefault: false, diff --git a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-connection.test.ts b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-connection.test.ts index 33554023ff..0ebf716ef2 100644 --- a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-connection.test.ts +++ b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-connection.test.ts @@ -49,6 +49,8 @@ const baseFormValues: FormValues = { enableConsumerOffsetSync: false, consumersMode: 'all', consumers: [], + rolesMode: 'all', + roles: [], aclsMode: 'all', aclFilters: [], excludeDefault: false, diff --git a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-consumer-groups.test.ts b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-consumer-groups.test.ts index 784c234123..ce231e43d2 100644 --- a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-consumer-groups.test.ts +++ b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-consumer-groups.test.ts @@ -49,6 +49,8 @@ const baseFormValues: FormValues = { enableConsumerOffsetSync: false, consumersMode: 'all', consumers: [], + rolesMode: 'all', + roles: [], aclsMode: 'all', aclFilters: [], enableSchemaRegistrySync: false, diff --git a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-roles.test.ts b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-roles.test.ts new file mode 100644 index 0000000000..0eab1433ee --- /dev/null +++ b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-roles.test.ts @@ -0,0 +1,451 @@ +/** + * Copyright 2026 Redpanda Data, Inc. + * + * Use of this software is governed by the Business Source License + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md + * + * As of the Change Date specified in that file, in accordance with + * the Business Source License, use of this software will be governed + * by the Apache License, Version 2.0 + */ + +import { FilterType, PatternType, ScramMechanism } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; +import { describe, expect, test } from 'vitest'; + +import { getUpdateValuesForRoles } from './shadowlink-edit-utils'; +import type { FormValues } from '../create/model'; +import { AUTH_METHOD, initialValues, TLS_MODE } from '../create/model'; + +// Base form values for testing +const baseFormValues: FormValues = { + name: 'test-shadow-link', + bootstrapServers: [{ value: 'localhost:9092' }], + advanceClientOptions: { + metadataMaxAgeMs: 10_000, + connectionTimeoutMs: 1000, + retryBackoffMs: 100, + fetchWaitMaxMs: 500, + fetchMinBytes: 5_242_880, + fetchMaxBytes: 20_971_520, + fetchPartitionMaxBytes: 1_048_576, + }, + authMethod: AUTH_METHOD.SCRAM, + scramCredentials: { + username: 'admin', + password: 'password123', + mechanism: ScramMechanism.SCRAM_SHA_256, + }, + plainCredentials: undefined, + useTls: true, + mtlsMode: TLS_MODE.PEM, + mtls: { + ca: undefined, + clientCert: undefined, + clientKey: undefined, + }, + topicsMode: 'all', + topics: [], + topicProperties: [], + enableConsumerOffsetSync: false, + consumersMode: 'all', + consumers: [], + rolesMode: 'all', + roles: [], + aclsMode: 'all', + aclFilters: [], + enableSchemaRegistrySync: false, + schemaRegistry: initialValues.schemaRegistry, + excludeDefault: false, +}; + +describe('getUpdateValuesForRoles', () => { + describe('Role mode changes', () => { + test('should detect change from all to specify mode', () => { + const original = { ...baseFormValues, rolesMode: 'all' as const, roles: [] }; + const updated = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('role-1'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + }); + + test('should detect change from specify to all mode', () => { + const original = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + const updated = { ...baseFormValues, rolesMode: 'all' as const, roles: [] }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('*'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + }); + }); + + describe('Role array changes', () => { + test('should detect when a role is added to the list', () => { + const original = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + const updated = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'role-2', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.value.roleNameFilters).toHaveLength(2); + expect(result.value.roleNameFilters[0].name).toBe('role-1'); + expect(result.value.roleNameFilters[1].name).toBe('role-2'); + }); + + test('should detect when a role is removed from the list', () => { + const original = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'role-2', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + const updated = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('role-1'); + }); + + test('should detect when a role name is changed', () => { + const original = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + const updated = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1-renamed', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('role-1-renamed'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + }); + + test('should detect when a role pattern type is changed', () => { + const original = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + const updated = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.PREFIX, + filterType: FilterType.INCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('role-1'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.PREFIX); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + }); + + test('should detect when a role filter type is changed', () => { + const original = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + const updated = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.EXCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('role-1'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.EXCLUDE); + }); + }); + + describe('No changes', () => { + test('should not emit a mask path when mode and roles are unchanged', () => { + const original = { ...baseFormValues, rolesMode: 'specify' as const, roles: [] }; + const updated = { ...baseFormValues, rolesMode: 'specify' as const, roles: [] }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toEqual([]); + }); + }); + + describe('Multiple changes', () => { + test('should detect multiple changes at once (mode + roles)', () => { + const original = { + ...baseFormValues, + rolesMode: 'all' as const, + roles: [], + }; + const updated = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'role-2', + patternType: PatternType.PREFIX, + filterType: FilterType.EXCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(updated, original); + + expect(result.fieldMaskPaths).toContain('configurations.role_sync_options'); + expect(result.fieldMaskPaths).toHaveLength(1); + + // Validate schema values + expect(result.value.roleNameFilters).toHaveLength(2); + expect(result.value.roleNameFilters[0].name).toBe('role-1'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + expect(result.value.roleNameFilters[1].name).toBe('role-2'); + expect(result.value.roleNameFilters[1].patternType).toBe(PatternType.PREFIX); + expect(result.value.roleNameFilters[1].filterType).toBe(FilterType.EXCLUDE); + }); + }); + + describe('Schema building', () => { + test('should build correct schema for all mode (wildcard filter with name=*)', () => { + const values = { + ...baseFormValues, + rolesMode: 'all' as const, + roles: [], + }; + + const result = getUpdateValuesForRoles(values, baseFormValues); + + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('*'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + }); + + test('should build correct schema for specify mode with single role', () => { + const values = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'my-role', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(values, baseFormValues); + + expect(result.value.roleNameFilters).toHaveLength(1); + expect(result.value.roleNameFilters[0].name).toBe('my-role'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + }); + + test('should build correct schema for specify mode with multiple roles', () => { + const values = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'role-1', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'role-2', + patternType: PatternType.PREFIX, + filterType: FilterType.EXCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(values, baseFormValues); + + expect(result.value.roleNameFilters).toHaveLength(2); + expect(result.value.roleNameFilters[0].name).toBe('role-1'); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + expect(result.value.roleNameFilters[1].name).toBe('role-2'); + expect(result.value.roleNameFilters[1].patternType).toBe(PatternType.PREFIX); + expect(result.value.roleNameFilters[1].filterType).toBe(FilterType.EXCLUDE); + }); + + test('should build correct schema with different pattern types (LITERAL, PREFIX)', () => { + const values = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'exact-match', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'prefix-', + patternType: PatternType.PREFIX, + filterType: FilterType.INCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(values, baseFormValues); + + expect(result.value.roleNameFilters).toHaveLength(2); + expect(result.value.roleNameFilters[0].patternType).toBe(PatternType.LITERAL); + expect(result.value.roleNameFilters[1].patternType).toBe(PatternType.PREFIX); + }); + + test('should build correct schema with different filter types (INCLUDE, EXCLUDE)', () => { + const values = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [ + { + name: 'included-role', + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + }, + { + name: 'excluded-role', + patternType: PatternType.LITERAL, + filterType: FilterType.EXCLUDE, + }, + ], + }; + + const result = getUpdateValuesForRoles(values, baseFormValues); + + expect(result.value.roleNameFilters).toHaveLength(2); + expect(result.value.roleNameFilters[0].filterType).toBe(FilterType.INCLUDE); + expect(result.value.roleNameFilters[1].filterType).toBe(FilterType.EXCLUDE); + }); + }); +}); diff --git a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-topics.test.ts b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-topics.test.ts index 1e57d9efbe..a9d8f8c92b 100644 --- a/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-topics.test.ts +++ b/frontend/src/components/pages/shadowlinks/edit/get-update-values-for-topics.test.ts @@ -60,6 +60,8 @@ const baseFormValues: FormValues = { enableConsumerOffsetSync: false, consumersMode: 'all', consumers: [], + rolesMode: 'all', + roles: [], aclsMode: 'all', aclFilters: [], enableSchemaRegistrySync: false, diff --git a/frontend/src/components/pages/shadowlinks/edit/shadowing-tab.tsx b/frontend/src/components/pages/shadowlinks/edit/shadowing-tab.tsx index bfb6b806d1..a70aea1572 100644 --- a/frontend/src/components/pages/shadowlinks/edit/shadowing-tab.tsx +++ b/frontend/src/components/pages/shadowlinks/edit/shadowing-tab.tsx @@ -9,9 +9,12 @@ * by the Apache License, Version 2.0 */ +import { isEmbedded } from 'config'; + import { SchemaRegistryEditSection } from './schema-registry-edit-section'; import { AclsStep } from '../create/configuration/acls-step'; import { ConsumerOffsetStep } from '../create/configuration/consumer-offset-step'; +import { RolesStep } from '../create/configuration/roles-step'; import { TopicsStep } from '../create/configuration/topics-step'; import { SCHEMA_REGISTRY_MODE, type SchemaRegistryMode } from '../create/model'; @@ -23,6 +26,8 @@ export const ShadowingTab = ({
+ {/* Hidden in embedded mode: the controlplane update cannot persist role changes yet */} + {!isEmbedded() && }
diff --git a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.tsx b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.tsx index 2c1a6fdcdb..1070cb4cbb 100644 --- a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.tsx +++ b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.tsx @@ -46,6 +46,8 @@ const getTabForField = (fieldName: string): string => { // Shadowing tab fields topicsMode: 'shadowing', topics: 'shadowing', + rolesMode: 'shadowing', + roles: 'shadowing', consumersMode: 'shadowing', consumers: 'shadowing', aclsMode: 'shadowing', diff --git a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts index abb9c6ce1f..dc606b8502 100644 --- a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts +++ b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts @@ -24,6 +24,7 @@ import { NameFilterSchema, PatternType, PlainConfigSchema, + RoleSyncOptionsSchema, type SchemaRegistrySyncOptions, SchemaRegistrySyncOptionsSchema, ScramConfigSchema, @@ -52,6 +53,19 @@ import { buildDefaultFormValues } from '../mappers/dataplane'; */ const CONFIGURATIONS_PREFIX_REGEX = /^configurations\./; +/** + * Wildcard filter meaning "sync everything" for the 'all' mode of a category. + * Must stay in sync with isAllNameFilter in the mappers, which round-trips + * this exact shape back to 'all' mode when hydrating the edit form. + */ +export const allNameFilter = [ + create(NameFilterSchema, { + patternType: PatternType.LITERAL, + filterType: FilterType.INCLUDE, + name: '*', + }), +]; + /** * Type for category update functions * Each category function returns the schema value and field mask paths @@ -306,14 +320,6 @@ export const getUpdateValuesForTopics = ( } // Build topic metadata sync options - const allNameFilter = [ - create(NameFilterSchema, { - patternType: PatternType.LITERAL, - filterType: FilterType.INCLUDE, - name: '*', - }), - ]; - const topicMetadataSyncOptions = create(TopicMetadataSyncOptionsSchema, { autoCreateShadowTopicFilters: values.topicsMode === 'all' @@ -361,14 +367,6 @@ export const getUpdateValuesForConsumerGroups = ( } // Build consumer offset sync options - const allNameFilter = [ - create(NameFilterSchema, { - patternType: PatternType.LITERAL, - filterType: FilterType.INCLUDE, - name: '*', - }), - ]; - const consumerOffsetSyncOptions = create(ConsumerOffsetSyncOptionsSchema, { groupFilters: values.consumersMode === 'all' @@ -388,6 +386,51 @@ export const getUpdateValuesForConsumerGroups = ( }; }; +/** + * Get update values for roles category + * Compares form values with original values and returns schema + field mask paths + */ +export const getUpdateValuesForRoles = ( + values: FormValues, + originalValues: FormValues +): UpdateResult>> => { + const fieldMaskPaths: string[] = []; + + // Compare roles mode and filters + const roleFiltersChanged = + values.rolesMode !== originalValues.rolesMode || + values.roles.length !== originalValues.roles.length || + values.roles.some( + (role, idx) => + role.name !== originalValues.roles[idx]?.name || + role.patternType !== originalValues.roles[idx]?.patternType || + role.filterType !== originalValues.roles[idx]?.filterType + ); + + if (roleFiltersChanged) { + fieldMaskPaths.push('configurations.role_sync_options'); + } + + // Build role sync options + const roleSyncOptions = create(RoleSyncOptionsSchema, { + roleNameFilters: + values.rolesMode === 'all' + ? allNameFilter + : values.roles.map((role) => + create(NameFilterSchema, { + patternType: role.patternType, + filterType: role.filterType, + name: role.name, + }) + ), + }); + + return { + value: roleSyncOptions, + fieldMaskPaths, + }; +}; + /** * Get update values for ACLs category * Compares form values with original values and returns schema + field mask paths @@ -507,6 +550,7 @@ export const buildControlplaneUpdateRequest = ( originalValues: FormValues ) => { // Get update values from existing category functions (reuse the logic) + // Roles are skipped: the controlplane proto does not expose role sync yet const connectionUpdate = getUpdateValuesForConnection(values, originalValues); const topicsUpdate = getUpdateValuesForTopics(values, originalValues); const consumerGroupsUpdate = getUpdateValuesForConsumerGroups(values, originalValues); @@ -558,7 +602,17 @@ export const buildDataplaneUpdateRequest = (name: string, values: FormValues, or const connectionUpdate = getUpdateValuesForConnection(values, originalValues); const topicsUpdate = getUpdateValuesForTopics(values, originalValues); const consumerGroupsUpdate = getUpdateValuesForConsumerGroups(values, originalValues); + const rolesUpdate = getUpdateValuesForRoles(values, originalValues); const aclsUpdate = getUpdateValuesForACLs(values, originalValues); + + // No UI exposes interval/paused: round-trip them from the fetched link so + // the whole-message mask path doesn't unpause a paused role sync task or + // reset a custom interval + const originalRoleSyncOptions = originalShadowLink.configurations?.roleSyncOptions; + if (originalRoleSyncOptions) { + rolesUpdate.value.interval = originalRoleSyncOptions.interval; + rolesUpdate.value.paused = originalRoleSyncOptions.paused; + } const schemaRegistryUpdate = getUpdateValuesForSchemaRegistry(values, originalValues); // Build configurations with all category values @@ -566,6 +620,7 @@ export const buildDataplaneUpdateRequest = (name: string, values: FormValues, or clientOptions: connectionUpdate.value, topicMetadataSyncOptions: topicsUpdate.value, consumerOffsetSyncOptions: consumerGroupsUpdate.value, + roleSyncOptions: rolesUpdate.value, securitySyncOptions: aclsUpdate.value, schemaRegistrySyncOptions: schemaRegistryUpdate.value, }); @@ -582,6 +637,7 @@ export const buildDataplaneUpdateRequest = (name: string, values: FormValues, or ...connectionUpdate.fieldMaskPaths, ...topicsUpdate.fieldMaskPaths, ...consumerGroupsUpdate.fieldMaskPaths, + ...rolesUpdate.fieldMaskPaths, ...aclsUpdate.fieldMaskPaths, ...schemaRegistryUpdate.fieldMaskPaths, ], diff --git a/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts b/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts index effb4a87f4..0ed07a6469 100644 --- a/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts +++ b/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts @@ -196,6 +196,62 @@ describe('fromDataplaneShadowLink role sync options', () => { }); }); +describe('buildDefaultFormValues role hydration', () => { + test('hydrates the wildcard include filter as all mode', () => { + const shadowLink = create(ShadowLinkSchema, { + name: 'test-link', + uid: 'uid-1', + configurations: { + roleSyncOptions: { + roleNameFilters: [{ name: '*', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], + }, + }, + }); + + const formValues = buildDefaultFormValues(shadowLink); + + expect(formValues.rolesMode).toBe('all'); + expect(formValues.roles).toEqual([]); + }); + + test('hydrates specific filters as specify mode', () => { + const shadowLink = create(ShadowLinkSchema, { + name: 'test-link', + uid: 'uid-1', + configurations: { + roleSyncOptions: { + roleNameFilters: [ + { name: 'my-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }, + { name: 'prefix-', patternType: PatternType.PREFIX, filterType: FilterType.EXCLUDE }, + ], + }, + }, + }); + + const formValues = buildDefaultFormValues(shadowLink); + + expect(formValues.rolesMode).toBe('specify'); + expect(formValues.roles).toEqual([ + { name: 'my-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }, + { name: 'prefix-', patternType: PatternType.PREFIX, filterType: FilterType.EXCLUDE }, + ]); + }); + + test('hydrates absent role sync options as specify mode with no filters', () => { + const shadowLink = create(ShadowLinkSchema, { + name: 'test-link', + uid: 'uid-1', + configurations: {}, + }); + + const formValues = buildDefaultFormValues(shadowLink); + + // Keeps role sync disabled: an untouched edit produces no role diff + expect(formValues.rolesMode).toBe('specify'); + expect(formValues.roles).toEqual([]); + }); +}); + describe('buildDefaultFormValues schema registry hydration', () => { const buildLinkWithConnection = ( schemaRegistrySyncOptions?: MessageInitShape diff --git a/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts b/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts index bc897c05cc..7a7290ce96 100644 --- a/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts +++ b/frontend/src/components/pages/shadowlinks/mappers/dataplane.ts @@ -463,6 +463,30 @@ export const buildDefaultConsumerGroupsValues = ( }; }; +/** + * Build default form values for roles category from shadow link configurations. + * A link without role sync options hydrates to specify mode with no filters, + * so an untouched edit keeps role sync disabled. + */ +export const buildDefaultRolesValues = (shadowLink: DataplaneShadowLink): Pick => { + const roleSyncOptions = shadowLink.configurations?.roleSyncOptions; + const roleNameFilters = roleSyncOptions?.roleNameFilters || []; + + // Check if using "all roles" mode + const isAllMode = isAllNameFilter(roleNameFilters); + + return { + rolesMode: isAllMode ? 'all' : 'specify', + roles: isAllMode + ? [] + : roleNameFilters.map((filter) => ({ + name: filter.name, + patternType: filter.patternType, + filterType: filter.filterType, + })), + }; +}; + /** * Build default form values for ACLs category from shadow link configurations */ @@ -507,17 +531,16 @@ export const buildDefaultFormValues = (shadowLink: DataplaneShadowLink): FormVal const connectionValues = buildDefaultConnectionValues(shadowLink); const topicsValues = buildDefaultTopicsValues(shadowLink); const consumerGroupsValues = buildDefaultConsumerGroupsValues(shadowLink); + const rolesValues = buildDefaultRolesValues(shadowLink); const aclsValues = buildDefaultACLsValues(shadowLink); const schemaRegistryValues = buildDefaultSchemaRegistryValues(shadowLink); return { name: shadowLink.name || '', - // Static defaults until role sync gets edit support - rolesMode: 'all', - roles: [], ...connectionValues, ...topicsValues, ...consumerGroupsValues, + ...rolesValues, ...aclsValues, ...schemaRegistryValues, }; diff --git a/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts b/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts index 19df16efba..2ce96590ef 100644 --- a/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts +++ b/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts @@ -536,7 +536,7 @@ export const addACLFilterCreate = async ( expect(scr.getByTestId('acls-specify-tab')).toBeInTheDocument(); }); - // Switch to specify mode + // Switch to specify mode (auto-expands the card and seeds an empty filter) const aclsSpecifyTab = scr.getByTestId('acls-specify-tab'); await user.click(aclsSpecifyTab); @@ -544,9 +544,11 @@ export const addACLFilterCreate = async ( expect(scr.getByTestId('add-acl-filter-button')).toBeInTheDocument(); }); - // Add an ACL filter - const addAclFilterButton = scr.getByTestId('add-acl-filter-button'); - await user.click(addAclFilterButton); + // Only add a filter when the mode switch did not seed one already + if (!scr.queryByTestId('acl-filter-0-principal')) { + const addAclFilterButton = scr.getByTestId('add-acl-filter-button'); + await user.click(addAclFilterButton); + } await waitFor(() => { expect(scr.getByTestId('acl-filter-0-principal')).toBeInTheDocument(); @@ -556,6 +558,30 @@ export const addACLFilterCreate = async ( await user.type(aclPrincipalInput, principal); }; +/** + * Add a role filter (create form - without tab navigation) + */ +export const addRoleFilterCreate = async ( + user: ReturnType, + scr: typeof import('@testing-library/react').screen, + name: string +) => { + await waitFor(() => { + expect(scr.getByTestId('roles-specify-tab')).toBeInTheDocument(); + }); + + // Switch to specify mode (auto-expands the card and seeds an empty filter) + const rolesSpecifyTab = scr.getByTestId('roles-specify-tab'); + await user.click(rolesSpecifyTab); + + await waitFor(() => { + expect(scr.getByTestId('role-filter-0-name')).toBeInTheDocument(); + }); + + const roleFilterInput = scr.getByTestId('role-filter-0-name'); + await user.type(roleFilterInput, name); +}; + /** * Add a topic filter with pattern options */ From 2600134b700fb67ea6e05220b90a63fd41cb6e30 Mon Sep 17 00:00:00 2001 From: Rogger Vasquez Date: Tue, 4 Aug 2026 15:43:04 -0700 Subject: [PATCH 4/4] shadowlink: gate role sync behind redpanda 26.2.0 Show the shadow roles card and send role sync options only on clusters running 26.2.0 or newer, reusing the endpoint compatibility check added for schema registry sync. The request builders skip role_sync_options too, since an untouched create form defaults to an include-all filter. --- backend/pkg/console/endpoint_compatibility.go | 6 ++ backend/pkg/console/redpanda_feature.go | 19 ++++-- .../create/configuration/roles-step.test.tsx | 28 +++++++++ .../create/configuration/roles-step.tsx | 6 ++ .../create/shadowlink-create-page.test.tsx | 49 ++++++++++++++- .../create/shadowlink-create-page.tsx | 37 ++++++----- .../edit/build-update-request.test.ts | 63 ++++++++++++++++--- .../edit/shadowlink-edit-page.test.tsx | 20 ++++-- .../edit/shadowlink-edit-utils.test.ts | 28 ++++++--- .../shadowlinks/edit/shadowlink-edit-utils.ts | 18 ++++-- .../shadowlinks/shadowlink-test-helpers.ts | 27 ++++++-- frontend/src/react-query/api/shadowlink.ts | 18 +++++- frontend/src/state/supported-features.ts | 13 +++- 13 files changed, 277 insertions(+), 55 deletions(-) diff --git a/backend/pkg/console/endpoint_compatibility.go b/backend/pkg/console/endpoint_compatibility.go index 6786e76624..46752f2b90 100644 --- a/backend/pkg/console/endpoint_compatibility.go +++ b/backend/pkg/console/endpoint_compatibility.go @@ -161,6 +161,12 @@ func (s *Service) GetEndpointCompatibility(ctx context.Context) (EndpointCompati HasRedpandaAPI: true, RedpandaFeature: redpandaFeatureShadowLinkSchemaRegistrySync, }, + { + URL: "/api/shadow-links/role-sync", + Method: "GET", + HasRedpandaAPI: true, + RedpandaFeature: redpandaFeatureShadowLinkRoleSync, + }, { URL: "/api/schema-registry/contexts", Method: "GET", diff --git a/backend/pkg/console/redpanda_feature.go b/backend/pkg/console/redpanda_feature.go index c6cf7763d1..3eb00bc83b 100644 --- a/backend/pkg/console/redpanda_feature.go +++ b/backend/pkg/console/redpanda_feature.go @@ -42,15 +42,22 @@ const ( // redpandaFeatureShadowLinkSchemaRegistrySync represents shadow link Schema Registry // sync over the Schema Registry API feature. redpandaFeatureShadowLinkSchemaRegistrySync redpandaFeature = "redpanda_feature_shadow_link_schema_registry_sync" + + // redpandaFeatureShadowLinkRoleSync represents shadow link role sync feature. + redpandaFeatureShadowLinkRoleSync redpandaFeature = "redpanda_feature_shadow_link_role_sync" ) // shadowLinkSchemaRegistrySyncMinVersion is the first Redpanda release whose shadow // links can sync schemas over the Schema Registry API. var shadowLinkSchemaRegistrySyncMinVersion = redpanda.MustParseVersion("26.2.0") -// checkShadowLinkSchemaRegistrySyncSupport reports whether the cluster is new enough to -// sync schemas over the Schema Registry API for shadow links. -func checkShadowLinkSchemaRegistrySyncSupport(ctx context.Context, redpandaCl redpandafactory.AdminAPIClient) bool { +// shadowLinkRoleSyncMinVersion is the first Redpanda release whose shadow links +// can sync roles. +var shadowLinkRoleSyncMinVersion = redpanda.MustParseVersion("26.2.0") + +// checkClusterVersionAtLeast reports whether the cluster runs at least the given +// Redpanda version, based on the first broker that reports one. +func checkClusterVersionAtLeast(ctx context.Context, redpandaCl redpandafactory.AdminAPIClient, minVersion redpanda.Version) bool { brokers, err := redpandaCl.Brokers(ctx) if err != nil { return false @@ -61,7 +68,7 @@ func checkShadowLinkSchemaRegistrySyncSupport(ctx context.Context, redpandaCl re continue } version, err := redpanda.VersionFromString(broker.Version) - return err == nil && version.IsAtLeast(shadowLinkSchemaRegistrySyncMinVersion) + return err == nil && version.IsAtLeast(minVersion) } return false @@ -100,7 +107,9 @@ func (*Service) checkRedpandaFeature(ctx context.Context, redpandaCl redpandafac } return true case redpandaFeatureShadowLinkSchemaRegistrySync: - return checkShadowLinkSchemaRegistrySyncSupport(ctx, redpandaCl) + return checkClusterVersionAtLeast(ctx, redpandaCl, shadowLinkSchemaRegistrySyncMinVersion) + case redpandaFeatureShadowLinkRoleSync: + return checkClusterVersionAtLeast(ctx, redpandaCl, shadowLinkRoleSyncMinVersion) case redpandaFeatureSchemaRegistryContexts: cfg, err := redpandaCl.SingleKeyConfig(ctx, clusterConfigSchemaRegistryQualifiedSubjects) if err != nil { diff --git a/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.test.tsx b/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.test.tsx index 98672beb56..dc96bb9a22 100644 --- a/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.test.tsx +++ b/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.test.tsx @@ -14,9 +14,11 @@ import userEvent from '@testing-library/user-event'; import { Form } from 'components/redpanda-ui/components/form'; import { FilterType, PatternType } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; import { useForm } from 'react-hook-form'; +import { useSupportedFeaturesStore } from 'state/supported-features'; import { render, screen, waitFor } from 'test-utils'; import { RolesStep } from './roles-step'; +import { setShadowLinkGatesSupported } from '../../shadowlink-test-helpers'; import { FormSchema, type FormValues, initialValues } from '../model'; const ROLE_FILTER_PATTERN = /^role-filter-\d+$/; @@ -37,6 +39,32 @@ const TestWrapper = ({ defaultValues = initialValues }: { defaultValues?: FormVa }; describe('RolesStep', () => { + beforeEach(() => { + // Reset the shared store, then open the role sync gate, these tests + // exercise the card itself, which only renders on Redpanda >= 26.2.0. + useSupportedFeaturesStore.setState({ endpointCompatibility: null, shadowLinkRoleSync: false }); + setShadowLinkGatesSupported({ roleSync: true }); + }); + + describe('Feature gate', () => { + test('should render nothing when the cluster does not support role sync', () => { + setShadowLinkGatesSupported({ roleSync: false }); + + render(); + + expect(screen.queryByTestId('roles-toggle-button')).not.toBeInTheDocument(); + expect(screen.queryByTestId('roles-all-tab')).not.toBeInTheDocument(); + }); + + test('should render nothing when endpoint compatibility has not loaded (fails closed)', () => { + useSupportedFeaturesStore.setState({ endpointCompatibility: null, shadowLinkRoleSync: false }); + + render(); + + expect(screen.queryByTestId('roles-toggle-button')).not.toBeInTheDocument(); + }); + }); + describe('Filter type options', () => { test('should show all filter type options when in specify roles mode', async () => { const user = userEvent.setup(); diff --git a/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx b/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx index b478e0ef0c..9029e629ef 100644 --- a/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx +++ b/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx @@ -19,6 +19,7 @@ import { ChevronDown } from 'lucide-react'; import { FilterType, PatternType } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; import { useState } from 'react'; import { type Control, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; +import { useSupportedFeaturesStore } from 'state/supported-features'; import { FilterItem } from './filter-item'; import type { FormValues } from '../model'; @@ -59,6 +60,7 @@ export const RolesStep = () => { control, name: 'roles', }); + const roleSyncSupported = useSupportedFeaturesStore((s) => s.shadowLinkRoleSync); const handleModeChange = (mode: string) => { setValue('rolesMode', mode as 'all' | 'specify'); @@ -82,6 +84,10 @@ export const RolesStep = () => { } }; + if (!roleSyncSupported) { + return null; + } + return ( diff --git a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx index a7a1df630f..d77c2f20b0 100644 --- a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx +++ b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx @@ -76,6 +76,7 @@ import { enableTLS, navigateToConfigurationStep, setSchemaRegistrySyncGateSupported as seedSchemaRegistrySyncGate, + setShadowLinkGatesSupported, } from '../shadowlink-test-helpers'; const pristineFeatureStoreState = useSupportedFeaturesStore.getState(); @@ -467,8 +468,9 @@ describe('ShadowLinkCreatePage', () => { beforeEach(() => { vi.clearAllMocks(); - // Gate closed: these cases exercise the legacy Schema Registry switch. - seedSchemaRegistrySyncGate(false); + // SR gate closed: these cases exercise the legacy Schema Registry switch. + // Role gate open: the roles card and its default-all payload need >= 26.2.0. + setShadowLinkGatesSupported({ schemaRegistrySync: false, roleSync: true }); mockMutateAsync.mockImplementation((_request) => Promise.resolve({})); @@ -544,6 +546,49 @@ describe('ShadowLinkCreatePage', () => { expect(toast.success).toHaveBeenCalledWith('Shadow link created'); }); }); + + test('hides the roles card and omits role sync options when the cluster does not support role sync', async () => { + setShadowLinkGatesSupported({ schemaRegistrySync: false, roleSync: false }); + + const user = userEvent.setup(); + + renderCreatePage(); + + await screen.findByPlaceholderText('my-shadow-link', {}, { timeout: 10_000 }); + + await performCreateAction(user, screen, { type: 'fillName', value: 'test-shadow-link' }); + await performCreateAction(user, screen, { + type: 'fillBootstrapServer', + index: 0, + value: 'server1.example.com:9092', + }); + await performCreateAction(user, screen, { type: 'fillScramUsername', value: 'admin' }); + await performCreateAction(user, screen, { type: 'fillScramPassword', value: 'admin-secret' }); + await performCreateAction(user, screen, { type: 'navigateToConfiguration' }); + + // The whole roles card is gated out + expect(screen.queryByTestId('roles-all-tab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('roles-toggle-button')).not.toBeInTheDocument(); + + const createButton = screen.getByRole('button', { name: 'Create shadow link' }); + await user.click(createButton); + + await waitFor( + () => { + expect(mockMutateAsync).toHaveBeenCalledTimes(1); + }, + { timeout: 5000 } + ); + + // Not even the default include-all filter may be sent to a cluster + // that predates role sync + const createRequest = mockMutateAsync.mock.calls[0][0]; + expect(createRequest.shadowLink.configurations.roleSyncOptions).toBeUndefined(); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith('Shadow link created'); + }); + }); }); describe('ShadowLinkCreatePage - Schema Registry sync over API', () => { diff --git a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx index 5a7d9c91f7..b2aedef952 100644 --- a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx +++ b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx @@ -32,6 +32,7 @@ import { TLSSettingsSchema } from 'protogen/redpanda/core/common/v1/tls_pb'; import { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; +import { useSupportedFeaturesStore } from 'state/supported-features'; import { uiState } from 'state/ui-state'; import { ConfigurationStep } from './configuration/configuration-step'; @@ -73,7 +74,7 @@ export const updatePageTitle = () => { /** * Transform form values to CreateShadowLinkRequest protobuf message */ -const buildCreateShadowLinkRequest = (values: FormValues) => { +const buildCreateShadowLinkRequest = (values: FormValues, { roleSyncSupported }: { roleSyncSupported: boolean }) => { // Build TLS settings from certificate configuration const tlsSettings = buildTLSSettings(values); @@ -141,19 +142,23 @@ const buildCreateShadowLinkRequest = (values: FormValues) => { ), }); - // Build role sync options (no interval/paused exposed) - const roleSyncOptions = create(RoleSyncOptionsSchema, { - roleNameFilters: - values.rolesMode === 'all' - ? allNameFilter - : values.roles.map((role) => - create(NameFilterSchema, { - patternType: role.patternType, - filterType: role.filterType, - name: role.name, - }) - ), - }); + // Build role sync options (no interval/paused exposed). Left unset on + // clusters without role sync (Redpanda < 26.2.0). The default 'all' mode + // would otherwise send an include-all filter the cluster can't handle. + const roleSyncOptions = roleSyncSupported + ? create(RoleSyncOptionsSchema, { + roleNameFilters: + values.rolesMode === 'all' + ? allNameFilter + : values.roles.map((role) => + create(NameFilterSchema, { + patternType: role.patternType, + filterType: role.filterType, + name: role.name, + }) + ), + }) + : undefined; // Build security sync options (ACL filters, ignore enabled field) const securitySyncOptions = create(SecuritySettingsSyncOptionsSchema, { @@ -213,6 +218,8 @@ export const ShadowLinkCreatePage = () => { } }, []); + const roleSyncSupported = useSupportedFeaturesStore((s) => s.shadowLinkRoleSync); + const { mutateAsync: createShadowLink, isPending: isCreating } = useCreateShadowLinkMutation({ onSuccess: () => { toast.success('Shadow link created'); @@ -226,7 +233,7 @@ export const ShadowLinkCreatePage = () => { }); const onSubmit = async (values: FormValues) => { - const request = buildCreateShadowLinkRequest(values); + const request = buildCreateShadowLinkRequest(values, { roleSyncSupported }); await createShadowLink(request); }; diff --git a/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts b/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts index 5914ed2763..1836d80826 100644 --- a/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts +++ b/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts @@ -403,7 +403,9 @@ describe('buildDataplaneUpdateRequest', () => { const baseShadowLink = createBaseShadowLink(); const updatedValues = { ...baseFormValues, ...changes }; - const result = buildDataplaneUpdateRequest('test-shadow-link', updatedValues, baseShadowLink); + const result = buildDataplaneUpdateRequest('test-shadow-link', updatedValues, baseShadowLink, { + roleSyncSupported: true, + }); expect(result.updateMask?.paths).toContain(expectedPath); }); @@ -415,7 +417,9 @@ describe('buildDataplaneUpdateRequest', () => { const baseShadowLink = createBaseShadowLink(); const updatedValues = { ...baseFormValues, bootstrapServers: [{ value: 'changed:9092' }] }; - const result = buildDataplaneUpdateRequest(shadowLinkName, updatedValues, baseShadowLink); + const result = buildDataplaneUpdateRequest(shadowLinkName, updatedValues, baseShadowLink, { + roleSyncSupported: true, + }); expect(result.shadowLink?.name).toBe(shadowLinkName); }); @@ -424,7 +428,9 @@ describe('buildDataplaneUpdateRequest', () => { const baseShadowLink = createBaseShadowLink(); const updatedValues = { ...baseFormValues, bootstrapServers: [{ value: 'changed:9092' }] }; - const result = buildDataplaneUpdateRequest('test-name', updatedValues, baseShadowLink); + const result = buildDataplaneUpdateRequest('test-name', updatedValues, baseShadowLink, { + roleSyncSupported: true, + }); // Dataplane uses nested structure - configurations object contains all options expect(result.shadowLink?.configurations).toBeDefined(); @@ -442,7 +448,9 @@ describe('buildDataplaneUpdateRequest', () => { bootstrapServers: [{ value: 'kafka1:9092' }, { value: 'kafka2:9092' }], }; - const result = buildDataplaneUpdateRequest('test-name', updatedValues, baseShadowLink); + const result = buildDataplaneUpdateRequest('test-name', updatedValues, baseShadowLink, { + roleSyncSupported: true, + }); expect(result.shadowLink?.configurations?.clientOptions?.bootstrapServers).toEqual([ 'kafka1:9092', @@ -497,7 +505,9 @@ describe('buildDataplaneUpdateRequest', () => { const baseShadowLink = createBaseShadowLink(); const updatedValues = { ...baseFormValues, ...changes }; - const result = buildDataplaneUpdateRequest('test-name', updatedValues, baseShadowLink); + const result = buildDataplaneUpdateRequest('test-name', updatedValues, baseShadowLink, { + roleSyncSupported: true, + }); expect(result.updateMask?.paths?.length).toBe(expectedPathCount); }); @@ -507,12 +517,45 @@ describe('buildDataplaneUpdateRequest', () => { test('should have empty updateMask paths when no changes from original', () => { const baseShadowLink = createBaseShadowLink(); - const result = buildDataplaneUpdateRequest('test-shadow-link', baseFormValues, baseShadowLink); + const result = buildDataplaneUpdateRequest('test-shadow-link', baseFormValues, baseShadowLink, { + roleSyncSupported: true, + }); expect(result.updateMask?.paths).toEqual([]); }); }); + describe('role sync feature gate', () => { + test('should carry no role sync value or mask path when the cluster does not support role sync', () => { + const baseShadowLink = createBaseShadowLink(); + const updatedValues = { + ...baseFormValues, + rolesMode: 'specify' as const, + roles: [{ name: 'test-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], + }; + + const result = buildDataplaneUpdateRequest('test-shadow-link', updatedValues, baseShadowLink, { + roleSyncSupported: false, + }); + + expect(result.shadowLink?.configurations?.roleSyncOptions).toBeUndefined(); + expect(result.updateMask?.paths).not.toContain('configurations.role_sync_options'); + }); + + test('should leave other categories untouched when role sync is unsupported', () => { + const baseShadowLink = createBaseShadowLink(); + const updatedValues = { ...baseFormValues, bootstrapServers: [{ value: 'changed:9092' }] }; + + const result = buildDataplaneUpdateRequest('test-shadow-link', updatedValues, baseShadowLink, { + roleSyncSupported: false, + }); + + expect(result.updateMask?.paths).toEqual(['configurations.client_options']); + expect(result.shadowLink?.configurations?.clientOptions?.bootstrapServers).toEqual(['changed:9092']); + expect(result.shadowLink?.configurations?.roleSyncOptions).toBeUndefined(); + }); + }); + describe('role sync interval and paused round-trip', () => { test('should preserve original interval and paused when role filters change', () => { const baseShadowLink = createBaseShadowLink(); @@ -529,7 +572,9 @@ describe('buildDataplaneUpdateRequest', () => { roles: [{ name: 'test-role', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], }; - const result = buildDataplaneUpdateRequest('test-shadow-link', updatedValues, baseShadowLink); + const result = buildDataplaneUpdateRequest('test-shadow-link', updatedValues, baseShadowLink, { + roleSyncSupported: true, + }); // The whole role_sync_options message is replaced by the mask path, so // the fields the UI never exposes must carry over from the fetched link @@ -568,7 +613,9 @@ describe('schema registry api mode through both builders', () => { }); test('dataplane request carries the api oneof with the prefixed mask path', () => { - const request = buildDataplaneUpdateRequest('test-shadow-link', apiFormValues(), createBaseShadowLink()); + const request = buildDataplaneUpdateRequest('test-shadow-link', apiFormValues(), createBaseShadowLink(), { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toEqual(['configurations.schema_registry_sync_options']); const mode = request.shadowLink?.configurations?.schemaRegistrySyncOptions?.schemaRegistryShadowingMode; diff --git a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.test.tsx b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.test.tsx index 0862c96da5..1310379169 100644 --- a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.test.tsx +++ b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-page.test.tsx @@ -314,8 +314,12 @@ describe('ShadowLinkEditPage', () => { beforeEach(() => { vi.clearAllMocks(); mockUpdateShadowLink.mockImplementation((_request) => Promise.resolve({})); - // The SR feature gate defaults to closed; api-mode tests seed it open. - useSupportedFeaturesStore.setState({ endpointCompatibility: null, shadowLinkSchemaRegistrySync: false }); + // The SR and role sync feature gates default to closed; api-mode tests seed SR open. + useSupportedFeaturesStore.setState({ + endpointCompatibility: null, + shadowLinkSchemaRegistrySync: false, + shadowLinkRoleSync: false, + }); }); test.each(testCases)('$description', async ({ actions, expectedFieldMaskPaths, verify }) => { @@ -352,7 +356,9 @@ describe('ShadowLinkEditPage', () => { // The test verifies form values, but the hook now builds the request internally // We need to build the request from form values to verify the update request structure const { buildDataplaneUpdateRequest } = await import('./shadowlink-edit-utils'); - const updateRequest = buildDataplaneUpdateRequest('test-shadow-link', formValuesArg, mockShadowLink); + const updateRequest = buildDataplaneUpdateRequest('test-shadow-link', formValuesArg, mockShadowLink, { + roleSyncSupported: true, + }); // Verify field mask includes all expected paths const fieldMaskPaths = updateRequest?.updateMask?.paths; @@ -424,7 +430,9 @@ describe('ShadowLinkEditPage', () => { const formValuesArg = mockUpdateShadowLink.mock.calls[0][0]; const { buildDataplaneUpdateRequest } = await import('./shadowlink-edit-utils'); - const updateRequest = buildDataplaneUpdateRequest(SHADOW_LINK_NAME, formValuesArg, mockShadowLink); + const updateRequest = buildDataplaneUpdateRequest(SHADOW_LINK_NAME, formValuesArg, mockShadowLink, { + roleSyncSupported: true, + }); expect(updateRequest.updateMask?.paths).toEqual(['configurations.schema_registry_sync_options']); const mode = updateRequest.shadowLink?.configurations?.schemaRegistrySyncOptions?.schemaRegistryShadowingMode; @@ -493,7 +501,9 @@ describe('ShadowLinkEditPage', () => { const formValuesArg = mockUpdateShadowLink.mock.calls[0][0]; const { buildDataplaneUpdateRequest } = await import('./shadowlink-edit-utils'); - const updateRequest = buildDataplaneUpdateRequest(SHADOW_LINK_NAME, formValuesArg, mockShadowLink); + const updateRequest = buildDataplaneUpdateRequest(SHADOW_LINK_NAME, formValuesArg, mockShadowLink, { + roleSyncSupported: true, + }); // Only the topic change goes out; the untouched SR slice emits no mask. expect(updateRequest.updateMask?.paths).toEqual(['configurations.topic_metadata_sync_options']); diff --git a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.test.ts b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.test.ts index a1b33bb2c6..4944dc11d0 100644 --- a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.test.ts +++ b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.test.ts @@ -89,7 +89,9 @@ describe('buildDataplaneUpdateRequest', () => { bootstrapServers: [...defaultFormValues.bootstrapServers, { value: 'localhost:9093' }], }; - const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink); + const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink, { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toEqual(['configurations.client_options']); expect(request.shadowLink?.configurations?.clientOptions?.bootstrapServers).toEqual([ @@ -108,7 +110,9 @@ describe('buildDataplaneUpdateRequest', () => { }, }; - const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink); + const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink, { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toHaveLength(2); expect(request.updateMask?.paths).toContain('configurations.client_options.tls_settings'); @@ -138,7 +142,9 @@ describe('buildDataplaneUpdateRequest', () => { ], }; - const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink); + const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink, { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toHaveLength(3); expect(request.updateMask?.paths).toContain('configurations.topic_metadata_sync_options'); @@ -187,7 +193,9 @@ describe('buildDataplaneUpdateRequest', () => { ], }; - const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink); + const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink, { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toEqual(['configurations.topic_metadata_sync_options']); expect(request.shadowLink?.configurations?.topicMetadataSyncOptions?.autoCreateShadowTopicFilters).toHaveLength(3); @@ -214,7 +222,9 @@ describe('buildDataplaneUpdateRequest', () => { }, }; - const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink); + const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink, { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toHaveLength(4); expect(request.updateMask?.paths).toContain('configurations.client_options.metadata_max_age_ms'); @@ -239,7 +249,9 @@ describe('buildDataplaneUpdateRequest', () => { consumers: [{ name: 'cross-consumer', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], }; - const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink); + const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink, { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toHaveLength(3); expect(request.updateMask?.paths).toContain('configurations.client_options'); @@ -263,7 +275,9 @@ describe('buildDataplaneUpdateRequest', () => { consumers: [{ name: 'selective-consumer', patternType: PatternType.LITERAL, filterType: FilterType.INCLUDE }], }; - const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink); + const request = buildDataplaneUpdateRequest('test-shadow-link', formValues, mockShadowLink, { + roleSyncSupported: true, + }); expect(request.updateMask?.paths).toHaveLength(2); expect(request.updateMask?.paths).toContain('configurations.topic_metadata_sync_options'); diff --git a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts index dc606b8502..e803e842e5 100644 --- a/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts +++ b/frontend/src/components/pages/shadowlinks/edit/shadowlink-edit-utils.ts @@ -594,22 +594,28 @@ export const buildControlplaneUpdateRequest = ( * Transform form values to UpdateShadowLinkRequest protobuf message (dataplane) * Only includes fields that have changed from the original shadow link */ -export const buildDataplaneUpdateRequest = (name: string, values: FormValues, originalShadowLink: ShadowLink) => { +export const buildDataplaneUpdateRequest = ( + name: string, + values: FormValues, + originalShadowLink: ShadowLink, + { roleSyncSupported }: { roleSyncSupported: boolean } +) => { // Build original form values for comparison const originalValues = buildDefaultFormValues(originalShadowLink); - // Get update values for all categories + // Get update values for all categories. Roles are skipped entirely on + // clusters without role sync (Redpanda < 26.2.0): no value, no mask path. const connectionUpdate = getUpdateValuesForConnection(values, originalValues); const topicsUpdate = getUpdateValuesForTopics(values, originalValues); const consumerGroupsUpdate = getUpdateValuesForConsumerGroups(values, originalValues); - const rolesUpdate = getUpdateValuesForRoles(values, originalValues); + const rolesUpdate = roleSyncSupported ? getUpdateValuesForRoles(values, originalValues) : undefined; const aclsUpdate = getUpdateValuesForACLs(values, originalValues); // No UI exposes interval/paused: round-trip them from the fetched link so // the whole-message mask path doesn't unpause a paused role sync task or // reset a custom interval const originalRoleSyncOptions = originalShadowLink.configurations?.roleSyncOptions; - if (originalRoleSyncOptions) { + if (rolesUpdate && originalRoleSyncOptions) { rolesUpdate.value.interval = originalRoleSyncOptions.interval; rolesUpdate.value.paused = originalRoleSyncOptions.paused; } @@ -620,7 +626,7 @@ export const buildDataplaneUpdateRequest = (name: string, values: FormValues, or clientOptions: connectionUpdate.value, topicMetadataSyncOptions: topicsUpdate.value, consumerOffsetSyncOptions: consumerGroupsUpdate.value, - roleSyncOptions: rolesUpdate.value, + roleSyncOptions: rolesUpdate?.value, securitySyncOptions: aclsUpdate.value, schemaRegistrySyncOptions: schemaRegistryUpdate.value, }); @@ -637,7 +643,7 @@ export const buildDataplaneUpdateRequest = (name: string, values: FormValues, or ...connectionUpdate.fieldMaskPaths, ...topicsUpdate.fieldMaskPaths, ...consumerGroupsUpdate.fieldMaskPaths, - ...rolesUpdate.fieldMaskPaths, + ...(rolesUpdate?.fieldMaskPaths ?? []), ...aclsUpdate.fieldMaskPaths, ...schemaRegistryUpdate.fieldMaskPaths, ], diff --git a/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts b/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts index 2ce96590ef..d930767892 100644 --- a/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts +++ b/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts @@ -16,23 +16,42 @@ import { Feature, useSupportedFeaturesStore } from 'state/supported-features'; /** * Seed the real supported-features store the way the app does at boot, so the - * Schema Registry sync gate exercises the actual endpoint-compatibility - * fail-closed logic instead of a mock: a supported endpoint opens the gate, + * shadow link feature gates exercise the actual endpoint-compatibility + * fail-closed logic instead of a mock: a supported endpoint opens its gate, * anything else (unsupported, absent, or a never-loaded null store) closes it. + * + * setEndpointCompatibility replaces the whole endpoints array, so seed every + * gate a test needs in one call — sequential per-gate calls would clobber + * each other. */ -export const setSchemaRegistrySyncGateSupported = (isSupported: boolean) => { +export const setShadowLinkGatesSupported = ({ + schemaRegistrySync = false, + roleSync = false, +}: { + schemaRegistrySync?: boolean; + roleSync?: boolean; +}) => { useSupportedFeaturesStore.getState().setEndpointCompatibility({ kafkaVersion: 'v26.2.0', endpoints: [ { endpoint: Feature.ShadowLinkSchemaRegistrySync.endpoint, method: Feature.ShadowLinkSchemaRegistrySync.method, - isSupported, + isSupported: schemaRegistrySync, + }, + { + endpoint: Feature.ShadowLinkRoleSync.endpoint, + method: Feature.ShadowLinkRoleSync.method, + isSupported: roleSync, }, ], }); }; +export const setSchemaRegistrySyncGateSupported = (isSupported: boolean) => { + setShadowLinkGatesSupported({ schemaRegistrySync: isSupported }); +}; + /** * Regex patterns for test IDs */ diff --git a/frontend/src/react-query/api/shadowlink.ts b/frontend/src/react-query/api/shadowlink.ts index 85e1cabba6..3ae1bf9215 100644 --- a/frontend/src/react-query/api/shadowlink.ts +++ b/frontend/src/react-query/api/shadowlink.ts @@ -73,6 +73,7 @@ import type { } from 'protogen/redpanda/core/admin/v2/shadow_link_pb'; import { useCallback, useMemo } from 'react'; import type { MessageInit, QueryOptions } from 'react-query/react-query.utils'; +import { useSupportedFeaturesStore } from 'state/supported-features'; import { useControlplaneDeleteShadowLinkMutation, @@ -412,6 +413,10 @@ export const useEditShadowLink = ( const dataplaneUpdate = useUpdateShadowLinkMutation(); const controlplaneUpdate = useControlplaneUpdateShadowLinkMutation(); + // Role sync needs Redpanda >= 26.2.0 (fails closed); on older clusters the + // update request must not carry role sync options at all. + const roleSyncSupported = useSupportedFeaturesStore((s) => s.shadowLinkRoleSync); + // Build form values based on data source const formValues = useMemo((): FormValues | undefined => { if (embedded && controlplaneShadowLink) { @@ -434,12 +439,21 @@ export const useEditShadowLink = ( } if (shadowLink) { // Use dataplane API with name - const request = buildDataplaneUpdateRequest(name, values, shadowLink); + const request = buildDataplaneUpdateRequest(name, values, shadowLink, { roleSyncSupported }); return dataplaneUpdate.mutateAsync(request); } return Promise.reject(new Error('No shadow link data available for update')); }, - [embedded, shadowLinkId, controlplaneShadowLink, shadowLink, name, controlplaneUpdate, dataplaneUpdate] + [ + embedded, + shadowLinkId, + controlplaneShadowLink, + shadowLink, + name, + controlplaneUpdate, + dataplaneUpdate, + roleSyncSupported, + ] ); // Check if data is available based on mode diff --git a/frontend/src/state/supported-features.ts b/frontend/src/state/supported-features.ts index b92bbdc948..f7f7ca573f 100644 --- a/frontend/src/state/supported-features.ts +++ b/frontend/src/state/supported-features.ts @@ -76,6 +76,10 @@ export class Feature { endpoint: '/api/shadow-links/schema-registry-sync', method: 'GET', }; + static readonly ShadowLinkRoleSync: FeatureEntry = { + endpoint: '/api/shadow-links/role-sync', + method: 'GET', + }; static readonly SchemaRegistryContexts: FeatureEntry = { endpoint: '/api/schema-registry/contexts', method: 'GET', @@ -96,6 +100,7 @@ function computeSupported(f: FeatureEntry, c: EndpointCompatibility | null): { s case Feature.SchemaRegistryACLApi.endpoint: case Feature.ShadowLinkService.endpoint: case Feature.ShadowLinkSchemaRegistrySync.endpoint: + case Feature.ShadowLinkRoleSync.endpoint: case Feature.GetQuotas.endpoint: case Feature.SchemaRegistryContexts.endpoint: case Feature.SQLService.endpoint: @@ -115,7 +120,8 @@ function computeSupported(f: FeatureEntry, c: EndpointCompatibility | null): { s f.endpoint.includes('.SecurityService') || f.endpoint.includes('.SecretService') || f.endpoint.includes('.SQLService') || - f.endpoint === Feature.ShadowLinkSchemaRegistrySync.endpoint + f.endpoint === Feature.ShadowLinkSchemaRegistrySync.endpoint || + f.endpoint === Feature.ShadowLinkRoleSync.endpoint ) { return { supported: false }; } @@ -178,6 +184,7 @@ function computeAllFeatures(c: EndpointCompatibility | null) { schemaRegistryACLApi: compute(Feature.SchemaRegistryACLApi), shadowLinkService: compute(Feature.ShadowLinkService), shadowLinkSchemaRegistrySync: compute(Feature.ShadowLinkSchemaRegistrySync), + shadowLinkRoleSync: compute(Feature.ShadowLinkRoleSync), schemaRegistryContexts: compute(Feature.SchemaRegistryContexts), sqlApi: compute(Feature.SQLService), featureErrors: errors, @@ -208,6 +215,7 @@ type SupportedFeaturesStore = { schemaRegistryACLApi: boolean; shadowLinkService: boolean; shadowLinkSchemaRegistrySync: boolean; + shadowLinkRoleSync: boolean; schemaRegistryContexts: boolean; sqlApi: boolean; @@ -294,6 +302,9 @@ const Features = { get shadowLinkSchemaRegistrySync() { return useSupportedFeaturesStore.getState().shadowLinkSchemaRegistrySync; }, + get shadowLinkRoleSync() { + return useSupportedFeaturesStore.getState().shadowLinkRoleSync; + }, get schemaRegistryContexts() { return useSupportedFeaturesStore.getState().schemaRegistryContexts; },