fix(authz): cover create/run/memory/agent routes with permission checks; scope USER.md per user (#4989)

* fix(authz): cover create/run/memory/agent routes with permission checks; scope USER.md per user

Route permissions: the AuthorizationProvider model only applied to routes
carrying @require_permission, so POST /api/threads, /api/threads/search,
POST /api/runs/stream and /api/runs/wait (runs:create), every /api/memory
route and the custom-agent routes ran with authentication only — a
provider configured to deny threads:write/runs:create/... could not
enforce those decisions. Add the missing decorators (threads:write,
threads:read, runs:create, memory:read/write, agents:read/write) and
register the new permission names in authz.Permissions. No owner checks
are added: per-user data scoping stays in the repository layer.

User profile: GET/PUT /api/user-profile read and wrote a single global
{base_dir}/USER.md, so in a multi-user deployment any authenticated user
could overwrite the prompt context injected for everyone else (and read
it). Scope the file to the caller's bucket
({base_dir}/users/{user_id}/USER.md) like user-scoped skills; no other
consumer of the old global path exists in the tree (verified by grep and
the updated tests). test_put_user_profile asserted the wrong effective
user under the autouse conftest user fixture; fixed to test-user-autouse.

* fix(authz): bind positional args in require_permission; migrate legacy USER.md

Review follow-up on #4989 (willem-bd):

- require_permission's wrapper only looked for request in keyword
  arguments, so direct positional calls like
  create_thread(body, request) made the injected keyword stub collide
  with the positional request (TypeError: multiple values). The wrapper
  now binds the wrapped signature via inspect.signature().bind() and
  honors a positionally-passed request (and thread_id) instead of
  assuming kwargs; the test-stub injection only fires when request is
  absent everywhere. The two positional callers in
  tests/test_threads_router.py pass again (8/8 channel tests).
- migrate_user_isolation.py now claims the legacy global USER.md for
  --user-id (default 'default') like the other unowned legacy
  artifacts; without it, upgrading installs with an existing profile
  would read content: null and later strand the old file beside the
  new per-user one. Conflict handling mirrors migrate_memory (rename to
  USER.legacy.md).

* style(scripts): keep migrate_user_isolation help within the line budget

* test(authz): give internal-request stubs realistic auth fields

The create_thread permission wrapper added in this PR authenticates
the direct calls in the internal-owner tests; their SimpleNamespace
requests had state.user but no auth_source and no cookies, so
get_current_user_from_request fell through to request.cookies.get and
raised AttributeError (verified introduced by this branch: both tests
pass on the base commit).

The stubs now carry cookies={} and
state.auth_source=AUTH_SOURCE_INTERNAL, which is exactly what
AuthMiddleware stamps on real internal requests, so state.user is
honored without the JWT path. All 80 tests in the file pass.

* fix(pat): keep memory/agent permissions out of PAT scopes by design

The route permissions exist (they guard the memory/agent routers) but PATs
govern the thread/run lifecycle only: _PAT_ROUTE_RULES default-denies those
routers for PAT callers regardless of scopes. The alignment invariant
becomes a subset check plus a pinned exclusion, and pat.py documents that
opening these scopes is a product decision requiring three synchronized
changes.

* test/docs: cover migrate_user_profile; drop the unclaimed USER.md injection wording

Five-scenario test class mirroring the sibling migration steps (move,
conflict-rename, noop, both under dry-run). The GET/PUT descriptions no
longer say USER.md is 'injected into agents' — nothing at this head consumes
it for prompts; the routes are storage/retrieval only. The memory AGENTS.md
migration pointer now enumerates skills/ and the global USER.md.

* docs(paths): align the USER.md layout comment with the injection disclaimer

* test(authz): align effective permission contracts

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
陈志谦 2026-09-23 15:37:26 +08:00 committed by GitHub
parent 4fe64ac916
commit 1b2898c367
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 412 additions and 149 deletions

View File

@ -39,6 +39,14 @@ PAT_ALLOWED_SCOPES: frozenset[str] = frozenset(
}
)
# Deliberately NOT in PAT_ALLOWED_SCOPES: the route permissions "memory:read",
# "memory:write", "agents:read", and "agents:write" exist in authz
# (they guard the memory/agent routers), but PATs stay on the thread/run
# lifecycle only — _PAT_ROUTE_RULES default-denies the memory/agent routes
# for PAT callers regardless of scopes, so admitting these scopes would not
# even reach those routes. Opening them to PATs is a product decision that
# must change all three of: this set, _PAT_ROUTE_RULES, and the API docs.
PAT_MAX_NAME_LENGTH = 128
# Default-deny route boundary for PAT callers (#5041 review P1-1): scope

View File

