# Performing OAUTH Scope Minimization Review

> 执行 OAuth 2.0 权限范围最小化审查，识别过度授权的第三方应用集成、 过多的 API 范围、未使用的令牌授权以及跨身份提供商和 SaaS 平台的 高风险 OAuth 同意模式。 适用于 OAuth 范围审计、API 权限审查、第三方应用风险评估或同意授权最小化的请求。

- Skill: `killvxk/performing-oauth-scope-minimization-review` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add killvxk/performing-oauth-scope-minimization-review`
- Raw SKILL.md: https://api.skillmd.com/api/skills/killvxk/performing-oauth-scope-minimization-review/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- License: Apache-2.0
- Author: killvxk (https://skillmd.com/u/killvxk)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/killvxk/performing-oauth-scope-minimization-review

---


# 执行 OAuth 权限范围最小化审查

## 适用场景

- 对第三方应用 OAuth 权限的年度或季度审查
- 涉及 OAuth 令牌泄露或未授权数据访问的安全事件发生后
- 需要记录第三方数据访问情况的合规审计（GDPR 第 28 条、SOC 2）
- 发现影子 IT 应用通过 OAuth 授权访问组织数据
- 需要权限清理的 SaaS 应用迁移或整合
- 为 API 集成实施最小权限原则

**不适用于**审查同一信任边界内第一方应用的权限；OAuth 范围最小化专注于第三方和跨边界的同意授权。

## 前提条件

- 对身份提供商（Microsoft Entra ID、Okta、Google Workspace）的管理员访问权限
- Microsoft Graph API 权限：Application.Read.All、OAuth2PermissionGrant.ReadWrite.All
- 来自采购或 IT 治理的已批准第三方集成清单
- OAuth 范围风险分类框架
- 令牌分析工具（手动审查使用 jwt.io，批量分析使用自动化脚本）

## 工作流程

### 步骤 1：清点所有 OAuth 授权和同意权限

枚举所有 OAuth 应用注册及委托权限：

```python
"""
OAuth 授权清点 - Microsoft Entra ID
枚举所有应用注册、服务主体、
委托权限及应用权限授予情况。
"""
import requests
import json
from collections import defaultdict

class EntraOAuthAuditor:
    def __init__(self, tenant_id, client_id, client_secret):
        self.tenant_id = tenant_id
        self.base_url = "https://graph.microsoft.com/v1.0"
        self.token = self._get_token(client_id, client_secret)
        self.headers = {"Authorization": f"Bearer {self.token}"}

    def _get_token(self, client_id, client_secret):
        url = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
        response = requests.post(url, data={
            "grant_type": "client_credentials",
            "client_id": client_id,
            "client_secret": client_secret,
            "scope": "https://graph.microsoft.com/.default"
        })
        return response.json()["access_token"]

    def get_all_service_principals(self):
        """获取所有服务主体（企业应用）。"""
        apps = []
        url = f"{self.base_url}/servicePrincipals?$top=999&$select=id,appId,displayName,appOwnerOrganizationId,servicePrincipalType,accountEnabled,createdDateTime"

        while url:
            response = requests.get(url, headers=self.headers)
            data = response.json()
            apps.extend(data.get("value", []))
            url = data.get("@odata.nextLink")

        return apps

    def get_oauth2_permission_grants(self):
        """获取所有委托权限授予（用户同意）。"""
        grants = []
        url = f"{self.base_url}/oauth2PermissionGrants?$top=999"

        while url:
            response = requests.get(url, headers=self.headers)
            data = response.json()
            grants.extend(data.get("value", []))
            url = data.get("@odata.nextLink")

        return grants

    def get_app_role_assignments(self, sp_id):
        """获取服务主体的应用权限分配。"""
        url = f"{self.base_url}/servicePrincipals/{sp_id}/appRoleAssignments"
        response = requests.get(url, headers=self.headers)
        return response.json().get("value", [])

    def build_permission_inventory(self):
        """构建全面的 OAuth 权限清单。"""
        service_principals = self.get_all_service_principals()
        delegated_grants = self.get_oauth2_permission_grants()

        # 将服务主体 ID 映射到名称
        sp_map = {sp["id"]: sp for sp in service_principals}

        inventory = []

        # 处理委托权限
        for grant in delegated_grants:
            sp = sp_map.get(grant["clientId"], {})
            scopes = grant.get("scope", "").split()

            for scope in scopes:
                if not scope:
                    continue
                inventory.append({
                    "app_name": sp.get("displayName", "Unknown"),
                    "app_id": grant.get("clientId"),
                    "publisher_tenant": sp.get("appOwnerOrganizationId"),
                    "is_third_party": sp.get("appOwnerOrganizationId") != self.tenant_id,
                    "permission_type": "Delegated",
                    "scope": scope,
                    "consent_type": grant.get("consentType"),  # AllPrincipals 或 Principal
                    "principal_id": grant.get("principalId"),
                    "granted_date": sp.get("createdDateTime"),
                    "is_enabled": sp.get("accountEnabled", True)
                })

        # 处理应用权限
        for sp in service_principals:
            app_roles = self.get_app_role_assignments(sp["id"])
            for role in app_roles:
                inventory.append({
                    "app_name": sp.get("displayName"),
                    "app_id": sp.get("id"),
                    "publisher_tenant": sp.get("appOwnerOrganizationId"),
                    "is_third_party": sp.get("appOwnerOrganizationId") != self.tenant_id,
                    "permission_type": "Application",
                    "scope": role.get("appRoleId"),
                    "consent_type": "AdminConsent",
                    "granted_date": role.get("createdDateTime"),
                    "is_enabled": sp.get("accountEnabled", True)
                })

        return inventory
