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

FreeOnePlus pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-mcp-server.git


The following commit(s) were added to refs/heads/master by this push:
     new bbd4f32  fix: use public Doris role metadata (#99)
bbd4f32 is described below

commit bbd4f32c8324c8d78062b6c8ba27ba82e0133a09
Author: Yijia Su <[email protected]>
AuthorDate: Wed Jul 29 17:49:32 2026 +0800

    fix: use public Doris role metadata (#99)
    
    Co-authored-by: FreeOnePlus <[email protected]>
---
 MCP-2026-07-28-DEVELOPMENT-LEDGER.md               | 56 +++++++++++--
 doris_mcp_server/utils/db.py                       | 27 +++++-
 doris_mcp_server/utils/security_analytics_tools.py | 61 ++++++++++----
 test/protocol/stdio_capability_server.py           | 83 ++++++++++++++++++
 test/protocol/test_mcp_v2_protocol.py              | 57 +++++++++++++
 test/tools/test_tools_operation_guard.py           | 97 ++++++++++++++++++++++
 test/utils/test_db.py                              | 37 +++++++++
 7 files changed, 393 insertions(+), 25 deletions(-)

diff --git a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md 
b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
index 19229d3..f85f837 100644
--- a/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
+++ b/MCP-2026-07-28-DEVELOPMENT-LEDGER.md
@@ -140,7 +140,7 @@
 | `CORE-009` | P2 | manager 模块职责拆分 | CORE-005 | 不改变行为前提下缩小超大文件,模块边界有测试 | 
`BACKLOG` |
 | `CORE-010` | P1 | 修复 SQL profile 分析未绑定 `auth_context` | 无 | 真实 Doris 
调用不再触发局部变量未赋值;鉴权上下文覆盖测试通过 | `DONE` |
 | `CORE-011` | P1 | 修复数据新鲜度空阈值比较 | 无 | 阈值缺失或为 `None` 时返回类型化错误/默认值,不抛 
`TypeError` | `DONE` |
-| `COMPAT-001` | P1 | Doris 4.0 元数据字段兼容 | 无 | Doris 4.0.5 的角色/权限查询不再依赖不存在的 
`Default_role` 字段 | `READY` |
+| `COMPAT-001` | P1 | Doris 4.0 元数据字段兼容 | 无 | Doris 4.0.5 的角色/权限查询不再依赖不存在的 
`Default_role` 字段 | `DONE` |
 | `COMPAT-002` | P2 | FE/BE HTTP 端点独立配置 | SEC-018 | SQL、FE HTTP、BE HTTP 
可分别配置主机/端口并通过代理/隧道环境测试 | `BACKLOG` |
 
 ## 7. 测试、构建和发布台账
@@ -510,16 +510,60 @@ recovery result: org_tenant=47040
 - STDIO modern/legacy:同样覆盖缺省与显式空阈值,随后真实查询成功;
 - 连接通过既有 SSH key 和临时本地隧道完成;凭据未写入仓库、测试或台账,探针完成后服务与隧道均已关闭。
 
+提交与评审回执:
+
+- commit:`7fe79bc fix: handle unknown data freshness safely`
+- Draft 
PR:[apache/doris-mcp-server#98](https://github.com/apache/doris-mcp-server/pull/98)
+
+### COMPAT-001
+
+Doris 4.0 角色分析改用公开 RBAC 元数据命令 `SHOW ALL GRANTS`,不再读取
+`mysql.user.Default_role`。当当前账号无权查看全部授权时,降级为 `SHOW GRANTS`
+读取当前用户授权。返回列会按列名归一化,支持多角色、空角色和带 host 的用户身份。
+
+角色元数据属于内部控制数据:固定 SQL 仍然经过当前 `auth_context` 的 SQL 安全校验,
+但通过显式 `mask_result=False` 跳过结果脱敏,避免 `UserIdentity` 被身份证规则破坏后使
+角色分析全部退化为 `unknown`。普通查询默认仍然执行结果脱敏。
+
+自动化验证:
+
+- 单元路径:覆盖多角色、空角色默认值和 `SHOW ALL GRANTS` 无权限时的
+  `SHOW GRANTS` 降级;
+- 数据库连接层:断言关闭控制数据脱敏时仍会执行 SQL 安全校验;
+- Streamable HTTP:生产角色分析路径返回 `operator -> root`;
+- 真实子进程 STDIO modern/legacy:同一路径返回 `operator`,进程保持可用;
+- 完整 pytest:`356 passed / 57 skipped / 0 failed / 252 warnings`;
+- `uv lock --check`、新增测试完整 Ruff、运行时严重错误规则 Ruff、`compileall`、
+  `uv build` 和 `git diff --check` 全部通过。
+
+真实 Doris 验证:
+
+```text
+environment: 192.168.31.63 / hhm_dt_sim
+Doris version: doris-4.0.5-rc01-59de8c4c524
+root role: operator
+hhm_nl2sql_reader role: hhm_nl2sql_readonly
+recovery result: org_tenant=47040
+```
+
+- 实库确认 `mysql.user` 不存在 `Default_role`,`SHOW ALL GRANTS` 返回
+  `UserIdentity` 和 `Roles`;
+- 生产 `_get_user_roles` 直接读取真实 RBAC 映射成功;
+- HTTP modern/legacy:`analyze_data_access_patterns` 返回 `operator -> root`,
+  随后真实查询成功;
+- STDIO modern/legacy:同样返回真实角色映射,随后真实查询成功;
+- 连接通过既有 SSH key 和临时本地隧道完成;凭据未写入仓库、测试或台账,
+  探针完成后服务与隧道均已关闭。
+
 ## 11. 下一开发批次
 
 批次:`BATCH-02-CONFORMANCE-AND-ERROR-SEMANTICS`
 
 按以下顺序推进:
 
-1. `COMPAT-001`:继续修复真实 Doris 已复现缺陷;
-2. `TEST-003`:运行官方 `server-stateless` Conformance;
-3. `TEST-005` / `TEST-012`:补权限不足、超时、故障恢复和工具错误路径;
-4. `PROTO-018` / `DOC-001` / `DOC-002`:版本单一来源和迁移文档;
-5. `SEC-003`~`SEC-005`:进入下一安全批,完成非 loopback fail-closed。
+1. `TEST-003`:运行官方 `server-stateless` Conformance;
+2. `TEST-005` / `TEST-012`:补权限不足、超时、故障恢复和工具错误路径;
+3. `PROTO-018` / `DOC-001` / `DOC-002`:版本单一来源和迁移文档;
+4. `SEC-003`~`SEC-005`:进入下一安全批,完成非 loopback fail-closed。
 
 `REL-001` 已达成。`REL-002` 仍由官方 Conformance、完整真实 Doris 矩阵、P0/P1 安全项、Compose 
和发布门阻塞。
diff --git a/doris_mcp_server/utils/db.py b/doris_mcp_server/utils/db.py
index 862c390..1151a98 100644
--- a/doris_mcp_server/utils/db.py
+++ b/doris_mcp_server/utils/db.py
@@ -148,8 +148,19 @@ class DorisConnection:
         self.owner_pool = owner_pool
         self.logger = get_logger(__name__)
 
-    async def execute(self, sql: str, params: tuple | None = None, 
auth_context=None) -> QueryResult:
-        """Execute SQL query"""
+    async def execute(
+        self,
+        sql: str,
+        params: tuple | None = None,
+        auth_context=None,
+        *,
+        mask_result: bool = True,
+    ) -> QueryResult:
+        """Execute SQL after validation, with optional result masking.
+
+        ``mask_result=False`` is reserved for trusted internal control data
+        that must remain machine-readable after authorization.
+        """
         start_time = time.time()
 
         try:
@@ -188,8 +199,16 @@ class DorisConnection:
 
                 # If security manager exists and has auth context, apply data 
masking
                 final_data = list(data) if data else []
-                if self.security_manager and auth_context and final_data:
-                    final_data = await 
self.security_manager.apply_data_masking(final_data, auth_context)
+                if (
+                    self.security_manager
+                    and auth_context
+                    and final_data
+                    and mask_result
+                ):
+                    final_data = await 
self.security_manager.apply_data_masking(
+                        final_data,
+                        auth_context,
+                    )
 
                 metadata = {"columns": columns, "query": sql, "params": params}
                 if security_result:
diff --git a/doris_mcp_server/utils/security_analytics_tools.py 
b/doris_mcp_server/utils/security_analytics_tools.py
index 25d24ca..8c52c62 100644
--- a/doris_mcp_server/utils/security_analytics_tools.py
+++ b/doris_mcp_server/utils/security_analytics_tools.py
@@ -494,24 +494,55 @@ class SecurityAnalyticsTools:
     async def _get_user_roles(self, connection) -> Dict[str, List[str]]:
         """Get user roles mapping"""
         try:
-            # Try to get user role information
-            roles_sql = """
-            SELECT 
-                User as user_name,
-                COALESCE(Default_role, 'default') as role_name
-            FROM mysql.user
-            """
-            
             auth_context = get_auth_context()
-            result = await connection.execute(roles_sql, 
auth_context=auth_context)
-            
+            try:
+                result = await connection.execute(
+                    "SHOW ALL GRANTS",
+                    auth_context=auth_context,
+                    mask_result=False,
+                )
+            except Exception as all_grants_error:
+                logger.debug(
+                    "SHOW ALL GRANTS unavailable, falling back to current 
user: %s",
+                    all_grants_error,
+                )
+                result = await connection.execute(
+                    "SHOW GRANTS",
+                    auth_context=auth_context,
+                    mask_result=False,
+                )
+
             user_roles = defaultdict(list)
             if result.data:
                 for row in result.data:
-                    user_name = row.get("user_name", "")
-                    role_name = row.get("role_name", "default")
-                    if user_name:
-                        user_roles[user_name].append(role_name)
+                    normalized = {
+                        str(key).lower().replace("_", ""): value
+                        for key, value in row.items()
+                    }
+                    identity = normalized.get("useridentity") or 
normalized.get("user")
+                    if not identity:
+                        continue
+
+                    user_name = (
+                        str(identity)
+                        .split("@", 1)[0]
+                        .strip()
+                        .strip("'\"`")
+                    )
+                    raw_roles = normalized.get("roles")
+                    if isinstance(raw_roles, (list, tuple, set)):
+                        role_names = list(raw_roles)
+                    else:
+                        role_names = str(raw_roles or "").split(",")
+                    role_names = [
+                        str(role).strip().strip("'\"`")
+                        for role in role_names
+                        if str(role).strip().strip("'\"`")
+                    ] or ["default"]
+
+                    for role_name in role_names:
+                        if role_name not in user_roles[user_name]:
+                            user_roles[user_name].append(role_name)
             
             return dict(user_roles)
             
@@ -785,4 +816,4 @@ class SecurityAnalyticsTools:
                 "action": "Consider implementing more granular role-based 
access control"
             })
         
-        return recommendations 
\ No newline at end of file
+        return recommendations
diff --git a/test/protocol/stdio_capability_server.py 
b/test/protocol/stdio_capability_server.py
index 0991a46..25847a7 100644
--- a/test/protocol/stdio_capability_server.py
+++ b/test/protocol/stdio_capability_server.py
@@ -38,6 +38,7 @@ from doris_mcp_server.tools.tools_manager import 
DorisToolsManager
 from doris_mcp_server.utils.analysis_tools import SQLAnalyzer
 from doris_mcp_server.utils.data_governance_tools import DataGovernanceTools
 from doris_mcp_server.utils.db import QueryResult
+from doris_mcp_server.utils.security_analytics_tools import 
SecurityAnalyticsTools
 
 REQUIRED_EXTENSION = "io.apache.doris/read"
 
@@ -106,6 +107,78 @@ class FreshnessGovernanceTools(DataGovernanceTools):
         }
 
 
