17 KiB
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_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 失败测试
新增:
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
新增:
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
分别测试以下入口,不能合并为一个配置:
body.config["context"]伪造is_internal=True。body.config["configurable"]伪造is_internal=True。- 两个入口分别伪造
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 防伪与注入
修改:
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.py,adapter 转换断言
放在 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 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 步骤先确认新增测试失败,再逐段实现:
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.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、目标测试和全量可运行测试通过。