# Phase 1A-1 实施计划:可信 Principal 链路 建议 PR 标题: ```text 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 完成一条可信、可测试的授权身份链路: ```text 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 个字段: ```text user_id / role / oauth_provider / oauth_id / channel_user_id / is_internal / attributes ``` 规则: - `user_role` 为 `None` 或空字符串时使用 `default_role`。 - 未知但非空的角色原样保留;不能回退到默认角色。 - `is_internal` 只有原值严格等于 `True` 时才为 `True`。 - `authz_attributes` 为 `None` 或缺失时解析为 `{}`。 - 非空 `authz_attributes` 必须实现 `Mapping`,否则抛 `TypeError`。 - attributes 每次都复制为新字典,不共享可变引用。 - builder 是纯函数:不读全局 config、不缓存、不修改输入。 ### 2.2 Gateway 可信边界 `is_internal`、`authz_attributes` 和 `channel_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 失败测试 新增: ```text backend/tests/test_authorization_principal.py ``` 覆盖: - 空 context、部分 context 和全部 7 个字段映射。 - 缺失、`None`、空字符串 role 使用 `default_role`。 - 未知非空 role 原样保留。 - `is_internal` 对 `True`、`False`、`1`、`"true"`、`None` 的严格布尔行为。 - attributes 缺失或 `None` 得到 `{}`。 - Mapping attributes 被复制;修改输入不影响 Principal。 - 非 Mapping attributes 抛 `TypeError`,错误包含实际类型。 - oauth 和 channel identity 正确映射。 先运行该测试并确认因 builder 不存在而失败。 ### Step 2:实现 Principal builder 新增: ```text backend/packages/harness/deerflow/authz/principal.py ``` 接口和核心实现: ```python 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 防伪失败测试 修改: ```text backend/tests/test_gateway_services.py ``` 必须复用真实配置装配顺序: ```text 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=None`、`auth_source=internal` 时仍写入 `True`。 - 非 Mapping runtime context 显式抛 `TypeError`,不静默跳过。 - 原有 user、owner、role、oauth 测试继续通过。 这些测试必须证明新增覆盖逻辑是唯一通过原因,不能使用本来就被 `body.context` 白名单过滤的空路径。 ### Step 4:实现 Gateway 防伪与注入 修改: ```text backend/app/gateway/services.py ``` 新增: ```python _SERVER_OWNED_AUTHZ_CONTEXT_KEYS = frozenset( {"is_internal", "authz_attributes", "channel_user_id"} ) ``` 在 `inject_authenticated_user_context()` 最顶部、读取 `user_id` 之前执行: ```python 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 身份捕获失败测试 修改: ```text 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 身份捕获 修改: ```text backend/packages/harness/deerflow/tools/builtins/task_tool.py ``` 在现有 parent identity capture 块增加: ```python 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 写回失败测试 修改: ```text 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 身份写回 修改: ```text backend/packages/harness/deerflow/subagents/executor.py ``` 构造参数增加: ```python is_internal: bool = False, authz_attributes: Mapping[str, Any] | None = None, ``` 构造时严格校验并复制: ```python 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 无条件执行: ```python context["is_internal"] = self.is_internal context["authz_attributes"] = dict(self.authz_attributes) ``` ### Step 9:新增 Guardrail/adapter 失败测试 修改: ```text 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.py`,adapter 转换断言 放在 `test_authorization_provider.py`;不得新建重复测试模块。 ### Step 10:扩展 GuardrailRequest 和 adapter 修改: ```text backend/packages/harness/deerflow/guardrails/provider.py backend/packages/harness/deerflow/guardrails/middleware.py backend/packages/harness/deerflow/authz/adapter.py ``` `GuardrailRequest` 增加带默认值的字段: ```python 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: ```python 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:更新实施记录 修改: ```text 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 个文件: ```text 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 个文件: ```text 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. 明确不修改 ```text 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 factory(Phase 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 步骤先确认新增测试失败,再逐段实现: ```powershell 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 ``` 提交前运行: ```powershell make test make lint make format ``` 若全量测试受外部环境阻塞,PR 描述必须列出具体命令、通过结果和阻塞原因。 ## 7. 验收门槛 - [ ] 删除 `is_internal` 服务端直接赋值后,Gateway 防伪测试失败。 - [ ] 删除 `configurable` 清理后,对应伪造测试失败。 - [ ] 普通请求不能通过 `body.context` 或 `body.config` 注入 `channel_user_id`;internal 请求只接受顶层 `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、目标测试和全量可运行测试通过。