+class RoleMetadataConnection:
+    async def execute(
+        self,
+        sql: str,
+        params=None,
+        auth_context=None,
+        *,
+        mask_result: bool = True,
+    ) -> QueryResult:
+        assert mask_result is False
+        if sql.strip() == "SHOW ALL GRANTS":
+            return QueryResult(
+                data=[
+                    {
+                        "UserIdentity": "'root'@'%'",
+                        "Roles": "operator",
+                    }
+                ],
+                metadata={},
+                execution_time=0.01,
+                row_count=1,
+                sql=sql,
+            )
+        raise RuntimeError("Unknown column 'Default_role'")
+
+
+class RoleConnectionManager:
+    def __init__(self) -> None:
+        self.connection = RoleMetadataConnection()
+
+    async def get_connection(self, session_id: str) -> RoleMetadataConnection:
+        return self.connection
+
+
+class RoleSecurityAnalyticsTools(SecurityAnalyticsTools):
+    async def _get_audit_log_data(
+        self,
+        connection,
+        start_date,
+        end_date,
+        include_system_users,
+    ) -> list[dict]:
+        return [{"user_name": "root"}]
+
+    async def _analyze_user_access_patterns(
+        self,
+        audit_data: list[dict],
+        min_query_threshold: int,
+    ) -> list[dict]:
+        return [
+            {
+                "user_name": "root",
+                "access_stats": {"total_queries": 1},
+                "query_type_distribution": {"SELECT": 1},
+            }
+        ]
+
+    async def _detect_security_anomalies(
+        self,
+        audit_data: list[dict],
+        user_access_analysis: list[dict],
+    ) -> list[dict]:
+        return []
+
+    async def _generate_access_insights(
+        self,
+        user_access_analysis: list[dict],
+        role_analysis: dict,
+    ) -> dict:
+        return {}
+
+
 class OneToolManager:
     def __init__(self) -> None:
         connection_manager = ProfileConnectionManager()