```

### 步骤 2：按风险等级对 OAuth 范围分类

根据数据访问敏感性对权限进行分类：

```python
"""
OAuth 范围风险分类
根据数据敏感性和访问广度将 API 范围映射到风险等级。
"""

MICROSOFT_GRAPH_SCOPE_RISK = {
    # 严重 - 完全管理或不受限制的访问
    "critical": {
        "scopes": [
            "Directory.ReadWrite.All",
            "RoleManagement.ReadWrite.Directory",
            "Application.ReadWrite.All",
            "AppRoleAssignment.ReadWrite.All",
            "Mail.ReadWrite",
            "Mail.Send",
            "Files.ReadWrite.All",
            "Sites.FullControl.All",
            "User.ReadWrite.All",
            "Group.ReadWrite.All",
            "MailboxSettings.ReadWrite",
            "full_access_as_app",
        ],
        "risk_description": "可读写所有数据、修改目录或模拟用户",
        "review_frequency": "每月",
        "requires_admin_consent": True
    },
    # 高 - 广泛读取访问或敏感数据写入
    "high": {
        "scopes": [
            "Mail.Read",
            "Mail.Read.Shared",
            "Calendars.ReadWrite",
            "Contacts.ReadWrite",
            "Files.Read.All",
            "Sites.Read.All",
            "User.Read.All",
            "Group.Read.All",
            "Directory.Read.All",
            "AuditLog.Read.All",
            "SecurityEvents.ReadWrite.All",
            "TeamSettings.ReadWrite.All",
        ],
        "risk_description": "对组织敏感数据具有广泛读取访问",
        "review_frequency": "每季度",
        "requires_admin_consent": True
    },
    # 中 - 范围限定的数据访问
    "medium": {
        "scopes": [
            "Calendars.Read",
            "Contacts.Read",
            "Files.ReadWrite",
            "Sites.ReadWrite.All",
            "Tasks.ReadWrite",
            "Notes.ReadWrite.All",
            "Chat.ReadWrite",
            "ChannelMessage.Send",
            "Team.ReadBasic.All",
        ],
        "risk_description": "对特定数据类型具有写入能力的范围限定访问",
        "review_frequency": "每半年"
    },
    # 低 - 最小或仅用户配置文件访问
    "low": {
        "scopes": [
            "User.Read",
            "openid",
            "profile",
            "email",
            "offline_access",
            "Calendars.Read.Shared",
            "People.Read",
            "User.ReadBasic.All",
        ],
        "risk_description": "基本用户配置文件或最小范围限定访问",
        "review_frequency": "每年"
    }
}

def classify_scope_risk(scope):
    """对单个 OAuth 范围按风险等级分类。"""
    for risk_level, config in MICROSOFT_GRAPH_SCOPE_RISK.items():
        if scope in config["scopes"]:
            return {
                "scope": scope,
                "risk_level": risk_level,
                "description": config["risk_description"],
                "review_frequency": config["review_frequency"]
            }
    # 未知范围默认为高风险
    return {
        "scope": scope,
        "risk_level": "high",
        "description": "未知范围 - 需要手动分类",
        "review_frequency": "每季度"
    }

