feat!: require explicit user on every tenant-data endpoint

Remove the implicit jpmschweitzer default tenant (DEFAULT_USER) from
src/core/multi_tenancy.py and every endpoint and request model that
inherited it (~40 endpoints across /query, /wiki, /vector, /graph,
/ingest, /volatile, /documents, /stats, /rag).

- Add validate_required_user() + RequiredUser pydantic type in
  multi_tenancy and a shared require_user FastAPI dependency
  (RequiredUserQuery) that rejects missing, empty, and whitespace-only
  users with 422, following the /maintenance/* pattern.
- Wiki page create / smart-create / dossier request models now require
  user (no fallback in wiki_service).
- /maintenance/cleanup/test-data derives the tenant from the page path
  instead of using the production tenant collection.
- Wiki.js change listener skips changes when no tenant user can be
  derived from the notification email instead of defaulting to the
  production tenant.
- Consolidation service internal helpers no longer default to the
  production tenant.
- Tool catalog marks user as required with honest descriptions.
- OpenAPI descriptions updated honestly; CHANGELOG notes that callers
  (tatlock, Scheduler ingest tasks) must now send explicit user.
- Offline tests: 422 coverage for query/body endpoints, required-user
  validator tests; updated legacy tests that assumed a default tenant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 11:06:11 +02:00
co-authored by Claude Fable 5
parent a66d801abd
commit 84e9185371
27 changed files with 481 additions and 153 deletions
+12 -6
View File
@@ -8,6 +8,8 @@ from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional
from datetime import datetime
from src.core.multi_tenancy import RequiredUser
class GraphNode(BaseModel):
"""Graph node representation."""
@@ -45,9 +47,13 @@ class CypherQueryRequest(BaseModel):
default_factory=dict,
description="Query parameters"
)
user: str = Field(
default="jpmschweitzer",
description="User for filtering (automatically scopes query)"
user: RequiredUser = Field(
...,
description=(
"User identifier (tenant). Required. NOTE: raw Cypher queries are NOT "
"automatically scoped to this tenant — the endpoint is read-only and "
"intended for admin/debug use. Results may span all tenants."
)
)
@@ -61,9 +67,9 @@ class CypherQueryResponse(BaseModel):
class UpdateFromPageRequest(BaseModel):
"""Request to update graph from a wiki page."""
page_id: int = Field(..., description="Wiki page ID to process")
user: str = Field(
default="jpmschweitzer",
description="User identifier for namespace scoping"
user: RequiredUser = Field(
...,
description="User identifier (tenant). Required — graph writes are scoped to this tenant's labels."
)
force_refresh: bool = Field(
default=False,
+4 -2
View File
@@ -5,11 +5,13 @@ from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from src.core.multi_tenancy import RequiredUser
class IngestionRequest(BaseModel):
"""Request to ingest a wiki page."""
page_id: int = Field(..., description="Wiki page ID to ingest")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
force_refresh: bool = Field(
default=False,
description="Force re-ingestion even if page hasn't changed"
@@ -21,7 +23,7 @@ class IngestionRequest(BaseModel):
class BatchIngestionRequest(BaseModel):
"""Request to ingest multiple wiki pages."""
page_ids: List[int] = Field(..., description="List of wiki page IDs to ingest")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — ingestion writes to this tenant's namespaces only.")
force_refresh: bool = Field(default=False)
skip_vectors: bool = Field(default=False)
skip_graph: bool = Field(default=False)
+4 -4
View File
@@ -8,7 +8,7 @@ from enum import Enum
from typing import Optional, List
from pydantic import BaseModel, Field
from src.core.multi_tenancy import DEFAULT_USER
from src.core.multi_tenancy import RequiredUser
class SearchType(str, Enum):
@@ -37,9 +37,9 @@ class RAGSearchRequest(BaseModel):
le=20,
description="Maximum number of results (1-20)"
)
user: str = Field(
default=DEFAULT_USER,
description="User identifier for rate limiting/personalization"
user: RequiredUser = Field(
...,
description="User identifier (tenant). Required — used for rate limiting/personalization."
)
+7 -5
View File
@@ -7,6 +7,8 @@ Provides models for semantic search, document chunks, and embeddings.
from pydantic import BaseModel, Field
from typing import List, Dict, Any, Optional
from src.core.multi_tenancy import RequiredUser
class DocumentChunk(BaseModel):
"""Document chunk with embedding."""
@@ -32,7 +34,7 @@ class SearchResult(BaseModel):
class SearchRequest(BaseModel):
"""Semantic search request."""
query: str = Field(..., min_length=1, description="Search query")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — search is scoped to this tenant's collection.")
limit: int = Field(default=10, ge=1, le=100, description="Maximum results")
score_threshold: float = Field(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score")
@@ -48,9 +50,9 @@ class SearchResponse(BaseModel):
class VectorUpdateRequest(BaseModel):
"""Request to update vectors from a wiki page."""
page_id: int = Field(..., description="Wiki page ID to process")
user: str = Field(
default="jpmschweitzer",
description="User identifier for namespace scoping"
user: RequiredUser = Field(
...,
description="User identifier (tenant). Required — vectors are written to this tenant's collection."
)
force_refresh: bool = Field(
default=False,
@@ -89,7 +91,7 @@ class CollectionListResponse(BaseModel):
class DeletePageChunksRequest(BaseModel):
"""Request to delete all chunks for a page."""
page_id: int = Field(..., description="Wiki page ID")
user: str = Field(default="jpmschweitzer", description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — deletion is scoped to this tenant's collection.")
class DeletePageChunksResponse(BaseModel):
+5 -3
View File
@@ -11,6 +11,8 @@ from pydantic import BaseModel, Field, field_validator
from typing import Optional, List, Dict, Any
from datetime import datetime
from src.core.multi_tenancy import RequiredUser
# Base models
class WikiPageBase(BaseModel):
@@ -35,7 +37,7 @@ class WikiPageCreate(WikiPageBase):
content: str = Field(..., description="Page content (markdown)")
path: str = Field(..., min_length=1, max_length=500, description="Page path (e.g., '/projects/library-desk')")
editor: str = Field(default="markdown", description="Editor type")
user: Optional[str] = Field(None, description="User identifier (defaults to configured user)")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — the page is created inside this tenant's namespace.")
@field_validator("path")
@classmethod
@@ -106,7 +108,7 @@ class DossierCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200, description="Human-readable title")
description: str = Field(..., min_length=1, description="Dossier description")
create_index_page: bool = Field(default=True, description="Create an index page for the dossier")
user: Optional[str] = Field(None, description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required.")
@field_validator("name")
@classmethod
@@ -198,7 +200,7 @@ class WikiSmartCreateRequest(BaseModel):
topic: str = Field(..., min_length=1, max_length=500, description="Topic to research and create page about")
path: Optional[str] = Field(None, description="Page path (auto-generated from topic if not provided)")
tags: List[str] = Field(default_factory=list, description="Tags for the page")
user: Optional[str] = Field(None, description="User identifier")
user: RequiredUser = Field(..., description="User identifier (tenant). Required — research results and the created page are scoped to this tenant.")
include_web_research: bool = Field(default=True, description="Include web search results")
include_wiki_search: bool = Field(default=True, description="Include existing wiki knowledge")