@@ -114,6 +187,7 @@ class OneToolManager:
         self.freshness_router.data_governance_tools = FreshnessGovernanceTools(
             connection_manager
         )
+        self.role_analyzer = 
RoleSecurityAnalyticsTools(RoleConnectionManager())
 
     async def list_tools(self) -> list[Tool]:
         return [
@@ -148,6 +222,11 @@ class OneToolManager:
                     },
                 },
             ),
+            Tool(
+                name="analyze_data_access_patterns",
+                description="Exercise Doris 4 role metadata compatibility.",
+                input_schema={"type": "object", "properties": {}},
+            ),
         ]
 
     async def call_tool(self, name: str, arguments: dict) -> str:
@@ -162,6 +241,10 @@ class OneToolManager:
             return json.dumps(
                 await 
self.freshness_router._monitor_data_freshness_tool(arguments)
             )
+        if name == "analyze_data_access_patterns":
+            return json.dumps(
+                await self.role_analyzer.analyze_data_access_patterns()
+            )
         return "{}"
 
 
diff --git a/test/protocol/test_mcp_v2_protocol.py 
b/test/protocol/test_mcp_v2_protocol.py
index 624a419..30252ca 100644
--- a/test/protocol/test_mcp_v2_protocol.py
+++ b/test/protocol/test_mcp_v2_protocol.py
@@ -701,6 +701,38 @@ async def 
test_http_unknown_freshness_uses_default_threshold():
         assert payload["table_freshness"]["org_tenant"]["status"] == "unknown"
 
 
