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}
+ >
+
+
+ ))}
+
+
+ append({
+ name: '',
+ patternType: PatternType.LITERAL,
+ filterType: FilterType.INCLUDE,
+ })
+ }
+ size="sm"
+ type="button"
+ variant="outline"
+ >
+ Add filter
+
+
+
+
+
+
+
+
+ );
+};
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;
},