* feat(browser): add agentic browser control
* fix(frontend): format browser view changes
* fix(browser): keep browser optional and isolate sidecar layout
* fix(browser): address PR review security and IME findings
- Nginx: add a browser-stream WebSocket location before the generic
/api/threads regex so Live upgrades instead of downgrading to HTTP
(both nginx.conf and nginx.local.conf).
- Ownership: require an existing owned thread for the WS stream and REST
navigate, and tear down the browser session on thread deletion so a
later caller cannot reuse a retained page/cookies by guessing the id.
- SSRF: enforce the URL policy at the browser request boundary via a
context-level route guard covering redirects, popups, iframes, and
subresources (skipped for CDP-attached Chrome).
- IME: skip key forwarding while a composition is active so confirming a
CJK candidate with Enter no longer submits the remote page form.
Adds regression tests for the request guard, session teardown on delete,
and the composing-Enter key decision.
* fix(frontend): smooth streaming in long tool threads
* Revert "fix(frontend): smooth streaming in long tool threads"
This reverts commit f0462516eabe77f138d4027ea1c714fb226683cf.
* fix(browser): address review security and lifecycle findings
- Reject cross-origin WebSocket upgrades on the live browser stream
(Origin allow-list reuse of CORS/same-origin helpers) to close a
WS-CSRF hole, and fail closed when the ownership store is absent.
- Warn when a CDP-attached session runs with the SSRF request guard
off, and drop the unreachable CDP screencast teardown dead code.
- Read browser session launch config from a single canonical source
(browser_navigate) so it is deterministic regardless of call order.
- Bound per-thread Chromium accumulation with idle-timeout eviction
and an LRU max-sessions cap.
- Reset the Live reconnect counter on a successful open so the stream
can't permanently stall after the cumulative attempt cap.
* fix(frontend): reduce long tool thread render stalls
Reuse stable historical message groups during streaming, defer heavy Markdown and browser previews, and lazy-decode message images.
* fix(browser): keep live control responsive during continuous input
Why: Manual browser control felt laggy — a physical click ran the remote
Playwright click three times and each non-move input synchronously awaited a
JPEG screenshot, so events queued behind capture (queue wait up to ~237ms).
The first async attempt used a trailing-edge debounce, which froze the visible
page until a wheel/keyboard gesture stopped ("scroll finishes, then it jumps").
What:
- Frontend forwards one `click` per physical click instead of also emitting
`down`/`up`, so the remote page is not clicked twice per gesture.
- Backend detaches live-frame capture from input dispatch: non-move actions
start a rate-limited background refresh loop (leading frame + bounded cadence)
that keeps emitting frames while input continues and never blocks dispatch.
- Add regression tests: input dispatch no longer awaits the screenshot, rapid
inputs coalesce, and continuous input keeps refreshing before it stops.
Scenarios: Verified in the live Browser panel — a single click completes in
~57ms (was blocked behind a 171ms capture), and a 1.14s sustained wheel gesture
renders ~7 frames throughout the scroll instead of one frame after it ends.
* fix(browser): harden worker and session lifecycle
* fix(browser): address latest review feedback
* fix(frontend): preserve optimistic new-chat message
* test(e2e): preserve mocked message run ids
* fix(browser): address capability review feedback
---------
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
42 KiB
实施连续性要求: 每个阶段的 PR 都必须阅读并更新 可插拔授权系统实施记录。 该文件记录已合并契约、review 决策、延期工作和实施前检查项。旧 RFC 示例与已合并 代码及测试不一致时,以已合并契约为准。
RFC: Pluggable Fine-Grained Authorization
Status: Draft for feedback (responds to #3462).
Affects: backend/packages/harness/deerflow/authz/ (new), backend/app/gateway/authz.py, backend/packages/harness/deerflow/guardrails/, backend/packages/harness/deerflow/agents/lead_agent/agent.py, backend/packages/harness/deerflow/client.py, backend/packages/harness/deerflow/subagents/executor.py, backend/app/gateway/services.py, config.example.yaml.
Maintainer guidance from the issue (@WillemJiang): "在开始实现之前,我们需要梳理相关的资源与权限的管理映射关系。具体工具调用和资源访问这块可以结合现有的 GuardrailProvider 来进行整合。另外建议在实现之前,做一个 RFC 的设计,方便大家的提反馈建议。"
This RFC does exactly that: (1) maps the resource × permission space, (2) integrates with
GuardrailProvider, (3) is an RFC for feedback before any implementation.
TL;DR
DeerFlow's authorization today is authentication + ownership: every authenticated user gets every API permission (authz.py:144 hands all users _ALL_PERMISSIONS), and the only real authorization beyond login is thread ownership (owner_check) and a handful of hard-coded require_admin_user() routes. There is no resource-level authorization — no role can be told "you may not call write_file", "you may not use model X", or "you may not run sandbox code".
This RFC proposes a single pluggable AuthorizationProvider Protocol that is the policy brain for all fine-grained authz, enforced at two layers from one policy:
- Capability filtering (assembly-time) — removes tools a role can never use before they are bound to the agent, so the model never sees them and
tool_searchcan never promote them back (fail-closed). - Execution authorization (run-time) — a per-call allow/deny that reuses the existing
GuardrailMiddlewareas its enforcement point, catching dynamic resources and argument-based restrictions.
We ship one built-in provider — RbacAuthorizationProvider — that reads config.yaml, so it works out-of-the-box (no code), and enterprises with LDAP/OPA/OAuth-scopes replace it by setting one class path. This is a deliberate hybrid of "Plan A" (pluggable hook) and "Plan B" (built-in RBAC) from the issue thread: pluggable surface, batteries-included default.
The two hard problems raised in the thread — dynamic resources and the filter-vs-deny security boundary — are both resolved by the two-layer design (see §7).
1. Background: what exists today
1.1 Authentication (who you are) — solid
AuthMiddleware (backend/app/gateway/auth_middleware.py:65) is a strict session gate. It stamps request.state.user from one of three sources:
| Source | system_role |
When |
|---|---|---|
Session (JWT cookie → User) |
"admin" or "user" |
Browser / API callers |
Internal (X-DeerFlow-Internal-Token) |
"internal" |
IM channel workers, scheduler |
| Auth-disabled | "admin" |
auth_disabled mode |
The User model (backend/app/gateway/auth/models.py:15) carries system_role: Literal["admin", "user"]; the DB column is String(16) deliberately, with a comment: "kept as plain string to avoid ALTER TABLE pain when new roles are introduced" (persistence/user/model.py:33). The schema is already forward-compatible with new roles.
1.2 Authorization (what you can do) — a placeholder
- Route-level:
@require_permission("resource", "action")(authz.py:197) checksAuthContext.permissions. But_authenticate()(authz.py:131) setspermissions=_ALL_PERMISSIONSfor every authenticated user. The comment atauthz.py:143admits it: "In future, permissions could be stored in user record." It is a placeholder. - Ownership:
owner_check=True(authz.py:278) scopes threads/runs to their owner viaThreadMetaStore.check_access. This is real and works. - Admin routes:
require_admin_user()(deps.py:482) hard-codessystem_role == "admin"for skill / MCP / channel-management endpoints. - Tool-level: none. No role can be restricted from any tool, model, skill, or sandbox.
1.3 The GuardrailProvider (the integration point)
The maintainer pointed here for good reason. GuardrailProvider (deerflow/guardrails/provider.py:46) is already a pluggable, class-path-loaded, per-call authorization hook:
@runtime_checkable
class GuardrailProvider(Protocol):
name: str
def evaluate(self, request: GuardrailRequest) -> GuardrailDecision: ...
async def aevaluate(self, request: GuardrailRequest) -> GuardrailDecision: ...
GuardrailRequest already carries identity — user_id, user_role, oauth_provider, oauth_id, thread_id, run_id, is_subagent, tool_call_id — populated from the run context by GuardrailMiddleware._build_request (guardrails/middleware.py:42). It is wired by GuardrailMiddleware (guardrails/middleware.py:22), which blocks denied calls with an error ToolMessage, fails closed by default, and audits to RunJournal. It is attached for both lead agent and subagents in _build_runtime_middlewares() (agents/middlewares/tool_error_handling_middleware.py:202).
What guardrails cannot do alone: they fire per tool call, at execution time. They cannot remove a tool from the schema the model sees, so a denied-but-visible tool is still a prompt-injection surface, and they cannot prevent tool_search from promoting a tool back into view. Guardrails are the execution layer; they are not a capability/visibility layer. This RFC makes them one half of a two-layer system.
1.4 The tool assembly pipeline (where visibility is decided)
All tools are merged in get_available_tools() (deerflow/tools/tools.py:44): config tools + built-ins + MCP (cached, hot-reloadable by mtime) + ACP, deduped by name. In the lead agent (agents/lead_agent/agent.py:561):
raw_tools = get_available_tools(...)
filtered = filter_tools_by_skill_allowed_tools(raw_tools + extra_tools, skills, ...) # name allowlist from skills
if non_interactive:
filtered = [t for t in filtered if t.name not in _NON_INTERACTIVE_DISABLED_TOOL_NAMES]
final_tools, setup = assemble_deferred_tools(filtered, enabled=...) # deferred catalog built from `filtered`
The skill filter (skills/tool_policy.py:42) is the exact pattern a role filter should mirror: a name-set intersection. Crucially, assemble_deferred_tools builds the tool_search catalog from the post-filter list (tool_search.py:182), so anything removed before this point can never be promoted back. This is the fail-closed insertion point.
Three agent build paths assemble tools, and they must all apply the same filter or the policy is bypassable:
| Path | File | Skill filter applied? |
|---|---|---|
| Lead agent | agents/lead_agent/agent.py:562 |
✅ |
| Subagent | subagents/executor.py:578 (_apply_skill_allowed_tools) |
✅ |
DeerFlowClient |
client.py:259 |
❌ (passes tools straight to assemble_deferred_tools) |
The DeerFlowClient gap is a pre-existing inconsistency; any new authz filter must be applied on all three.
1.5 Identity flow into the run — reliable for the web, gap for channels
inject_authenticated_user_context() (app/gateway/services.py:278) stamps user_id / user_role / oauth_provider / oauth_id into config["context"], which GuardrailMiddleware reads. Reliability:
| Caller path | user_role |
|---|---|
| Web / browser session | always populated ("admin"/"user") |
| Auth-disabled | always "admin" |
| IM channel, bound connection | connection owner's system_role |
| IM channel, unbound / lookup miss | None (explicitly popped, services.py:301) |
| Scheduled task | owner's role if resolvable, else None |
So user_role reaches the tool layer reliably except for unbound IM channels, where it is None. Any role-based design must define behavior for the None case (see §8).
2. Goals & non-goals
Goals
- G1 — Pluggable. A single
AuthorizationProviderProtocol, class-path-loaded exactly likeGuardrailProvider/ models / sandbox (resolve_variable). Enterprises plug LDAP, OPA, OAuth-scopes, or home-grown RBAC; no fork required. - G2 — Out-of-the-box. A built-in
RbacAuthorizationProviderreadsconfig.yaml; operators configure roles without writing Python. - G3 — Resource-level. Covers tools, models, skills, sandbox, MCP, and routes — not just API permissions.
- G4 — Defense-in-depth. Visibility filter and execution deny, from one policy. Neither alone is sufficient against prompt injection or dynamic promotion.
- G5 — Fail-closed. Provider error, missing role, or unresolvable identity defaults to deny (configurable to fail-open for non-security contexts).
- G6 — Non-breaking. Default config (
authorization.enabled: false) preserves today's behavior (all authenticated users get everything). Adoption is opt-in per deployment.
Non-goals
- Not a full user-management UI (create/disable users, bulk ops). The issue asks for it; this RFC covers authorization, not identity lifecycle. User provisioning remains in the auth module.
- Not per-message/per-sender IM roles. Channel senders inherit the connection owner's role today; per-sender identity is a separate, larger effort (see §11 open questions).
- Not replacing ownership checks.
owner_checkstays; the provider adds role policy on top. - Not re-implementing what
require_admin_useralready does for management routes. Those migrate to the provider in a later phase, but behavior is preserved.
3. Resource × Permission map
The maintainer's first ask: “梳理相关的资源与权限的管理映射关系.” Here is the full inventory.
| Resource | Identifier (target) |
Actions | Current enforcement | Proposed enforcement |
|---|---|---|---|---|
| Tool (built-in / MCP / ACP) | tool name (write_file, mcp__github__*) |
call |
none | assembly filter + guardrail (§5) |
| Model | model name (claude-sonnet-4-6) |
list, use |
none | models router + _resolve_model_name |
| Skill | skill name | activate, read, manage |
manage admin-gated; activate ungated |
SkillActivationMiddleware + describe_skill + (keep admin gate) |
| Sandbox | (singleton) | execute |
none (global on/off) | SandboxMiddleware |
| MCP server | server name | use (its tools), manage |
manage admin-gated |
tool filter covers use; keep admin gate for manage |
| Thread | thread_id | read, write, delete |
owner_check ✅ |
keep ownership; provider may add role policy |
| Run | run_id | create, read, cancel |
ownership via thread | keep |
| Route | resource:action (threads:read) |
(decorator) | _ALL_PERMISSIONS placeholder |
provider-derived permissions (§6.3) |
| Scheduled task | task_id | create, manage, execute |
router-level auth | provider |
| Agent self-mutation | update_agent tool |
call |
withheld from webhook channels ✅ | assembly filter (per-role deny) + keep webhook withhold |
| Memory | (owner-scoped) | read, write |
owner-scoped ✅ | keep |
The headline gap is the first four rows — tools, models, skills, sandbox have no role gate today. Routes are nominally gated but effectively wide-open via _ALL_PERMISSIONS.
4. Proposed architecture
┌─────────────────────────────┐
│ AuthorizationProvider │ ← pluggable policy brain
│ (Protocol, class-path) │ (built-in: RbacAuthorizationProvider)
└──────────────┬───────────────┘
│ one policy
┌────────────────────┴───────────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌──────────────────────────────────┐
│ Layer 1: Capability filter │ │ Layer 2: Execution authorization │
│ (assembly-time, per build) │ │ (run-time, per call) │
│ │ │ │
│ filter_resources(principal, │ │ authorize(AuthzRequest) │
│ "tool", candidates) -> list │ │ reuses GuardrailMiddleware │
│ │ │ via a thin adapter │
│ removes tools before │ │ │
│ create_agent → invisible + │ │ catches dynamic resources + │
│ unpromotable (fail-closed) │ │ argument-based deny │
└───────────────────────────────┘ └──────────────────────────────────┘
One policy, two enforcement layers. Layer 1 answers "what can this role's agent ever see?" (static, batch, at build). Layer 2 answers "may this specific call proceed?" (dynamic, per-call, at execution). Both consult the same provider, so an enterprise's LDAP-backed provider is the single source of truth.
Why two layers (not one)
- Layer 1 only (visibility filter) — vulnerable to any future code path that binds a tool after the filter runs (e.g. a new dynamic tool source). Also leaks nothing, but can't do per-argument policy.
- Layer 2 only (guardrail) — the model still sees every tool schema, so a denied tool is a prompt-injection target and consumes context. hata33's concern in the thread is exactly this.
- Both — Layer 1 removes tools the role can never use (clean schema, no injection surface, unpromotable); Layer 2 is the safety net for anything that slips through and the place for argument-based rules. This is the design.
5. The AuthorizationProvider Protocol
Lives in a new package deerflow/authz/ (harness), mirroring deerflow/guardrails/.
# deerflow/authz/provider.py
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
@dataclass
class Principal:
"""The actor. Built once per run from request.state.user + context."""
user_id: str | None = None
role: str | None = None # system_role today; provider may map richer
oauth_provider: str | None = None
oauth_id: str | None = None
channel_user_id: str | None = None # IM sender (distinct from connection owner)
is_internal: bool = False # internal/system caller
attributes: dict[str, Any] = field(default_factory=dict) # extensible: dept, team, quota…
@dataclass
class AuthzRequest:
principal: Principal
resource: str # "tool" | "model" | "skill" | "sandbox" | "mcp_server" | "thread" | "route" | …
action: str # "call" | "list" | "use" | "activate" | "execute" | "read" | "write" | "delete" | "manage"
target: str # resource id: tool name, model name, skill name, "route:threads:read", …
context: dict[str, Any] = field(default_factory=dict) # thread_id, run_id, tool args, …
@dataclass
class AuthzReason:
code: str
message: str = ""
@dataclass
class AuthzDecision:
allow: bool
reasons: list[AuthzReason] = field(default_factory=list)
policy_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@runtime_checkable
class AuthorizationProvider(Protocol):
"""Pluggable fine-grained authorization. No base class required.
Loaded by class path via resolve_variable() — the same mechanism as
GuardrailProvider, models, tools, and sandbox.
"""
name: str
def authorize(self, request: AuthzRequest) -> AuthzDecision:
"""Per-call decision. Feeds Layer 2 (execution) and route checks."""
...
async def aauthorize(self, request: AuthzRequest) -> AuthzDecision:
...
# Layer 1: batch visibility filter. Default = "delegate to authorize per item"
# (correct but slow). Providers with a static role→resource map override
# this for O(1) assembly-time filtering and fail-closed visibility.
def filter_resources(
self, principal: Principal, resource_type: str, candidates: list[str],
) -> list[str]: ...
Design notes:
Principalis built once per run (fromrequest.state.user+ context) and threaded to both layers, so providers don't re-resolve identity per call.resource/action/targetare free-form strings, not an enum, so new resource types (and provider-specific resources) need no schema change. The built-in RBAC provider interprets them; custom providers define their own.filter_resourceshas a default (per-itemauthorize) so a provider that only implementsauthorizestill works for Layer 1 — but the built-in RBAC provider overrides it for fail-closed static filtering.- Mirrors
GuardrailProviderexactly in shape (sync + async,@runtime_checkable, no base class,nameattribute) so the mental model is uniform.
6. Integration with GuardrailProvider (the maintainer's ask)
6.1 The execution layer reuses GuardrailMiddleware verbatim
We do not write a new middleware. GuardrailMiddleware already does everything Layer 2 needs: per-call hook, fail-closed, audit, error ToolMessage, sync+async. We add one thin adapter that presents an AuthorizationProvider as a GuardrailProvider:
# deerflow/authz/adapter.py
class GuardrailAuthorizationAdapter:
"""Adapt an AuthorizationProvider to the GuardrailProvider Protocol.
Lets the existing GuardrailMiddleware enforce AuthorizationProvider
decisions at tool-call time — no new middleware class required.
"""
name = "authorization"
def __init__(self, provider: AuthorizationProvider, *, resource_type: str = "tool",
action: str = "call"):
self._provider = provider
self._resource_type = resource_type
self._action = action
def _to_authz(self, gr: GuardrailRequest) -> AuthzRequest:
return AuthzRequest(
principal=Principal(
user_id=gr.user_id, role=gr.user_role,
oauth_provider=gr.oauth_provider, oauth_id=gr.oauth_id,
is_internal=(gr.user_role == "internal"),
),
resource=self._resource_type, action=self._action, target=gr.tool_name,
context={"thread_id": gr.thread_id, "run_id": gr.run_id,
"tool_call_id": gr.tool_call_id, "tool_input": gr.tool_input,
"is_subagent": gr.is_subagent},
)
def evaluate(self, request): # -> GuardrailDecision
d = self._provider.authorize(self._to_authz(request))
return GuardrailDecision(allow=d.allow,
reasons=[GuardrailReason(code=r.code, message=r.message) for r in d.reasons],
policy_id=d.policy_id, metadata=d.metadata)
async def aevaluate(self, request):
d = await self._provider.aauthorize(self._to_authz(request))
return GuardrailDecision(allow=d.allow, reasons=[...], policy_id=d.policy_id, metadata=d.metadata)
6.2 Auto-wiring in _build_runtime_middlewares
In agents/middlewares/tool_error_handling_middleware.py:202 (the existing guardrail block), add a parallel block: when authorization.enabled and no explicit guardrails.provider is set, auto-attach GuardrailMiddleware(GuardrailAuthorizationAdapter(authz_provider)). Users who want an external guardrail (OAP) and role filtering configure both sections; users who want only RBAC configure only authorization. The built-in RbacAuthorizationProvider also natively implements the GuardrailProvider methods, so it can be set directly as guardrails.provider too — the adapter is the general escape hatch.
6.3 Route-level: replacing _ALL_PERMISSIONS
AuthContext.permissions is today a flat constant list. We make it provider-derived: _authenticate() (authz.py:131) asks the provider authorize(principal, "route", action, target=f"{resource}:{action}") for each registered permission (cached per-request). @require_permission is unchanged. Behavior with authorization.enabled: false is identical to today (all permissions granted). This is a phased migration (§9) — we don't touch every route on day one.
7. The two hard problems (resolved)
7.1 Dynamic resources
| Dynamic path | Why it's a problem | How the two-layer design handles it |
|---|---|---|
| MCP hot-reload | New MCP tools appear after agent build (cache mtime check, mcp/cache.py:31) |
Tools are read at build time per run (get_available_tools). A newly-added MCP tool appears on the next run, where Layer 1 filters it. Mid-run appearance doesn't happen within a built graph. |
tool_search deferred promotion |
A hidden tool can be promoted back into the schema mid-run | Layer 1 runs before assemble_deferred_tools (agent.py:565). Filtered-out tools never enter the DeferredToolCatalog (tool_search.py:182), so they can never be promoted. Fail-closed by construction. |
McpRoutingMiddleware auto-promotion |
Keyword-based auto-promotion could surface a tool | Same: the catalog is post-filter, so auto-promotion can only pick from already-allowed tools. |
Skill runtime discovery (describe_skill, /skill activation) |
Skills can be discovered/activated mid-run | Skills are a resource type in the provider. SkillActivationMiddleware calls authorize(principal, "skill", "activate", target=skill_name) before injecting the skill. Denied skills never load. |
Argument-based rules (e.g. write_file only to /tmp) |
Static filter can't see args | Layer 2 (authorize with context.tool_input) handles this. The tool stays visible (it's allowed in principle) but specific calls are denied. |
Principle: Layer 1 = "what you can ever see" (static, fails closed). Layer 2 = "may this call proceed" (dynamic, argument-aware). Any resource that is dynamic-only (appears after build) is caught by Layer 2; any resource known at build is removed by Layer 1 and made unpromotable.
7.2 The filter-vs-deny security boundary
hata33's concern: "只做'过滤可见性'不做'拦截执行', prompt injection 仍能让模型调被隐藏的工具."
This is only true if filtering happens at the visibility layer (DeferredToolFilterMiddleware, which hides schemas but doesn't remove tools from the bound set). This RFC filters at the assembly layer — the tool is removed from final_tools passed to create_agent, so it is not bound to the model at all. The model cannot call a tool it was never given.
The residual risk — a tool that is allowed at assembly (visible) but should be denied for a specific call — is exactly what Layer 2 (guardrail) catches. Filter + deny, both enforced, from one policy. Neither is bypassable alone:
- A tool removed at assembly: not bound → uncallable → no injection surface.
- A tool visible but role-denied: guardrail blocks the call → uncallable.
- Provider error:
fail_closed=true→ deny.
8. Identity prerequisites (close the user_role gap)
Role-based authz is only as good as the role reaching the provider. Today user_role is None for unbound IM channels (services.py:301). We close this in inject_authenticated_user_context and a new Principal builder:
default_roleconfig (authorization.default_role, default"user"): whenuser_roleisNone, thePrincipal.rolefalls back to this. Unbound channels get a defined, restrictable role instead of an implicit "admin-by-accident."Principalis built once per run fromrequest.state.user+ context, inservices.pyalongsideinject_authenticated_user_context, and stored on the run context asprincipal(alongside the existinguser_id/user_role). Both layers read it.- Role taxonomy lives in config, not the schema.
User.system_rolestaysLiteral["admin","user"]for the built-in identity provider, but the DB column is alreadyString(16). The RBAC config definesguest,operator, etc.; a custom identity provider can mint richer roles. No schema migration required — by design (persistence/user/model.py:33). internalrole (channel workers/scheduler) is a real role in the RBAC config, not a special case. The provider decides whatinternalmay do (typically: call tools on behalf of the owner, but noupdate_agent, no admin routes).
9. Phased rollout
Each phase is independently shippable and behind authorization.enabled (default false = today's behavior).
Phase 0 — Foundations (no behavior change).
- New
deerflow/authz/package:provider.py(Protocol + dataclasses),principal.py(builder),adapter.py. Principalbuilt inservices.py, stored on run context.default_roleconfig; close theuser_role=Nonegap.RbacAuthorizationProviderskeleton +AuthorizationConfig(AppConfig section, singleton, live-reload — mirrorsguardrails_config.py; not inSTARTUP_ONLY_FIELDS).
Phase 1 — Tool authorization (highest value, lowest risk).
- Layer 1:
filter_tools_by_authorization(tools, principal)applied atagent.py:562,executor.py:578, andclient.py:259(fixing the existing skill-filter gap there too). - Layer 2: auto-wire
GuardrailAuthorizationAdapterin_build_runtime_middlewares. - Built-in RBAC provider covers
tool:callwith allow/deny lists +*wildcard. - Tests: per-role tool visibility, deferred-promotion fail-closed, prompt-injection-can't-call-removed-tool, subagent inheritance.
Phase 2 — Route-level migration.
- Replace
_ALL_PERMISSIONSwith provider-derived permissions in_authenticate()(per-request cached). @require_permissionunchanged. Keeprequire_admin_user()for management endpoints (skills/MCP/channel config) — the project already settled on admin-gating there (GHSA-4693 → #3855/#3425; see §12 Q6). Only ordinary routes migrate.
Phase 3 — Models, skills, sandbox.
models.py:40list_modelsfilters byauthorize("model","list");_resolve_model_namechecksauthorize("model","use")(deny → fall back to an allowed default, not error, to avoid breaking runs).SkillActivationMiddleware+describe_skillgate onauthorize("skill","activate").SandboxMiddlewaregates onauthorize("sandbox","execute")(deny → tool returns a "sandbox not permitted for your role" error message, not a crash).
Phase 4 (optional) — Frontend. Surface the user's effective permissions so the UI can hide disabled models/tools/menus. Out of scope for the backend RFC; noted for completeness.
10. Config schema
Mirrors the guardrails section exactly (config.example.yaml:1786):
# ============================================================================
# Authorization Configuration
# ============================================================================
# Optional fine-grained, role-based authorization. When enabled, a pluggable
# AuthorizationProvider decides what each role may call/use/see. Two layers
# are enforced from one policy: assembly-time capability filtering (tools the
# agent can never see) and run-time execution deny (reuses GuardrailMiddleware).
# See docs/plans/2026-07-10-pluggable-authorization-rfc.md.
authorization:
enabled: false
fail_closed: true # block on provider error / unresolved identity
default_role: user # applied when user_role is None (unbound IM channels)
provider:
use: deerflow.authz.rbac:RbacAuthorizationProvider
config:
# role -> resource policy. "*" = all. Omitted resource type = unaffected.
roles:
admin:
tools: {allow: "*"}
models: {allow: "*"}
sandbox: {allow: true}
skills: {allow: "*"}
user:
tools: {allow: "*", deny: ["update_agent"]}
models: {allow: ["claude-sonnet-4-6", "gpt-4o"]}
sandbox: {allow: true}
skills: {allow: "*"}
guest:
tools: {allow: ["web_search", "read_file"]}
models: {allow: ["gpt-4o-mini"]}
sandbox: {allow: false}
skills: {allow: []}
internal: # IM channel workers / scheduler
tools: {allow: "*", deny: ["update_agent"]}
models: {allow: ["claude-sonnet-4-6"]}
sandbox: {allow: true}
# Optional: derive role from Principal.attributes instead of system_role.
# Default: role = principal.role (i.e. User.system_role).
# role_mapping:
# source: attribute # or "system_role"
# attribute: department
# map: {eng: admin, cs: user}
# When authorization.enabled and guardrails.provider is unset, the authorization
# provider is auto-wired as the tool-call guardrail. To use an external guardrail
# (e.g. OAP) AS WELL, set guardrails.provider explicitly; both then enforce.
Built-in provider semantics:
allow: "*"= all tools/models;allow: [list]= allowlist; omitted = inherit parent / allow.denyalways wins overallow(defense-in-depth: a deny can never be overridden).- Unknown role →
default_role→ if still unknown →fail_closeddecides. sandbox: {allow: false}makesSandboxMiddlewaredeny execution for that role.- Live-reloadable:
AuthorizationConfigis a singleton reloaded byget_app_config()'s signature check, and the provider is re-instantiated per agent build (same as guardrails) — not inSTARTUP_ONLY_FIELDS.
11. Alternatives considered
| Approach | From | Verdict |
|---|---|---|
Plan A — minimal filter hook only (ResourcePermissionProvider.filter_tools) |
hata33 | Adopted as Layer 1, but insufficient alone (no execution deny → injection risk; no non-tool resources). |
| Plan B — built-in config RBAC only | hata33 | Adopted as the built-in provider, but insufficient alone (enterprises still need to plug LDAP/OPA; no execution layer). |
Extend GuardrailProvider with filter_tools |
— | Rejected: bloats the OAP-aligned guardrail Protocol and couples visibility (per-build) with execution (per-call) semantics. Keep guardrails minimal; add a sibling Protocol. |
A new AuthorizationMiddleware (parallel to guardrail) |
— | Rejected: duplicates GuardrailMiddleware's fail-closed/audit/error-message logic. Reuse guardrail via the adapter instead. |
Store permissions in the User row |
authz.py:143 comment |
Rejected for the pluggable case: a DB column per permission doesn't scale to enterprise LDAP/OPA. The provider can be DB-backed if a deployment wants that — it's an implementation choice, not the architecture. |
This RFC = Plan A's pluggability + Plan B's batteries-included default + the two-layer enforcement that neither alone provides.
Prior-art note. The choice to add a sibling
AuthorizationProviderrather than extendGuardrailProvideris not second-guessing the guardrail design - it is what that design explicitly deferred. PR #3665 (which addeduser_role/user_idtoGuardrailRequest) states its scope as: "保持 Guardrail 的职责边界不变:不新增 policy engine、RBAC 系统、governance 子系统" ("keeps the guardrail boundary: adds no policy engine, RBAC system, or governance subsystem"). The guardrail is the execution enforcement point; the RBAC brain that #3665 deliberately left out is what this RFC adds - and it reuses the guardrail as that enforcement point (§6).
12. Open questions for feedback
- Role for unbound IM channels. Default
default_role: user, or a dedicatedguest-like role? Unbound channels today have no owner in the user DB; should they be allowed to run at all underauthorization.enabled, or require a bound connection? internalrole scope. Shouldinternal(channel workers) be a fully-configurable role, or keep special-cased bypass semantics for backward compat with existing channel deployments?- Model-deny behavior. When a role requests a denied model, fall back to an allowed default (graceful) or hard-deny the run (strict)? Proposal: graceful fallback + audit, but strict is defensible.
- Argument-based tool rules. In scope for the built-in RBAC provider (e.g.
write_filepath restrictions), or left to custom providers / OAP? Proposal: out of scope for v1 built-in; theauthorizehook supports it for custom providers. - Per-sender IM roles. Defer entirely (separate RFC), or lay groundwork now via
Principal.channel_user_id? Proposal: lay thePrincipalgroundwork (already in the dataclass), defer the policy. - Route migration cadence. Migrate the
_ALL_PERMISSIONSplaceholder to provider-derived permissions, but keeprequire_admin_user()hard-coded for management endpoints (skills/MCP/channel config). Precedent: GHSA-4693 (#2996) proposed@require_permissionfor the MCP/memory/skills routers and was closed; the merged fix (#3855, #3425) choserequire_admin_userinstead. So for management surfaces, the project has already settled on admin-gating - this RFC respects that and only migrates ordinary routes. Proposal: Phase 2 migrates_ALL_PERMISSIONSonly; management endpoints stay admin-gated (the provider may additionally be consulted, butadminremains the floor).
13. Test strategy (TDD, per AGENTS.md)
Backend tests in backend/tests/. Minimum coverage for Phase 1:
test_authz_provider_protocol.py— Protocol conformance,@runtime_checkable, defaultfilter_resourcesdelegates toauthorize.test_rbac_authorization_provider.py— per-role allow/deny,*wildcard,denywins, unknown role →default_role→fail_closed, all resource types.test_authz_tool_filter.py— Layer 1: tools removed at assembly on all three build paths (lead / subagent /DeerFlowClient); filtered tools absent fromDeferredToolCatalog(fail-closed promotion).test_authz_guardrail_adapter.py— Layer 2: adapter deny →GuardrailMiddlewarereturns errorToolMessage;user_roleflows from context;fail_closedon provider error.test_authz_prompt_injection.py— a tool removed at assembly cannot be invoked even when the prompt tries to call it (the security-boundary guarantee).test_authz_principal.py—user_role=None→default_role; internal caller; subagent inherits principal.- Extend
test_app_config_reload.py—authorizationsection live-reloads; not inSTARTUP_ONLY_FIELDSdrift test.
14. References
- Issue: #3462
- Existing design:
backend/docs/AUTH_DESIGN.md,backend/docs/GUARDRAILS.md - Guardrail provider:
backend/packages/harness/deerflow/guardrails/provider.py - Guardrail middleware:
backend/packages/harness/deerflow/guardrails/middleware.py - Tool assembly:
backend/packages/harness/deerflow/agents/lead_agent/agent.py:561,deerflow/tools/tools.py:44,deerflow/tools/builtins/tool_search.py:190 - Skill filter pattern:
backend/packages/harness/deerflow/skills/tool_policy.py:42 - Identity injection:
backend/app/gateway/services.py:278 - Config resolution:
backend/packages/harness/deerflow/reflection/resolvers.py,deerflow/config/guardrails_config.py,deerflow/config/reload_boundary.py - Middleware wiring:
backend/packages/harness/deerflow/agents/middlewares/tool_error_handling_middleware.py:154
15. Related work & prior art (upstream issues & PRs)
A search of bytedance/deer-flow confirms no RBAC implementation exists; RBAC is a recognized but unowned roadmap item. This RFC situates itself in the prior work below.
Direct lineage - what this RFC builds on
- #1669 - Q2 Roadmap. Lists "Implement Role-Based Access Control (RBAC)" under "Security and Permission Strengthening 🔥🔥🔥🔥" (top priority), referencing #1721 and #3506. This is the roadmap slot #3462 and this RFC fill.
- #1213 (closed) - the original RFC proposing OAP
before_tool_callauthorization for tools/skills. TheGuardrailProvider/GuardrailMiddlewaresystem is the implemented result. Our RFC is the next layer on top of that lineage. - #3664 / PR #3665 (merged) - added
user_id/user_role/oauth_*/run_id/tool_call_idtoGuardrailRequestand wired Gateway -> context -> middleware. This is the plumbing our Layer 2 relies on. Critically, #3665 explicitly scoped guardrails to not be an RBAC system (quoted in §11) - the RBAC brain is the deliberately-deferred gap this RFC adds. - #3672 / PR #3839 (merged) - propagate the bound connection owner's
role/oauthinto the guardrail context for IM/internal-auth runs. Documents the exactuser_role=Nonegap for unbound channels ("If owner lookup fails, the run continues with role/oauth attribution unset") that ourdefault_role(§8) closes. - #2507 / RFC PR #2504 (closed) - "Deferred MCP tools can execute before
tool_searchpromotion." Proposed direction: "Add an execution gate before tool execution." This becameDeferredToolFilterMiddleware.wrap_tool_call's execution deny. Our §7.2 two-layer design is consistent with this precedent - the project already chose "execution gate" as the pattern for the deferred capability boundary.
Security precedents - the gaps that motivate #3462
- GHSA-4693 / #2996 (closed) - proposed
@require_permissionfor MCP/memory/skills routers after any authenticated user could RCE via MCP stdio config injection. The merged fix was #3855 (admin-gate skills) + #3425 (harden MCP config endpoint) - i.e. the project choserequire_admin_userover fine-grained permissions for management surfaces. This RFC respects that precedent (§9 Phase 2, §12 Q6): management endpoints stay admin-gated; only ordinary routes migrate to the provider. - #1646, #1648, #2531 (open) - unauthenticated/over-broad MCP config + memory disclosure. Further evidence that resource-level authz is the open gap.
Complementary (distinct axis, not overlapping)
- #2470 (open RFC) - "Pluggable auth providers with request-level hook." This is authentication (trusted-header/gateway SSO via an
AuthProviderextension), and its non-goal #4 explicitly excludes authorization policy. Complementary, not overlapping - it decides who you are; this RFC decides what you can do. - #3322, #3476, #2761 (open) - per-user credential isolation (per-user MCP tokens, user connectors for GitHub/Linear, per-user model API keys). This is a different axis from per-role tool authorization: per-user creds = "act as this user on external service X"; this RFC = "may role Y use tool/model Z at all." The
Principal(§5) and provider hook could eventually support per-user policies, but per-user credential plumbing is a separate effort. - #1721 (closed RFC) - the original user-authentication module design (the
AUTH_DESIGN.mdlineage). Scoped RBAC out as a non-goal ("当前用户角色只有 admin 和 user,尚未实现细粒度 RBAC"). This RFC is the RBAC that #1721 deferred.
Other relevant security work
- #3630 / #3662 / #3661 (merged) - prompt-injection input sanitization + role isolation via system-message injection. Orthogonal defense; our Layer 1 (remove tools from the bound set) is the complementary capability-layer defense.
- #3837 (merged) - persist guardrail interventions as run events. Our Layer 2 reuses this audit trail for free.
- #3929 (open) - sandbox NodePort->ClusterIP (same author family of security hardening RFCs).
Net takeaway: the upstream has spent real effort plumbing identity into the guardrail execution point (#3665, #3839) and has explicitly deferred the RBAC policy brain. The two-layer design in this RFC is the natural next step the prior work points at - not a competing or redundant proposal.