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 1b8911374 feat(acl): add ACL risk diagnostics (#3010)
1b8911374 is described below
commit 1b8911374a77cde8b5d12dff5e145dce584aeaa0
Author: coder999o <[email protected]>
AuthorDate: Fri Sep 4 14:55:52 2026 +0800
feat(acl): add ACL risk diagnostics (#3010)
---
web/src/i18n/translations.ts | 18 +
web/src/pages/instance/__tests__/AclPage.test.tsx | 43 +-
web/src/pages/instance/acl.tsx | 193 ++++++++
web/src/utils/aclRiskDiagnostics.test.ts | 172 +++++++
web/src/utils/aclRiskDiagnostics.ts | 557 ++++++++++++++++++++++
5 files changed, 980 insertions(+), 3 deletions(-)
diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 71f0c18c6..b010c0dd2 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1101,6 +1101,24 @@ const translations: Record<string, Record<Lang, string>>
= {
'acl.permType': { zh: '权限类型', en: 'Permission' },
'acl.plainAccessSaved': { zh: 'Plain Access 账号已保存', en: 'Plain access
account saved' },
'acl.configExamined': { zh: '集群 ACL 配置已加载', en: 'Cluster ACL config loaded'
},
+ 'acl.riskDiagnostics': { zh: 'ACL 风险诊断', en: 'ACL Risk Diagnostics' },
+ 'acl.riskHealthyMessage': {
+ zh: '未发现高风险 ACL 配置',
+ en: 'No high-risk ACL configuration found',
+ },
+ 'acl.riskIssues': { zh: '风险项', en: 'Risks' },
+ 'acl.riskRecommendations': { zh: '处理建议', en: 'Recommendations' },
+ 'acl.riskSeverity': { zh: '级别', en: 'Severity' },
+ 'acl.riskCritical': { zh: '高风险', en: 'Critical' },
+ 'acl.riskWarning': { zh: '关注', en: 'Warning' },
+ 'acl.riskInfo': { zh: '提示', en: 'Info' },
+ 'acl.riskItem': { zh: '风险项', en: 'Risk' },
+ 'acl.riskEvidence': { zh: '依据', en: 'Evidence' },
+ 'acl.riskRecommendation': { zh: '建议', en: 'Recommendation' },
+ 'acl.riskAdminAccounts': { zh: '管理员账号', en: 'Admin Accounts' },
+ 'acl.riskDefaultAllows': { zh: '默认放行账号', en: 'Default Allows' },
+ 'acl.riskWildcardAccounts': { zh: '通配授权账号', en: 'Wildcard Accounts' },
+ 'acl.riskBroadWhitelists': { zh: '宽松白名单', en: 'Broad Whitelists' },
// ─── Topic Page (unique keys, duplicates merged into Topic section below)
───
'topic.name': { zh: 'Topic 名称', en: 'Topic Name' },
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index a753c34c5..309903968 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -620,7 +620,7 @@ describe('ACL page', () => {
await user.type(clusterInput, 'DefaultCluster');
await user.click(await screen.findByRole('button', { name: /检\s*查\s*配\s*置/
}));
- expect(await screen.findByText('rocketmq-admin')).toBeInTheDocument();
+ expect(await screen.findAllByText('rocketmq-admin')).not.toHaveLength(0);
expect(screen.getByText('ACL 2.0')).toBeInTheDocument();
expect(aclService.examineBrokerClusterAclConfig).toHaveBeenCalledTimes(1);
});
@@ -717,8 +717,8 @@ describe('ACL page', () => {
});
});
- expect(await screen.findByText('latest-account')).toBeInTheDocument();
- expect(screen.getByText('ACL latest')).toBeInTheDocument();
+ expect(await screen.findAllByText('latest-account')).not.toHaveLength(0);
+ expect(screen.getAllByText('ACL latest')).not.toHaveLength(0);
await waitFor(() =>
expect(examineButton).not.toHaveClass('ant-btn-loading'));
expect(successSpy).toHaveBeenCalledTimes(1);
expect(errorSpy).not.toHaveBeenCalled();
@@ -803,6 +803,43 @@ describe('ACL page', () => {
expect(errorSpy).not.toHaveBeenCalled();
});
+ it('renders ACL risk diagnostics for the examined cluster config', async ()
=> {
+ const user = userEvent.setup();
+ vi.mocked(aclService.examineBrokerClusterAclConfig).mockResolvedValue({
+ clusterId: 'DefaultCluster',
+ aclEnabled: true,
+ aclVersion: 'ACL 2.0',
+ globalWhiteRemoteAddresses: ['*'],
+ accounts: [
+ {
+ accessKey: 'admin-ak',
+ admin: true,
+ whiteRemoteAddress: '0.0.0.0/0',
+ defaultTopicPerm: 'ALL',
+ defaultGroupPerm: 'ALL',
+ topicPerms: ['*=ALL'],
+ groupPerms: ['*=SUB'],
+ },
+ ],
+ accountCount: 1,
+ });
+ renderWithProviders(<AclPage />);
+
+ await user.click(await screen.findByText('集群 ACL 配置'));
+ await user.click(await screen.findByRole('button', { name: /检\s*查\s*配\s*置/
}));
+
+ const diagnostics = await screen.findByTestId('acl-risk-diagnostics');
+ expect(within(diagnostics).getByText('ACL 风险诊断')).toBeInTheDocument();
+ expect(within(diagnostics).getByText('ACL 配置存在高风险')).toBeInTheDocument();
+ expect(within(diagnostics).getByText('全局 IP 白名单范围过大')).toBeInTheDocument();
+ expect(within(diagnostics).getByText('管理员账号可从宽网段访问')).toBeInTheDocument();
+ expect(
+ within(diagnostics).getAllByText(
+ '将默认 Topic 权限改为 DENY,并为确需访问的 Topic 配置最小权限。',
+ ),
+ ).not.toHaveLength(0);
+ });
+
it('creates a plain access account', async () => {
const user = userEvent.setup();
vi.mocked(aclService.createAndUpdatePlainAccessConfig).mockResolvedValue({
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index d08ab9884..58f8924d1 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -17,6 +17,7 @@
import { useEffect, useRef, useState } from 'react';
import {
+ Alert,
Table,
Card,
Button,
@@ -33,6 +34,8 @@ import {
Badge,
Typography,
Flex,
+ Progress,
+ Statistic,
message,
} from 'antd';
import {
@@ -66,6 +69,7 @@ import {
import type { AclRule, AclUser, AclClusterConfig, PlainAccessConfig } from
'../../api/acl';
import { useInstanceFilter } from '../../hooks/useInstanceFilter';
import { tableScrollX } from '../../utils/table';
+import { analyzeAclRisk, type AclRiskIssue } from
'../../utils/aclRiskDiagnostics';
type AclEntityId = AclRule['id'];
type AclRuleFormValues = Pick<
@@ -856,6 +860,18 @@ const AclPageContent = ({
DENY: 'red',
};
+ const riskSeverityColor: Record<AclRiskIssue['severity'], string> = {
+ critical: 'red',
+ warning: 'gold',
+ info: 'blue',
+ };
+
+ const riskSeverityText: Record<AclRiskIssue['severity'], string> = {
+ critical: t('acl.riskCritical'),
+ warning: t('acl.riskWarning'),
+ info: t('acl.riskInfo'),
+ };
+
const plainColumns: ColumnsType<PlainAccessConfig> = [
{
title: t('acl.accessKey'),
@@ -942,6 +958,108 @@ const AclPageContent = ({
},
];
+ const aclRiskDiagnostics = clusterConfig ? analyzeAclRisk(clusterConfig) :
null;
+
+ const aclRiskProgressStatus =
+ aclRiskDiagnostics?.status === 'critical'
+ ? 'exception'
+ : aclRiskDiagnostics?.status === 'healthy'
+ ? 'success'
+ : 'normal';
+
+ const aclRiskStrokeColor =
+ aclRiskDiagnostics?.status === 'critical'
+ ? '#ff4d4f'
+ : aclRiskDiagnostics?.status === 'warning'
+ ? '#faad14'
+ : '#52c41a';
+
+ const aclRiskSummaryItems = aclRiskDiagnostics
+ ? [
+ {
+ key: 'adminAccountCount',
+ label: t('acl.riskAdminAccounts'),
+ value: aclRiskDiagnostics.summary.adminAccountCount,
+ },
+ {
+ key: 'defaultAllowAccountCount',
+ label: t('acl.riskDefaultAllows'),
+ value: aclRiskDiagnostics.summary.defaultAllowAccountCount,
+ },
+ {
+ key: 'wildcardPermissionAccountCount',
+ label: t('acl.riskWildcardAccounts'),
+ value: aclRiskDiagnostics.summary.wildcardPermissionAccountCount,
+ },
+ {
+ key: 'broadWhitelistCount',
+ label: t('acl.riskBroadWhitelists'),
+ value: aclRiskDiagnostics.summary.broadWhitelistCount,
+ },
+ ]
+ : [];
+
+ const aclRiskColumns: ColumnsType<AclRiskIssue> = [
+ {
+ title: t('acl.riskSeverity'),
+ dataIndex: 'severity',
+ key: 'severity',
+ width: 100,
+ render: (severity: AclRiskIssue['severity']) => (
+ <Tag
color={riskSeverityColor[severity]}>{riskSeverityText[severity]}</Tag>
+ ),
+ },
+ {
+ title: t('acl.riskItem'),
+ key: 'item',
+ width: 260,
+ render: (_: unknown, record) => (
+ <Space direction="vertical" size={2}>
+ <Typography.Text strong>{record.title}</Typography.Text>
+ <Typography.Text
type="secondary">{record.description}</Typography.Text>
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.accessKey'),
+ dataIndex: 'account',
+ key: 'account',
+ width: 160,
+ render: (account?: string) =>
+ account ? (
+ <Typography.Text style={{ fontFamily: 'monospace'
}}>{account}</Typography.Text>
+ ) : (
+ <span style={{ color: '#8c8c8c' }}>-</span>
+ ),
+ },
+ {
+ title: t('acl.riskEvidence'),
+ dataIndex: 'evidence',
+ key: 'evidence',
+ width: 220,
+ render: (evidence: string[]) => (
+ <Space size={4} wrap>
+ {evidence.length === 0 ? (
+ <span style={{ color: '#8c8c8c' }}>-</span>
+ ) : (
+ evidence.map((item) => (
+ <Typography.Text key={item} code>
+ {item}
+ </Typography.Text>
+ ))
+ )}
+ </Space>
+ ),
+ },
+ {
+ title: t('acl.riskRecommendation'),
+ dataIndex: 'recommendation',
+ key: 'recommendation',
+ width: 280,
+ render: (text: string) => <Typography.Text>{text}</Typography.Text>,
+ },
+ ];
+
/* ═══════════════════════════════════════════
Render
═══════════════════════════════════════════ */
@@ -1222,6 +1340,81 @@ const AclPageContent = ({
)}
</div>
+ {aclRiskDiagnostics && (
+ <div
+ data-testid="acl-risk-diagnostics"
+ style={{
+ border: '1px solid #f0f0f0',
+ borderRadius: 8,
+ padding: 16,
+ marginBottom: 16,
+ }}
+ >
+ <Flex gap={20} align="center" wrap="wrap" style={{
marginBottom: 16 }}>
+ <Progress
+ type="circle"
+ percent={aclRiskDiagnostics.score}
+ size={96}
+ status={aclRiskProgressStatus}
+ strokeColor={aclRiskStrokeColor}
+ format={(percent) => `${percent}`}
+ />
+ <div style={{ minWidth: 220, flex: '1 1 260px' }}>
+ <Typography.Title level={5} style={{ margin: 0
}}>
+ {t('acl.riskDiagnostics')}
+ </Typography.Title>
+ <Typography.Text type="secondary">
+ {aclRiskDiagnostics.statusText}
+ </Typography.Text>
+ <div style={{ marginTop: 8 }}>
+ <Tag color={aclRiskDiagnostics.statusColor}>
+ {t('acl.riskIssues')}:
{aclRiskDiagnostics.issues.length}
+ </Tag>
+ <Tag>
+ {t('acl.accountCount')}:
{aclRiskDiagnostics.summary.accountCount}
+ </Tag>
+ </div>
+ </div>
+ <Flex gap={16} wrap="wrap" style={{ flex: '2 1
420px' }}>
+ {aclRiskSummaryItems.map((item) => (
+ <div key={item.key} style={{ minWidth: 118 }}>
+ <Statistic
+ title={item.label}
+ value={item.value}
+ valueStyle={{ fontSize: 22 }}
+ />
+ </div>
+ ))}
+ </Flex>
+ </Flex>
+
+ {aclRiskDiagnostics.issues.length === 0 ? (
+ <Alert type="success" showIcon
message={t('acl.riskHealthyMessage')} />
+ ) : (
+ <Table<AclRiskIssue>
+ columns={aclRiskColumns}
+ dataSource={aclRiskDiagnostics.issues}
+ rowKey="id"
+ pagination={false}
+ size="small"
+ scroll={{ x: tableScrollX(aclRiskColumns) }}
+ style={{ marginBottom: 12 }}
+ />
+ )}
+
+ <div>
+ <Typography.Text
strong>{t('acl.riskRecommendations')}</Typography.Text>
+ <ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
+ {aclRiskDiagnostics.recommendations.map((item)
=> (
+ <li key={item}>
+ <Typography.Text>{item}</Typography.Text>
+ </li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ )}
+
{/* Accounts table */}
<Table<PlainAccessConfig>
columns={plainColumns}
diff --git a/web/src/utils/aclRiskDiagnostics.test.ts
b/web/src/utils/aclRiskDiagnostics.test.ts
new file mode 100644
index 000000000..c3648ec86
--- /dev/null
+++ b/web/src/utils/aclRiskDiagnostics.test.ts
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { AclClusterConfig, PlainAccessConfig } from '../api/acl';
+import { analyzeAclRisk } from './aclRiskDiagnostics';
+
+const account = (overrides: Partial<PlainAccessConfig>): PlainAccessConfig =>
({
+ accessKey: 'svc-order',
+ secretKey: '******',
+ whiteRemoteAddress: '10.0.2.15',
+ admin: false,
+ defaultTopicPerm: 'DENY',
+ defaultGroupPerm: 'DENY',
+ topicPerms: ['order-events=PUB'],
+ groupPerms: ['cg-order=SUB'],
+ ...overrides,
+});
+
+const config = (overrides: Partial<AclClusterConfig>): AclClusterConfig => ({
+ clusterId: 'DefaultCluster',
+ aclEnabled: true,
+ aclVersion: 'ACL 2.0',
+ globalWhiteRemoteAddresses: [],
+ accounts: [account({})],
+ accountCount: 1,
+ ...overrides,
+});
+
+describe('ACL risk diagnostics', () => {
+ it('marks a least-privilege ACL config as healthy', () => {
+ const diagnostics = analyzeAclRisk(config({}));
+
+ expect(diagnostics.status).toBe('healthy');
+ expect(diagnostics.score).toBe(100);
+ expect(diagnostics.issues).toEqual([]);
+ expect(diagnostics.summary).toEqual({
+ accountCount: 1,
+ adminAccountCount: 0,
+ defaultAllowAccountCount: 0,
+ wildcardPermissionAccountCount: 0,
+ broadWhitelistCount: 0,
+ duplicateAccessKeyCount: 0,
+ });
+ expect(diagnostics.recommendations).toEqual([
+ '保持默认权限为 DENY,新增账号时继续按业务资源最小授权。',
+ ]);
+ });
+
+ it('flags disabled ACL and missing accounts as critical risks', () => {
+ const diagnostics = analyzeAclRisk(
+ config({
+ aclEnabled: false,
+ aclVersion: 'ACL 1.0',
+ accounts: [],
+ accountCount: 0,
+ globalWhiteRemoteAddresses: ['*'],
+ }),
+ );
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.score).toBeLessThan(60);
+ expect(diagnostics.summary.accountCount).toBe(0);
+ expect(diagnostics.summary.broadWhitelistCount).toBe(1);
+ expect(diagnostics.issues.map((item) => item.code)).toEqual(
+ expect.arrayContaining([
+ 'ACL_DISABLED',
+ 'LEGACY_ACL_VERSION',
+ 'NO_PLAIN_ACCESS_ACCOUNTS',
+ 'BROAD_GLOBAL_WHITELIST',
+ ]),
+ );
+ });
+
+ it('finds duplicated access keys and over-broad admin access', () => {
+ const diagnostics = analyzeAclRisk(
+ config({
+ accounts: [
+ account({
+ accessKey: 'admin-ak',
+ admin: true,
+ whiteRemoteAddress: '0.0.0.0/0',
+ defaultTopicPerm: 'ALL',
+ defaultGroupPerm: 'ALL',
+ }),
+ account({
+ accessKey: 'admin-ak',
+ admin: true,
+ whiteRemoteAddress: '10.0.0.0/8',
+ defaultTopicPerm: 'DENY',
+ defaultGroupPerm: 'DENY',
+ }),
+ ],
+ accountCount: 2,
+ }),
+ );
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary).toMatchObject({
+ accountCount: 2,
+ adminAccountCount: 2,
+ defaultAllowAccountCount: 1,
+ broadWhitelistCount: 2,
+ duplicateAccessKeyCount: 1,
+ });
+ expect(diagnostics.issues.map((item) => item.code)).toEqual(
+ expect.arrayContaining([
+ 'MULTIPLE_ADMIN_ACCOUNTS',
+ 'DUPLICATE_ACCESS_KEY',
+ 'BROAD_ACCOUNT_WHITELIST',
+ 'ADMIN_WITH_BROAD_ACCESS',
+ 'DEFAULT_TOPIC_ALLOW',
+ 'DEFAULT_GROUP_ALLOW',
+ ]),
+ );
+ });
+
+ it('reports wildcard permissions and malformed permission entries', () => {
+ const diagnostics = analyzeAclRisk(
+ config({
+ accounts: [
+ account({
+ topicPerms: ['*=ALL', 'billing-events=PUB'],
+ groupPerms: ['*=SUB', 'invalid-entry'],
+ }),
+ ],
+ }),
+ );
+
+ expect(diagnostics.status).toBe('critical');
+ expect(diagnostics.summary.wildcardPermissionAccountCount).toBe(1);
+ expect(diagnostics.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ code: 'WILDCARD_TOPIC_PERMISSION',
+ severity: 'critical',
+ evidence: ['*=ALL'],
+ }),
+ expect.objectContaining({
+ code: 'WILDCARD_GROUP_PERMISSION',
+ severity: 'warning',
+ evidence: ['*=SUB'],
+ }),
+ expect.objectContaining({
+ code: 'INVALID_PERMISSION_ENTRY',
+ severity: 'warning',
+ evidence: ['invalid-entry'],
+ }),
+ ]),
+ );
+ expect(diagnostics.recommendations).toEqual(
+ expect.arrayContaining([
+ '将通配 Topic 授权收敛为具体 Topic 或业务前缀,并避免 *=ALL。',
+ '按 resource=PUB、resource=SUB、resource=ALL 或 resource=DENY 的格式修正条目。',
+ ]),
+ );
+ });
+});
diff --git a/web/src/utils/aclRiskDiagnostics.ts
b/web/src/utils/aclRiskDiagnostics.ts
new file mode 100644
index 000000000..0cbda1159
--- /dev/null
+++ b/web/src/utils/aclRiskDiagnostics.ts
@@ -0,0 +1,557 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { AclClusterConfig, PlainAccessConfig } from '../api/acl';
+
+export type AclRiskStatus = 'healthy' | 'warning' | 'critical';
+export type AclRiskSeverity = Exclude<AclRiskStatus, 'healthy'> | 'info';
+
+export type AclRiskIssueCode =
+ | 'ACL_DISABLED'
+ | 'NO_PLAIN_ACCESS_ACCOUNTS'
+ | 'LEGACY_ACL_VERSION'
+ | 'BROAD_GLOBAL_WHITELIST'
+ | 'BROAD_ACCOUNT_WHITELIST'
+ | 'DUPLICATE_ACCESS_KEY'
+ | 'MISSING_ACCESS_KEY'
+ | 'MULTIPLE_ADMIN_ACCOUNTS'
+ | 'ADMIN_WITH_BROAD_ACCESS'
+ | 'DEFAULT_TOPIC_ALLOW'
+ | 'DEFAULT_GROUP_ALLOW'
+ | 'WILDCARD_TOPIC_PERMISSION'
+ | 'WILDCARD_GROUP_PERMISSION'
+ | 'INVALID_PERMISSION_ENTRY';
+
+export interface AclRiskIssue {
+ id: string;
+ code: AclRiskIssueCode;
+ severity: AclRiskSeverity;
+ title: string;
+ description: string;
+ account?: string;
+ evidence: string[];
+ recommendation: string;
+}
+
+export interface AclRiskSummary {
+ accountCount: number;
+ adminAccountCount: number;
+ defaultAllowAccountCount: number;
+ wildcardPermissionAccountCount: number;
+ broadWhitelistCount: number;
+ duplicateAccessKeyCount: number;
+}
+
+export interface AclRiskDiagnostics {
+ status: AclRiskStatus;
+ statusText: string;
+ statusColor: 'success' | 'warning' | 'error';
+ score: number;
+ summary: AclRiskSummary;
+ issues: AclRiskIssue[];
+ recommendations: string[];
+}
+
+type AclPermission = 'DENY' | 'PUB' | 'SUB' | 'ALL' | 'UNKNOWN';
+
+interface ParsedPermissionEntry {
+ raw: string;
+ resource: string;
+ permission: AclPermission;
+}
+
+type WhitelistClass = 'open' | 'broad' | 'scoped';
+
+const STATUS_TEXT: Record<AclRiskStatus, string> = {
+ healthy: 'ACL 配置健康',
+ warning: 'ACL 配置需要关注',
+ critical: 'ACL 配置存在高风险',
+};
+
+const STATUS_COLOR: Record<AclRiskStatus, 'success' | 'warning' | 'error'> = {
+ healthy: 'success',
+ warning: 'warning',
+ critical: 'error',
+};
+
+const PERMISSION_ALLOW_RANK: Record<AclPermission, number> = {
+ DENY: 0,
+ PUB: 1,
+ SUB: 1,
+ ALL: 2,
+ UNKNOWN: 0,
+};
+
+const OPEN_WHITELIST_VALUES = new Set(['*', '0.0.0.0/0', '::/0', '0/0']);
+
+const normalizePermission = (value?: string | null): AclPermission => {
+ const normalized = (value ?? '').trim().toUpperCase();
+ if (
+ normalized === 'DENY' ||
+ normalized === 'PUB' ||
+ normalized === 'SUB' ||
+ normalized === 'ALL'
+ ) {
+ return normalized;
+ }
+ return 'UNKNOWN';
+};
+
+const splitWhitelist = (value?: string | null): string[] =>
+ (value ?? '')
+ .split(/[,\s;]+/)
+ .map((item) => item.trim())
+ .filter(Boolean);
+
+const cidrPrefix = (address: string): number | null => {
+ const match = address.match(/\/(\d{1,3})$/);
+ if (!match) return null;
+ const prefix = Number(match[1]);
+ return Number.isFinite(prefix) ? prefix : null;
+};
+
+const classifyWhitelistAddress = (address: string): WhitelistClass => {
+ const normalized = address.trim().toLowerCase();
+ if (!normalized) return 'scoped';
+ if (OPEN_WHITELIST_VALUES.has(normalized)) return 'open';
+ if (normalized.includes('*')) return 'open';
+
+ const prefix = cidrPrefix(normalized);
+ if (prefix === null) return 'scoped';
+ if (normalized.includes(':')) {
+ return prefix <= 16 ? 'broad' : 'scoped';
+ }
+ return prefix <= 8 ? 'broad' : 'scoped';
+};
+
+const classifyWhitelist = (addresses: string[]): WhitelistClass => {
+ if (addresses.some((address) => classifyWhitelistAddress(address) ===
'open')) return 'open';
+ if (addresses.some((address) => classifyWhitelistAddress(address) ===
'broad')) return 'broad';
+ return 'scoped';
+};
+
+const parsePermissionEntry = (entry: string): ParsedPermissionEntry => {
+ const raw = entry.trim();
+ const separatorIndex = raw.search(/[:=]/);
+ if (separatorIndex < 0) {
+ return {
+ raw,
+ resource: raw,
+ permission: 'UNKNOWN',
+ };
+ }
+ return {
+ raw,
+ resource: raw.slice(0, separatorIndex).trim(),
+ permission: normalizePermission(raw.slice(separatorIndex + 1)),
+ };
+};
+
+const parsePermissionEntries = (entries?: string[]): ParsedPermissionEntry[] =>
+ (entries ?? []).map(parsePermissionEntry).filter((entry) => entry.raw.length
> 0);
+
+const isWildcardResource = (resource: string): boolean => {
+ const normalized = resource.trim();
+ return normalized === '*' || normalized === '*>*' || normalized === '*/*';
+};
+
+const accountKey = (account: PlainAccessConfig, index: number): string =>
+ (account.accessKey || `account-${index + 1}`).trim();
+
+const issue = (
+ code: AclRiskIssueCode,
+ severity: AclRiskSeverity,
+ title: string,
+ description: string,
+ recommendation: string,
+ options: {
+ account?: string;
+ evidence?: string[];
+ id?: string;
+ } = {},
+): AclRiskIssue => ({
+ id: options.id ?? [options.account, code, ...(options.evidence ??
[])].filter(Boolean).join(':'),
+ code,
+ severity,
+ title,
+ description,
+ account: options.account,
+ evidence: options.evidence ?? [],
+ recommendation,
+});
+
+const hasDefaultAllow = (account: PlainAccessConfig): boolean =>
+ PERMISSION_ALLOW_RANK[normalizePermission(account.defaultTopicPerm)] > 0 ||
+ PERMISSION_ALLOW_RANK[normalizePermission(account.defaultGroupPerm)] > 0;
+
+const wildcardEntries = (entries: ParsedPermissionEntry[]):
ParsedPermissionEntry[] =>
+ entries.filter((entry) => isWildcardResource(entry.resource) &&
entry.permission !== 'DENY');
+
+const hasWildcardPermission = (account: PlainAccessConfig): boolean =>
+ wildcardEntries(parsePermissionEntries(account.topicPerms)).length > 0 ||
+ wildcardEntries(parsePermissionEntries(account.groupPerms)).length > 0;
+
+const collectDuplicateAccessKeys = (accounts: PlainAccessConfig[]):
Set<string> => {
+ const seen = new Set<string>();
+ const duplicates = new Set<string>();
+
+ accounts.forEach((account, index) => {
+ const key = accountKey(account, index);
+ if (!account.accessKey?.trim()) return;
+ if (seen.has(key)) duplicates.add(key);
+ seen.add(key);
+ });
+
+ return duplicates;
+};
+
+const accountWhitelistClass = (account: PlainAccessConfig): WhitelistClass =>
+ classifyWhitelist(splitWhitelist(account.whiteRemoteAddress));
+
+const addDefaultPermissionIssues = (
+ issues: AclRiskIssue[],
+ account: PlainAccessConfig,
+ accessKey: string,
+) => {
+ const topicPermission = normalizePermission(account.defaultTopicPerm);
+ const groupPermission = normalizePermission(account.defaultGroupPerm);
+
+ if (topicPermission === 'ALL') {
+ issues.push(
+ issue(
+ 'DEFAULT_TOPIC_ALLOW',
+ 'critical',
+ '默认 Topic 权限过大',
+ '该账号默认允许所有 Topic 操作,新增 Topic 会自动继承高权限。',
+ '将默认 Topic 权限改为 DENY,并为确需访问的 Topic 配置最小权限。',
+ { account: accessKey, evidence:
[`defaultTopicPerm=${account.defaultTopicPerm ?? '-'}`] },
+ ),
+ );
+ } else if (topicPermission === 'PUB' || topicPermission === 'SUB') {
+ issues.push(
+ issue(
+ 'DEFAULT_TOPIC_ALLOW',
+ 'warning',
+ '默认 Topic 权限非 DENY',
+ '该账号会自动获得新增 Topic 的默认访问能力,权限边界依赖命名规范。',
+ '优先使用 DENY 作为默认 Topic 权限,再通过 Topic 权限列表授权。',
+ { account: accessKey, evidence:
[`defaultTopicPerm=${account.defaultTopicPerm ?? '-'}`] },
+ ),
+ );
+ }
+
+ if (groupPermission === 'ALL') {
+ issues.push(
+ issue(
+ 'DEFAULT_GROUP_ALLOW',
+ 'critical',
+ '默认 Group 权限过大',
+ '该账号默认允许所有 Consumer Group 操作,新增 Group 会自动继承高权限。',
+ '将默认 Group 权限改为 DENY,并为确需订阅的 Group 配置最小权限。',
+ { account: accessKey, evidence:
[`defaultGroupPerm=${account.defaultGroupPerm ?? '-'}`] },
+ ),
+ );
+ } else if (groupPermission === 'PUB' || groupPermission === 'SUB') {
+ issues.push(
+ issue(
+ 'DEFAULT_GROUP_ALLOW',
+ 'warning',
+ '默认 Group 权限非 DENY',
+ '该账号会自动获得新增 Consumer Group 的默认访问能力。',
+ '优先使用 DENY 作为默认 Group 权限,再通过 Group 权限列表授权。',
+ { account: accessKey, evidence:
[`defaultGroupPerm=${account.defaultGroupPerm ?? '-'}`] },
+ ),
+ );
+ }
+};
+
+const addWildcardPermissionIssues = (
+ issues: AclRiskIssue[],
+ account: PlainAccessConfig,
+ accessKey: string,
+) => {
+ const topicWildcards =
wildcardEntries(parsePermissionEntries(account.topicPerms));
+ const groupWildcards =
wildcardEntries(parsePermissionEntries(account.groupPerms));
+
+ topicWildcards.forEach((entry) => {
+ issues.push(
+ issue(
+ 'WILDCARD_TOPIC_PERMISSION',
+ entry.permission === 'ALL' ? 'critical' : 'warning',
+ 'Topic 通配授权过大',
+ '该账号通过通配资源获得 Topic 访问能力,可能覆盖未来新增 Topic。',
+ '将通配 Topic 授权收敛为具体 Topic 或业务前缀,并避免 *=ALL。',
+ { account: accessKey, evidence: [entry.raw] },
+ ),
+ );
+ });
+
+ groupWildcards.forEach((entry) => {
+ issues.push(
+ issue(
+ 'WILDCARD_GROUP_PERMISSION',
+ entry.permission === 'ALL' ? 'critical' : 'warning',
+ 'Group 通配授权过大',
+ '该账号通过通配资源获得 Consumer Group 访问能力,可能覆盖未来新增 Group。',
+ '将通配 Group 授权收敛为具体 Group 或业务前缀,并避免 *=ALL。',
+ { account: accessKey, evidence: [entry.raw] },
+ ),
+ );
+ });
+};
+
+const addInvalidPermissionEntryIssues = (
+ issues: AclRiskIssue[],
+ account: PlainAccessConfig,
+ accessKey: string,
+) => {
+ const invalidEntries = [
+ ...parsePermissionEntries(account.topicPerms),
+ ...parsePermissionEntries(account.groupPerms),
+ ].filter((entry) => entry.permission === 'UNKNOWN');
+
+ invalidEntries.forEach((entry) => {
+ issues.push(
+ issue(
+ 'INVALID_PERMISSION_ENTRY',
+ 'warning',
+ '权限条目格式无法识别',
+ '该权限条目没有明确的 PUB、SUB、ALL 或 DENY 决策,诊断无法判断最终权限。',
+ '按 resource=PUB、resource=SUB、resource=ALL 或 resource=DENY 的格式修正条目。',
+ { account: accessKey, evidence: [entry.raw] },
+ ),
+ );
+ });
+};
+
+const addAccountIssues = (
+ issues: AclRiskIssue[],
+ account: PlainAccessConfig,
+ index: number,
+ duplicateAccessKeys: Set<string>,
+) => {
+ const accessKey = accountKey(account, index);
+ const whitelistClass = accountWhitelistClass(account);
+ const whitelist = splitWhitelist(account.whiteRemoteAddress);
+
+ if (!account.accessKey?.trim()) {
+ issues.push(
+ issue(
+ 'MISSING_ACCESS_KEY',
+ 'critical',
+ 'Access Key 缺失',
+ 'Plain Access 账号缺少 Access Key,无法形成可审计的身份边界。',
+ '补全 Access Key,或删除无法识别身份的账号配置。',
+ { account: accessKey, id: `${index}:MISSING_ACCESS_KEY` },
+ ),
+ );
+ }
+
+ if (duplicateAccessKeys.has(accessKey)) {
+ issues.push(
+ issue(
+ 'DUPLICATE_ACCESS_KEY',
+ 'critical',
+ 'Access Key 重复',
+ '同一个 Access Key 出现在多个 Plain Access 账号中,权限合并结果容易被误判。',
+ '保留唯一账号定义,合并必要权限后删除重复条目。',
+ {
+ account: accessKey,
+ evidence: [accessKey],
+ id: `${index}:${accessKey}:DUPLICATE_ACCESS_KEY`,
+ },
+ ),
+ );
+ }
+
+ if (whitelistClass === 'open' || whitelistClass === 'broad') {
+ issues.push(
+ issue(
+ 'BROAD_ACCOUNT_WHITELIST',
+ whitelistClass === 'open' ? 'critical' : 'warning',
+ '账号 IP 白名单范围过大',
+ '该账号的 IP 白名单覆盖范围过宽,弱化了 ACL 账号和网络来源的双重约束。',
+ '将账号白名单收敛到应用出口地址或可信网段。',
+ { account: accessKey, evidence: whitelist.length ? whitelist :
['<empty>'] },
+ ),
+ );
+ }
+
+ if (account.admin && (whitelistClass === 'open' || whitelistClass ===
'broad')) {
+ issues.push(
+ issue(
+ 'ADMIN_WITH_BROAD_ACCESS',
+ 'critical',
+ '管理员账号可从宽网段访问',
+ '管理员账号叠加宽松 IP 白名单后,误用或泄露影响范围会扩大到整个集群。',
+ '为管理员账号配置专用 Access Key、强约束 IP 白名单,并尽量减少长期管理员账号。',
+ { account: accessKey, evidence: whitelist.length ? whitelist :
['<empty>'] },
+ ),
+ );
+ }
+
+ addDefaultPermissionIssues(issues, account, accessKey);
+ addWildcardPermissionIssues(issues, account, accessKey);
+ addInvalidPermissionEntryIssues(issues, account, accessKey);
+};
+
+const buildSummary = (config: AclClusterConfig): AclRiskSummary => {
+ const duplicateAccessKeys = collectDuplicateAccessKeys(config.accounts);
+ const broadGlobalCount = config.globalWhiteRemoteAddresses.filter((address)
=> {
+ const whitelistClass = classifyWhitelistAddress(address);
+ return whitelistClass === 'open' || whitelistClass === 'broad';
+ }).length;
+
+ const accountBroadCount = config.accounts.filter((account) => {
+ const whitelistClass = accountWhitelistClass(account);
+ return whitelistClass === 'open' || whitelistClass === 'broad';
+ }).length;
+
+ return {
+ accountCount: config.accountCount ?? config.accounts.length,
+ adminAccountCount: config.accounts.filter((account) =>
account.admin).length,
+ defaultAllowAccountCount: config.accounts.filter(hasDefaultAllow).length,
+ wildcardPermissionAccountCount:
config.accounts.filter(hasWildcardPermission).length,
+ broadWhitelistCount: broadGlobalCount + accountBroadCount,
+ duplicateAccessKeyCount: duplicateAccessKeys.size,
+ };
+};
+
+const scoreDiagnostics = (issues: AclRiskIssue[]): number => {
+ const penalty = issues.reduce((sum, item) => {
+ if (item.severity === 'critical') return sum + 25;
+ if (item.severity === 'warning') return sum + 10;
+ return sum + 4;
+ }, 0);
+ return Math.max(0, 100 - penalty);
+};
+
+const statusFromIssues = (issues: AclRiskIssue[], score: number):
AclRiskStatus => {
+ if (issues.some((item) => item.severity === 'critical') || score < 60)
return 'critical';
+ if (issues.some((item) => item.severity === 'warning') || score < 90) return
'warning';
+ return 'healthy';
+};
+
+const buildRecommendations = (issues: AclRiskIssue[]): string[] => {
+ const recommendations: string[] = [];
+ const seen = new Set<string>();
+
+ issues.forEach((item) => {
+ if (seen.has(item.recommendation)) return;
+ seen.add(item.recommendation);
+ recommendations.push(item.recommendation);
+ });
+
+ if (recommendations.length === 0) {
+ recommendations.push('保持默认权限为 DENY,新增账号时继续按业务资源最小授权。');
+ }
+
+ return recommendations.slice(0, 6);
+};
+
+export const analyzeAclRisk = (config: AclClusterConfig): AclRiskDiagnostics
=> {
+ const issues: AclRiskIssue[] = [];
+ const accounts = config.accounts ?? [];
+ const duplicateAccessKeys = collectDuplicateAccessKeys(accounts);
+ const globalWhitelistClass =
classifyWhitelist(config.globalWhiteRemoteAddresses ?? []);
+
+ if (!config.aclEnabled) {
+ issues.push(
+ issue(
+ 'ACL_DISABLED',
+ 'critical',
+ 'ACL 未启用',
+ '当前集群没有启用 ACL,客户端访问主要依赖网络边界。',
+ '在生产集群启用 ACL,并为管理员和应用账号配置最小权限。',
+ { evidence: [`clusterId=${config.clusterId}`] },
+ ),
+ );
+ }
+
+ if (!config.aclVersion.toUpperCase().includes('2.0')) {
+ issues.push(
+ issue(
+ 'LEGACY_ACL_VERSION',
+ 'info',
+ 'ACL 版本较旧或未知',
+ '当前版本不是明确的 ACL 2.0,部分细粒度权限能力可能不可用。',
+ '确认集群 ACL 版本,并在升级窗口评估迁移到 ACL 2.0。',
+ { evidence: [config.aclVersion || '<unknown>'] },
+ ),
+ );
+ }
+
+ if (accounts.length === 0) {
+ issues.push(
+ issue(
+ 'NO_PLAIN_ACCESS_ACCOUNTS',
+ config.aclEnabled ? 'critical' : 'warning',
+ '未配置 Plain Access 账号',
+ '集群配置中没有 Plain Access 账号,启用 ACL 后可能导致客户端或运维账号无法认证。',
+ '至少配置一个受控管理员账号和必要的应用账号,再启用严格 ACL 策略。',
+ { evidence: [`accountCount=${config.accountCount ?? 0}`] },
+ ),
+ );
+ }
+
+ if (globalWhitelistClass === 'open' || globalWhitelistClass === 'broad') {
+ issues.push(
+ issue(
+ 'BROAD_GLOBAL_WHITELIST',
+ globalWhitelistClass === 'open' ? 'critical' : 'warning',
+ '全局 IP 白名单范围过大',
+ '全局白名单会绕过账号级权限判断,过宽网段会降低 ACL 的实际隔离效果。',
+ '删除全局通配白名单,改为按账号配置必要的应用出口地址。',
+ { evidence: config.globalWhiteRemoteAddresses },
+ ),
+ );
+ }
+
+ const adminAccounts = accounts.filter((account) => account.admin);
+ if (adminAccounts.length > 1) {
+ issues.push(
+ issue(
+ 'MULTIPLE_ADMIN_ACCOUNTS',
+ 'warning',
+ '管理员账号数量偏多',
+ '多个长期管理员账号会增加凭据轮转和误授权的管理成本。',
+ '保留最少数量的管理员账号,并将日常应用访问改为非管理员账号。',
+ { evidence: adminAccounts.map((account, index) => accountKey(account,
index)) },
+ ),
+ );
+ }
+
+ accounts.forEach((account, index) => {
+ addAccountIssues(issues, account, index, duplicateAccessKeys);
+ });
+
+ const score = scoreDiagnostics(issues);
+ const status = statusFromIssues(issues, score);
+
+ return {
+ status,
+ statusText: STATUS_TEXT[status],
+ statusColor: STATUS_COLOR[status],
+ score,
+ summary: buildSummary({
+ ...config,
+ accounts,
+ }),
+ issues,
+ recommendations: buildRecommendations(issues),
+ };
+};