deer-flow/docs/plans/2026-07-15-authz-phase1a1-trusted-principal-implementation-plan.md

17 KiB
Raw Blame History

Phase 1A-1 实施计划:可信 Principal 链路

建议 PR 标题:

feat(authz): propagate trusted authorization principal context

前置基线PR #4127 / 1300c6d3 总计划:docs/plans/2026-07-15-authz-phase1a-implementation-plan.md 实施记录:docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md

1. 目标

本 PR 完成一条可信、可测试的授权身份链路:

Gateway 认证状态
  → 清除客户端伪造字段
  → 服务端注入 is_internal
  → lead runtime context
  → task_tool 捕获
  → SubagentExecutor 复制
  → subagent runtime context
  → GuardrailRequest
  → GuardrailAuthorizationAdapter
  → build_principal_from_context()
  → Principal

本 PR 不接入 Layer 1 工具过滤,也不自动安装 Layer 2 middleware。合并后的兼容性承诺

  • authorization.enabled: false 时,工具集合和工具执行决策不变。
  • runtime context 新增服务端身份字段是有意的可观察变化。
  • 现有 adapter 异常传播语义保持不变。

2. 固定安全契约

2.1 Principal

build_principal_from_context() 必须显式构造全部 7 个字段:

user_id / role / oauth_provider / oauth_id / channel_user_id /
is_internal / attributes

规则:

  • user_roleNone 或空字符串时使用 default_role
  • 未知但非空的角色原样保留;不能回退到默认角色。
  • is_internal 只有原值严格等于 True 时才为 True
  • authz_attributesNone 或缺失时解析为 {}
  • 非空 authz_attributes 必须实现 Mapping,否则抛 TypeError
  • attributes 每次都复制为新字典,不共享可变引用。
  • builder 是纯函数:不读全局 config、不缓存、不修改输入。

2.2 Gateway 可信边界

is_internalauthz_attributeschannel_user_id 是服务端拥有字段:

  • 必须从 config["context"]config["configurable"] 清除客户端值。
  • is_internal 只能由 request.state.auth_source == AUTH_SOURCE_INTERNAL 产生。
  • channel_user_id 只能来自内部认证 IM 调用方的顶层 body.context;普通 session 请求和 body.config 两个 section 中的值必须删除。
  • 必须使用直接赋值,不能使用 setdefault
  • 必须在 user_id is None 等所有 early return 之前写入。
  • Phase 1A-1 没有 Gateway 侧 attributes 权威生产者,因此 Gateway 请求中的 authz_attributes 一律删除。

2.3 传递语义

  • Subagent 必须继承 parent context 的 is_internal 和合法 attributes。
  • is_internal=False 也必须显式写回,不能按 truthy 条件省略。
  • attributes 在 task 捕获、executor 构造和 context 写回时均使用副本。
  • 非 Mapping attributes 不能在某些层抛错、另一些层静默变成 {};所有进程内消费 边界统一抛 TypeError
  • Guardrail adapter 必须复用 Principal builder不能维护第二套默认角色或 attributes 解析逻辑。

3. TDD 实施顺序

Step 1新增 Principal builder 失败测试

新增:

backend/tests/test_authorization_principal.py

覆盖:

  • 空 context、部分 context 和全部 7 个字段映射。
  • 缺失、None、空字符串 role 使用 default_role
  • 未知非空 role 原样保留。
  • is_internalTrueFalse1"true"None 的严格布尔行为。
  • attributes 缺失或 None 得到 {}
  • Mapping attributes 被复制;修改输入不影响 Principal。
  • 非 Mapping attributes 抛 TypeError,错误包含实际类型。
  • oauth 和 channel identity 正确映射。

先运行该测试并确认因 builder 不存在而失败。

Step 2实现 Principal builder

新增:

backend/packages/harness/deerflow/authz/principal.py

接口和核心实现:

from collections.abc import Mapping
from typing import Any

from deerflow.authz.provider import Principal


