feat(authz): filter per-caller skill visibility on the skill listing surfaces (#4063 Phase 4) (#5489)

* feat(authz): filter per-caller skill visibility on the listing surfaces (#4063 Phase 4)

GET /api/skills, GET /api/skills/custom, and GET /api/skills/{name} now
filter the user-scoped catalog through filter_resources(principal,
"skill", ...) — mirroring list_models. Anonymous callers are unfiltered;
provider errors follow authorization.fail_closed (fail-closed -> empty
listing / 404, fail-open -> full listing). An invisible skill on the
detail surface returns the standard 404 so the endpoint cannot become an
existence oracle the filtered list closed. Management endpoints stay
require_admin_user-gated; runtime activation is #4541's layer.

resolve_skill_authorization joins resolve_model_authorization as a thin
sibling over a shared _resolve_route_scoped_authorization core.

* docs(authz): reflect per-caller skill visibility in OpenAPI metadata and implementation notes (#5489)

Address the two non-blocking review findings on #5489:

- The three user-facing GET routes (/skills, /skills/custom,
  /skills/{name}) now say in their /docs-visible descriptions that
  authorization filters the response (hidden skills 404 on detail).
- Add the dated Phase 4 decision-log entry to the authorization
  implementation notes, per the convention of every prior merged
  authz PR: listing-visibility semantics, the 404-vs-403
  existence-oracle rationale, anonymous-caller behavior, and the
  #4541 rebase reconciliation points (config.example.yaml roles
  comment + this file's decision log).

* docs(authz): move route guidance into Gateway module guide

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
hataa 2026-09-18 08:02:52 +08:00 committed by GitHub
parent aa7f616734
commit d811143b52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 662 additions and 19 deletions

View File

@ -168,3 +168,9 @@ Proxied through nginx: `/api/langgraph/*` → Gateway LangGraph-compatible runti
archive/search behavior, read [Thread lifecycle invariants](../../docs/THREAD_LIFECYCLE.md).
It owns lineage and settled-checkpoint rules, legacy fallback boundaries, archive
filtering before pagination, owner isolation, and activity-time preservation.
### Route and skill-listing authorization
Gateway route authorization uses `authz.py::resolve_route_permissions()` as the single provider integration point for both `AuthMiddleware` and decorator-only authentication. When enabled, it evaluates the six registered `threads:*` / `runs:*` permissions as `resource="route"` requests whose targets are the full `resource:action` strings. Decisions use the async provider API and are cached for the request in `AuthContext`; decorators do not call the provider again. Provider resolution or decision errors follow `authorization.fail_closed`, scoped per permission for decision errors. When authorization is disabled, the legacy complete permission set is returned without resolving a provider. Existing `owner_check` enforcement and `require_admin_user()` management gates remain independent and unchanged. Tests: `tests/test_authorization_route_permissions.py`, `tests/test_auth.py`, and `tests/test_auth_middleware.py`.
Skill listing authorization mirrors the models pattern: `routers/skills.py` routes resolve `(provider, principal)` through `authz.py::resolve_skill_authorization()` — a thin sibling of `resolve_model_authorization`, both delegating to the shared `_resolve_route_scoped_authorization` core — and the shared `_filter_visible_skills` helper filters the user-scoped catalog (public + caller's custom skills) through `filter_resources(principal, "skill", ...)` by name. Three user-facing surfaces apply it: `GET /api/skills` (the frontend skill list / slash-command autocomplete), `GET /api/skills/custom`, and `GET /api/skills/{name}` — the detail endpoint returns the standard 404 for an invisible skill so it cannot become an existence oracle the filtered list closed (`get_model`'s 403 is an execution decision via `authorize("model", "use")`, which has no skills equivalent in this layer). Anonymous callers are unfiltered (mirroring `list_models`), and provider resolution/decision errors follow `authorization.fail_closed` (fail-closed → empty listing / 404, fail-open → full listing). Skill management endpoints (`install`/`upload`/`reload`/custom-skill CRUD) stay `require_admin_user`-gated, and runtime activation authorization is a separate layer. The built-in RBAC provider maps this to the per-role `skills` policy key. Tests: `tests/test_skills_listing_authorization.py`.

View File

@ -317,11 +317,11 @@ class _AuthorizationUnavailable(Exception):
self.fail_closed = fail_closed
def resolve_model_authorization(user: User, *, is_internal: bool) -> tuple[AuthorizationProvider | None, Principal | None]:
"""Return ``(provider, principal)`` for model-route authorization.
def _resolve_route_scoped_authorization(user: User, *, is_internal: bool) -> tuple[AuthorizationProvider | None, Principal | None]:
"""Return ``(provider, principal)`` for route-level resource authorization.
When authorization is disabled, returns ``(None, None)`` so callers can
short-circuit to legacy behavior (all models visible). When enabled,
short-circuit to legacy behavior (resource visible). When enabled,
resolves the cached provider and builds a Principal identical to
``resolve_route_permissions`` (including the ``INTERNAL_SYSTEM_ROLE``
``None`` pop so internal callers fall under ``default_role``).
@ -339,7 +339,7 @@ def resolve_model_authorization(user: User, *, is_internal: bool) -> tuple[Autho
if provider is None:
raise ValueError("authorization is enabled but provider resolution returned None")
except Exception:
logger.warning("Failed to resolve authorization provider for model routes", exc_info=True)
logger.warning("Failed to resolve authorization provider for route-level resources", exc_info=True)
raise _AuthorizationUnavailable(fail_closed=config.fail_closed)
principal = build_principal_from_context(
@ -349,13 +349,35 @@ def resolve_model_authorization(user: User, *, is_internal: bool) -> tuple[Autho
return provider, principal
def resolve_model_authorization(user: User, *, is_internal: bool) -> tuple[AuthorizationProvider | None, Principal | None]:
"""Return ``(provider, principal)`` for model-route authorization.
Delegates to ``_resolve_route_scoped_authorization``: disabled
``(None, None)``; provider-resolution failure raises
``_AuthorizationUnavailable`` (carrying ``fail_closed``).
"""
return _resolve_route_scoped_authorization(user, is_internal=is_internal)
def resolve_skill_authorization(user: User, *, is_internal: bool) -> tuple[AuthorizationProvider | None, Principal | None]:
"""Return ``(provider, principal)`` for skill-route authorization.
Same resolution and Principal construction as ``resolve_model_authorization``
(disabled ``(None, None)``; provider-resolution failure raises
``_AuthorizationUnavailable`` carrying ``fail_closed``). Consumers translate
that into the appropriate deny response (empty skill listing).
"""
return _resolve_route_scoped_authorization(user, is_internal=is_internal)
def _route_authz_context(user: User, *, is_internal: bool) -> dict:
"""Build the shared Principal context dict for a request-scoped user.
Applies the ``INTERNAL_SYSTEM_ROLE None`` pop so internal callers fall
under ``default_role`` (mirrors ``inject_authenticated_user_context``).
Used by ``resolve_model_authorization`` and ``authorize_sandbox_for_request``
so every route-level authorization path builds the identity the same way.
Used by ``_resolve_route_scoped_authorization`` (model/skill routes) and
``authorize_sandbox_for_request`` so every route-level authorization path
builds the identity the same way.
"""
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE

View File

@ -10,7 +10,12 @@ from pydantic import BaseModel, Field
from starlette.datastructures import FormData, Headers, UploadFile
from starlette.formparsers import MultiPartException, MultiPartParser
from app.gateway.deps import get_config, require_admin_user
from app.gateway.authz import (
_AuthorizationUnavailable,
_is_internal_caller,
resolve_skill_authorization,
)
from app.gateway.deps import get_config, get_optional_user_from_request, require_admin_user
from app.gateway.path_utils import resolve_thread_virtual_path
from app.gateway.skill_export import ExportClientDisconnected, SkillExportManifestResponse, SkillExportResponse, export_http_error, run_export_work
from deerflow.agents.lead_agent.prompt import clear_skills_system_prompt_cache, refresh_skills_system_prompt_cache_async, refresh_user_skills_system_prompt_cache_async
@ -268,21 +273,69 @@ async def _install_skill_archive(archive_path: Path, config: AppConfig) -> Skill
raise HTTPException(status_code=500, detail=f"Failed to install skill: {str(e)}") from e
async def _filter_visible_skills(
request: Request,
config: AppConfig,
skills: list[Skill],
) -> list[Skill]:
"""Apply the per-caller skill visibility filter (mirrors ``list_models``).
Anonymous callers are not filtered. Provider resolution or decision
errors follow ``authorization.fail_closed``: fail-closed returns an
empty list (nothing visible), fail-open returns the unfiltered input.
"""
fail_closed = config.authorization.fail_closed
user = await get_optional_user_from_request(request)
if user is None:
return skills
try:
provider, principal = resolve_skill_authorization(user, is_internal=_is_internal_caller(request, user))
except _AuthorizationUnavailable as exc:
return [] if exc.fail_closed else skills
if provider is None or principal is None:
return skills
try:
allowed_names = provider.filter_resources(principal, "skill", [skill.name for skill in skills])
if not isinstance(allowed_names, list) or any(not isinstance(name, str) for name in allowed_names):
raise TypeError("AuthorizationProvider.filter_resources must return list[str]")
allowed_set = set(allowed_names)
return [skill for skill in skills if skill.name in allowed_set]
except Exception:
logger.warning("Authorization provider failed while filtering skills", exc_info=True)
return [] if fail_closed else skills
@router.get(
"/skills",
response_model=SkillsListResponse,
summary="List All Skills",
description="Retrieve a list of all available skills from both public and custom directories.",
description=("Retrieve a list of all available skills from both public and custom directories. When authorization is enabled, only skills visible to the caller's role are returned."),
)
async def list_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
async def list_skills(request: Request, config: AppConfig = Depends(get_config)) -> SkillsListResponse:
"""List all skills visible to the caller.
Uses user-scoped storage: loads public (global) + custom (user-level +
fallback) skills.
When ``authorization.enabled`` is true, only skills the caller's role may
see are returned (filtered via ``provider.filter_resources`` with
``resource_type="skill"``, mirroring ``list_models``). A provider error
yields an empty list (fail-closed) or all skills (fail-open).
"""
try:
# Use user-scoped storage: loads public (global) + custom (user-level + fallback)
skills = _get_user_skill_storage(config).load_skills(enabled_only=False)
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
except Exception as e:
logger.error(f"Failed to load skills: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to load skills: {str(e)}")
visible_skills = await _filter_visible_skills(request, config, skills)
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in visible_skills])
@router.post(
"/skills/install",
@ -375,18 +428,28 @@ async def reload_skills(request: Request) -> SkillReloadResponse:
)
@router.get("/skills/custom", response_model=SkillsListResponse, summary="List Custom Skills")
async def list_custom_skills(config: AppConfig = Depends(get_config)) -> SkillsListResponse:
@router.get(
"/skills/custom",
response_model=SkillsListResponse,
summary="List Custom Skills",
description=("Retrieve the caller's user-owned custom skills. When authorization is enabled, only skills visible to the caller's role are returned."),
)
async def list_custom_skills(request: Request, config: AppConfig = Depends(get_config)) -> SkillsListResponse:
"""List only user-owned custom skills (SkillCategory.CUSTOM).
Legacy shared skills (SkillCategory.LEGACY) are NOT included here
they are read-only and appear in the full ``list_skills`` endpoint.
The frontend should use ``list_skills`` to display all available
skills including legacy ones.
When ``authorization.enabled`` is true, the same per-caller visibility
filter as ``list_skills`` applies without it this endpoint would
surface names the main listing hides.
"""
try:
skills = [skill for skill in _get_user_skill_storage(config).load_skills(enabled_only=False) if skill.category == SkillCategory.CUSTOM]
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in skills])
visible_skills = await _filter_visible_skills(request, config, skills)
return SkillsListResponse(skills=[_skill_to_response(skill) for skill in visible_skills])
except Exception as e:
logger.error("Failed to list custom skills: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list custom skills: {str(e)}")
@ -604,9 +667,9 @@ async def rollback_custom_skill(skill_name: str, body: SkillRollbackRequest, req
"/skills/{skill_name}",
response_model=SkillResponse,
summary="Get Skill Details",
description="Retrieve detailed information about a specific skill by its name.",
description=("Retrieve detailed information about a specific skill by its name. When authorization is enabled, a skill hidden from the caller's role returns 404, indistinguishable from a missing skill."),
)
async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillResponse:
async def get_skill(skill_name: str, request: Request, config: AppConfig = Depends(get_config)) -> SkillResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = _get_user_skill_storage(config).load_skills(enabled_only=False)
@ -615,7 +678,16 @@ async def get_skill(skill_name: str, config: AppConfig = Depends(get_config)) ->
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
return _skill_to_response(skill)
# Visibility filter: a skill the caller's role may not see is
# indistinguishable from a nonexistent one. Unlike ``get_model``
# (which enforces ``model:use`` and 403s on an execution decision),
# this layer is listing visibility only — 404 keeps the detail
# surface from becoming an existence oracle the filtered list closed.
visible_skills = await _filter_visible_skills(request, config, [skill])
if not visible_skills:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
return _skill_to_response(visible_skills[0])
except HTTPException:
raise
except Exception as e:

View File

@ -86,7 +86,7 @@ strict providers reject.
Authorization identity is independent of enforcement. Gateway strips client identity overrides: only the server auth source sets `is_internal`, and only authenticated IM `body.context` supplies `channel_user_id` (never `body.config`). `build_principal_from_context` applies role defaults, strict provenance, and copied attributes; RBAC rejects unknown defaults. Delegation and `GuardrailMiddleware` share this identity. Layer 1 precedes deferred assembly across agent paths and its provider is reused for Layer 2; framework skill/memory ordering stays stable. Trusted `DeerFlowClient.stream()` accepts identity overrides. Its graph key always includes effective storage `user_id` and, when enforced, the full Principal; nested attributes are copied so mutation cannot hide stale cache state.
Gateway route authorization uses `authz.py::resolve_route_permissions()` as the single provider integration point for both `AuthMiddleware` and decorator-only authentication. When enabled, it evaluates the six registered `threads:*` / `runs:*` permissions as `resource="route"` requests whose targets are the full `resource:action` strings. Decisions use the async provider API and are cached for the request in `AuthContext`; decorators do not call the provider again. Provider resolution or decision errors follow `authorization.fail_closed`, scoped per permission for decision errors. When authorization is disabled, the legacy complete permission set is returned without resolving a provider. Existing `owner_check` enforcement and `require_admin_user()` management gates remain independent and unchanged. Tests: `tests/test_authorization_route_permissions.py`, `tests/test_auth.py`, and `tests/test_auth_middleware.py`.
Gateway route permissions and per-caller skill visibility are documented in the [Gateway guide](../../../../../app/gateway/AGENTS.md#route-and-skill-listing-authorization). Runtime skill activation remains a separate layer.
Model authorization uses `authz.py::resolve_model_authorization()` (same cached-provider, internal-role, and principal-building path as route authorization) as the Gateway integration point for the `models` router: `list_models` filters names through `filter_resources(principal, "model", ...)`, and `get_model` enforces `authorize(resource="model", action="use")` with a deny surfacing as 403; provider errors follow `authorization.fail_closed` (fail-open returns the unfiltered list / proceeds). At runtime, `lead_agent/agent.py::_authorize_model_name` — called from `_make_lead_agent` and from `DeerFlowClient._ensure_agent` — applies the same `model:use` check to the resolved model name. On deny it scans the `filter_resources`-visible names (excluding the denied model), re-verifying each candidate with `authorize("model", "use")` before falling back, because a custom provider may allow `list` while denying `use`; no usable fallback raises under `fail_closed` and keeps the original model under fail-open. The built-in RBAC provider maps this to the per-role `models` policy key. Tests: `tests/test_models_authorization.py`.

View File

@ -15,6 +15,7 @@ from app.gateway.auth.models import User
from app.gateway.deps import get_config
from app.gateway.routers import skills as skills_router
from app.gateway.routers import uploads as uploads_router
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.skills.security_static_scanner import StaticScannerError
from deerflow.skills.storage.user_scoped_skill_storage import UserScopedSkillStorage
from deerflow.skills.types import Skill
@ -51,6 +52,13 @@ def _make_skill(name: str, *, enabled: bool) -> Skill:
def _make_test_app(config) -> FastAPI:
# The listing/detail routes read config.authorization.fail_closed even
# when authorization is disabled (mirroring list_models). Many tests here
# build minimal SimpleNamespace configs; backfill the real disabled
# shape once so each of them exercises the unfiltered path instead of
# crashing on a missing attribute.
if not hasattr(config, "authorization"):
config.authorization = AuthorizationConfig(enabled=False)
app = make_authed_test_app(user_factory=_make_admin_user)
app.state.config = config # kept for any startup-style reads
app.dependency_overrides[get_config] = lambda: config
@ -1122,6 +1130,11 @@ class TestMultiUserSkillIsolation:
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
# list_skills reads config.authorization.fail_closed even when
# authorization is disabled (mirroring list_models); give the
# fake the real disabled shape so the isolation path (no
# provider filtering) stays exercised.
authorization=AuthorizationConfig(enabled=False),
)
return alice_storage, bob_storage, config
@ -1322,6 +1335,9 @@ class TestMultiUserSkillIsolation:
use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage",
),
skill_evolution=SimpleNamespace(enabled=True, moderation_model_name=None),
# Same shape note as _setup_two_user_env: list_skills reads
# config.authorization.fail_closed even when disabled.
authorization=AuthorizationConfig(enabled=False),
)
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: alice_storage)
monkeypatch.setattr(skills_router, "get_effective_user_id", lambda: "alice")