@ -25,6 +25,10 @@ Inspired by LangGraph Auth system: https://github.com/langchain-ai/langgraph/blo
- runs:create - Run agent
- runs:read - View run
- runs:cancel - Cancel run
- memory:read - View memory data/config
- memory:write - Modify memory data (create/update/delete facts, import, clear)
- agents:read - View custom agents and the user profile
- agents:write - Create/update/delete custom agents and the user profile
"""
from __future__ import annotations
@ -73,6 +77,14 @@ class Permissions:
PROJECTS_WRITE = "projects:write"
PROJECTS_DELETE = "projects:delete"
# Memory (per-user memory data surfaced by /api/memory*)
MEMORY_READ = "memory:read"
MEMORY_WRITE = "memory:write"
# Custom agents and the per-user USER.md profile (/api/agents*, /api/user-profile)
AGENTS_READ = "agents:read"
AGENTS_WRITE = "agents:write"
class AuthContext:
"""Authentication context for the current request.
@ -153,6 +165,10 @@ _ALL_PERMISSIONS: list[str] = [
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
@ -657,20 +673,25 @@ def require_permission(
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> Any:
# Bind the wrapped signature so a request passed positionally
# (direct calls in unit tests, non-FastAPI callers) is honored
# instead of colliding with an injected keyword stub.
signature = inspect.signature(func)
try:
bound = signature.bind(*args, **kwargs)
except TypeError:
bound = None
request = kwargs.get("request")
if request is None and bound is not None:
request = bound.arguments.get("request")
if request is None:
# Unit tests may call decorated route handlers directly — with
# or without constructing a FastAPI Request object — and may
# pass ``request`` positionally. Bind to the real signature
# first so a positional request is found rather than
# duplicated by the stub injection below.
try:
bound = inspect.signature(func).bind_partial(*args, **kwargs)
except TypeError:
bound = None
if bound is not None and "request" in bound.arguments:
request = bound.arguments["request"]
elif "request" in inspect.signature(func).parameters:
# pass ``request`` positionally. The full-signature bind at
# the top of this wrapper already recovered a positional
# request, so only the stub injection for handlers that
# declare ``request`` but received none remains here.
if "request" in signature.parameters:
kwargs["request"] = _make_test_request_stub()
request = kwargs["request"]
else:
@ -707,6 +728,8 @@ def require_permission(
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
thread_id = kwargs.get("thread_id")
if thread_id is None and bound is not None:
thread_id = bound.arguments.get("thread_id")
if thread_id is None:
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")

View File

@ -5,9 +5,10 @@ import logging
import re
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from deerflow.agents.memory.manager import get_memory_manager
from deerflow.config.agents_api_config import get_agents_api_config
from deerflow.config.agents_config import (
@ -221,7 +222,8 @@ def _agent_config_to_response(agent_cfg: AgentConfig, include_soul: bool = False
summary="List Custom Agents",
description="List all custom agents available in the agents directory, including their soul content.",
)
async def list_agents() -> AgentsListResponse:
@require_permission("agents", "read")
async def list_agents(request: Request) -> AgentsListResponse:
"""List all custom agents.
Returns:
@ -250,7 +252,8 @@ async def list_agents() -> AgentsListResponse:
summary="Check Agent Name",
description="Validate an agent name and check if it is available (case-insensitive).",
)
async def check_agent_name(name: str) -> dict:
@require_permission("agents", "read")
async def check_agent_name(name: str, request: Request) -> dict:
"""Check whether an agent name is valid and not yet taken.
Args:
@ -283,7 +286,8 @@ async def check_agent_name(name: str) -> dict:
summary="Get Custom Agent",
description="Retrieve details and SOUL.md content for a specific custom agent.",
)
async def get_agent(name: str) -> AgentResponse:
@require_permission("agents", "read")
async def get_agent(name: str, request: Request) -> AgentResponse:
"""Get a specific custom agent by name.
Args:
@ -321,11 +325,13 @@ async def get_agent(name: str) -> AgentResponse:
summary="Create Custom Agent",
description="Create a new custom agent with its config and SOUL.md.",
)
async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
@require_permission("agents", "write")
async def create_agent_endpoint(body: AgentCreateRequest, request: Request) -> AgentResponse:
"""Create a new custom agent.
Args:
request: The agent creation request.
body: The agent creation request.
request: The FastAPI request (used by the permission decorator).
Returns:
The created agent details.
@ -334,36 +340,36 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
HTTPException: 409 if agent already exists, 422 if name is invalid.
"""
_require_agents_api_enabled()
_validate_agent_name(request.name)
_validate_model_exists(request.model)
normalized_name = _normalize_agent_name(request.name)
_validate_agent_name(body.name)
_validate_model_exists(body.model)
normalized_name = _normalize_agent_name(body.name)
user_id = get_effective_user_id()
# Config document — only the fields the caller set, matching the historical
# writer (an omitted field stays absent rather than being materialized).
config_data: dict = {"name": normalized_name}
if request.display_name:
config_data["display_name"] = request.display_name
if request.description:
config_data["description"] = request.description
if request.tool_groups is not None:
config_data["tool_groups"] = request.tool_groups
if request.knowledge_scope is not None:
config_data["knowledge_scope"] = canonicalize_knowledge_scope(request.knowledge_scope)
if request.mcp_plugins is not None:
config_data["mcp_plugins"] = request.mcp_plugins
if request.skills is not None:
config_data["skills"] = request.skills
if request.allowed_subagents is not None:
config_data["allowed_subagents"] = request.allowed_subagents
if body.display_name:
config_data["display_name"] = body.display_name
if body.description:
config_data["description"] = body.description
if body.tool_groups is not None:
config_data["tool_groups"] = body.tool_groups
if body.knowledge_scope is not None:
config_data["knowledge_scope"] = canonicalize_knowledge_scope(body.knowledge_scope)
if body.mcp_plugins is not None:
config_data["mcp_plugins"] = body.mcp_plugins
if body.skills is not None:
config_data["skills"] = body.skills
if body.allowed_subagents is not None:
config_data["allowed_subagents"] = body.allowed_subagents
# model / model_settings / thinking_enabled / reasoning_effort (issue #4336).
_apply_model_behavior(config_data, request)
_apply_model_behavior(config_data, body)
def _create_agent() -> AgentResponse:
# Worker thread: existence checks + persistence (file IO or a DB round
# trip) must stay off the event loop.
store = get_agent_store()
store.create(normalized_name, config_data, request.soul, user_id=user_id)
store.create(normalized_name, config_data, body.soul, user_id=user_id)
logger.info("Created agent '%s'", normalized_name)
agent_cfg = load_agent_config(normalized_name, user_id=user_id)
return _agent_config_to_response(agent_cfg, include_soul=True, user_id=user_id)
@ -373,7 +379,7 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
except AgentExistsError:
raise HTTPException(status_code=409, detail=f"Agent '{normalized_name}' already exists")
except Exception as e:
logger.error(f"Failed to create agent '{request.name}': {e}", exc_info=True)
logger.error(f"Failed to create agent '{body.name}': {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to create agent: {str(e)}")
@ -383,12 +389,14 @@ async def create_agent_endpoint(request: AgentCreateRequest) -> AgentResponse:
summary="Update Custom Agent",
description="Update an existing custom agent's config and/or SOUL.md.",
)
async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
@require_permission("agents", "write")
async def update_agent(name: str, body: AgentUpdateRequest, request: Request) -> AgentResponse:
"""Update an existing custom agent.
Args:
name: The agent name.
request: The update request (all fields optional).
body: The update request (all fields optional).
request: The FastAPI request (used by the permission decorator).
Returns:
The updated agent details.
@ -427,52 +435,52 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
detail=(f"Agent '{name}' only exists in the legacy shared layout and is not scoped to a user. Run scripts/migrate_user_isolation.py to move legacy agents into the per-user layout before updating."),
)
if "model" in request.model_fields_set:
_validate_model_exists(request.model)
if "model" in body.model_fields_set:
_validate_model_exists(body.model)
try:
# Update config if any config fields changed
# Use model_fields_set to distinguish "field omitted" from "explicitly set to null".
# This is critical for skills where None means "inherit all" (not "don't change").
fields_set = request.model_fields_set
fields_set = body.model_fields_set
config_changed = bool(fields_set & ({"display_name", "description", "tool_groups", "skills", "mcp_plugins", "knowledge_scope", "allowed_subagents"} | set(_MODEL_BEHAVIOR_FIELDS)))
updated: dict | None = None
if config_changed:
updated = {
"name": agent_cfg.name,
"description": request.description if "description" in fields_set else agent_cfg.description,
"description": body.description if "description" in fields_set else agent_cfg.description,
}
if "display_name" in fields_set:
updated["display_name"] = request.display_name or None
updated["display_name"] = body.display_name or None
new_tool_groups = request.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
new_tool_groups = body.tool_groups if "tool_groups" in fields_set else agent_cfg.tool_groups
if new_tool_groups is not None:
updated["tool_groups"] = new_tool_groups
if "knowledge_scope" in fields_set:
updated["knowledge_scope"] = canonicalize_knowledge_scope(request.knowledge_scope) if request.knowledge_scope is not None else None
updated["knowledge_scope"] = canonicalize_knowledge_scope(body.knowledge_scope) if body.knowledge_scope is not None else None
if "mcp_plugins" in fields_set:
updated["mcp_plugins"] = request.mcp_plugins
updated["mcp_plugins"] = body.mcp_plugins
# skills: None = inherit all, [] = no skills, ["a","b"] = whitelist
if "skills" in fields_set:
new_skills = request.skills
new_skills = body.skills
else:
new_skills = agent_cfg.skills
if new_skills is not None:
updated["skills"] = new_skills
# allowed_subagents: None = all, [] = hard deny, list = whitelist.
new_allowed_subagents = request.allowed_subagents if "allowed_subagents" in fields_set else agent_cfg.allowed_subagents
new_allowed_subagents = body.allowed_subagents if "allowed_subagents" in fields_set else agent_cfg.allowed_subagents
if new_allowed_subagents is not None:
updated["allowed_subagents"] = new_allowed_subagents
# model / model_settings / thinking_enabled / reasoning_effort:
# take explicitly-set request fields, else preserve the existing
# value (issue #4336).
_apply_model_behavior(updated, request, existing=agent_cfg)
_apply_model_behavior(updated, body, existing=agent_cfg)
# Carry forward every top-level AgentConfig field this route does
# not manage (currently ``github:``, plus any future field added
@ -487,10 +495,10 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
# Persist config (when changed) and/or soul (when provided) off the
# event loop. A no-change PATCH commits nothing and re-reads current state.
if updated is not None or request.soul is not None:
if updated is not None or body.soul is not None:
def _update_agent() -> None:
get_agent_store().update(name, updated, request.soul, user_id=user_id)
get_agent_store().update(name, updated, body.soul, user_id=user_id)
await asyncio.to_thread(_update_agent)
@ -511,13 +519,13 @@ async def update_agent(name: str, request: AgentUpdateRequest) -> AgentResponse:
class UserProfileResponse(BaseModel):
"""Response model for the global user profile (USER.md)."""
"""Response model for the user-scoped profile (USER.md)."""
content: str | None = Field(default=None, description="USER.md content, or null if not yet created")
class UserProfileUpdateRequest(BaseModel):
"""Request body for setting the global user profile."""
"""Request body for setting the user-scoped profile."""
content: str = Field(default="", description="USER.md content — describes the user's background and preferences")
@ -526,10 +534,15 @@ class UserProfileUpdateRequest(BaseModel):
"/user-profile",
response_model=UserProfileResponse,
summary="Get User Profile",
description="Read the global USER.md file that is injected into all custom agents.",
description="Read the caller's per-user USER.md file.",
)
async def get_user_profile() -> UserProfileResponse:
"""Return the current USER.md content.
@require_permission("agents", "read")
async def get_user_profile(request: Request) -> UserProfileResponse:
"""Return the current user's USER.md content.
The file is scoped to the caller's user bucket
(``{base_dir}/users/{user_id}/USER.md``), so one user can never read or
write the prompt context of another.
Returns:
UserProfileResponse with content=None if USER.md does not exist yet.
@ -537,7 +550,7 @@ async def get_user_profile() -> UserProfileResponse:
_require_agents_api_enabled()
try:
user_md_path = get_paths().user_md_file
user_md_path = get_paths().user_md_file(get_effective_user_id())
if not user_md_path.exists():
return UserProfileResponse(content=None)
raw = user_md_path.read_text(encoding="utf-8").strip()
@ -551,13 +564,20 @@ async def get_user_profile() -> UserProfileResponse:
"/user-profile",
response_model=UserProfileResponse,
summary="Update User Profile",
description="Write the global USER.md file that is injected into all custom agents.",
description="Write the caller's per-user USER.md file.",
)
async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileResponse:
"""Create or overwrite the global USER.md.
@require_permission("agents", "write")
async def update_user_profile(body: UserProfileUpdateRequest, request: Request) -> UserProfileResponse:
"""Create or overwrite the current user's USER.md.
The write targets the caller's own user bucket, so one user can never
write the profile of another. (Storage and retrieval only — nothing at
this head consumes USER.md for prompt injection; this route and the GET
above are its only readers/writers.)
Args:
request: The update request with the new USER.md content.
body: The update request with the new USER.md content.
request: The FastAPI request (used by the permission decorator).
Returns:
UserProfileResponse with the saved content.
@ -566,10 +586,11 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
try:
paths = get_paths()
paths.base_dir.mkdir(parents=True, exist_ok=True)
paths.user_md_file.write_text(request.content, encoding="utf-8")
logger.info(f"Updated USER.md at {paths.user_md_file}")
return UserProfileResponse(content=request.content or None)
user_md_path = paths.user_md_file(get_effective_user_id())
user_md_path.parent.mkdir(parents=True, exist_ok=True)
user_md_path.write_text(body.content, encoding="utf-8")
logger.info(f"Updated USER.md at {user_md_path}")
return UserProfileResponse(content=body.content or None)
except Exception as e:
logger.error(f"Failed to update user profile: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to update user profile: {str(e)}")
@ -581,7 +602,8 @@ async def update_user_profile(request: UserProfileUpdateRequest) -> UserProfileR
summary="Delete Custom Agent",
description="Delete a custom agent and all its files (config, SOUL.md, memory).",
)
async def delete_agent(name: str) -> None:
@require_permission("agents", "write")
async def delete_agent(name: str, request: Request) -> None:
"""Delete a custom agent.
Args:

View File

@ -6,6 +6,7 @@ from typing import Any, Literal
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field, field_validator
from app.gateway.authz import require_permission
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager
from deerflow.config.memory_config import get_memory_config
@ -208,7 +209,8 @@ class MemoryStatusResponse(BaseModel):
summary="Get Memory Data",
description="Retrieve the current global memory data including user context, history, and facts.",
)
async def get_memory(http_request: Request) -> MemoryResponse:
@require_permission("memory", "read")
async def get_memory(request: Request) -> MemoryResponse:
"""Get the current global memory data.
Returns:
@ -243,7 +245,7 @@ async def get_memory(http_request: Request) -> MemoryResponse:
```
"""
manager = await asyncio.to_thread(get_memory_manager)
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(http_request), "get memory")
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "get memory")
return MemoryResponse(**memory_data)
@ -254,7 +256,8 @@ async def get_memory(http_request: Request) -> MemoryResponse:
summary="Reload Memory Data",
description="Reload memory data from the storage file, refreshing the in-memory cache.",
)
async def reload_memory(http_request: Request) -> MemoryResponse:
@require_permission("memory", "read")
async def reload_memory(request: Request) -> MemoryResponse:
"""Reload memory data from file.
This forces a reload of the memory data from the storage file,
@ -263,7 +266,7 @@ async def reload_memory(http_request: Request) -> MemoryResponse:
Returns:
The reloaded memory data.
"""
user_id = _resolve_memory_user_id(http_request)
user_id = _resolve_memory_user_id(request)
manager = await asyncio.to_thread(get_memory_manager)
try:
memory_data = await asyncio.to_thread(manager.reload_memory, user_id=user_id)
@ -287,11 +290,12 @@ async def reload_memory(http_request: Request) -> MemoryResponse:
summary="Clear All Memory Data",
description="Delete all saved memory data and reset the memory structure to an empty state.",
)
async def clear_memory(http_request: Request) -> MemoryResponse:
@require_permission("memory", "write")
async def clear_memory(request: Request) -> MemoryResponse:
"""Clear all persisted memory data."""
manager = await asyncio.to_thread(get_memory_manager)
try:
memory_data = await asyncio.to_thread(manager.clear_memory, user_id=_resolve_memory_user_id(http_request))
memory_data = await asyncio.to_thread(manager.clear_memory, user_id=_resolve_memory_user_id(request))
except NotImplementedError:
raise _unsupported_501(manager, "clear memory") from None
except (MemoryConflictError, MemoryCorruptionError) as exc:
@ -309,16 +313,17 @@ async def clear_memory(http_request: Request) -> MemoryResponse:
summary="Create Memory Fact",
description="Create a single saved memory fact manually.",
)
async def create_memory_fact_endpoint(request: FactCreateRequest, http_request: Request) -> MemoryResponse:
@require_permission("memory", "write")
async def create_memory_fact_endpoint(body: FactCreateRequest, request: Request) -> MemoryResponse:
"""Create a single fact manually."""
manager = await asyncio.to_thread(get_memory_manager)
try:
memory_data, fact_id = await asyncio.to_thread(
manager.create_fact,
content=request.content,
category=request.category,
confidence=request.confidence,
user_id=_resolve_memory_user_id(http_request),
content=body.content,
category=body.category,
confidence=body.confidence,
user_id=_resolve_memory_user_id(request),
)
except NotImplementedError:
raise _unsupported_501(manager, "create fact") from None
@ -342,11 +347,12 @@ async def create_memory_fact_endpoint(request: FactCreateRequest, http_request:
summary="Delete Memory Fact",
description="Delete a single saved memory fact by its fact id.",
)
async def delete_memory_fact_endpoint(fact_id: str, http_request: Request) -> MemoryResponse:
@require_permission("memory", "write")
async def delete_memory_fact_endpoint(fact_id: str, request: Request) -> MemoryResponse:
"""Delete a single fact from memory by fact id."""
manager = await asyncio.to_thread(get_memory_manager)
try:
memory_data = await asyncio.to_thread(manager.delete_fact, fact_id, user_id=_resolve_memory_user_id(http_request))
memory_data = await asyncio.to_thread(manager.delete_fact, fact_id, user_id=_resolve_memory_user_id(request))
except NotImplementedError:
raise _unsupported_501(manager, "delete fact") from None
except KeyError as exc:
@ -366,17 +372,18 @@ async def delete_memory_fact_endpoint(fact_id: str, http_request: Request) -> Me
summary="Patch Memory Fact",
description="Partially update a single saved memory fact by its fact id while preserving omitted fields.",
)
async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest, http_request: Request) -> MemoryResponse:
@require_permission("memory", "write")
async def update_memory_fact_endpoint(fact_id: str, body: FactPatchRequest, request: Request) -> MemoryResponse:
"""Partially update a single fact manually."""
manager = await asyncio.to_thread(get_memory_manager)
try:
memory_data = await asyncio.to_thread(
manager.update_fact,
fact_id=fact_id,
content=request.content,
category=request.category,
confidence=request.confidence,
user_id=_resolve_memory_user_id(http_request),
content=body.content,
category=body.category,
confidence=body.confidence,
user_id=_resolve_memory_user_id(request),
)
except NotImplementedError:
raise _unsupported_501(manager, "update fact") from None
@ -399,10 +406,11 @@ async def update_memory_fact_endpoint(fact_id: str, request: FactPatchRequest, h
summary="Export Memory Data",
description="Export the current global memory data as JSON for backup or transfer.",
)
async def export_memory(http_request: Request) -> MemoryResponse:
@require_permission("memory", "read")
async def export_memory(request: Request) -> MemoryResponse:
"""Export the current memory data."""
manager = await asyncio.to_thread(get_memory_manager)
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(http_request), "export memory")
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "export memory")
return MemoryResponse(**memory_data)
@ -413,14 +421,15 @@ async def export_memory(http_request: Request) -> MemoryResponse:
summary="Import Memory Data",
description="Import and overwrite the current global memory data from a JSON payload.",
)
async def import_memory(request: MemoryResponse, http_request: Request) -> MemoryResponse:
@require_permission("memory", "write")
async def import_memory(body: MemoryResponse, request: Request) -> MemoryResponse:
"""Import and persist memory data."""
manager = await asyncio.to_thread(get_memory_manager)
try:
memory_data = await asyncio.to_thread(
manager.import_memory,
request.model_dump(exclude_none=True),
user_id=_resolve_memory_user_id(http_request),
body.model_dump(exclude_none=True),
user_id=_resolve_memory_user_id(request),
)
except NotImplementedError:
raise _unsupported_501(manager, "import memory") from None
@ -438,7 +447,8 @@ async def import_memory(request: MemoryResponse, http_request: Request) -> Memor
summary="Get Memory Configuration",
description="Retrieve the current memory system configuration.",
)
async def get_memory_config_endpoint() -> MemoryConfigResponse:
@require_permission("memory", "read")
async def get_memory_config_endpoint(request: Request) -> MemoryConfigResponse:
"""Get the memory system configuration.
Returns:
@ -488,7 +498,8 @@ async def get_memory_config_endpoint() -> MemoryConfigResponse:
summary="Get Memory Status",
description="Retrieve both memory configuration and current data in a single request.",
)
async def get_memory_status(http_request: Request) -> MemoryStatusResponse:
@require_permission("memory", "read")
async def get_memory_status(request: Request) -> MemoryStatusResponse:
"""Get the memory system status including configuration and data.
Returns:
@ -496,7 +507,7 @@ async def get_memory_status(http_request: Request) -> MemoryStatusResponse:
"""
config = get_memory_config()
manager = await asyncio.to_thread(get_memory_manager)
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(http_request), "get memory status")
memory_data = await _get_memory_or_501(manager, _resolve_memory_user_id(request), "get memory status")
return MemoryStatusResponse(
config=MemoryConfigResponse(

View File

@ -148,6 +148,9 @@ The older isolation migration remains available:
PYTHONPATH=. python scripts/migrate_user_isolation.py --dry-run
```
It assigns legacy `memory.json`, `threads/`, `agents/`, `skills/`, and the global
`USER.md` to `--user-id` (default `default`).
#### Retrieval
`retrieval_adapter` owns indexing and retrieval.