def analyze_app_risk(app_permissions):
    """计算应用权限的综合风险评分。"""
    risk_weights = {"critical": 40, "high": 20, "medium": 10, "low": 2}
    total_score = 0
    classified_scopes = []

    for perm in app_permissions:
        classification = classify_scope_risk(perm["scope"])
        classified_scopes.append(classification)
        total_score += risk_weights.get(classification["risk_level"], 10)

    # 应用类型权限（vs 委托权限）的额外风险加分
    app_type_permissions = [p for p in app_permissions if p["permission_type"] == "Application"]
    total_score += len(app_type_permissions) * 15

    # 管理员同意的广泛访问的额外风险加分
    admin_consent = [p for p in app_permissions if p["consent_type"] == "AllPrincipals"]
    total_score += len(admin_consent) * 10

    if total_score >= 100:
        aggregate_risk = "CRITICAL"
    elif total_score >= 60:
        aggregate_risk = "HIGH"
    elif total_score >= 30:
        aggregate_risk = "MEDIUM"
    else:
        aggregate_risk = "LOW"

    return {
        "total_score": total_score,
        "aggregate_risk": aggregate_risk,
        "scope_count": len(app_permissions),
        "critical_scopes": len([s for s in classified_scopes if s["risk_level"] == "critical"]),
        "high_scopes": len([s for s in classified_scopes if s["risk_level"] == "high"]),
        "classified_scopes": classified_scopes
    }
```

### 步骤 3：识别过度授权的应用

检测请求权限超出功能需求的应用：

```python
"""
过度授权检测
识别 OAuth 范围相对于其功能而言过度的应用。
"""

def detect_over_permissions(inventory, approved_apps_catalog):
    """
    将实际权限与已批准的范围目录进行比对，
    找出过度授权的应用。
    """
    findings = []

    # 按应用分组权限
    app_permissions = defaultdict(list)
    for perm in inventory:
        app_permissions[perm["app_name"]].append(perm)

    for app_name, permissions in app_permissions.items():
        # 与已批准目录进行比对
        approved = approved_apps_catalog.get(app_name)

        if not approved:
            # 未知/未批准的应用
            findings.append({
                "app_name": app_name,
                "finding_type": "UNAPPROVED_APPLICATION",
                "severity": "HIGH",
                "detail": f"应用不在已批准目录中，共有 {len(permissions)} 个权限授予",
                "scopes": [p["scope"] for p in permissions],
                "recommendation": "审查并批准或撤销所有权限"
            })
            continue

        approved_scopes = set(approved.get("approved_scopes", []))
        actual_scopes = set(p["scope"] for p in permissions)

        # 找出过多范围（已授予但未批准）
        excessive = actual_scopes - approved_scopes
        if excessive:
            risk = analyze_app_risk([p for p in permissions if p["scope"] in excessive])
            findings.append({
                "app_name": app_name,
                "finding_type": "EXCESSIVE_SCOPES",
                "severity": risk["aggregate_risk"],
                "detail": f"{len(excessive)} 个超出已批准列表的范围",
                "excessive_scopes": list(excessive),
                "approved_scopes": list(approved_scopes),
                "recommendation": "移除过多范围或更新已批准目录"
            })

        # 找出未使用的范围（已批准但活动日志显示无 API 调用）
        # 这需要与 API 活动日志进行关联
        unused = approved_scopes - actual_scopes
        if unused:
            findings.append({
                "app_name": app_name,
                "finding_type": "UNUSED_APPROVED_SCOPES",
                "severity": "LOW",
                "detail": f"{len(unused)} 个已批准范围当前未授予",
                "unused_scopes": list(unused)
            })

        # 检查过于宽泛的权限
        broad_patterns = [
            ("Mail.ReadWrite", "Mail.Read", "仅需读取权限时却授予了邮件写入权限"),
            ("Files.ReadWrite.All", "Files.Read.All", "仅需读取权限时却授予了所有文件写入权限"),
            ("Directory.ReadWrite.All", "Directory.Read.All", "仅需读取权限时却授予了目录写入权限"),
            ("User.ReadWrite.All", "User.Read.All", "仅需读取权限时却授予了用户写入权限"),
        ]

        for broad, narrow, description in broad_patterns:
            if broad in actual_scopes:
                findings.append({
                    "app_name": app_name,
                    "finding_type": "OVERLY_BROAD_SCOPE",
                    "severity": "MEDIUM",
                    "detail": description,
                    "current_scope": broad,
                    "recommended_scope": narrow,
                    "recommendation": f"从 {broad} 降级为 {narrow}"
                })

    return findings