View File

@ -0,0 +1,471 @@
"""Phase 4 skill-listing authorization tests.
Covers the Gateway route layer only (``list_skills``): the request-scoped
Principal and ``filter_resources(principal, "skill", ...)`` visibility filter,
mirroring Phase 3's ``list_models`` tests (``resolve_skill_authorization`` is
the ``resolve_model_authorization`` twin). Runtime skill authorization
assembly filtering and slash-activation is the #4541 layer and is
deliberately out of scope here; this file pins only which skills a caller
may *see* on the user-facing listing surface.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import skills as skills_router
from deerflow.authz.provider import AuthzDecision, AuthzReason
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.skills import Skill
from deerflow.skills.types import SkillCategory
# ── Helpers ────────────────────────────────────────────────────────────
def _user(**overrides):
values = {
"id": "user-123",
"system_role": "user",
"oauth_provider": "github",
"oauth_id": "oauth-456",
}
values.update(overrides)
return SimpleNamespace(**values)
def _skill(name: str, *, category: SkillCategory = SkillCategory.PUBLIC, enabled: bool = True) -> Skill:
"""Build a minimal Skill with dummy paths; only name/category/enabled matter here."""
return Skill(
name=name,
description=f"Skill {name}",
license=None,
skill_dir=Path(f"/skills/{category}/{name}"),
skill_file=Path(f"/skills/{category}/{name}/SKILL.md"),
relative_path=Path(name),
category=category,
enabled=enabled,
)
class _FakeStorage:
"""Stand-in for user-scoped SkillStorage: returns preloaded skills."""
def __init__(self, skills: list[Skill]) -> None:
self._skills = skills
self.load_calls: list[bool] = []
def load_skills(self, *, enabled_only: bool = False) -> list[Skill]:
self.load_calls.append(enabled_only)
return list(self._skills)
def _make_app_config() -> AppConfig:
return AppConfig(
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
authorization=AuthorizationConfig(),
)
def _make_skills_app(app_config: AppConfig) -> FastAPI:
"""Build a FastAPI app with the skills router and a pinned config."""
app = FastAPI()
app.include_router(skills_router.router)
app.dependency_overrides[skills_router.get_config] = lambda: app_config
return app
def _stub_storage(monkeypatch, storage: _FakeStorage) -> None:
# list_skills resolves the candidate universe through user-scoped storage
# (a plain module-level call, not a FastAPI dependency); pin it so tests
# control the catalog without touching the filesystem.
monkeypatch.setattr("app.gateway.routers.skills._get_user_skill_storage", lambda config: storage)
def _enable_authorization(monkeypatch, provider, *, fail_closed: bool = True, default_role: str = "user") -> None:
config = AuthorizationConfig(
enabled=True,
fail_closed=fail_closed,
default_role=default_role,
)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", lambda c: provider)
class _RecordingProvider:
"""Provider that records all requests and can deny/error specific targets."""
name = "recording"
def __init__(self, *, denied: set[str] | None = None, errors: set[str] | None = None) -> None:
self.denied = denied or set()
self.errors = errors or set()
self.authorize_requests: list = []
self.filter_requests: list = []
def authorize(self, request):
self.authorize_requests.append(request)
if request.target in self.errors:
raise RuntimeError(f"provider failed for {request.target}")
allowed = request.target not in self.denied
return AuthzDecision(
allow=allowed,
reasons=[AuthzReason(code="authz.allowed" if allowed else "authz.denied")],
)
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
self.filter_requests.append((resource_type, list(candidates)))
if resource_type in self.errors:
raise RuntimeError(f"provider failed for {resource_type}")
return [c for c in candidates if c not in self.denied]
def _stub_user(monkeypatch, user) -> None:
monkeypatch.setattr(
"app.gateway.routers.skills.get_optional_user_from_request",
AsyncMock(return_value=user),
)
# ── list_skills tests ──────────────────────────────────────────────────
def test_list_skills_disabled_returns_all(monkeypatch):
"""When authorization is disabled, all skills are visible."""
config = AuthorizationConfig(enabled=False)
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
cached = AsyncMock(side_effect=AssertionError("disabled must not resolve provider"))
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", cached)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["pdf-export", "web-research"]
cached.assert_not_called()
def test_list_skills_anonymous_user_returns_all(monkeypatch):
"""Anonymous requests (user=None) are not filtered."""
provider = _RecordingProvider()
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, None)
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["pdf-export", "web-research"]
assert provider.filter_requests == []
def test_list_skills_rbac_filters_by_allow(monkeypatch):
"""Role with a skill allowlist sees only allowed skills, in storage order."""
provider = RbacAuthorizationProvider(
roles={"user": {"skills": {"allow": ["pdf-export"]}}},
)
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research"), _skill("data-viz")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["pdf-export"]
def test_list_skills_rbac_filters_by_deny(monkeypatch):
"""Role with a skill denylist no longer sees denied skills."""
provider = RbacAuthorizationProvider(
roles={"user": {"skills": {"allow": "*", "deny": ["web-research"]}}},
)
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research"), _skill("data-viz")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["pdf-export", "data-viz"]
def test_list_skills_wildcard_returns_all(monkeypatch):
"""Role with skills allow: '*' sees all skills."""
provider = RbacAuthorizationProvider(
roles={"user": {"skills": {"allow": "*"}}},
)
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["pdf-export", "web-research"]
def test_list_skills_no_role_policy_returns_all(monkeypatch):
"""A role with no ``skills`` policy is unrestricted for skills (mirrors the
permissive-on-absent default; policies for other resources don't leak in)."""
provider = RbacAuthorizationProvider(
roles={"user": {"models": {"allow": ["gpt-4"]}}},
)
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["pdf-export", "web-research"]
def test_list_skills_filters_custom_and_public_uniformly(monkeypatch):
"""The visibility filter applies by name across categories: a denied
custom skill disappears even while other custom skills remain."""
provider = RbacAuthorizationProvider(
roles={"user": {"skills": {"allow": "*", "deny": ["my-private-skill"]}}},
)
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage(
[
_skill("pdf-export"),
_skill("my-private-skill", category=SkillCategory.CUSTOM),
_skill("team-playbook", category=SkillCategory.CUSTOM),
]
)
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["pdf-export", "team-playbook"]
@pytest.mark.parametrize(
("fail_closed", "expected_count"),
[(True, 0), (False, 3)],
)
def test_list_skills_provider_error_fail_closed_vs_open(monkeypatch, fail_closed, expected_count):
"""Provider error → empty (fail-closed) or all (fail-open)."""
provider = _RecordingProvider(errors={"skill"})
_enable_authorization(monkeypatch, provider, fail_closed=fail_closed)
app_config = _make_app_config()
app_config.authorization.fail_closed = fail_closed
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research"), _skill("data-viz")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(app_config)) as client:
response = client.get("/api/skills")
assert response.status_code == 200
assert len(response.json()["skills"]) == expected_count
@pytest.mark.parametrize(
("fail_closed", "expected_count"),
[(True, 0), (False, 2)],
)
def test_list_skills_provider_unavailable_fail_closed_vs_open(monkeypatch, fail_closed, expected_count):
"""Provider cannot be resolved → empty (fail-closed) or all (fail-open)."""
config = AuthorizationConfig(enabled=True, fail_closed=fail_closed, default_role="user")
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config)
def _raise_resolution_error(c):
raise RuntimeError("provider resolution failed")
monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", _raise_resolution_error)
app_config = _make_app_config()
app_config.authorization.fail_closed = fail_closed
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(app_config)) as client:
response = client.get("/api/skills")
assert response.status_code == 200
assert len(response.json()["skills"]) == expected_count
@pytest.mark.parametrize(
("fail_closed", "expected_count"),
[(True, 0), (False, 2)],
)
def test_list_skills_provider_bad_filter_return_type(monkeypatch, fail_closed, expected_count):
"""A provider whose filter_resources returns non-list[str] is treated as a
provider failure (TypeError guard), not an unfiltered passthrough."""
provider = SimpleNamespace(
filter_resources=lambda principal, resource_type, candidates: "pdf-export",
)
_enable_authorization(monkeypatch, provider, fail_closed=fail_closed)
app_config = _make_app_config()
app_config.authorization.fail_closed = fail_closed
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(app_config)) as client:
response = client.get("/api/skills")
assert response.status_code == 200
assert len(response.json()["skills"]) == expected_count
def test_list_skills_requests_skill_resource_type(monkeypatch):
"""The route filters through resource_type "skill" with the full catalog
as candidates (pins the provider contract the RBAC ``skills`` key maps to)."""
provider = _RecordingProvider()
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills")
assert response.status_code == 200
assert provider.filter_requests == [
("skill", ["pdf-export", "web-research"]),
]
# ── list_custom_skills / get_skill: the remaining visibility surfaces ──
def test_list_custom_skills_rbac_filters_by_deny(monkeypatch):
"""The custom-only listing surface applies the same visibility filter:
without it, a denied name hidden from GET /api/skills would remain
visible on GET /api/skills/custom."""
provider = RbacAuthorizationProvider(
roles={"user": {"skills": {"allow": "*", "deny": ["my-private-skill"]}}},
)
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage(
[
_skill("pdf-export"),
_skill("my-private-skill", category=SkillCategory.CUSTOM),
_skill("team-playbook", category=SkillCategory.CUSTOM),
]
)
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills/custom")
assert response.status_code == 200
names = [s["name"] for s in response.json()["skills"]]
assert names == ["team-playbook"]
def test_get_skill_denied_is_indistinguishable_from_missing(monkeypatch):
"""A denied skill returns the same 404 as a nonexistent one — the detail
surface must not become an existence oracle that the filtered list
closed (403 would leak that the name exists)."""
provider = RbacAuthorizationProvider(
roles={"user": {"skills": {"allow": ["pdf-export"]}}},
)
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export"), _skill("web-research")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
denied = client.get("/api/skills/web-research")
missing = client.get("/api/skills/does-not-exist")
allowed = client.get("/api/skills/pdf-export")
assert denied.status_code == 404
assert missing.status_code == 404
# The detail echoes the caller-supplied name through the standard
# not-found template — byte-identical to a genuine miss of that name,
# so the response carries no extra information.
assert denied.json()["detail"] == "Skill 'web-research' not found"
assert missing.json()["detail"] == "Skill 'does-not-exist' not found"
assert allowed.status_code == 200
assert allowed.json()["name"] == "pdf-export"
def test_get_skill_anonymous_returns_200(monkeypatch):
"""Anonymous requests (user=None) are not filtered, mirroring list_skills."""
provider = _RecordingProvider()
_enable_authorization(monkeypatch, provider)
storage = _FakeStorage([_skill("pdf-export")])
_stub_user(monkeypatch, None)
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(_make_app_config())) as client:
response = client.get("/api/skills/pdf-export")
assert response.status_code == 200
assert provider.filter_requests == []
@pytest.mark.parametrize(
("fail_closed", "expected_status"),
[(True, 404), (False, 200)],
)
def test_get_skill_provider_error_fail_closed_vs_open(monkeypatch, fail_closed, expected_status):
"""Provider error → invisible (fail-closed, 404) or visible (fail-open, 200)."""
provider = _RecordingProvider(errors={"skill"})
_enable_authorization(monkeypatch, provider, fail_closed=fail_closed)
app_config = _make_app_config()
app_config.authorization.fail_closed = fail_closed
storage = _FakeStorage([_skill("pdf-export")])
_stub_user(monkeypatch, _user())
_stub_storage(monkeypatch, storage)
with TestClient(_make_skills_app(app_config)) as client:
response = client.get("/api/skills/pdf-export")
assert response.status_code == expected_status

