You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@rocketmq.apache.org by li...@apache.org on 2026/10/19 09:00:34 UTC

(rocketmq-dashboard) branch rocketmq-studio updated: fix(acl): clear stale rows, drop page-local sorting and list principals beyond the page (#6715)

This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 2e211671c fix(acl): clear stale rows, drop page-local sorting and list principals beyond the page (#6715)
2e211671c is described below

commit 2e211671cf75df555984004e2af27ab5bdc6a913
Author: Loyal-Young <hj...@gmail.com>
AuthorDate: Mon Oct 19 17:00:26 2026 +0800

    fix(acl): clear stale rows, drop page-local sorting and list principals beyond the page (#6715)
    
    Batched integration of @Loyal-Young's ACL fixes. This branch carries five PRs' worth of changes; the
    individual PRs are closed as folded into this one.
    
    - #6715 — clear stale rule/user rows and totals when a filtered reload fails, so the table cannot keep
      offering edit/delete against rows that are no longer there.
    - #6912 — clear stale cluster examine results on a failed re-examine.
    - #6913 — drop the page-local sorters from the two paged ACL tables (server-side paging makes an
      in-page sort misleading).
    - #6914 — offer principals beyond the current user page when composing ACL rules.
    - #6717 — ignore invalid wildcard permission entries in the risk score instead of counting them as
      open wildcards.
    
    Maintainer fix on top: fold one over-width assertion in `aclRiskDiagnostics.test.ts` to match the
    configured print width.
    
    Verified locally against `rocketmq-studio`: `AclPage.test.tsx` + `aclRiskDiagnostics.test.ts` 42/42
    green, eslint clean, and reverting the #6715 guard turns its regression test red.
---
 web/src/pages/instance/__tests__/AclPage.test.tsx | 143 ++++++++++++++++++++++
 web/src/pages/instance/acl.tsx                    |  76 +++++++++---
 web/src/utils/aclRiskDiagnostics.test.ts          |  15 +++
 web/src/utils/aclRiskDiagnostics.ts               |   4 +-
 4 files changed, 219 insertions(+), 19 deletions(-)

diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx b/web/src/pages/instance/__tests__/AclPage.test.tsx
index 753923fb2..1b6a61fa8 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -89,6 +89,7 @@ describe('ACL page', () => {
   beforeEach(() => {
     vi.clearAllMocks();
     vi.mocked(instanceService.listInstances).mockResolvedValue([]);
+    vi.mocked(aclService.listAclUsers).mockResolvedValue([]);
 
     vi.mocked(aclService.listAclRules).mockResolvedValue({
       items: [
@@ -179,6 +180,77 @@ describe('ACL page', () => {
     expect(aclService.pageAclUsers).toHaveBeenCalledTimes(1);
   });
 
+  it('does not offer page-local sorting on either server-paginated ACL table', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<AclPage />);
+
+    expect(await screen.findByText('remote-user')).toBeInTheDocument();
+    expect(document.querySelectorAll('th.ant-table-column-has-sorters')).toHaveLength(0);
+
+    await user.click(screen.getByText('用户管理'));
+    expect(await screen.findByText('remote-admin')).toBeInTheDocument();
+    expect(document.querySelectorAll('th.ant-table-column-has-sorters')).toHaveLength(0);
+  });
+
+  it('offers an ACL principal outside the loaded user page and shows its admin badge', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    vi.mocked(aclService.listAclUsers).mockResolvedValue([
+      {
+        id: 11,
+        username: 'remote-admin',
+        accessKey: 'acce****3456',
+        secretKey: 'secr****7654',
+        admin: false,
+        clusters: ['cluster-a'],
+      },
+      {
+        id: 21,
+        username: 'directory-only-user',
+        accessKey: 'dire****1234',
+        secretKey: 'secr****4321',
+        admin: true,
+        clusters: ['cluster-a'],
+      },
+    ]);
+    vi.mocked(aclService.listAclRules).mockResolvedValue({
+      items: [{
+        id: 21,
+        principal: 'directory-only-user',
+        resource: 'orders',
+        resourceType: 'Topic',
+        resourcePattern: 'LITERAL',
+        actions: ['PUB'],
+        decision: 'ALLOW',
+        scope: 'cluster',
+        aclVersion: 2,
+        gmtCreate: '2026-07-23T00:00:00Z',
+      }],
+      total: 1,
+      page: 1,
+      size: 20,
+    });
+    renderWithProviders(<AclPage />);
+
+    await openRulesTab(user);
+    const row = await screen.findByRole('row', { name: /directory-only-user/ });
+    expect(within(row).getByText('管理员')).toBeInTheDocument();
+    await user.click(screen.getByRole('button', { name: /添加规则/ }));
+    const dialog = await screen.findByRole('dialog');
+    await user.click(within(dialog).getByLabelText('主体'));
+    await user.click(
+      await screen.findByText('directory-only-user', {
+        selector: '.ant-select-item-option-content',
+      }),
+    );
+    expect(
+      within(dialog).getByText('directory-only-user', { selector: '.ant-select-selection-item' }),
+    ).toBeInTheDocument();
+    expect(aclService.listAclUsers).toHaveBeenCalledWith({ instanceId: undefined });
+    expect(aclService.pageAclUsers).toHaveBeenCalledWith(
+      expect.objectContaining({ page: 1, pageSize: 20 }),
+    );
+  });
+
   it('reloads the server rule page after deleting one ACL rule', async () => {
     const user = userEvent.setup({ pointerEventsCheck: 0 });
     const confirmSpy = vi.spyOn(Modal, 'confirm').mockImplementation((config) => {
@@ -354,6 +426,38 @@ describe('ACL page', () => {
     expect(screen.getByText('cluster-a')).toBeInTheDocument();
   });
 
+  it('clears stale user rows and total when a filtered user reload fails', async () => {
+    renderWithProviders(<AclPage />);
+    const panel = screen.getByRole('tabpanel', { name: '用户管理' });
+    expect(await within(panel).findByText('remote-admin')).toBeInTheDocument();
+    expect(within(panel).getByText('共 1 个用户')).toBeInTheDocument();
+
+    vi.mocked(aclService.pageAclUsers).mockRejectedValueOnce(new Error('users unavailable'));
+    fireEvent.change(within(panel).getByRole('searchbox'), { target: { value: 'missing' } });
+
+    await waitFor(() => expect(aclService.pageAclUsers).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(within(panel).queryByText('remote-admin')).not.toBeInTheDocument());
+    expect(within(panel).queryByText('共 1 个用户')).not.toBeInTheDocument();
+  });
+
+  it('clears stale rule rows and total when a filtered rule reload fails', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    renderWithProviders(<AclPage />);
+    await openRulesTab(user);
+    const panel = screen.getByRole('tabpanel', { name: 'ACL 规则' });
+    expect(await within(panel).findByText('remote-topic')).toBeInTheDocument();
+    expect(within(panel).getByText('共 1 条规则')).toBeInTheDocument();
+
+    vi.mocked(aclService.listAclRules).mockRejectedValueOnce(new Error('rules unavailable'));
+    fireEvent.change(within(panel).getByPlaceholderText('搜索主体'), {
+      target: { value: 'missing' },
+    });
+
+    await waitFor(() => expect(aclService.listAclRules).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(within(panel).queryByText('remote-topic')).not.toBeInTheDocument());
+    expect(within(panel).queryByText('共 1 条规则')).not.toBeInTheDocument();
+  });
+
   it('opens on the user tab and lists it before the rule tab', async () => {
     renderWithProviders(<AclPage />);
 
@@ -952,6 +1056,45 @@ describe('ACL page', () => {
     expect(aclService.examineBrokerClusterAclConfig).toHaveBeenCalledTimes(1);
   });
 
+  it('removes a prior cluster result when a different cluster examine fails', async () => {
+    const user = userEvent.setup({ pointerEventsCheck: 0 });
+    vi.mocked(aclService.examineBrokerClusterAclConfig)
+      .mockResolvedValueOnce({
+        clusterId: 'cluster-a',
+        aclEnabled: true,
+        aclVersion: 'ACL 2.0',
+        globalWhiteRemoteAddresses: [],
+        accounts: [{
+          accessKey: 'cluster-a-account',
+          admin: false,
+          defaultTopicPerm: 'DENY',
+          defaultGroupPerm: 'DENY',
+          topicPerms: [],
+          groupPerms: [],
+        }],
+        accountCount: 1,
+      })
+      .mockRejectedValueOnce(new Error('cluster-b unavailable'));
+    renderWithProviders(<AclPage />);
+
+    await user.click(await screen.findByText('集群 ACL 配置'));
+    const clusterInput = screen.getByPlaceholderText('请输入集群 ID');
+    const examineButton = screen.getByRole('button', { name: /检\s*查\s*配\s*置/ });
+    await user.type(clusterInput, 'cluster-a');
+    await user.click(examineButton);
+    expect(await screen.findByText('cluster-a-account')).toBeInTheDocument();
+    expect(screen.getByText('集群: cluster-a')).toBeInTheDocument();
+
+    await user.clear(clusterInput);
+    await user.type(clusterInput, 'cluster-b');
+    await user.click(examineButton);
+    await waitFor(() => expect(aclService.examineBrokerClusterAclConfig).toHaveBeenCalledTimes(2));
+    await waitFor(() => expect(examineButton).not.toHaveClass('ant-btn-loading'));
+
+    expect(screen.queryByText('cluster-a-account')).not.toBeInTheDocument();
+    expect(screen.queryByText('集群: cluster-a')).not.toBeInTheDocument();
+  });
+
   it('keeps cluster config ownership with the latest examine request', async () => {
     const firstExamine =
       deferred<Awaited<ReturnType<typeof aclService.examineBrokerClusterAclConfig>>>();
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index 51f9eca91..15f5574c7 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -65,6 +65,7 @@ import {
   getAclUserCredentials,
   examineBrokerClusterAclConfig,
   listAclRules,
+  listAclUsers,
   pageAclUsers,
   pushAclToBroker,
   updateAclRule,
@@ -171,6 +172,11 @@ const AclPageContent = ({
   const [activeTab, setActiveTab] = useState('users');
   const [ruleRefreshKey, setRuleRefreshKey] = useState(0);
   const [userRefreshKey, setUserRefreshKey] = useState(0);
+  const [directoryState, setDirectoryState] = useState<{
+    instanceId?: string;
+    refreshKey: number;
+    users: NormalizedAclUser[];
+  }>({ instanceId: selectedInstanceId, refreshKey: userRefreshKey, users: [] });
   const [ruleTotal, setRuleTotal] = useState(0);
   const [rulePage, setRulePage] = useState(1);
   const [rulePageSize, setRulePageSize] = useState(20);
@@ -237,7 +243,11 @@ const AclPageContent = ({
         }
       })
       .catch(() => {
-        if (mounted) message.error(t('common.fetchDataFailed'));
+        if (mounted) {
+          setRules([]);
+          setRuleTotal(0);
+          message.error(t('common.fetchDataFailed'));
+        }
       })
       .finally(() => {
         if (mounted) setRulesLoading(false);
@@ -259,7 +269,11 @@ const AclPageContent = ({
         }
       })
       .catch(() => {
-        if (mounted) message.error(t('common.fetchDataFailed'));
+        if (mounted) {
+          setUsers([]);
+          setUserTotal(0);
+          message.error(t('common.fetchDataFailed'));
+        }
       })
       .finally(() => {
         if (mounted) setUsersLoading(false);
@@ -285,9 +299,38 @@ const AclPageContent = ({
     userRefreshKey,
   ]);
 
+  useEffect(() => {
+    let mounted = true;
+    void listAclUsers({ instanceId: selectedInstanceId })
+      .then((allUsers) => {
+        if (mounted) {
+          setDirectoryState({
+            instanceId: selectedInstanceId,
+            refreshKey: userRefreshKey,
+            users: allUsers.map(normalizeUser),
+          });
+        }
+      })
+      .catch(() => {
+        if (mounted) message.error(t('common.fetchDataFailed'));
+      });
+    return () => {
+      mounted = false;
+    };
+  }, [selectedInstanceId, userRefreshKey, t]);
+
   /* ─── Rule helpers ─── */
+  const principalDirectory =
+    directoryState.instanceId === selectedInstanceId &&
+    directoryState.refreshKey === userRefreshKey
+      ? directoryState.users
+      : [];
+  const principalUsers = [
+    ...principalDirectory,
+    ...users.filter((user) => !principalDirectory.some((entry) => entry.username === user.username)),
+  ];
   const isAdmin = (principal: string) =>
-    users.find((u) => u.username === principal)?.admin ?? false;
+    principalUsers.find((u) => u.username === principal)?.admin ?? false;
 
   const actionTagColor: Record<string, string> = {
     PUB: 'blue',
@@ -447,6 +490,7 @@ const AclPageContent = ({
         });
         const normalized = normalizeUser(updated);
         setUsers((prev) => prev.map((u) => (u.id === editingUser.id ? normalized : u)));
+        setUserRefreshKey((prev) => prev + 1);
         message.success(t('acl.userUpdated'));
       } else {
         await createAclUser({
@@ -496,6 +540,7 @@ const AclPageContent = ({
       });
       const normalized = normalizeUser(updated);
       setUsers((prev) => prev.map((u) => (u.id === user.id ? normalized : u)));
+      setUserRefreshKey((prev) => prev + 1);
       message.success(checked ? t('acl.adminSet') : t('acl.adminRemoved'));
     } catch {
       message.error(t('common.operationFailed'));
@@ -518,6 +563,7 @@ const AclPageContent = ({
     }
     const requestGeneration = examineRequestGenerationRef.current + 1;
     examineRequestGenerationRef.current = requestGeneration;
+    setClusterConfig(null);
     try {
       setConfigLoading(true);
       const config = await examineBrokerClusterAclConfig(clusterId);
@@ -705,7 +751,6 @@ const AclPageContent = ({
       key: 'principal',
       // 唯一可伸展列:容器比表宽时余量集中在此,其余列保持声明宽度
       minWidth: 200,
-      sorter: (a, b) => a.principal.localeCompare(b.principal),
       render: (text: string) => (
         <Space size={6}>
           <User size={14} color="#8c8c8c" weight="fill" />
@@ -723,7 +768,6 @@ const AclPageContent = ({
       title: t('acl.resource'),
       key: 'resource',
       minWidth: 240,
-      sorter: (a, b) => a.resource.localeCompare(b.resource),
       render: (_: unknown, record: AclRule) => (
         <Space size={6}>
           <span style={{ fontFamily: 'monospace', fontWeight: 500 }}>{record.resource}</span>
@@ -757,7 +801,6 @@ const AclPageContent = ({
       dataIndex: 'decision',
       key: 'decision',
       width: 80,
-      sorter: (a, b) => a.decision.localeCompare(b.decision),
       render: (decision: string) => (
         <Tag color={decision === 'ALLOW' ? 'green' : 'red'} style={{ fontWeight: 600 }}>
           {decision === 'ALLOW' ? t('acl.allow') : t('acl.deny')}
@@ -769,7 +812,6 @@ const AclPageContent = ({
       dataIndex: 'aclVersion',
       key: 'aclVersion',
       width: 100,
-      sorter: (a, b) => String(a.aclVersion).localeCompare(String(b.aclVersion)),
       render: (version: AclRule['aclVersion']) => (
         <Tag color={String(version) === '2.0' ? 'geekblue' : 'default'}>{version}</Tag>
       ),
@@ -779,7 +821,6 @@ const AclPageContent = ({
       dataIndex: 'scope',
       key: 'scope',
       width: 100,
-      sorter: (a, b) => a.scope.localeCompare(b.scope),
       render: (scope: string) => (
         <span style={{ fontSize: 14 }}>
           {scope === 'cluster' ? t('acl.cluster') : t('acl.namespace')}
@@ -791,7 +832,6 @@ const AclPageContent = ({
       dataIndex: 'gmtCreate',
       key: 'gmtCreate',
       width: 160,
-      sorter: (a, b) => (a.gmtCreate ?? '').localeCompare(b.gmtCreate ?? ''),
       render: (iso?: string | null) => (
         <span style={{ fontSize: 14, color: '#8c8c8c' }}>{formatDate(iso)}</span>
       ),
@@ -841,7 +881,6 @@ const AclPageContent = ({
       key: 'username',
       // 唯一可伸展列:容器比表宽时余量集中在此,其余列保持声明宽度
       minWidth: 200,
-      sorter: (a, b) => a.username.localeCompare(b.username),
       render: (text: string, record: NormalizedAclUser) => (
         <Space size={6}>
           <User size={14} color="#8c8c8c" weight="fill" />
@@ -860,7 +899,6 @@ const AclPageContent = ({
       dataIndex: 'accessKey',
       key: 'accessKey',
       width: 220,
-      sorter: (a, b) => a.accessKey.localeCompare(b.accessKey),
       render: (text: string, record: NormalizedAclUser) => {
         const revealed = revealedKeys.has(record.id);
         const fullAccessKey = credentialsByUser[String(record.id)]?.accessKey ?? text;
@@ -908,7 +946,6 @@ const AclPageContent = ({
       dataIndex: 'admin',
       key: 'admin',
       width: 100,
-      sorter: (a, b) => Number(a.admin) - Number(b.admin),
       render: (val: boolean, record: AclUser) => (
         <Switch
           checked={val}
@@ -924,7 +961,6 @@ const AclPageContent = ({
       dataIndex: 'clusters',
       key: 'clusters',
       width: 280,
-      sorter: (a, b) => a.clusters.length - b.clusters.length,
       render: (clusters: string[]) => (
         <TagList
           items={clusters.map((c) => ({ key: c, label: c, color: 'processing' }))}
@@ -938,7 +974,6 @@ const AclPageContent = ({
       dataIndex: 'gmtCreate',
       key: 'gmtCreate',
       width: 160,
-      sorter: (a, b) => (a.gmtCreate ?? '').localeCompare(b.gmtCreate ?? ''),
       render: (iso?: string | null) => (
         <span style={{ fontSize: 14, color: '#8c8c8c' }}>{formatDate(iso)}</span>
       ),
@@ -1440,6 +1475,9 @@ const AclPageContent = ({
 
                   {clusterConfig && (
                     <div style={{ paddingBottom: 16 }}>
+                      <div style={{ marginBottom: 12, color: '#8c8c8c', fontSize: 14 }}>
+                        {t('acl.examineCluster')}: {clusterConfig.clusterId}
+                      </div>
                       {/* Summary cards */}
                       <div
                         style={{
@@ -1595,10 +1633,12 @@ const AclPageContent = ({
               disabled={tencentRoleMode && !!editingRule}
               showSearch
               optionFilterProp="label"
-              options={users.map((u) => ({
-                value: u.username,
-                label: u.username,
-              }))}
+              options={[
+                ...principalUsers.map((u) => ({ value: u.username, label: u.username })),
+                ...(editingRule && !principalUsers.some((u) => u.username === editingRule.principal)
+                  ? [{ value: editingRule.principal, label: editingRule.principal }]
+                  : []),
+              ]}
             />
           </Form.Item>
 
diff --git a/web/src/utils/aclRiskDiagnostics.test.ts b/web/src/utils/aclRiskDiagnostics.test.ts
index 4b5c1c13e..210a7f4ab 100644
--- a/web/src/utils/aclRiskDiagnostics.test.ts
+++ b/web/src/utils/aclRiskDiagnostics.test.ts
@@ -170,6 +170,21 @@ describe('ACL risk diagnostics', () => {
     );
   });
 
+  it('reports malformed wildcard entries without counting them as grants', () => {
+    const diagnostics = analyzeAclRisk(
+      config({
+        accounts: [account({ topicPerms: ['*=INVALID'], groupPerms: ['*'] })],
+      }),
+    );
+
+    expect(diagnostics.summary.wildcardPermissionAccountCount).toBe(0);
+    expect(
+      diagnostics.issues.filter((item) => item.code === 'INVALID_PERMISSION_ENTRY'),
+    ).toHaveLength(2);
+    expect(diagnostics.issues.map((item) => item.code)).not.toContain('WILDCARD_TOPIC_PERMISSION');
+    expect(diagnostics.issues.map((item) => item.code)).not.toContain('WILDCARD_GROUP_PERMISSION');
+  });
+
   it('reads the comma-joined action list the server writes for a multi-action rule', () => {
     const diagnostics = analyzeAclRisk(
       config({
diff --git a/web/src/utils/aclRiskDiagnostics.ts b/web/src/utils/aclRiskDiagnostics.ts
index dfe61fcb0..17a47ab57 100644
--- a/web/src/utils/aclRiskDiagnostics.ts
+++ b/web/src/utils/aclRiskDiagnostics.ts
@@ -206,7 +206,9 @@ const hasDefaultAllow = (account: PlainAccessConfig): boolean =>
   PERMISSION_ALLOW_RANK[normalizePermission(account.defaultGroupPerm)] > 0;
 
 const wildcardEntries = (entries: ParsedPermissionEntry[]): ParsedPermissionEntry[] =>
-  entries.filter((entry) => isWildcardResource(entry.resource) && entry.permission !== 'DENY');
+  entries.filter(
+    (entry) => isWildcardResource(entry.resource) && PERMISSION_ALLOW_RANK[entry.permission] > 0,
+  );
 
 const hasWildcardPermission = (account: PlainAccessConfig): boolean =>
   wildcardEntries(parsePermissionEntries(account.topicPerms)).length > 0 ||