```

### 步骤 4：审计令牌使用情况并检测过期授权

识别不再活跃使用的 OAuth 令牌：

```python
"""
令牌使用情况审计
分析登录日志和 API 活动，识别过期的 OAuth 授权。
"""

def audit_token_usage(auditor, days_inactive=90):
    """识别近期无 API 活动的 OAuth 授权。"""
    # 获取服务主体的登录活动
    url = f"{auditor.base_url}/auditLogs/signIns"
    params = {
        "$filter": f"createdDateTime ge {(datetime.utcnow() - timedelta(days=days_inactive)).isoformat()}Z and signInEventTypes/any(t: t eq 'servicePrincipal')",
        "$top": 999
    }

    active_apps = set()
    while url:
        response = requests.get(url, headers=auditor.headers, params=params)
        data = response.json()
        for signin in data.get("value", []):
            active_apps.add(signin.get("appId"))
        url = data.get("@odata.nextLink")
        params = {}

    # 与所有已授权应用进行比对
    all_grants = auditor.get_oauth2_permission_grants()
    sp_map = {sp["id"]: sp for sp in auditor.get_all_service_principals()}

    stale_grants = []
    for grant in all_grants:
        sp = sp_map.get(grant["clientId"], {})
        app_id = sp.get("appId")

        if app_id and app_id not in active_apps:
            stale_grants.append({
                "app_name": sp.get("displayName", "Unknown"),
                "app_id": app_id,
                "scopes": grant.get("scope", "").split(),
                "consent_type": grant.get("consentType"),
                "is_third_party": sp.get("appOwnerOrganizationId") != auditor.tenant_id,
                "days_inactive": days_inactive,
                "recommendation": "撤销 - {days_inactive} 天内无 API 活动"
            })

    return sorted(stale_grants, key=lambda x: len(x["scopes"]), reverse=True)
```

### 步骤 5：生成修复计划并执行范围缩减

创建并执行范围最小化修复计划：

```python
"""
OAuth 范围修复
生成并执行范围缩减操作。
"""

def generate_remediation_plan(findings, stale_grants):
    """创建优先级排序的修复计划。"""
    plan = []

    # 优先级 1：撤销未批准的应用
    for f in findings:
        if f["finding_type"] == "UNAPPROVED_APPLICATION":
            plan.append({
                "priority": 1,
                "action": "REVOKE_ALL_PERMISSIONS",
                "app_name": f["app_name"],
                "reason": "未批准的第三方应用",
                "impact": f"移除 {len(f['scopes'])} 个权限授予",
                "risk_if_not_addressed": "CRITICAL"
            })

    # 优先级 2：移除已批准应用的过多范围
    for f in findings:
        if f["finding_type"] == "EXCESSIVE_SCOPES":
            plan.append({
                "priority": 2,
                "action": "REMOVE_EXCESSIVE_SCOPES",
                "app_name": f["app_name"],
                "scopes_to_remove": f["excessive_scopes"],
                "reason": "超出已批准目录的范围",
                "risk_if_not_addressed": f["severity"]
            })

    # 优先级 3：降级过于宽泛的范围
    for f in findings:
        if f["finding_type"] == "OVERLY_BROAD_SCOPE":
            plan.append({
                "priority": 3,
                "action": "DOWNGRADE_SCOPE",
                "app_name": f["app_name"],
                "current_scope": f["current_scope"],
                "target_scope": f["recommended_scope"],
                "reason": f["detail"],
                "risk_if_not_addressed": "MEDIUM"
            })

    # 优先级 4：撤销过期授权
    for grant in stale_grants:
        plan.append({
            "priority": 4,
            "action": "REVOKE_STALE_GRANT",
            "app_name": grant["app_name"],
            "scopes_to_revoke": grant["scopes"],
            "reason": f"{grant['days_inactive']} 天内无 API 活动",
            "risk_if_not_addressed": "MEDIUM"
        })

    return sorted(plan, key=lambda x: x["priority"])