View File

@ -30,6 +30,7 @@ from fastapi.testclient import TestClient
from app.gateway.auth.models import User
from app.gateway.deps import get_config
from app.gateway.routers import skills as skills_router
from deerflow.config.authorization_config import AuthorizationConfig
def _make_user(system_role: str) -> User:
@ -129,7 +130,10 @@ def test_basic_skill_listing_stays_open_to_normal_users(monkeypatch):
]
app = _make_app(system_role="user")
app.dependency_overrides[get_config] = lambda: SimpleNamespace()
# list_skills reads config.authorization.fail_closed even when
# authorization is disabled (mirroring list_models); give the fake the
# real disabled shape so the open-to-normal-users path stays exercised.
app.dependency_overrides[get_config] = lambda: SimpleNamespace(authorization=AuthorizationConfig(enabled=False))
monkeypatch.setattr(skills_router, "_get_user_skill_storage", lambda cfg: SimpleNamespace(load_skills=_load_skills))
with TestClient(app) as client:
assert client.get("/api/skills").status_code == 200

View File

@ -2805,17 +2805,23 @@ run_ownership:
# config:
# # A known role with no policy for a resource is unrestricted for it.
# # Define both `tools` and `routes` wherever access should be constrained.
# # `models` / `skills` govern the per-caller listing/detail surfaces
# # (which models/skills the caller sees on GET /api/models and the
# # GET /api/skills[.../custom, /{name}] skill routes); skill
# # management endpoints (install/edit/export) stay admin-gated.
# roles:
# admin:
# tools: {allow: "*"}
# routes: {allow: "*"}
# models: {allow: "*"}
# sandbox: {allow: "*"}
# skills: {allow: "*"}
# user:
# tools: {allow: "*", deny: ["update_agent"]}
# routes: {allow: "*"}
# models: {allow: "*"}
# sandbox: {allow: "*"}
# skills: {allow: "*"}
# guest:
# # web-only role: sandbox-dependent tools (read_file, bash, glob,
# # grep, write_file, ...) are omitted — with sandbox:execute denied
@ -2825,6 +2831,7 @@ run_ownership:
# routes: {allow: ["threads:read", "runs:read"]}
# models: {allow: ["gpt-4o-mini"]}
# sandbox: {allow: false} # deny sandbox execution
# skills: {allow: []} # no skills visible in the listing
authorization:
enabled: false