def build_principal_from_context(
    context: Mapping[str, Any],
    *,
    default_role: str,
) -> Principal:
    resolved_role = context.get("user_role")
    if resolved_role is None or resolved_role == "":
        resolved_role = default_role

    raw_attributes = context.get("authz_attributes")
    if raw_attributes is None:
        attributes: dict[str, Any] = {}
    elif isinstance(raw_attributes, Mapping):
        attributes = dict(raw_attributes)
    else:
        raise TypeError(
            "authz_attributes must be a Mapping, "
            f"got {type(raw_attributes).__name__}"
        )

    return Principal(
        user_id=context.get("user_id"),
        role=resolved_role,
        oauth_provider=context.get("oauth_provider"),
        oauth_id=context.get("oauth_id"),
        channel_user_id=context.get("channel_user_id"),
        is_internal=context.get("is_internal") is True,
        attributes=attributes,
    )

修改 backend/packages/harness/deerflow/authz/__init__.py 导出 build_principal_from_context

Step 3新增 Gateway 防伪失败测试

修改:

backend/tests/test_gateway_services.py

必须复用真实配置装配顺序:

build_run_config
  → merge_run_context_overrides
  → strip_internal_context_keys非 internal 请求)
  → inject_authenticated_user_context

分别测试以下入口,不能合并为一个配置:

  1. body.config["context"] 伪造 is_internal=True
  2. body.config["configurable"] 伪造 is_internal=True
  3. 两个入口分别伪造 authz_attributes

断言:

  • 普通/session 请求最终 context.is_internal is False
  • configurable.is_internal 被删除。
  • 两个 section 中的 authz_attributes 都被删除。
  • internal 请求最终 context.is_internal is True
  • internal 请求携带的客户端伪造 attributes 同样被删除。
  • user=None、auth source 缺失或 session 时仍写入 False
  • user=Noneauth_source=internal 时仍写入 True
  • 非 Mapping runtime context 显式抛 TypeError,不静默跳过。
  • 原有 user、owner、role、oauth 测试继续通过。

这些测试必须证明新增覆盖逻辑是唯一通过原因,不能使用本来就被 body.context 白名单过滤的空路径。

Step 4实现 Gateway 防伪与注入

修改:

backend/app/gateway/services.py

新增:

_SERVER_OWNED_AUTHZ_CONTEXT_KEYS = frozenset(
    {"is_internal", "authz_attributes", "channel_user_id"}
)

inject_authenticated_user_context() 最顶部、读取 user_id 之前执行:

runtime_context = config.setdefault("context", {})
if not isinstance(runtime_context, dict):
    raise TypeError("run context must be a mapping")

for key in _SERVER_OWNED_AUTHZ_CONTEXT_KEYS:
    runtime_context.pop(key, None)

configurable = config.get("configurable")
if isinstance(configurable, dict):
    for key in _SERVER_OWNED_AUTHZ_CONTEXT_KEYS:
        configurable.pop(key, None)

auth_source = getattr(getattr(request, "state", None), "auth_source", None)
runtime_context["is_internal"] = auth_source == AUTH_SOURCE_INTERNAL

之后保留现有普通用户、internal owner 和 early return 逻辑。AUTH_SOURCE_INTERNAL 已经由当前模块导入,不重复增加角色名称推导。

Step 5新增 task_tool 身份捕获失败测试

修改:

backend/tests/test_task_tool_core_logic.py

复用现有 test_task_tool_forwards_channel_user_id_to_executor 的 DummyExecutor 模式,覆盖:

  • parent is_internal=True 传给 executor。
  • parent is_internal=False 仍显式传给 executor。
  • Mapping attributes 被复制后传给 executor。
  • 捕获后修改 parent attributes 不影响 executor kwargs。
  • 非 Mapping attributes 抛 TypeError,不能静默变成 {}

Step 6实现 task_tool 身份捕获

修改:

backend/packages/harness/deerflow/tools/builtins/task_tool.py

在现有 parent identity capture 块增加:

is_internal = parent_context.get("is_internal") is True
raw_attributes = parent_context.get("authz_attributes")
if raw_attributes is None:
    authz_attributes: dict[str, Any] = {}
elif isinstance(raw_attributes, Mapping):
    authz_attributes = dict(raw_attributes)
else:
    raise TypeError(
        "authz_attributes must be a Mapping, "
        f"got {type(raw_attributes).__name__}"
    )

将二者无条件加入 executor_kwargs

Step 7新增 executor 写回失败测试

修改:

backend/tests/test_subagent_executor.py