def execute_scope_reduction(auditor, grant_id, scopes_to_remove):
    """从 OAuth 权限授予中移除特定范围。"""
    # 获取当前授权
    url = f"{auditor.base_url}/oauth2PermissionGrants/{grant_id}"
    response = requests.get(url, headers=auditor.headers)
    current_grant = response.json()

    current_scopes = set(current_grant.get("scope", "").split())
    updated_scopes = current_scopes - set(scopes_to_remove)

    if not updated_scopes:
        # 移除整个授权
        requests.delete(url, headers=auditor.headers)
        return {"action": "grant_deleted", "grant_id": grant_id}
    else:
        # 使用缩减后的范围进行更新
        update_body = {"scope": " ".join(updated_scopes)}
        requests.patch(url, headers=auditor.headers, json=update_body)
        return {
            "action": "scopes_reduced",
            "grant_id": grant_id,
            "removed": list(scopes_to_remove),
            "remaining": list(updated_scopes)
        }
```

## 核心概念

| 术语 | 定义 |
|------|------|
| **OAuth 范围** | 定义授予客户端应用的特定 API 访问级别的权限字符串（例如：Mail.Read、Files.ReadWrite.All） |
| **委托权限** | 代表已登录用户行使的 OAuth 范围，受应用权限和用户自身访问权的双重限制 |
| **应用权限** | 无需用户上下文直接授予应用的 OAuth 范围，可访问所有用户数据（高风险） |
| **管理员同意** | 由管理员做出的租户级权限授予，无需个人同意即可应用于所有用户 |
| **范围最小化** | 将 OAuth 权限减少到应用功能所需最小集合的安全原则 |
| **过期授权** | 保持活跃但近期无 API 使用记录的 OAuth 权限，表明集成已废弃或过时 |

## 工具与系统

- **Microsoft Entra 管理中心**：用于审查企业应用、同意权限和 OAuth 授权管理的门户
- **Nudge Security**：用于发现 OAuth 授权、评估第三方风险并自动化范围审查的 SaaS 安全平台
- **Cerby**：用于审计 OAuth 集成和管理共享账户的非 SSO 应用管理平台
- **Microsoft Graph API**：用于大规模枚举和修改 OAuth 权限授予的编程接口

## 常见场景

### 场景：安全事件后的 OAuth 范围审计

**背景**：一次钓鱼攻击导致管理员账户被入侵，调查发现攻击者注册了一个带有 Mail.ReadWrite 和 Files.ReadWrite.All 范围的恶意 OAuth 应用，窃取了 6 个月的电子邮件。组织需要进行全面的 OAuth 范围审查。

**处理方法**：
1. 立即撤销被入侵管理员会话的所有 OAuth 授权
2. 枚举租户内所有服务主体和权限授予
3. 标记过去 90 天内注册的所有应用进行手动审查
4. 使用风险框架对所有第三方应用范围进行分类
5. 识别具有严重范围的应用（Mail.ReadWrite、Files.ReadWrite.All、Directory.ReadWrite.All）
6. 与 IT 采购的已批准应用目录进行交叉对比
7. 立即撤销所有未批准应用的权限
8. 将过度授权的已批准应用降级为所需最小范围
9. 实施管理员同意工作流，防止未来出现不受控的 OAuth 授权
10. 启用同意策略，要求对高风险范围进行管理员审批

**注意事项**：
- 未经协调撤销业务关键集成的权限会导致服务中断
- 未检查应用级权限（vs 委托权限），这类权限风险更高且常被忽视
- 遗漏多租户应用，即发布者租户与使用租户不同的情况
- 修复后未实施持续监控，导致无法检测新的未授权 OAuth 授权

## 输出格式

```
OAUTH 范围最小化审查报告
=========================================
租户:              corp.onmicrosoft.com
审查周期:          2026-02-01 至 2026-02-24
应用总数:          147
第三方应用:        98
第一方应用:        49

权限清单
OAuth 授权总数:        487
  委托权限:            312
  应用权限:            175
  管理员同意:          89
  用户同意:            223

风险分类
严重风险应用:     7
  - UnknownCRMApp (Mail.ReadWrite, Files.ReadWrite.All - 未批准)
  - LegacySync (Directory.ReadWrite.All - 过多权限)
  - DevToolX (Application.ReadWrite.All - 权限过于宽泛)
高风险应用:         18
中风险应用:         34
低风险应用:         88

发现问题
未批准应用:              12（立即撤销）
过多范围:                23 个应用的范围超出已批准列表
过于宽泛的权限:          15 个应用可降级处理
过期授权（90天以上）:    31 个应用近期无 API 活动

修复计划
优先级 1（立即）:    12 个未批准应用撤销
优先级 2（本周）:    23 个过多范围移除
优先级 3（本月）:    15 个范围降级
优先级 4（下季度）:  31 个过期授权撤销

预计范围缩减:  总权限的 34%
```