View File

@ -438,6 +438,51 @@ Phase 1 最低验证要求:
event looploop-affine provider 在 `__init__` 调用 `asyncio.get_running_loop()` 并在
`aauthorize()` 验证仍是同一个 loop。
### 2026-09-17 — Phase 4 / PR #5489 Skills listing visibility (list / detail)
- **背景:** Phase 4 PR 1#5228 `/me` route permissions与 PR 2#5294 前端权限门控)
合并后,模型已有 per-caller listing/use 授权(#4540sandbox 已有 execute 授权
#4911),但 skill 列表表面仍对全部已认证用户开放——`GET /api/skills`
`GET /api/skills/custom``GET /api/skills/{name}` 不检查角色,是 Phase 3 资源类型
清单里最后未收口的 listing 面。
- **决策(表面清单):** 恰好三个非管理 GET 表面接入 per-caller 可见过滤:`list_skills`
`list_custom_skills``get_skill`,共享 `_filter_visible_skills(request, config, skills)`
helper语义镜像 `list_models``provider.filter_resources(principal, "skill", names)`
批量过滤provider 解析失败 → `_AuthorizationUnavailable`(携带 `fail_closed` 标志);
provider 抛错或返回非 `list[str]` → 空fail-closed或全量fail-open。skills.py
其余全部路由维持 `require_admin_user` 门控,不在本层重复过滤。
- **决策detail 404 而非 403** `get_skill` 对被过滤 skill 返回与真实缺失逐字一致的
404而非 `get_model` 的 403。理由`get_model` 执行的是 `authorize("model", "use")`
使用决策(模型存在但角色无权使用 → 403 合理);本层只有 listing visibility没有
skill 执行决策的对应物403 会让 detail 端点变成过滤清单刚关掉的 existence oracle。
- **决策resolver 结构):**`resolve_model_authorization` 提取共享核心
`_resolve_route_scoped_authorization(user, *, is_internal)`
`resolve_skill_authorization` 与 model 版本互为薄封装(含 `INTERNAL_SYSTEM_ROLE → None`
pop 与 internal-caller 语义。RBAC `_RESOURCE_POLICY_KEYS` 已含 `"skill": "skills"`
rbac.pyroles 的 `skills: {allow: [...]}` 直接生效,无 schema 变更。
- **否决方案:** 不为 skill 引入 `authorize("skill", "read")` 逐名授权——listing 表面
用批量 `filter_resources` 一次往返即可,逐名决策增加配置面且与 `list_models` 不对称。
不只过滤 `/skills` 主列表——自审发现 `/skills/custom``/skills/{name}` 会原样
泄露主列表隐藏的名字,三个表面必须同批收口。
- **兼容性:** `authorization.enabled: false` 时三表面均 no-op返回全量。匿名请求
user=None不过滤——生产 auth 开启时 `AuthMiddleware` 先行 401该分支实际只覆盖
auth-disabled 本地模式,与 `list_models` 对齐。skill 管理端点install/edit/export/
delete 等)保持 `require_admin_user`不受本过滤影响。RBAC 缺 `skills` 键 = 放行,
`allow: []` = 全拒(与 `models` 键同语义)。
- **证据:** `tests/test_skills_listing_authorization.py` 覆盖 disabled/anonymous/RBAC
allow/deny/wildcard/absent-policy、custom+public 一致过滤、provider error/unavailable/
坏返回类型 × fail-closed/fail-open、`("skill", [...])` 契约、custom 列表绕过封闭、
detail 404 与真实缺失逐字一致;`test_skills_router_authz.py`
`test_skills_custom_router.py` 的 fake config 补 `AuthorizationConfig`(含
`_make_test_app` 回填 shim。reviewwillem-bd在 head tree 执行验证4 套件
78 tests 通过,且移除 custom-listing 过滤的突变使
`test_list_custom_skills_rbac_filters_by_deny` 变红,守护测试真实。
- **延期:** #4541Phase 3 执行层assembly 过滤 + slash-activation 授权)与本 PR
互补(本 PR 管 listing visibility#4541 管 runtime use其 rebase 时需双向调和:
`config.example.yaml` roles 注释段两 PR 均改;本文件决策日志两 PR 也在同一插入点
各追加条目。前端 effective-permissions 展示剩余项management route 的 provider
迁移(沿袭前阶段延期项)。
### 新记录模板
```markdown