[email protected]
+async def test_http_doris4_role_metadata_uses_public_grants_command():
+    app = create_test_server(
+        tools_manager=ProfileToolManager(),
+    ).streamable_http_app(
+        json_response=True,
+        stateless_http=True,
+        host="127.0.0.1",
+        transport_security=create_transport_security("127.0.0.1"),
+    )
+
+    async with (
+        app.router.lifespan_context(app),
+        httpx2.ASGITransport(app) as transport,
+        httpx2.AsyncClient(
+            transport=transport,
+            base_url="http://127.0.0.1:3000";,
+        ) as client,
+    ):
+        result = await client.post(
+            "/mcp",
+            json=modern_tool_request(1, "analyze_data_access_patterns", {}),
+            headers=modern_tool_headers("analyze_data_access_patterns"),
+        )
+
+        assert result.status_code == 200
+        assert result.json()["result"]["isError"] is False
+        roles = result.json()["result"]["structuredContent"]["role_analysis"]
+        assert list(roles) == ["operator"]
+        assert roles["operator"]["users"] == ["root"]
+
+
 @pytest.mark.asyncio
 async def test_stdio_validates_capabilities_versions_and_process_survival():
     server_script = Path(__file__).with_name("stdio_capability_server.py")
@@ -751,6 +783,7 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
             "echo",
             "get_sql_profile",
             "monitor_data_freshness",