View File

@ -113,18 +113,21 @@ class Paths:
Directory layout (host side):
{base_dir}/
├── memory.json
├── USER.md <-- global user profile (injected into all agents)
├── agents/
├── agents/ <-- legacy shared layout (read-only fallback)
│ └── {agent_name}/
│ ├── config.yaml
│ ├── SOUL.md <-- agent personality/identity (injected alongside lead prompt)
│ └── memory.json
└── threads/
└── {thread_id}/
└── user-data/ <-- mounted as /mnt/user-data/ inside sandbox
├── workspace/ <-- /mnt/user-data/workspace/
├── uploads/ <-- /mnt/user-data/uploads/
└── outputs/ <-- /mnt/user-data/outputs/
├── users/{user_id}/
│ ├── USER.md <-- per-user profile (storage/retrieval via the user-profile routes)
│ ├── agents/... <-- per-user custom agents (current layout)
│ ├── skills/... <-- per-user custom skills
│ └── threads/
│ └── {thread_id}/
│ └── user-data/ <-- mounted as /mnt/user-data/ inside sandbox
│ ├── workspace/ <-- /mnt/user-data/workspace/
│ ├── uploads/ <-- /mnt/user-data/uploads/
│ └── outputs/ <-- /mnt/user-data/outputs/
BaseDir resolution (in priority order):
1. Constructor argument `base_dir`
@ -172,10 +175,13 @@ class Paths:
"""Path to the persisted memory file: `{base_dir}/memory.json`."""
return self.base_dir / "memory.json"
@property
def user_md_file(self) -> Path:
"""Path to the global user profile file: `{base_dir}/USER.md`."""
return self.base_dir / "USER.md"
def user_md_file(self, user_id: str) -> Path:
"""Path to a user-scoped profile file: `{base_dir}/users/{user_id}/USER.md`.
The profile is per-user (like custom skills/agents) so one user's
prompt context can never be written or injected for another user.
"""
return self.user_dir(user_id) / "USER.md"
@property
def agents_dir(self) -> Path:

