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/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', () => { + 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(); + 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..9029e629ef --- /dev/null +++ b/frontend/src/components/pages/shadowlinks/create/configuration/roles-step.tsx @@ -0,0 +1,195 @@ +/** + * 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 { useSupportedFeaturesStore } from 'state/supported-features'; + +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 roleSyncSupported = useSupportedFeaturesStore((s) => s.shadowLinkRoleSync); + + 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([]); + } + }; + + if (!roleSyncSupported) { + return null; + } + + 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.test.tsx b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx index 49ea32ac4f..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 @@ -71,10 +71,12 @@ import { addACLFilterCreate, addBootstrapServer, addConsumerFilterCreate, + addRoleFilterCreate, addTopicFilterCreate, enableTLS, navigateToConfigurationStep, setSchemaRegistrySyncGateSupported as seedSchemaRegistrySyncGate, + setShadowLinkGatesSupported, } from '../shadowlink-test-helpers'; const pristineFeatureStoreState = useSupportedFeaturesStore.getState(); @@ -105,6 +107,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 +242,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 +288,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 +336,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: [ @@ -423,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({})); @@ -500,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 698c25259a..b2aedef952 100644 --- a/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx +++ b/frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.tsx @@ -20,9 +20,8 @@ import { ACLFilterSchema, ConsumerOffsetSyncOptionsSchema, CreateShadowLinkRequestSchema, - FilterType, NameFilterSchema, - PatternType, + RoleSyncOptionsSchema, SecuritySettingsSyncOptionsSchema, ShadowLinkClientOptionsSchema, ShadowLinkConfigurationsSchema, @@ -33,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'; @@ -48,7 +48,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( @@ -74,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); @@ -97,14 +97,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: { @@ -150,25 +142,44 @@ const buildCreateShadowLinkRequest = (values: FormValues) => { ), }); + // 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, { - 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, @@ -180,6 +191,7 @@ const buildCreateShadowLinkRequest = (values: FormValues) => { clientOptions, topicMetadataSyncOptions, consumerOffsetSyncOptions, + roleSyncOptions, securitySyncOptions, schemaRegistrySyncOptions, }); @@ -206,6 +218,8 @@ export const ShadowLinkCreatePage = () => { } }, []); + const roleSyncSupported = useSupportedFeaturesStore((s) => s.shadowLinkRoleSync); + const { mutateAsync: createShadowLink, isPending: isCreating } = useCreateShadowLinkMutation({ onSuccess: () => { toast.success('Shadow link created'); @@ -219,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/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..ac37647614 100644 --- a/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx +++ b/frontend/src/components/pages/shadowlinks/details/config/configuration-shadowing.tsx @@ -29,16 +29,26 @@ 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 (
{filterLabel}
{filter.name ? ( - + {filter.name} ) : ( @@ -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; @@ -194,6 +209,7 @@ export const ConfigurationShadowing = ({ shadowLink }: ConfigurationShadowingPro @@ -201,10 +217,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/edit/build-update-request.test.ts b/frontend/src/components/pages/shadowlinks/edit/build-update-request.test.ts index d75c9709cf..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 @@ -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 }, @@ -366,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); }); @@ -378,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); }); @@ -387,13 +428,16 @@ 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(); 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(); }); @@ -404,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', @@ -430,13 +476,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,13 +499,15 @@ describe('buildDataplaneUpdateRequest', () => { ], enableSchemaRegistrySync: true, }, - expectedPathCount: 5, + expectedPathCount: 6, }, ])('should have $expectedPathCount paths when $description changed', ({ changes, expectedPathCount }) => { 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); }); @@ -467,11 +517,75 @@ 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(); + 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, { + 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 + 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', () => { @@ -499,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/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.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-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.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 abb9c6ce1f..e803e842e5 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); @@ -550,15 +594,31 @@ 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 = 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 (rolesUpdate && originalRoleSyncOptions) { + rolesUpdate.value.interval = originalRoleSyncOptions.interval; + rolesUpdate.value.paused = originalRoleSyncOptions.paused; + } const schemaRegistryUpdate = getUpdateValuesForSchemaRegistry(values, originalValues); // Build configurations with all category values @@ -566,6 +626,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 +643,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/controlplane.ts b/frontend/src/components/pages/shadowlinks/mappers/controlplane.ts index 43a73c64c9..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), }; @@ -445,6 +446,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.test.ts b/frontend/src/components/pages/shadowlinks/mappers/dataplane.test.ts index c048c8a409..0ed07a6469 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,100 @@ 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 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 332638c32b..7a7290ce96 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), }; @@ -443,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 */ @@ -487,6 +531,7 @@ 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); @@ -495,6 +540,7 @@ export const buildDefaultFormValues = (shadowLink: DataplaneShadowLink): FormVal ...connectionValues, ...topicsValues, ...consumerGroupsValues, + ...rolesValues, ...aclsValues, ...schemaRegistryValues, }; 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; }; 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 diff --git a/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts b/frontend/src/components/pages/shadowlinks/shadowlink-test-helpers.ts index 19df16efba..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 */ @@ -536,7 +555,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 +563,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 +577,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 */ 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; },