复用现有 channel identity context 测试模式,覆盖:

  • 构造参数 is_internal=True/False 都写回 subagent context。
  • attributes 在构造时复制。
  • attributes 在 context write-back 时再次复制。
  • 修改调用方原字典或写回后的字典均不影响 executor 内部副本。
  • executor 直接收到非 Mapping attributes 时抛 TypeError

Step 8实现 executor 身份写回

修改:

backend/packages/harness/deerflow/subagents/executor.py

构造参数增加:

is_internal: bool = False,
authz_attributes: Mapping[str, Any] | None = None,

构造时严格校验并复制:

self.is_internal = is_internal
if authz_attributes is None:
    self.authz_attributes = {}
elif isinstance(authz_attributes, Mapping):
    self.authz_attributes = dict(authz_attributes)
else:
    raise TypeError(
        "authz_attributes must be a Mapping, "
        f"got {type(authz_attributes).__name__}"
    )

context write-back 无条件执行:

context["is_internal"] = self.is_internal
context["authz_attributes"] = dict(self.authz_attributes)

Step 9新增 Guardrail/adapter 失败测试

修改:

backend/tests/test_authorization_provider.py
backend/tests/test_guardrail_middleware.py

覆盖:

  • GuardrailRequest 新字段默认值向后兼容。
  • middleware 映射 channel_user_id、严格布尔 is_internal、合法 attributes。
  • middleware 遇到非 Mapping attributes 时抛 TypeError
  • adapter 同步和异步路径产生相同 Principal。
  • adapter 正确映射全部 7 个 Principal 字段。
  • role 缺失时 adapter 通过 builder 应用 default_role
  • 未知非空 role 不回退。
  • 将旧的 Phase 0 is_internal 不映射测试改为正确映射测试。
  • provider 异常仍穿过 adapter现有异常传播测试继续通过。

middleware context 映射断言放在现有 test_guardrail_middleware.pyadapter 转换断言 放在 test_authorization_provider.py;不得新建重复测试模块。

Step 10扩展 GuardrailRequest 和 adapter

修改:

backend/packages/harness/deerflow/guardrails/provider.py
backend/packages/harness/deerflow/guardrails/middleware.py
backend/packages/harness/deerflow/authz/adapter.py

GuardrailRequest 增加带默认值的字段:

channel_user_id: str | None = None
is_internal: bool = False
authz_attributes: dict[str, Any] = field(default_factory=dict)

middleware 在构造 request 前用普通分支完成 attributes 校验,不使用 walrus one-liner

raw_attributes = context.get("authz_attributes")
if raw_attributes is None:
    authz_attributes: dict[str, Any] = {}
elif isinstance(raw_attributes, Mapping):
    authz_attributes = dict(raw_attributes)
else:
    raise TypeError(...)

adapter

  • 构造函数增加 default_role: str = "user" 并保存。
  • _to_authz() 从 GuardrailRequest 组装 context 字典。
  • 调用 build_principal_from_context(),不得手工构造 Principal。
  • 删除 Phase 0 关于 is_internal 尚未映射的 note。
  • 不捕获 provider 异常,不实现 fail_closed

同时更新 authz/provider.py 中 Principal 的生命周期说明adapter 每次请求实时构建 Principal不得继续描述为“每个 run 只构建一次”。

Phase 1B 自动装配 adapter 时必须显式传入 AuthorizationConfig.default_role;本 PR 不进行该装配。

Step 11更新实施记录

修改:

docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md

只追加 Phase 1A-1 决策日志,记录:

  • internal 来自服务端 auth source。
  • Gateway 清除服务端拥有字段。
  • adapter 复用唯一 Principal builder。
  • attributes 使用严格 Mapping + copy 语义。
  • subagent 无条件继承 internal 布尔值。
  • RBAC、provider factory 和执行接线继续延期。

不修改 Phase 0 历史记录,不 bump config_version

4. 精确文件清单

新增 5 个文件:

backend/packages/harness/deerflow/authz/principal.py
backend/tests/test_authorization_principal.py
docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md
docs/plans/2026-07-15-authz-phase1a-implementation-plan.md
docs/plans/2026-07-15-authz-phase1a1-trusted-principal-implementation-plan.md

修改 17 个文件:

backend/AGENTS.md
backend/packages/harness/deerflow/authz/__init__.py
backend/packages/harness/deerflow/authz/adapter.py
backend/packages/harness/deerflow/authz/provider.py
backend/packages/harness/deerflow/guardrails/provider.py
backend/packages/harness/deerflow/guardrails/middleware.py
backend/app/gateway/services.py
backend/packages/harness/deerflow/tools/builtins/task_tool.py
backend/packages/harness/deerflow/subagents/executor.py
backend/packages/harness/deerflow/sandbox/tools.py
backend/tests/test_authorization_provider.py
backend/tests/test_channel_user_id_env.py
backend/tests/test_guardrail_middleware.py
backend/tests/test_task_tool_core_logic.py
backend/tests/test_subagent_executor.py
backend/tests/test_gateway_services.py
docs/plans/2026-07-10-pluggable-authorization-rfc.md

合计 22 个文件。文件数较多是因为它固定一条跨 Gateway、lead、subagent 和 guardrail 的端到端身份链;继续拆分会产生可合并但身份链不完整的中间状态。

5. 明确不修改

backend/packages/harness/deerflow/config/authorization_config.py
config.example.yaml
backend/packages/harness/deerflow/agents/lead_agent/agent.py
backend/packages/harness/deerflow/client.py

本 PR 不实现:

  • RBAC provider 和 provider factoryPhase 1A-2
  • Layer 1 工具过滤和 deferred catalog 防提升Phase 1B
  • Layer 2 自动接线及与显式 guardrail 的组合顺序Phase 1B
  • DeerFlowClient skill filter gap独立 bugfix PR
  • route、model、skill、sandbox、MCP server 授权。
  • RBAC 配置示例和配置版本变更。

6. 测试命令

按 TDD 步骤先确认新增测试失败,再逐段实现:

cd backend

uv run pytest tests/test_authorization_principal.py -q
uv run pytest tests/test_gateway_services.py -q
uv run pytest tests/test_task_tool_core_logic.py -q
uv run pytest tests/test_subagent_executor.py -q
uv run pytest tests/test_authorization_provider.py -q
uv run pytest tests/test_guardrail_middleware.py -q
uv run pytest tests/test_channel_user_id_env.py -q

uv run pytest tests/test_authorization_principal.py tests/test_gateway_services.py tests/test_channel_user_id_env.py tests/test_task_tool_core_logic.py tests/test_subagent_executor.py tests/test_authorization_provider.py tests/test_guardrail_middleware.py -q
uv run pytest tests/test_harness_boundary.py -q

uv run ruff check packages/harness/deerflow/authz packages/harness/deerflow/guardrails packages/harness/deerflow/tools/builtins/task_tool.py packages/harness/deerflow/subagents/executor.py packages/harness/deerflow/sandbox/tools.py app/gateway/services.py tests/test_authorization_principal.py tests/test_authorization_provider.py tests/test_channel_user_id_env.py tests/test_task_tool_core_logic.py tests/test_subagent_executor.py tests/test_gateway_services.py
uv run ruff format --check packages/harness/deerflow/authz packages/harness/deerflow/guardrails packages/harness/deerflow/tools/builtins/task_tool.py packages/harness/deerflow/subagents/executor.py packages/harness/deerflow/sandbox/tools.py app/gateway/services.py tests/test_authorization_principal.py tests/test_authorization_provider.py tests/test_channel_user_id_env.py tests/test_task_tool_core_logic.py tests/test_subagent_executor.py tests/test_gateway_services.py

提交前运行:

make test
make lint
make format

若全量测试受外部环境阻塞PR 描述必须列出具体命令、通过结果和阻塞原因。

7. 验收门槛

  • 删除 is_internal 服务端直接赋值后Gateway 防伪测试失败。
  • 删除 configurable 清理后,对应伪造测试失败。
  • 普通请求不能通过 body.contextbody.config 注入 channel_user_idinternal 请求只接受顶层 body.context 的值。
  • 删除任意一段 task/executor identity wiring 后subagent 测试失败。
  • builder、task、executor、middleware 对 attributes 非 Mapping 的行为一致。
  • adapter 同步和异步生成语义一致的完整 Principal。
  • user=None 的 internal 和 non-internal 分支都被固定。
  • 原有 Gateway、guardrail 和 subagent 测试继续通过。
  • authorization.enabled: false 时工具集合与执行决策不变。
  • 没有 Layer 1、Layer 2、RBAC 或 client 额外改动混入。
  • implementation notes 已追加决策记录。
  • git diff --check、ruff、目标测试和全量可运行测试通过。