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]