+            "analyze_data_access_patterns",
         ]
 
     async with Client(stdio_client(server_params), mode="legacy") as legacy:
@@ -758,6 +791,7 @@ async def 
test_stdio_validates_capabilities_versions_and_process_survival():
             "echo",
             "get_sql_profile",
             "monitor_data_freshness",
+            "analyze_data_access_patterns",
         ]
         legacy_error = await legacy.read_resource("doris://table/missing")
         assert json.loads(legacy_error.contents[0].text)["error_code"] == 
"RESOURCE_NOT_FOUND"
@@ -825,6 +859,29 @@ async def 
test_stdio_unknown_freshness_uses_default_threshold():
         assert payload["monitoring_scope"]["time_threshold_hours"] == 24
 
 
[email protected]
+async def test_stdio_doris4_role_metadata_uses_public_grants_command():
+    server_script = Path(__file__).with_name("stdio_capability_server.py")
+    server_params = StdioServerParameters(
+        command=sys.executable,
+        args=[str(server_script)],
+    )
+
+    async with Client(
+        stdio_client(server_params),
+        extensions=[advertise(REQUIRED_EXTENSION)],
+    ) as modern:
+        result = await modern.call_tool("analyze_data_access_patterns", {})
+        assert result.is_error is False
+        roles = result.structured_content["role_analysis"]
+        assert list(roles) == ["operator"]
+
+    async with Client(stdio_client(server_params), mode="legacy") as legacy:
+        result = await legacy.call_tool("analyze_data_access_patterns", {})
+        roles = json.loads(result.content[0].text)["role_analysis"]
+        assert list(roles) == ["operator"]
+
+
 @pytest.mark.asyncio
 async def test_stdio_prompt_errors_are_typed_and_process_survives():
     server_script = Path(__file__).with_name("stdio_capability_server.py")
diff --git a/test/tools/test_tools_operation_guard.py 
b/test/tools/test_tools_operation_guard.py
index b7dd71d..3641c26 100644
--- a/test/tools/test_tools_operation_guard.py
+++ b/test/tools/test_tools_operation_guard.py
@@ -22,6 +22,7 @@ from doris_mcp_server.utils.security import (
     reset_auth_context,
     set_current_auth_context,
 )
+from doris_mcp_server.utils.security_analytics_tools import 
SecurityAnalyticsTools
 
 
 class FakeRoutedConnection:
