ForcePilot/backend/package/yuxi/channels/adapters/msteams/audit.py
Kris bd60c15df0 feat(msteams): 新增完整的 Microsoft Teams 适配器模块
实现了 Teams 机器人所需的全功能组件,包括:
- 基础命令解析与帮助卡片生成
- 租户验证与访问控制
- 自定义 UA 与媒体工具
- 消息分块、批注处理与会话管理
- 防抖、缓存与配置路由能力
- 投票、配对、审计与运行时状态管理
- TTS 语音合成与卡片构建工具
- 群组管理与权限控制逻辑
2026-05-12 00:46:44 +08:00

123 lines
4.6 KiB
Python

"""Microsoft Teams Graph API 权限审计。
检查 Bot 所需的 Graph API 权限是否已授予,返回缺失权限列表。
"""
from __future__ import annotations
import aiohttp
from yuxi.utils.logging_config import logger
REQUIRED_GRAPH_PERMISSIONS = [
"TeamsAppInstallation.ReadWriteForUser.All",
"ChannelMessage.Read.All",
"ChannelMessage.Send",
"User.Read.All",
"Team.ReadBasic.All",
"Group.Read.All",
]
GRAPH_SP_URL = (
"https://graph.microsoft.com/v1.0/servicePrincipals"
"?$filter=appId eq '{app_id}'&$select=appRoles,oauth2PermissionScopes"
)
BOT_SP_URL = (
"https://graph.microsoft.com/v1.0/servicePrincipals"
"?$filter=appId eq '{app_id}'&$select=appRoles,oauth2PermissionScopes"
)
class GraphPermissionAuditor:
def __init__(self, token: str, app_id: str):
self._token = token
self._app_id = app_id
async def audit(self) -> dict[str, bool]:
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
url = GRAPH_SP_URL.format(app_id=self._app_id)
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
logger.warning(f"Graph SP query failed: HTTP {resp.status}")
return {perm: False for perm in REQUIRED_GRAPH_PERMISSIONS}
data = await resp.json()
granted = self._parse_oauth2_permissions(data)
except Exception as e:
logger.error(f"Graph permission audit failed: {e}")
granted: set[str] = set()
return {perm: perm in granted for perm in REQUIRED_GRAPH_PERMISSIONS}
def _parse_oauth2_permissions(self, data: dict) -> set[str]:
values = data.get("value", [])
if not values:
return set()
sp = values[0]
scopes = sp.get("oauth2PermissionScopes", [])
return {scope.get("value", "") for scope in scopes if isinstance(scope, dict)}
def get_missing_permissions(self, audit_result: dict[str, bool]) -> list[str]:
return [perm for perm, granted in audit_result.items() if not granted]
async def extract_scopes_and_roles(self) -> dict[str, Any]:
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
try:
async with aiohttp.ClientSession() as session:
url = GRAPH_SP_URL.format(app_id=self._app_id)
async with session.get(url, headers=headers) as resp:
if resp.status != 200:
return {"error": f"HTTP {resp.status}"}
data = await resp.json()
values = data.get("value", [])
if not values:
return {"scopes": [], "roles": []}
sp = values[0]
scopes = [
{
"value": s.get("value", ""),
"type": s.get("type", ""),
"admin_consent_display_name": s.get("adminConsentDisplayName", ""),
}
for s in (sp.get("oauth2PermissionScopes", []) or [])
if isinstance(s, dict)
]
roles = [
{
"value": r.get("value", ""),
"display_name": r.get("displayName", ""),
"description": r.get("description", ""),
}
for r in (sp.get("appRoles", []) or [])
if isinstance(r, dict)
]
return {"scopes": scopes, "roles": roles}
except Exception as e:
logger.error(f"Scopes/roles extraction failed: {e}")
return {"error": str(e)}
@staticmethod
def format_audit_display(audit_data: dict[str, Any]) -> str:
lines: list[str] = []
scopes = audit_data.get("scopes", [])
roles = audit_data.get("roles", [])
if scopes:
lines.append("**OAuth2 Permission Scopes:**")
for s in scopes:
lines.append(f"- `{s['value']}` ({s.get('type', '')})")
if roles:
lines.append("\n**App Roles:**")
for r in roles:
lines.append(f"- `{r['value']}`: {r.get('display_name', '')}")
return "\n".join(lines) if lines else "No scopes or roles found"