View File

@ -1,4 +1,4 @@
"""One-time migration: move legacy thread dirs, memory, agents, and skills into per-user layout.
"""One-time migration: move legacy thread dirs, memory, agents, skills, and the global USER.md profile into per-user layout.
Usage:
PYTHONPATH=. python scripts/migrate_user_isolation.py [--dry-run] [--user-id USER_ID]
@ -244,6 +244,44 @@ def migrate_memory(
shutil.move(str(legacy_mem), str(dest))
def migrate_user_profile(
paths: Paths,
user_id: str = "default",
*,
dry_run: bool = False,
) -> None:
"""Move the legacy global USER.md profile into per-user layout.
The profile became per-user (``{base_dir}/users/{user_id}/USER.md``) so
one user's prompt context can never leak into another's; without this
migration an existing single-user or auth-disabled installation would
see ``content: null`` after upgrading and later strand the old file
next to a newly created per-user one.
Args:
paths: Paths instance.
user_id: Target user to receive the legacy profile.
dry_run: If True, only log.
"""
legacy_profile = paths.base_dir / "USER.md"
if not legacy_profile.exists():
logger.info("No legacy USER.md found — nothing to migrate.")
return
dest = paths.user_md_file(user_id)
if dest.exists():
legacy_backup = paths.base_dir / "USER.legacy.md"
logger.warning("Destination %s exists; renaming legacy to %s", dest, legacy_backup)
if not dry_run:
legacy_profile.rename(legacy_backup)
return
logger.info("Migrating USER.md -> %s", dest)
if not dry_run:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(legacy_profile), str(dest))
def _build_owner_map_from_db(paths: Paths) -> dict[str, str]:
"""Query threads_meta table for thread_id -> user_id mapping.
@ -274,7 +312,9 @@ def main() -> None:
"--user-id",
default="default",
metavar="USER_ID",
help=("User ID to claim un-owned legacy data (global memory.json and legacy custom agents). Defaults to 'default'. In multi-user installs, set this to the operator account that should inherit those legacy artifacts."),
help=(
"User ID to claim un-owned legacy data (global memory.json, USER.md profile, and legacy custom agents). Defaults to 'default'. In multi-user installs, set this to the operator account that should inherit those legacy artifacts."
),
)
args = parser.parse_args()
@ -290,6 +330,7 @@ def main() -> None:
report = migrate_thread_dirs(paths, owner_map, dry_run=args.dry_run)
migrate_memory(paths, user_id=args.user_id, dry_run=args.dry_run)
migrate_user_profile(paths, user_id=args.user_id, dry_run=args.dry_run)
agent_report = migrate_agents(paths, user_id=args.user_id, dry_run=args.dry_run)
skill_report = migrate_skills(paths, user_id=args.user_id, dry_run=args.dry_run)

View File

@ -48,6 +48,10 @@ _STUB_PERMISSIONS: list[str] = [
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
]

View File

@ -30,6 +30,10 @@ _ALL_PERMISSIONS = [
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,

View File

@ -91,6 +91,10 @@ async def test_route_permissions_disabled_preserves_all_permissions(monkeypatch)
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
@ -110,6 +114,10 @@ async def test_route_permissions_use_async_provider_and_trusted_principal(monkey
Permissions.THREADS_WRITE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
@ -121,6 +129,10 @@ async def test_route_permissions_use_async_provider_and_trusted_principal(monkey
("route", "create", Permissions.RUNS_CREATE),
("route", "read", Permissions.RUNS_READ),
("route", "cancel", Permissions.RUNS_CANCEL),
("route", "read", Permissions.MEMORY_READ),
("route", "write", Permissions.MEMORY_WRITE),
("route", "read", Permissions.AGENTS_READ),
("route", "write", Permissions.AGENTS_WRITE),
("route", "read", Permissions.PROJECTS_READ),
("route", "write", Permissions.PROJECTS_WRITE),
("route", "delete", Permissions.PROJECTS_DELETE),
@ -146,6 +158,10 @@ async def test_route_permissions_fail_closed_denies_only_the_failed_permission(m
Permissions.THREADS_DELETE,
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
@ -166,6 +182,10 @@ async def test_route_permissions_fail_open_allows_the_failed_permission(monkeypa
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,
@ -186,6 +206,10 @@ async def test_route_permissions_fail_open_allows_the_failed_permission(monkeypa
Permissions.RUNS_CREATE,
Permissions.RUNS_READ,
Permissions.RUNS_CANCEL,
Permissions.MEMORY_READ,
Permissions.MEMORY_WRITE,
Permissions.AGENTS_READ,
Permissions.AGENTS_WRITE,
Permissions.PROJECTS_READ,
Permissions.PROJECTS_WRITE,
Permissions.PROJECTS_DELETE,

View File

@ -68,7 +68,8 @@ class TestPaths:
def test_user_md_file(self, tmp_path):
paths = _make_paths(tmp_path)
assert paths.user_md_file == tmp_path / "USER.md"
assert paths.user_md_file("alice") == tmp_path / "users" / "alice" / "USER.md"
assert paths.user_md_file("bob") != paths.user_md_file("alice")
def test_paths_are_different_from_global(self, tmp_path):
paths = _make_paths(tmp_path)
@ -529,12 +530,17 @@ def _stub_app_config():
def _make_test_app(tmp_path: Path):
"""Create a FastAPI app with the agents router, patching paths to tmp_path."""
from fastapi import FastAPI
"""Create a FastAPI app with the agents router, patching paths to tmp_path.
Uses the stub-auth helper so the ``@require_permission`` decorators on the
agents routes see an authenticated user with all permissions (mirroring
what ``AuthMiddleware`` does in the real gateway).
"""
from _router_auth_helpers import make_authed_test_app
from app.gateway.routers.agents import router
app = FastAPI()
app = make_authed_test_app()
app.include_router(router)
return app
@ -881,11 +887,29 @@ class TestUserProfileAPI:
assert response.status_code == 200
assert response.json()["content"] == content
# File should be written to disk
user_md = tmp_path / "USER.md"
# File should be written to the caller's per-user bucket. The autouse
# _auto_user_context fixture in conftest.py sets user
# "test-user-autouse", so that is the effective id here.
user_md = tmp_path / "users" / "test-user-autouse" / "USER.md"
assert user_md.exists()
assert user_md.read_text(encoding="utf-8") == content
def test_user_profile_is_isolated_per_user(self, agent_client, tmp_path):
"""A legacy global USER.md must never leak into a user's profile read.
Pre-fix behavior: GET/PUT /api/user-profile read and wrote the shared
``{base_dir}/USER.md`` singleton, so any authenticated user could
overwrite the prompt context injected for every other user.
"""
legacy_global = tmp_path / "USER.md"
legacy_global.write_text("# injected by another user", encoding="utf-8")
got = agent_client.get("/api/user-profile")
assert got.status_code == 200
# Per-user file does not exist yet and the legacy global file is not
# consulted as a fallback.
assert got.json()["content"] is None
def test_get_user_profile_after_put(self, agent_client):
content = "# Profile\n\nI work on data science."
agent_client.put("/api/user-profile", json={"content": content})

View File

@ -4,7 +4,7 @@ import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from _router_auth_helpers import call_unwrapped, make_authed_test_app
from fastapi.testclient import TestClient
from app.gateway.routers import memory
@ -34,7 +34,7 @@ def _sample_memory(facts: list[dict] | None = None) -> dict:
def test_export_memory_route_returns_current_memory() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
exported_memory = _sample_memory(facts=[{"id": "fact_export", "content": "User prefers concise responses.", "category": "preference", "confidence": 0.9, "createdAt": "2026-03-20T00:00:00Z", "source": "thread-1"}])
@ -62,14 +62,14 @@ def test_get_memory_route_offloads_manager_call_from_event_loop() -> None:
patch("app.gateway.routers.memory.get_memory_manager", return_value=manager),
patch("app.gateway.routers.memory._resolve_memory_user_id", return_value="user-1"),
):
response = asyncio.run(memory.get_memory(request))
response = asyncio.run(call_unwrapped(memory.get_memory, request))
assert response.facts == []
assert called_from and called_from[0] != event_loop_thread
def test_export_memory_route_preserves_source_error() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
exported_memory = _sample_memory(
facts=[
@ -98,7 +98,7 @@ def test_export_memory_route_preserves_source_error() -> None:
def test_import_memory_route_returns_imported_memory() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
imported_memory = _sample_memory(facts=[{"id": "fact_import", "content": "User works on DeerFlow.", "category": "context", "confidence": 0.87, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
@ -112,7 +112,7 @@ def test_import_memory_route_returns_imported_memory() -> None:
def test_import_route_without_agent_name_persists_default_bucket_markdown(tmp_path) -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
manager = DeerMem(backend_config={"storage_path": str(tmp_path)})
imported_memory = _sample_memory(
@ -142,7 +142,7 @@ def test_import_route_without_agent_name_persists_default_bucket_markdown(tmp_pa
def test_import_memory_route_preserves_source_error() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
imported_memory = _sample_memory(
facts=[
@ -171,7 +171,7 @@ def test_import_memory_route_preserves_source_error() -> None:
def test_clear_memory_route_returns_cleared_memory() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.clear_memory.return_value = _sample_memory()
@ -186,7 +186,7 @@ def test_clear_memory_route_returns_cleared_memory() -> None:
def test_create_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_new", "content": "User prefers concise code reviews.", "category": "preference", "confidence": 0.88, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
@ -200,7 +200,7 @@ def test_create_memory_fact_route_returns_updated_memory() -> None:
def test_create_memory_fact_route_maps_conflict_to_409() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.create_fact.side_effect = MemoryConflictError("stale write")
@ -214,7 +214,7 @@ def test_create_memory_fact_route_maps_conflict_to_409() -> None:
def test_create_memory_fact_route_maps_duplicate_to_409() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.create_fact.side_effect = ValueError("Duplicate fact")
@ -228,7 +228,7 @@ def test_create_memory_fact_route_maps_duplicate_to_409() -> None:
def test_get_memory_route_maps_corruption_to_stable_500() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.get_memory.side_effect = MemoryCorruptionError("private path and parser detail")
@ -242,7 +242,7 @@ def test_get_memory_route_maps_corruption_to_stable_500() -> None:
def test_delete_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_keep", "content": "User likes Python", "category": "preference", "confidence": 0.9, "createdAt": "2026-03-20T00:00:00Z", "source": "thread-1"}])
@ -256,7 +256,7 @@ def test_delete_memory_fact_route_returns_updated_memory() -> None:
def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.delete_fact.side_effect = KeyError("fact_missing")
@ -268,7 +268,7 @@ def test_delete_memory_fact_route_returns_404_for_missing_fact() -> None:
def test_update_memory_fact_route_returns_updated_memory() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_edit", "content": "User prefers spaces", "category": "workflow", "confidence": 0.91, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
@ -283,7 +283,7 @@ def test_update_memory_fact_route_returns_updated_memory() -> None:
def test_settings_fact_crud_without_agent_name_uses_default_agent(tmp_path) -> None:
"""The current Settings API sends no agent_name; it must remain usable."""
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
memory_path = tmp_path / "users" / "alice" / "memory.json"
memory_path.parent.mkdir(parents=True)
@ -339,7 +339,7 @@ def test_settings_fact_crud_without_agent_name_uses_default_agent(tmp_path) -> N
def test_update_memory_fact_route_preserves_omitted_fields() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
updated_memory = _sample_memory(facts=[{"id": "fact_edit", "content": "User prefers spaces", "category": "preference", "confidence": 0.8, "createdAt": "2026-03-20T00:00:00Z", "source": "manual"}])
@ -363,7 +363,7 @@ def test_update_memory_fact_route_preserves_omitted_fields() -> None:
def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.update_fact.side_effect = KeyError("fact_missing")
@ -375,7 +375,7 @@ def test_update_memory_fact_route_returns_404_for_missing_fact() -> None:
def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
mock_mgr = MagicMock()
mock_mgr.update_fact.side_effect = ValueError("confidence")
@ -409,7 +409,7 @@ def test_get_memory_honors_bound_owner_header() -> None:
mock_mgr = MagicMock()
mock_mgr.get_memory.side_effect = fake_get_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
response = asyncio.run(memory.get_memory(_internal_owner_request("owner-1")))
response = asyncio.run(call_unwrapped(memory.get_memory, _internal_owner_request("owner-1")))
assert seen["user_id"] == "owner-1"
assert response.facts[0].content == "owner fact"
@ -427,7 +427,7 @@ def test_get_memory_sanitizes_unsafe_owner_header() -> None:
mock_mgr = MagicMock()
mock_mgr.get_memory.side_effect = fake_get_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
asyncio.run(memory.get_memory(_internal_owner_request(raw_owner)))
asyncio.run(call_unwrapped(memory.get_memory, _internal_owner_request(raw_owner)))
expected = make_safe_user_id(raw_owner)
assert seen["user_id"] == expected
assert seen["user_id"] != raw_owner
@ -451,7 +451,7 @@ def test_get_memory_falls_back_to_effective_user_for_browser_requests() -> None:
mock_mgr.get_memory.side_effect = fake_get_memory
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
asyncio.run(memory.get_memory(browser_request))
asyncio.run(call_unwrapped(memory.get_memory, browser_request))
assert seen["user_id"] == "real-user"
@ -474,11 +474,11 @@ def test_clear_memory_scopes_destructive_write_to_bound_owner() -> None:
mock_mgr = MagicMock()
mock_mgr.clear_memory.side_effect = fake_clear
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
asyncio.run(memory.clear_memory(_internal_owner_request("owner-1")))
asyncio.run(call_unwrapped(memory.clear_memory, _internal_owner_request("owner-1")))
assert seen["user_id"] == "owner-1"
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
asyncio.run(memory.clear_memory(_browser_request_with_spoofed_owner_header()))
asyncio.run(call_unwrapped(memory.clear_memory, _browser_request_with_spoofed_owner_header()))
assert seen["user_id"] == "real-user"
@ -493,11 +493,11 @@ def test_import_memory_scopes_overwrite_to_bound_owner() -> None:
mock_mgr = MagicMock()
mock_mgr.import_memory.side_effect = fake_import
with patch("app.gateway.routers.memory.get_memory_manager", return_value=mock_mgr):
asyncio.run(memory.import_memory(payload, _internal_owner_request("owner-1")))
asyncio.run(call_unwrapped(memory.import_memory, payload, _internal_owner_request("owner-1")))
assert seen["user_id"] == "owner-1"
with patch("app.gateway.routers.memory.get_effective_user_id", return_value="real-user"):
asyncio.run(memory.import_memory(payload, _browser_request_with_spoofed_owner_header()))
asyncio.run(call_unwrapped(memory.import_memory, payload, _browser_request_with_spoofed_owner_header()))
assert seen["user_id"] == "real-user"
@ -522,7 +522,7 @@ def _unsupported_manager() -> MagicMock:
def test_get_memory_route_returns_501_for_unsupported_backend() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
with TestClient(app) as client:
@ -532,7 +532,7 @@ def test_get_memory_route_returns_501_for_unsupported_backend() -> None:
def test_export_memory_route_returns_501_for_unsupported_backend() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
with TestClient(app) as client:
@ -541,7 +541,7 @@ def test_export_memory_route_returns_501_for_unsupported_backend() -> None:
def test_memory_status_route_returns_501_for_unsupported_backend() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
cfg = SimpleNamespace(
enabled=True,
@ -561,7 +561,7 @@ def test_memory_status_route_returns_501_for_unsupported_backend() -> None:
def test_clear_memory_route_returns_501_for_unsupported_backend() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
with TestClient(app) as client:
@ -570,7 +570,7 @@ def test_clear_memory_route_returns_501_for_unsupported_backend() -> None:
def test_import_memory_route_returns_501_for_unsupported_backend() -> None:
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
with TestClient(app) as client:
@ -581,7 +581,7 @@ def test_import_memory_route_returns_501_for_unsupported_backend() -> None:
def test_reload_memory_route_returns_501_when_read_also_unsupported() -> None:
"""reload falls back to get_memory; if both raise (minimal backend), the
fallback surfaces 501 instead of a raw 500 from the uncaught raise."""
app = FastAPI()
app = make_authed_test_app()
app.include_router(memory.router)
with patch("app.gateway.routers.memory.get_memory_manager", return_value=_unsupported_manager()):
with TestClient(app) as client:

View File

@ -279,3 +279,65 @@ class TestMigrateSkills:
assert not (base_dir / "skills" / "custom").exists()
assert (base_dir / "skills").exists()
assert (base_dir / "skills" / "public").exists()
class TestMigrateUserProfile:
def test_moves_global_user_md(self, base_dir: Path, paths: Paths):
legacy = base_dir / "USER.md"
legacy.write_text("# About me\n", encoding="utf-8")
from scripts.migrate_user_isolation import migrate_user_profile
migrate_user_profile(paths, user_id="default")
expected = base_dir / "users" / "default" / "USER.md"
assert expected.read_text(encoding="utf-8") == "# About me\n"
assert not legacy.exists()
def test_conflict_renames_legacy_and_keeps_destination(self, base_dir: Path, paths: Paths):
legacy = base_dir / "USER.md"
legacy.write_text("# legacy\n", encoding="utf-8")
dest = base_dir / "users" / "default" / "USER.md"
dest.parent.mkdir(parents=True)
dest.write_text("# current\n", encoding="utf-8")
from scripts.migrate_user_isolation import migrate_user_profile
migrate_user_profile(paths, user_id="default")
# The per-user copy wins; the legacy global file is preserved aside
# instead of silently overwritten.
assert dest.read_text(encoding="utf-8") == "# current\n"
assert (base_dir / "USER.legacy.md").read_text(encoding="utf-8") == "# legacy\n"
def test_no_legacy_user_md_is_noop(self, base_dir: Path, paths: Paths):
from scripts.migrate_user_isolation import migrate_user_profile
migrate_user_profile(paths, user_id="default") # should not raise
def test_dry_run_moves_nothing(self, base_dir: Path, paths: Paths):
legacy = base_dir / "USER.md"
legacy.write_text("# About me\n", encoding="utf-8")
from scripts.migrate_user_isolation import migrate_user_profile
migrate_user_profile(paths, user_id="default", dry_run=True)
assert legacy.exists()
assert not (base_dir / "users" / "default" / "USER.md").exists()
def test_dry_run_conflict_renames_nothing(self, base_dir: Path, paths: Paths):
legacy = base_dir / "USER.md"
legacy.write_text("# legacy\n", encoding="utf-8")
dest = base_dir / "users" / "default" / "USER.md"
dest.parent.mkdir(parents=True)
dest.write_text("# current\n", encoding="utf-8")
from scripts.migrate_user_isolation import migrate_user_profile
migrate_user_profile(paths, user_id="default", dry_run=True)
assert not (base_dir / "USER.legacy.md").exists()
assert dest.read_text(encoding="utf-8") == "# current\n"
assert legacy.exists()

View File

@ -89,10 +89,16 @@ def test_validate_scopes_deduplicates_and_rejects_unknown():
def test_pat_scopes_stay_aligned_with_route_permissions():
"""PAT scopes are exactly the authz route permissions — fail on drift."""
"""Every PAT scope must be a real route permission, and the memory/agent
permissions are deliberately excluded: PATs govern the thread/run
lifecycle only (_PAT_ROUTE_RULES default-denies those routers for PAT
callers regardless of scopes). Fail on drift in either direction."""
from app.gateway.authz import _ALL_PERMISSIONS
assert PAT_ALLOWED_SCOPES == frozenset(_ALL_PERMISSIONS)
route_permissions = frozenset(_ALL_PERMISSIONS)
assert PAT_ALLOWED_SCOPES <= route_permissions
# The deliberate exclusions, pinned so they cannot drift silently.
assert not (PAT_ALLOWED_SCOPES & {"memory:read", "memory:write", "agents:read", "agents:write"})
# ── Repository ────────────────────────────────────────────────────────────

View File

@ -946,7 +946,14 @@ def test_insert_race_recovery_claims_unscoped_row_for_trusted_owner() -> None:
thread_store = _RacingOwnerStore(store)
request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE), auth_source=AUTH_SOURCE_INTERNAL),
# Realistic internal-auth fields: the create_thread permission wrapper
# authenticates these direct calls, and get_current_user_from_request
# honors state.user only when auth_source marks a trusted origin.
cookies={},
state=SimpleNamespace(
user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE),
auth_source=AUTH_SOURCE_INTERNAL,
),
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
)
@ -1204,7 +1211,14 @@ def test_internal_owner_header_assigns_thread_to_owner() -> None:
thread_store = MemoryThreadMetaStore(store)
request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE), auth_source=AUTH_SOURCE_INTERNAL),
# Realistic internal-auth fields: the create_thread permission wrapper
# authenticates these direct calls, and get_current_user_from_request
# honors state.user only when auth_source marks a trusted origin.
cookies={},
state=SimpleNamespace(
user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE),
auth_source=AUTH_SOURCE_INTERNAL,
),
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
)
@ -1236,7 +1250,14 @@ def test_goal_thread_creation_uses_internal_owner_header() -> None:
thread_store = MemoryThreadMetaStore(store)
request = SimpleNamespace(
headers={INTERNAL_OWNER_USER_ID_HEADER_NAME: "owner-1"},
state=SimpleNamespace(user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE), auth_source=AUTH_SOURCE_INTERNAL),
# Realistic internal-auth fields: the create_thread permission wrapper
# authenticates these direct calls, and get_current_user_from_request
# honors state.user only when auth_source marks a trusted origin.
cookies={},
state=SimpleNamespace(
user=SimpleNamespace(id="default", system_role=INTERNAL_SYSTEM_ROLE),
auth_source=AUTH_SOURCE_INTERNAL,
),
app=SimpleNamespace(state=SimpleNamespace(checkpointer=checkpointer, thread_store=thread_store)),
)

View File

@ -16,6 +16,10 @@ const AUTH_DISABLED_PERMISSIONS = [
"runs:create",
"runs:read",
"runs:cancel",
"memory:read",
"memory:write",
"agents:read",
"agents:write",
"projects:read",
"projects:write",
"projects:delete",