@@ -117,6 +118,76 @@ class FakeRoutedConnectionManager:
         raise AssertionError("Doris OAuth tool path fell back to token 
routing")
 
 
+class Doris4RoleMetadataConnection:
+    def __init__(self):
+        self.calls = []
+
+    async def execute(
+        self,
+        sql,
+        params=None,
+        auth_context=None,
+        *,
+        mask_result=True,
+    ):
+        self.calls.append(sql.strip())
+        assert mask_result is False
+        if sql.strip() == "SHOW ALL GRANTS":
+            return QueryResult(
+                data=[
+                    {
+                        "UserIdentity": "'root'@'%'",
+                        "Roles": "operator, admin",
+                    },
+                    {
+                        "UserIdentity": "'reader'@'10.%'",
+                        "Roles": "readonly",
+                    },
+                    {
+                        "UserIdentity": "'loader'@'%'",
+                        "Roles": "",
+                    },
+                ],
+                metadata={},
+                execution_time=0.01,
+                row_count=3,
+                sql=sql,
+            )
+        raise RuntimeError("Unknown column 'Default_role'")
+
+
+class CurrentUserRoleMetadataConnection:
+    def __init__(self):
+        self.calls = []
+
+    async def execute(
+        self,
+        sql,
+        params=None,
+        auth_context=None,
+        *,
+        mask_result=True,
+    ):
+        self.calls.append(sql.strip())
+        assert mask_result is False
+        if sql.strip() == "SHOW ALL GRANTS":
+            raise PermissionError("SHOW ALL GRANTS requires elevated 
privileges")
+        if sql.strip() == "SHOW GRANTS":
+            return QueryResult(
+                data=[
+                    {
+                        "UserIdentity": "'reader'@'%'",
+                        "Roles": "readonly",
+                    }
+                ],
+                metadata={},
+                execution_time=0.01,
+                row_count=1,
+                sql=sql,
+            )
+        raise AssertionError(f"Unexpected SQL: {sql}")
+
+
 def doris_context(
     scopes,
     *,
@@ -450,6 +521,32 @@ async def 
test_unknown_data_freshness_is_not_compared_as_a_number():
     assert issues == []
 
 
[email protected]
+async def test_doris4_user_roles_use_show_grants_metadata():
+    connection = Doris4RoleMetadataConnection()
+    analytics = SecurityAnalyticsTools(SimpleNamespace())
+
+    roles = await analytics._get_user_roles(connection)
+
+    assert roles == {
+        "root": ["operator", "admin"],
+        "reader": ["readonly"],
+        "loader": ["default"],
+    }
+    assert connection.calls == ["SHOW ALL GRANTS"]
+
+
[email protected]
+async def test_user_roles_fall_back_to_current_user_grants():
+    connection = CurrentUserRoleMetadataConnection()
+    analytics = SecurityAnalyticsTools(SimpleNamespace())
+
+    roles = await analytics._get_user_roles(connection)
+
+    assert roles == {"reader": ["readonly"]}
+    assert connection.calls == ["SHOW ALL GRANTS", "SHOW GRANTS"]
+
+
 @pytest.mark.asyncio
 async def 
test_doris_oauth_list_tools_uses_configured_default_scope_visibility(tmp_path):
     manager, _connection_manager = _real_tool_manager_for_routing(tmp_path)
diff --git a/test/utils/test_db.py b/test/utils/test_db.py
index e12d507..158e4e9 100644
--- a/test/utils/test_db.py
+++ b/test/utils/test_db.py
@@ -1,4 +1,5 @@
 from unittest.mock import AsyncMock, MagicMock
+
 import pytest
 
 from doris_mcp_server.utils.db import (
@@ -213,6 +214,42 @@ class TestExecuteResultSetDetection:
         assert result.data == rows
         assert result.row_count == len(rows)
 
+    async def test_can_skip_masking_without_skipping_security_validation(self):
+        rows = [{"UserIdentity": "'root'@'%'", "Roles": "operator"}]
+        conn = _make_doris_connection(
+            cursor_description=[
+                ("UserIdentity", None, None, None, None, None, None),
+                ("Roles", None, None, None, None, None, None),
+            ],
+            fetchall_rows=rows,
+        )
+        security_manager = MagicMock()
+        security_manager.validate_sql_security = AsyncMock(
+            return_value=MagicMock(
+                is_valid=True,
+                risk_level="low",
+                blocked_operations=[],
+            )
+        )
+        security_manager.apply_data_masking = AsyncMock(
+            return_value=[{"UserIdentity": "masked", "Roles": "operator"}]
+        )
+        conn.security_manager = security_manager
+        auth_context = object()
+
+        result = await conn.execute(
+            "SHOW ALL GRANTS",
+            auth_context=auth_context,
+            mask_result=False,
+        )
+
+        assert result.data == rows
+        security_manager.validate_sql_security.assert_awaited_once_with(
+            "SHOW ALL GRANTS",
+            auth_context,
+        )
+        security_manager.apply_data_masking.assert_not_awaited()
+
     @pytest.mark.parametrize(
         "sql, affected",
         [


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to