diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dbb067..19bdf4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **BREAKING: `user` is now required on every tenant-data endpoint** - The implicit `jpmschweitzer` default tenant (`DEFAULT_USER`) has been removed everywhere. All endpoints that read or write tenant data (`/query/*`, `/wiki/*`, `/vector/*`, `/graph/*`, `/ingest/*`, `/volatile/*`, `/documents/*`, `/stats`, `/rag/search`) now reject requests without an explicit, non-empty, non-whitespace `user` (HTTP 422), matching the existing `/maintenance/*` pattern. A shared validator (`require_user` dependency / `RequiredUser` model type) also rejects blank users. The Wiki.js change listener now skips changes whose notification email yields no user instead of attributing them to the production tenant. **Caller coordination required:** tatlock and the Scheduler ingest/prefetch/consolidation tasks must send an explicit `user` on every call — see the deploy checklist. + ### Added - **Degradation signaling** - `HybridRAGResponse` now includes `source_status` (per-leg `'ok'`/`'failed'`/`'disabled'` for vector, graph, web, volatile, documents) and `degraded` (true when any enabled leg failed). Retrieval legs report errors instead of silently swallowing them; failed legs are logged at WARNING. Both fields are additive and optional, so clients that ignore them are unaffected. diff --git a/PROJECT_CLAUDIFICATION_HANDOVER.md b/PROJECT_CLAUDIFICATION_HANDOVER.md new file mode 100644 index 0000000..00ad36b --- /dev/null +++ b/PROJECT_CLAUDIFICATION_HANDOVER.md @@ -0,0 +1,59 @@ +# Tatlock Claudification Handover - library-desk + +## Context + +Tatlock (the butler) is being upgraded to use Claude as its primary LLM backend instead of Ollama. This gives all agents 200k token context and improved reasoning. Library-desk is the backend for **The Librarian** agent. + +**Parent Issue:** See `/mnt/media/Projects/tatlock/PROJECT_CLAUDIFICATION.md` + +## Impact on library-desk + +Library-desk's API is consumed by The Librarian agent via `LibraryDeskClient`. With Claude's larger context and better reasoning: + +1. **Larger response payloads are now viable** - Claude can process more search results +2. **Better synthesis** - Claude can better combine HybridRAG sources +3. **Faster processing** - May need to review rate limiting + +## Required Changes + +### Priority: Low (No blocking changes) + +Library-desk likely works as-is. These are optimizations: + +- [ ] **Review `hybrid_search` response size limits** + - Current defaults may be conservative for 8k Ollama context + - Consider increasing `max_results` defaults for Claude's 200k context + - Add optional `context_budget` parameter? + +- [ ] **Review `smart_create` endpoint** + - Claude's reasoning may benefit from more research context + - Consider returning more source material for synthesis + +- [ ] **Evaluate response formats** + - Are responses optimized for LLM consumption? + - Could structured metadata help Claude's reasoning? + +### Priority: None (Infrastructure) + +- No API key changes needed (library-desk doesn't call LLMs directly) +- No authentication changes +- Existing endpoints remain compatible + +## Testing + +Once Tatlock is running with Claude backend: + +```bash +# Test from Tatlock +curl -X POST http://localhost:8777/v1/responses \ + -H "Content-Type: application/json" \ + -d '{"input": "Search for Docker networking best practices"}' + +# Verify Librarian delegation works with Claude +``` + +## Timeline + +- **Blocking:** No +- **When to implement:** After Tatlock Phase 1 is tested and stable +- **Effort:** ~2-4 hours for optimizations diff --git a/src/core/dependencies.py b/src/core/dependencies.py index 3df364f..75501bf 100644 --- a/src/core/dependencies.py +++ b/src/core/dependencies.py @@ -10,7 +10,7 @@ Provides FastAPI dependencies for service clients with: from functools import lru_cache from typing import Annotated -from fastapi import Depends +from fastapi import Depends, HTTPException, Query import logging import redis.asyncio as aioredis @@ -38,6 +38,35 @@ logger = logging.getLogger(__name__) SettingsDep = Annotated[Settings, Depends(get_settings)] +# Tenant user dependency +def require_user( + user: str = Query( + ..., + description=( + "User identifier (tenant). Required — every operation is scoped to " + "this tenant's namespace (Qdrant collection, Neo4j labels, wiki path, " + "Redis keys). Requests without an explicit non-empty user are " + "rejected with 422. There is no default tenant." + ), + ) +) -> str: + """ + FastAPI dependency: required tenant user query parameter. + + Rejects missing (FastAPI returns 422 automatically), empty, and + whitespace-only user values. Use via the RequiredUserQuery alias. + """ + from src.core.multi_tenancy import validate_required_user + + try: + return validate_required_user(user) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +RequiredUserQuery = Annotated[str, Depends(require_user)] + + # Client factory functions with @lru_cache for singletons @lru_cache def get_neo4j_client() -> Neo4jClient: diff --git a/src/core/multi_tenancy.py b/src/core/multi_tenancy.py index 8ca468c..330c806 100644 --- a/src/core/multi_tenancy.py +++ b/src/core/multi_tenancy.py @@ -8,9 +8,9 @@ Provides utilities for user namespace management across: """ import re +from typing import Annotated -# Default user for all operations -DEFAULT_USER = "jpmschweitzer" +from pydantic import AfterValidator def sanitize_user_id(user_id: str) -> str: @@ -186,6 +186,45 @@ def validate_user_id(user_id: str) -> bool: return True +def validate_required_user(user: str) -> str: + """ + Validate that a tenant user identifier is present and usable. + + There is NO default tenant: every operation that touches tenant data + must receive an explicit user. Empty or whitespace-only values are + rejected, as are values that fail :func:`validate_user_id`. + + Args: + user: Raw user identifier from a request + + Returns: + The stripped user identifier + + Raises: + ValueError: If the user is missing, blank, or invalid + + Examples: + >>> validate_required_user("llm_tester") + 'llm_tester' + >>> validate_required_user(" ") + Traceback (most recent call last): + ... + ValueError: user is required and must be a non-empty, non-whitespace string + """ + if user is None or not str(user).strip(): + raise ValueError( + "user is required and must be a non-empty, non-whitespace string" + ) + stripped = str(user).strip() + if not validate_user_id(stripped): + raise ValueError(f"Invalid user identifier: {user!r}") + return stripped + + +# Pydantic annotated type for request models: a required, validated tenant user. +RequiredUser = Annotated[str, AfterValidator(validate_required_user)] + + def is_path_in_user_namespace(path: str, user_id: str) -> bool: """ Check if a Wiki.js path belongs to user's namespace. diff --git a/src/main.py b/src/main.py index 0634a84..beeb653 100644 --- a/src/main.py +++ b/src/main.py @@ -18,9 +18,9 @@ from pathlib import Path from src.config import Settings, get_settings, __version__ from src.core.dependencies import ( - verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep + verify_api_key, QdrantDep, WikiJSDep, OllamaDep, Neo4jDep, PaperlessDep, + RequiredUserQuery ) -from src.core.multi_tenancy import DEFAULT_USER # Configure logging logging.basicConfig( @@ -151,7 +151,7 @@ async def health(settings: Settings = Depends(get_settings)) -> HealthResponse: @app.get("/stats", response_model=StatsResponse, tags=["System"]) async def stats( - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, neo4j: Neo4jDep = None, qdrant: QdrantDep = None, wikijs: WikiJSDep = None, @@ -290,8 +290,8 @@ async def get_repo_status( @app.post("/query/semantic", tags=["Query"]) async def semantic_query( + user: RequiredUserQuery, query: str = Query(..., min_length=1, description="Search query text"), - user: str = Query(default=DEFAULT_USER, description="User identifier"), limit: int = Query(default=10, ge=1, le=100, description="Maximum results"), score_threshold: float = Query(default=0.5, ge=0.0, le=1.0, description="Minimum similarity score"), qdrant_client: QdrantDep = None, @@ -331,8 +331,8 @@ async def semantic_query( @app.post("/query/graph", tags=["Query"]) async def graph_query( + user: RequiredUserQuery, query: str = Query(..., description="Cypher query to execute"), - user: str = Query(default=DEFAULT_USER, description="User for scoping (auto-filters results)"), neo4j_client: Neo4jDep = None, wiki_client: WikiJSDep = None, api_key: str = Depends(verify_api_key) diff --git a/src/models/graph.py b/src/models/graph.py index 4da9a4e..6503a6d 100644 --- a/src/models/graph.py +++ b/src/models/graph.py @@ -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, diff --git a/src/models/ingestion.py b/src/models/ingestion.py index 4973d5f..c7b2b76 100644 --- a/src/models/ingestion.py +++ b/src/models/ingestion.py @@ -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) diff --git a/src/models/rag_search.py b/src/models/rag_search.py index 8cdfbf8..f29eb1b 100644 --- a/src/models/rag_search.py +++ b/src/models/rag_search.py @@ -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." ) diff --git a/src/models/vector.py b/src/models/vector.py index 6f1a088..a3ea996 100644 --- a/src/models/vector.py +++ b/src/models/vector.py @@ -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): diff --git a/src/models/wiki.py b/src/models/wiki.py index 3b71bda..7ff0468 100644 --- a/src/models/wiki.py +++ b/src/models/wiki.py @@ -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") diff --git a/src/routers/documents.py b/src/routers/documents.py index 55ddf85..f733be4 100644 --- a/src/routers/documents.py +++ b/src/routers/documents.py @@ -29,7 +29,7 @@ from src.core.dependencies import ( Neo4jDep, WikiJSDep, ) -from src.core.multi_tenancy import DEFAULT_USER +from src.core.dependencies import RequiredUserQuery from src.config import get_settings logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ async def receive_webhook( ollama: OllamaDep, neo4j: Neo4jDep, wiki: WikiJSDep, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, ): """ Receive webhook events from Paperless-ngx. @@ -161,9 +161,9 @@ async def capture_webhook(request: Request): @router.post("/webhook-simple", response_model=WebhookResponse) async def receive_webhook_simple( + user: RequiredUserQuery, doc_url: str = Query(..., description="Paperless document URL containing ID"), title: str = Query(default="", description="Document title"), - user: str = Query(default=DEFAULT_USER, description="User identifier"), paperless: PaperlessDep = None, qdrant: QdrantDep = None, ollama: OllamaDep = None, @@ -347,7 +347,7 @@ async def search_documents( request: DocumentSearchRequest, qdrant: QdrantDep, ollama: OllamaDep, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, api_key: str = Depends(verify_api_key), ): """ diff --git a/src/routers/graph.py b/src/routers/graph.py index a6d3db0..5629108 100644 --- a/src/routers/graph.py +++ b/src/routers/graph.py @@ -15,8 +15,9 @@ from src.models.graph import ( MindMapResponse ) from src.services.graph_service import GraphService -from src.core.dependencies import Neo4jDep, WikiJSDep, verify_api_key -from src.core.multi_tenancy import DEFAULT_USER +from src.core.dependencies import ( + Neo4jDep, WikiJSDep, verify_api_key, RequiredUserQuery +) logger = logging.getLogger(__name__) @@ -49,7 +50,7 @@ async def execute_cypher_query( { "query": "MATCH (d:Document) RETURN d LIMIT 10", "parameters": {}, - "user": "jpmschweitzer" + "user": "" } ``` @@ -70,7 +71,7 @@ async def execute_cypher_query( @router.get("/nodes", response_model=NodeListResponse) async def list_nodes( - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, node_type: Optional[str] = Query(default=None, description="Node type filter"), limit: int = Query(default=100, ge=1, le=500, description="Maximum nodes"), graph_service: GraphService = Depends(get_graph_service), @@ -81,7 +82,7 @@ async def list_nodes( Optionally filter by node type (Document, Person, Project, Concept, etc.). - **Example:** `/graph/nodes?user=jpmschweitzer&node_type=Document&limit=50` + **Example:** `/graph/nodes?user=&node_type=Document&limit=50` """ try: return await graph_service.list_nodes( @@ -97,7 +98,7 @@ async def list_nodes( @router.get("/nodes/{node_id}", response_model=GraphNodeDetail) async def get_node( node_id: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) ): @@ -106,7 +107,7 @@ async def get_node( Returns the node, its relationships, and connected nodes. - **Example:** `/graph/nodes/4:abc123def:0?user=jpmschweitzer` + **Example:** `/graph/nodes/4:abc123def:0?user=` """ try: node = await graph_service.get_node(node_id, user) @@ -123,7 +124,7 @@ async def get_node( @router.post("/update-from-page/{page_id}", response_model=GraphUpdateSummary) async def update_graph_from_page( page_id: int, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, force_refresh: bool = Query(default=False, description="Force re-extraction"), graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) @@ -143,7 +144,7 @@ async def update_graph_from_page( - Called manually by user/Librarian to refresh graph - Called by Scheduler for batch processing - **Example:** `POST /graph/update-from-page/4?user=jpmschweitzer` + **Example:** `POST /graph/update-from-page/4?user=` **Returns:** Summary with nodes/relationships created and entities extracted """ @@ -171,8 +172,8 @@ async def update_graph_from_page( @router.post("/mindmap", response_model=MindMapResponse) async def generate_mindmap( + user: RequiredUserQuery, center_node_id: str = Query(..., description="Central node ID"), - user: str = Query(default=DEFAULT_USER, description="User identifier"), depth: int = Query(default=2, ge=1, le=5, description="Traversal depth"), graph_service: GraphService = Depends(get_graph_service), api_key: str = Depends(verify_api_key) @@ -205,7 +206,7 @@ async def generate_mindmap( @router.post("/generate-entity-pages") async def generate_entity_pages( - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, min_mentions: int = Query(default=5, ge=1, le=100, description="Minimum mentions threshold"), entity_types: Optional[List[str]] = Query(default=None, description="Entity types to process"), graph_service: GraphService = Depends(get_graph_service), diff --git a/src/routers/hybrid_rag.py b/src/routers/hybrid_rag.py index 89f44d5..4467c7f 100644 --- a/src/routers/hybrid_rag.py +++ b/src/routers/hybrid_rag.py @@ -5,14 +5,15 @@ Provides endpoint for combining vector, graph, volatile cache, and web search with RRF fusion and LLM re-ranking. """ -from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi import APIRouter, HTTPException, Depends import logging from src.models.hybrid_rag import HybridRAGRequest, HybridRAGResponse from src.services.hybrid_rag_service import HybridRAGService from src.core.dependencies import ( Neo4jDep, WikiJSDep, QdrantDep, OllamaDep, - SearXNGDep, ContentExtractorDep, verify_api_key, get_settings + SearXNGDep, ContentExtractorDep, verify_api_key, get_settings, + RequiredUserQuery ) from src.config import Settings @@ -56,7 +57,7 @@ def get_hybrid_rag_service( @router.post("/hybrid", response_model=HybridRAGResponse) async def hybrid_search( request: HybridRAGRequest, - user: str = Query(default="jpmschweitzer", description="User identifier for multi-tenancy"), + user: RequiredUserQuery, hybrid_rag_service: HybridRAGService = Depends(get_hybrid_rag_service), api_key: str = Depends(verify_api_key) ): @@ -72,11 +73,13 @@ async def hybrid_search( 6. **Context Formatting**: Format for LLM consumption 7. **Persistence**: Store for Librarian knowledge consolidation - **Example Request:** + **Multi-tenancy:** the `user` query parameter is REQUIRED — all retrieval + legs and persistence are scoped to that tenant's namespaces. + + **Example Request** (`POST /query/hybrid?user=`): ```json { "query": "What's the weather in Rotterdam?", - "user": "jpmschweitzer", "config": { "vector_limit": 10, "graph_limit": 10, diff --git a/src/routers/ingestion.py b/src/routers/ingestion.py index 9fcdafd..09f2920 100644 --- a/src/routers/ingestion.py +++ b/src/routers/ingestion.py @@ -13,7 +13,7 @@ from src.models.ingestion import ( BatchIngestionRequest, BatchIngestionResult ) -from src.core.dependencies import get_ingestion_service, verify_api_key +from src.core.dependencies import get_ingestion_service, verify_api_key, RequiredUserQuery router = APIRouter(prefix="/ingest", tags=["Document Ingestion"]) @@ -52,7 +52,7 @@ async def ingest_page( -H "Content-Type: application/json" \ -d '{ "page_id": 19, - "user": "jpmschweitzer", + "user": "", "force_refresh": false }' ``` @@ -104,7 +104,7 @@ async def ingest_batch( -H "Content-Type: application/json" \ -d '{ "page_ids": [19, 20, 21, 22], - "user": "jpmschweitzer", + "user": "", "max_concurrent": 3 }' ``` @@ -123,8 +123,8 @@ async def ingest_batch( @router.post("/all", response_model=BatchIngestionResult) async def ingest_all_pages( - user: str = Query(default="jpmschweitzer", description="User identifier"), - path_prefix: Optional[str] = Query(None, description="Path prefix filter (e.g., 'users/jpmschweitzer/tech')"), + user: RequiredUserQuery, + path_prefix: Optional[str] = Query(None, description="Path prefix filter within the user's namespace (e.g., 'users//tech')"), force_refresh: bool = Query(False, description="Force re-ingestion of all pages"), max_concurrent: int = Query(3, ge=1, le=10, description="Maximum concurrent ingestion tasks"), ingestion: IngestionService = Depends(get_ingestion_service), @@ -151,11 +151,11 @@ async def ingest_all_pages( ```bash # Ingest all pages for user - curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer" \ + curl -X POST "http://192.168.86.149:8089/ingest/all?user=" \ -H "Authorization: Bearer $API_KEY" # Ingest only tech docs - curl -X POST "http://192.168.86.149:8089/ingest/all?user=jpmschweitzer&path_prefix=users/jpmschweitzer/tech" \ + curl -X POST "http://192.168.86.149:8089/ingest/all?user=&path_prefix=users//tech" \ -H "Authorization: Bearer $API_KEY" ``` """ diff --git a/src/routers/maintenance.py b/src/routers/maintenance.py index ba1a9e1..33b26d2 100644 --- a/src/routers/maintenance.py +++ b/src/routers/maintenance.py @@ -22,7 +22,6 @@ from src.core.dependencies import ( QdrantDep, OllamaDep, PaperlessDep, verify_api_key ) from src.config import get_settings -from src.core.multi_tenancy import DEFAULT_USER from datetime import datetime, timezone logger = logging.getLogger(__name__) @@ -262,6 +261,14 @@ def _matches_test_user_path(path: str) -> bool: return any(path_lower.startswith(prefix) for prefix in TEST_USER_PATH_PREFIXES) +def _tenant_from_path(path: str) -> str: + """Extract the tenant user from a 'users/{tenant}/...' wiki path.""" + parts = path.lstrip("/").split("/") + if len(parts) >= 2 and parts[0] == "users": + return parts[1] + raise ValueError(f"Cannot derive tenant from path: {path}") + + # ========== Endpoints ========== @router.post("/cleanup/vectors", response_model=VectorCleanupResponse) @@ -652,12 +659,14 @@ async def cleanup_test_data( page_path = page["path"] try: - # Delete vector chunks for this page (using DEFAULT_USER collection) - chunks_removed = await vector_service.delete_page_chunks(page_id, DEFAULT_USER) + # Delete vector chunks/graph node scoped to the tenant that + # owns the page (derived from its users/{tenant}/ path) + tenant = _tenant_from_path(page_path) + chunks_removed = await vector_service.delete_page_chunks(page_id, tenant) vector_deleted += chunks_removed # Delete graph node for this page (returns count, may be 0 if no node) - graph_removed = await graph_service.delete_page(page_id, DEFAULT_USER) + graph_removed = await graph_service.delete_page(page_id, tenant) graph_deleted += graph_removed # Delete wiki page (raises exception on failure, returns None on success) diff --git a/src/routers/tools.py b/src/routers/tools.py index bc7cd8a..a956282 100644 --- a/src/routers/tools.py +++ b/src/routers/tools.py @@ -25,10 +25,9 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier", - required=False, - default="jpmschweitzer", - example="jpmschweitzer" + description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace", + required=True, + example="llm_tester" ), ToolParameter( name="tag", @@ -66,9 +65,8 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier for access control", - required=False, - default="jpmschweitzer" + description="User identifier (tenant) for access control. Required", + required=True ), ], returns="Complete page object with content", @@ -119,9 +117,8 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier", - required=False, - default="jpmschweitzer" + description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace", + required=True ), ], returns="Created page object", @@ -130,7 +127,7 @@ def get_wiki_tools() -> list[ToolDefinition]: "path": "/projects/my-project", "content": "# My Project\n\nProject description here.", "tags": ["projects"], - "user": "jpmschweitzer" + "user": "" }, fast=True ), @@ -175,9 +172,8 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier", - required=False, - default="jpmschweitzer" + description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace", + required=True ), ], returns="Updated page object", @@ -200,9 +196,8 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier", - required=False, - default="jpmschweitzer" + description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace", + required=True ), ], returns="Success confirmation", @@ -225,9 +220,8 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier", - required=False, - default="jpmschweitzer" + description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace", + required=True ), ToolParameter( name="limit", @@ -250,9 +244,8 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier", - required=False, - default="jpmschweitzer" + description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace", + required=True ), ], returns="List of dossiers with page counts", @@ -275,9 +268,8 @@ def get_wiki_tools() -> list[ToolDefinition]: ToolParameter( name="user", type=ParameterType.STRING, - description="User identifier", - required=False, - default="jpmschweitzer" + description="User identifier (tenant). Required - all operations are scoped to this tenant's namespace", + required=True ), ToolParameter( name="limit", diff --git a/src/routers/vector.py b/src/routers/vector.py index ebb121f..2083538 100644 --- a/src/routers/vector.py +++ b/src/routers/vector.py @@ -18,8 +18,9 @@ from src.services.vector_service import VectorService from src.clients.qdrant_client import QdrantClientWrapper from src.clients.wikijs_client import WikiJSClient from src.clients.ollama_client import OllamaClient -from src.core.dependencies import QdrantDep, WikiJSDep, OllamaDep, verify_api_key -from src.core.multi_tenancy import DEFAULT_USER +from src.core.dependencies import ( + QdrantDep, WikiJSDep, OllamaDep, verify_api_key, RequiredUserQuery +) logger = logging.getLogger(__name__) @@ -52,7 +53,7 @@ async def semantic_search( ```json { "query": "how to configure docker", - "user": "jpmschweitzer", + "user": "", "limit": 10, "score_threshold": 0.5 } @@ -77,7 +78,7 @@ async def semantic_search( @router.post("/update-from-page/{page_id}", response_model=VectorUpdateSummary) async def update_vectors_from_page( page_id: int, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, force_refresh: bool = Query(default=False, description="Force re-embedding"), vector_service: VectorService = Depends(get_vector_service), api_key: str = Depends(verify_api_key) @@ -96,7 +97,7 @@ async def update_vectors_from_page( - Called manually by user/Librarian to refresh vectors - Called by Scheduler for batch processing - **Example:** `POST /vector/update-from-page/5?user=jpmschweitzer` + **Example:** `POST /vector/update-from-page/5?user= (user is REQUIRED)` **Returns:** Summary with chunks created and processing time """ @@ -125,7 +126,7 @@ async def update_vectors_from_page( @router.delete("/pages/{page_id}", response_model=DeletePageChunksResponse) async def delete_page_chunks( page_id: int, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, vector_service: VectorService = Depends(get_vector_service), api_key: str = Depends(verify_api_key) ): @@ -134,7 +135,7 @@ async def delete_page_chunks( This is automatically called when a page is deleted from the wiki. - **Example:** `DELETE /vector/pages/5?user=jpmschweitzer` + **Example:** `DELETE /vector/pages/5?user= (user is REQUIRED)` """ try: deleted_count = await vector_service.delete_page_chunks( diff --git a/src/routers/volatile.py b/src/routers/volatile.py index 9fc16ac..be0e93f 100644 --- a/src/routers/volatile.py +++ b/src/routers/volatile.py @@ -28,7 +28,7 @@ from src.core.dependencies import ( get_news_provider, get_alphavantage_provider, ) -from src.core.multi_tenancy import DEFAULT_USER +from src.core.dependencies import RequiredUserQuery from src.config import get_settings logger = logging.getLogger(__name__) @@ -48,7 +48,7 @@ def get_volatile_service(qdrant: QdrantDep, ollama: OllamaDep) -> VolatileCacheS @router.get("/stats", response_model=VolatileStatsResponse) async def get_stats( - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, qdrant: QdrantDep = None, ollama: OllamaDep = None, api_key: str = Depends(verify_api_key) @@ -72,7 +72,7 @@ async def get_stats( @router.get("/scheduled", response_model=VolatileScheduledResponse) async def get_scheduled( - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, qdrant: QdrantDep = None, ollama: OllamaDep = None, api_key: str = Depends(verify_api_key) @@ -134,8 +134,8 @@ def _get_namespace_description(ns: VolatileNamespace) -> str: @router.get("/search") async def search_volatile( + user: RequiredUserQuery, q: str = Query(..., min_length=1, description="Search query"), - user: str = Query(default=DEFAULT_USER, description="User identifier"), limit: int = Query(default=5, ge=1, le=20, description="Maximum results"), threshold: float = Query(default=0.75, ge=0.5, le=1.0, description="Minimum similarity score"), qdrant: QdrantDep = None, @@ -166,10 +166,10 @@ async def search_volatile( @router.post("/store", response_model=VolatileRecordResponse) async def store_volatile( + user: RequiredUserQuery, namespace: str = Query(..., description="Data namespace (weather, news, etc.)"), key: str = Query(..., description="Record key (e.g., 'rotterdam', 'nos-headlines')"), request: VolatileRecordCreate = None, - user: str = Query(default=DEFAULT_USER, description="User identifier"), qdrant: QdrantDep = None, ollama: OllamaDep = None, api_key: str = Depends(verify_api_key) @@ -232,7 +232,7 @@ async def store_volatile( @router.post("/fetch/weather/{city}") async def fetch_weather( city: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"), qdrant: QdrantDep = None, ollama: OllamaDep = None, @@ -246,7 +246,7 @@ async def fetch_weather( **Example:** ``` - POST /volatile/fetch/weather/amsterdam?user=jpmschweitzer + POST /volatile/fetch/weather/amsterdam?user= ``` """ volatile_service = get_volatile_service(qdrant, ollama) @@ -273,7 +273,7 @@ async def fetch_weather( @router.post("/fetch/forecast/{city}") async def fetch_forecast( city: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, days: int = Query(default=7, ge=1, le=16, description="Forecast days (1-16)"), ttl: int = Query(default=86400, ge=60, le=604800, description="TTL in seconds (default 24 hours)"), qdrant: QdrantDep = None, @@ -288,7 +288,7 @@ async def fetch_forecast( **Example:** ``` - POST /volatile/fetch/forecast/amsterdam?user=jpmschweitzer&days=7 + POST /volatile/fetch/forecast/amsterdam?user=&days=7 ``` """ volatile_service = get_volatile_service(qdrant, ollama) @@ -314,8 +314,8 @@ async def fetch_forecast( @router.post("/fetch/news/{category}") async def fetch_news( + user: RequiredUserQuery, category: str = "general", - user: str = Query(default=DEFAULT_USER, description="User identifier"), limit: int = Query(default=10, ge=1, le=50, description="Max headlines"), ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds"), qdrant: QdrantDep = None, @@ -330,7 +330,7 @@ async def fetch_news( **Example:** ``` - POST /volatile/fetch/news/tech?user=jpmschweitzer&limit=15 + POST /volatile/fetch/news/tech?user=&limit=15 ``` """ volatile_service = get_volatile_service(qdrant, ollama) @@ -359,7 +359,7 @@ async def fetch_news( @router.post("/fetch/stock/{symbol}") async def fetch_stock( symbol: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"), qdrant: QdrantDep = None, ollama: OllamaDep = None, @@ -372,7 +372,7 @@ async def fetch_stock( **Example:** ``` - POST /volatile/fetch/stock/AAPL?user=jpmschweitzer + POST /volatile/fetch/stock/AAPL?user= ``` """ volatile_service = get_volatile_service(qdrant, ollama) @@ -407,8 +407,8 @@ async def fetch_stock( @router.post("/fetch/crypto/{symbol}") async def fetch_crypto( symbol: str, + user: RequiredUserQuery, market: str = Query(default="USD", description="Market currency"), - user: str = Query(default=DEFAULT_USER, description="User identifier"), ttl: int = Query(default=600, ge=60, le=3600, description="TTL in seconds (default 10 min)"), qdrant: QdrantDep = None, ollama: OllamaDep = None, @@ -421,7 +421,7 @@ async def fetch_crypto( **Example:** ``` - POST /volatile/fetch/crypto/BTC?market=EUR&user=jpmschweitzer + POST /volatile/fetch/crypto/BTC?market=EUR&user= ``` """ volatile_service = get_volatile_service(qdrant, ollama) @@ -456,7 +456,7 @@ async def fetch_crypto( @router.post("/fetch/sun/{city}") async def fetch_sun_times( city: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, ttl: int = Query(default=172800, ge=60, le=604800, description="TTL in seconds (default 48 hours)"), qdrant: QdrantDep = None, ollama: OllamaDep = None, @@ -469,7 +469,7 @@ async def fetch_sun_times( **Example:** ``` - POST /volatile/fetch/sun/rotterdam?user=jpmschweitzer + POST /volatile/fetch/sun/rotterdam?user= ``` **Response data includes:** @@ -502,7 +502,7 @@ async def fetch_sun_times( @router.post("/fetch/air_quality/{city}") async def fetch_air_quality( city: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, ttl: int = Query(default=7200, ge=60, le=86400, description="TTL in seconds (default 2 hours)"), qdrant: QdrantDep = None, ollama: OllamaDep = None, @@ -515,7 +515,7 @@ async def fetch_air_quality( **Example:** ``` - POST /volatile/fetch/air_quality/rotterdam?user=jpmschweitzer + POST /volatile/fetch/air_quality/rotterdam?user= ``` **Response data includes:** @@ -548,7 +548,7 @@ async def fetch_air_quality( @router.post("/fetch/environment/{city}") async def fetch_environment( city: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, weather_ttl: int = Query(default=7200, ge=60, le=86400, description="Weather TTL in seconds (default 2 hours)"), air_quality_ttl: int = Query(default=7200, ge=60, le=86400, description="Air quality TTL in seconds (default 2 hours)"), qdrant: QdrantDep = None, @@ -564,7 +564,7 @@ async def fetch_environment( **Example:** ``` - POST /volatile/fetch/environment/rotterdam?user=jpmschweitzer + POST /volatile/fetch/environment/rotterdam?user= ``` **Response includes:** @@ -607,7 +607,7 @@ async def fetch_environment( async def get_record( namespace: str, key: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, qdrant: QdrantDep = None, ollama: OllamaDep = None, api_key: str = Depends(verify_api_key) @@ -617,7 +617,7 @@ async def get_record( **Example:** ``` - GET /volatile/weather/rotterdam?user=jpmschweitzer + GET /volatile/weather/rotterdam?user= ``` """ service = get_volatile_service(qdrant, ollama) @@ -636,7 +636,7 @@ async def get_record( async def delete_record( namespace: str, key: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, qdrant: QdrantDep = None, ollama: OllamaDep = None, api_key: str = Depends(verify_api_key) diff --git a/src/routers/wiki.py b/src/routers/wiki.py index efefd63..237db7a 100644 --- a/src/routers/wiki.py +++ b/src/routers/wiki.py @@ -24,9 +24,9 @@ from src.clients.qdrant_client import QdrantClientWrapper from src.clients.ollama_client import OllamaClient from src.core.dependencies import ( WikiJSDep, Neo4jDep, QdrantDep, OllamaDep, SearXNGDep, ContentExtractorDep, - verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service + verify_api_key, get_settings, get_hybrid_rag_service, get_ingestion_service, + RequiredUserQuery ) -from src.core.multi_tenancy import DEFAULT_USER from src.services.hybrid_rag_service import HybridRAGService from src.services.wiki_page_writer import WikiPageWriter from src.services.entity_linking_utils import apply_bidirectional_entity_linking @@ -62,7 +62,7 @@ def get_vector_service( # Page operations @router.get("/pages", response_model=WikiPageList) async def list_pages( - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, tag: Optional[str] = Query(default=None, description="Filter by tag (dossier)"), limit: int = Query(default=50, ge=1, le=200, description="Maximum pages to return"), wiki_service: WikiService = Depends(get_wiki_service), @@ -87,7 +87,7 @@ async def list_pages( @router.get("/pages/{page_id}", response_model=WikiPage) async def get_page( page_id: int, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): @@ -139,14 +139,16 @@ async def create_page( "content": "# Architecture\\n\\nThis describes...", "description": "Architecture documentation", "tags": ["projects", "architecture"], - "user": "jpmschweitzer" + "user": "" } ``` + + The `user` field is REQUIRED (no default tenant). """ try: page = await wiki_service.create_page(page_data) - user = page_data.user or DEFAULT_USER + user = page_data.user # Schedule BOTH graph and vector updates in background (non-blocking) background_tasks.add_task( @@ -214,7 +216,7 @@ async def smart_create_page( - Entity linking statistics (forward/backward links) """ try: - user = request.user or DEFAULT_USER + user = request.user # Build services wiki_service = WikiService(wiki_client) @@ -294,7 +296,7 @@ async def update_page( page_id: int, page_data: WikiPageUpdate, background_tasks: BackgroundTasks, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), graph_service: GraphService = Depends(get_graph_service), vector_service: VectorService = Depends(get_vector_service), @@ -353,7 +355,7 @@ async def update_page( async def delete_page( page_id: int, background_tasks: BackgroundTasks, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), vector_service: VectorService = Depends(get_vector_service), graph_service: GraphService = Depends(get_graph_service), @@ -402,7 +404,7 @@ async def delete_page( async def move_page( page_id: int, move_data: WikiPageMove, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): @@ -445,8 +447,8 @@ async def move_page( # Search operations @router.get("/search", response_model=WikiSearchResponse) async def search_pages( + user: RequiredUserQuery, q: str = Query(..., min_length=1, description="Search query"), - user: str = Query(default=DEFAULT_USER, description="User identifier"), limit: int = Query(default=20, ge=1, le=100, description="Maximum results"), wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) @@ -487,7 +489,7 @@ async def search_pages( # Dossier operations @router.get("/dossiers", response_model=DossierList) async def list_dossiers( - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) ): @@ -510,7 +512,7 @@ async def list_dossiers( @router.get("/dossiers/{dossier_name}/pages", response_model=WikiPageList) async def get_dossier_pages( dossier_name: str, - user: str = Query(default=DEFAULT_USER, description="User identifier"), + user: RequiredUserQuery, limit: int = Query(default=100, ge=1, le=500, description="Maximum pages"), wiki_service: WikiService = Depends(get_wiki_service), api_key: str = Depends(verify_api_key) diff --git a/src/services/consolidation_service.py b/src/services/consolidation_service.py index c01235d..e79a27a 100644 --- a/src/services/consolidation_service.py +++ b/src/services/consolidation_service.py @@ -398,7 +398,7 @@ class ConsolidationService: query: str, web_results: List[Dict[str, Any]], keywords: List[str], - user: str = "jpmschweitzer" + user: str ) -> Optional[Dict[str, Any]]: """ Analyze web results with Ollama for novel information. @@ -977,7 +977,7 @@ JSON:""" query: str, web_results: List[Dict[str, Any]], keywords: List[str], - user: str = "jpmschweitzer" + user: str ) -> MemoryRoutingResult: """ Unified classification of web results for memory routing. diff --git a/src/services/wiki_change_listener.py b/src/services/wiki_change_listener.py index dee699c..85a34ba 100644 --- a/src/services/wiki_change_listener.py +++ b/src/services/wiki_change_listener.py @@ -103,8 +103,17 @@ class WikiChangeListener: } event = event_map.get(operation, 'page.update') - # Extract user from email - user = user_email.split('@')[0] if '@' in user_email else 'jpmschweitzer' + # Extract user from email. There is NO default tenant: if no user + # can be derived from the notification, skip processing instead of + # attributing the change to an arbitrary tenant. + if '@' in user_email and user_email.split('@')[0].strip(): + user = user_email.split('@')[0].strip() + else: + logger.warning( + f"Skipping page {page_id} change: cannot derive tenant user " + f"from notification email {user_email!r}" + ) + return # Process the change await self._process_page_change( diff --git a/src/services/wiki_service.py b/src/services/wiki_service.py index 43f454a..98b3bb1 100644 --- a/src/services/wiki_service.py +++ b/src/services/wiki_service.py @@ -12,7 +12,7 @@ from typing import List, Optional, Dict, Any import logging from src.clients.wikijs_client import WikiJSClient -from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id, DEFAULT_USER +from src.core.multi_tenancy import get_wikijs_namespace, validate_user_id from src.models.wiki import ( WikiPage, WikiPageSummary, WikiPageList, WikiPageCreate, WikiPageUpdate, @@ -184,7 +184,7 @@ class WikiService: Raises: ValueError: If creation fails """ - user = page_data.user or DEFAULT_USER + user = page_data.user # Ensure path is in user's namespace full_path = self._ensure_user_path(page_data.path, user) diff --git a/tests/test_multi_tenancy.py b/tests/test_multi_tenancy.py index d4e277b..7c69e88 100644 --- a/tests/test_multi_tenancy.py +++ b/tests/test_multi_tenancy.py @@ -7,8 +7,8 @@ from src.core.multi_tenancy import ( get_wikijs_namespace, get_neo4j_user_label, validate_user_id, + validate_required_user, is_path_in_user_namespace, - DEFAULT_USER ) @@ -46,8 +46,8 @@ class TestQdrantCollectionName: def test_email_user(self): assert get_qdrant_collection_name("john@example.com") == "library_desk_john_at_example_com" - def test_default_user(self): - assert get_qdrant_collection_name(DEFAULT_USER) == f"library_desk_{DEFAULT_USER}" + def test_test_tenant(self): + assert get_qdrant_collection_name("llm_tester") == "library_desk_llm_tester" class TestWikijsNamespace: @@ -99,6 +99,41 @@ class TestValidateUserId: assert validate_user_id("___") is False +class TestValidateRequiredUser: + """Test the required-user validator (no default tenant).""" + + def test_no_default_user_constant(self): + """The DEFAULT_USER escape hatch must not exist anymore.""" + import src.core.multi_tenancy as mt + assert not hasattr(mt, "DEFAULT_USER") + + def test_valid_user_returned(self): + assert validate_required_user("llm_tester") == "llm_tester" + + def test_valid_user_stripped(self): + assert validate_required_user(" llm_tester ") == "llm_tester" + + def test_empty_rejected(self): + with pytest.raises(ValueError): + validate_required_user("") + + def test_whitespace_rejected(self): + with pytest.raises(ValueError): + validate_required_user(" ") + + def test_none_rejected(self): + with pytest.raises(ValueError): + validate_required_user(None) + + def test_no_alphanumeric_rejected(self): + with pytest.raises(ValueError): + validate_required_user("___") + + def test_too_long_rejected(self): + with pytest.raises(ValueError): + validate_required_user("a" * 101) + + class TestPathInNamespace: """Test path namespace checking.""" diff --git a/tests/test_rag_search.py b/tests/test_rag_search.py index 386aaec..b70cce6 100644 --- a/tests/test_rag_search.py +++ b/tests/test_rag_search.py @@ -37,12 +37,19 @@ class TestRAGSearchModels: """Tests for RAG search Pydantic models.""" def test_search_request_defaults(self): - """Test RAGSearchRequest with default values.""" - request = RAGSearchRequest(query="test query") + """Test RAGSearchRequest defaults (user is required, no default tenant).""" + request = RAGSearchRequest(query="test query", user="llm_tester") assert request.query == "test query" assert request.search_type == SearchType.WEB assert request.limit == 10 + assert request.user == "llm_tester" + + def test_search_request_requires_user(self): + """A request without an explicit user must be rejected.""" + import pytest + with pytest.raises(ValueError): + RAGSearchRequest(query="test query") def test_search_request_custom_values(self): """Test RAGSearchRequest with custom values.""" diff --git a/tests/test_required_user.py b/tests/test_required_user.py new file mode 100644 index 0000000..4e19f0e --- /dev/null +++ b/tests/test_required_user.py @@ -0,0 +1,117 @@ +""" +Offline unit tests: every tenant-data endpoint must REQUIRE an explicit user. + +A request without a user (query param or body field) must be rejected with +422 before any service is touched. Empty/whitespace users are also rejected. + +No external services are contacted: validation failures short-circuit the +request before the endpoint body executes. +""" + +import pytest +from fastapi.testclient import TestClient + +from src.main import app +from src.core.dependencies import verify_api_key + + +@pytest.fixture(scope="module") +def client(): + """TestClient with API-key auth stubbed out (no lifespan startup).""" + app.dependency_overrides[verify_api_key] = lambda: "test-key" + try: + # No context manager: startup/lifespan events are NOT triggered, + # so no connections to external services are attempted. + yield TestClient(app) + finally: + app.dependency_overrides.pop(verify_api_key, None) + + +QUERY_PARAM_ENDPOINTS = [ + ("GET", "/stats"), + ("POST", "/query/semantic?query=test"), + ("POST", "/query/graph?query=MATCH%20(n)%20RETURN%20n"), + ("GET", "/wiki/pages"), + ("GET", "/wiki/pages/1"), + ("PUT", "/wiki/pages/1"), + ("DELETE", "/wiki/pages/1"), + ("GET", "/wiki/search?q=test"), + ("GET", "/wiki/dossiers"), + ("POST", "/vector/update-from-page/1"), + ("DELETE", "/vector/pages/1"), + ("GET", "/graph/nodes"), + ("POST", "/graph/update-from-page/1"), + ("POST", "/graph/generate-entity-pages"), + ("POST", "/ingest/all"), + ("GET", "/volatile/stats"), + ("GET", "/volatile/search?q=test"), + ("POST", "/volatile/store?namespace=weather&key=test"), + ("GET", "/volatile/weather/rotterdam"), + ("DELETE", "/volatile/weather/rotterdam"), + ("POST", "/documents/webhook-simple?doc_url=http://x/documents/1/"), +] + + +@pytest.mark.unit +class TestUserQueryParamRequired: + """Endpoints with a user query parameter must 422 without it.""" + + @pytest.mark.parametrize("method,path", QUERY_PARAM_ENDPOINTS) + def test_missing_user_is_422(self, client, method, path): + response = client.request(method, path, json={}) + assert response.status_code == 422, ( + f"{method} {path} returned {response.status_code}, expected 422" + ) + + @pytest.mark.parametrize("blank", ["", " ", "%20%20"]) + def test_blank_user_is_422(self, client, blank): + response = client.get(f"/wiki/pages?user={blank}") + assert response.status_code == 422 + + def test_hybrid_query_missing_user_is_422(self, client): + response = client.post("/query/hybrid", json={"query": "test"}) + assert response.status_code == 422 + + def test_hybrid_query_whitespace_user_is_422(self, client): + response = client.post("/query/hybrid?user=%20", json={"query": "test"}) + assert response.status_code == 422 + + +@pytest.mark.unit +class TestUserBodyFieldRequired: + """Request models with a user field must reject missing/blank values.""" + + def test_ingest_page_missing_user_is_422(self, client): + response = client.post("/ingest/page", json={"page_id": 1}) + assert response.status_code == 422 + + def test_ingest_page_blank_user_is_422(self, client): + response = client.post("/ingest/page", json={"page_id": 1, "user": " "}) + assert response.status_code == 422 + + def test_ingest_batch_missing_user_is_422(self, client): + response = client.post("/ingest/batch", json={"page_ids": [1]}) + assert response.status_code == 422 + + def test_wiki_create_page_missing_user_is_422(self, client): + response = client.post( + "/wiki/pages", + json={"title": "T", "path": "/t", "content": "c"}, + ) + assert response.status_code == 422 + + def test_wiki_smart_create_missing_user_is_422(self, client): + response = client.post("/wiki/pages/smart-create", json={"topic": "T"}) + assert response.status_code == 422 + + def test_vector_search_missing_user_is_422(self, client): + response = client.post("/vector/search", json={"query": "test"}) + assert response.status_code == 422 + + def test_graph_query_missing_user_is_422(self, client): + response = client.post("/graph/query", json={"query": "MATCH (n) RETURN n"}) + assert response.status_code == 422 + + def test_rag_search_missing_user_is_422(self, client): + response = client.post("/rag/search", json={"query": "test"}) + assert response.status_code == 422 diff --git a/tests/test_smart_create.py b/tests/test_smart_create.py index 7310a52..03cc1c9 100644 --- a/tests/test_smart_create.py +++ b/tests/test_smart_create.py @@ -26,15 +26,20 @@ class TestWikiSmartCreateRequest: """Tests for WikiSmartCreateRequest model validation.""" def test_minimal_request(self): - """Test request with only required field.""" - request = WikiSmartCreateRequest(topic="Docker containers") + """Test request with only required fields (topic AND user).""" + request = WikiSmartCreateRequest(topic="Docker containers", user="llm_tester") assert request.topic == "Docker containers" assert request.path is None assert request.tags == [] - assert request.user is None + assert request.user == "llm_tester" assert request.include_web_research is True assert request.include_wiki_search is True + def test_user_is_required(self): + """A request without an explicit user must be rejected.""" + with pytest.raises(ValueError): + WikiSmartCreateRequest(topic="Docker containers") + def test_full_request(self): """Test request with all fields.""" request = WikiSmartCreateRequest( @@ -68,7 +73,8 @@ class TestWikiSmartCreateRequest: """Test that path without leading slash gets one added.""" request = WikiSmartCreateRequest( topic="Test", - path="technology/test" + path="technology/test", + user="llm_tester" ) assert request.path == "/technology/test" @@ -76,7 +82,8 @@ class TestWikiSmartCreateRequest: """Test that trailing slash is removed.""" request = WikiSmartCreateRequest( topic="Test", - path="/technology/test/" + path="/technology/test/", + user="llm_tester" ) assert request.path == "/technology/test" @@ -84,7 +91,8 @@ class TestWikiSmartCreateRequest: """Test that duplicate tags are removed.""" request = WikiSmartCreateRequest( topic="Test", - tags=["devops", "devops", "containers", "devops"] + tags=["devops", "devops", "containers", "devops"], + user="llm_tester" ) assert len(request.tags) == 2 assert "devops" in request.tags @@ -94,7 +102,8 @@ class TestWikiSmartCreateRequest: """Test that tag whitespace is cleaned.""" request = WikiSmartCreateRequest( topic="Test", - tags=[" devops ", "containers", " ", ""] + tags=[" devops ", "containers", " ", ""], + user="llm_tester" ) assert "devops" in request.tags assert "containers" in request.tags @@ -536,7 +545,8 @@ class TestSmartCreateEndpoint: # For now, we test the model validation request = WikiSmartCreateRequest( topic="Test Topic", - tags=["test"] + tags=["test"], + user="llm_tester" ) assert request.topic == "Test Topic" @@ -546,8 +556,8 @@ class TestSmartCreateEndpoint: WikiSmartCreateRequest(topic="") def test_request_accepts_minimal_input(self): - """Test that only topic is required.""" - request = WikiSmartCreateRequest(topic="Minimal test") + """Test that topic and user are the only required fields.""" + request = WikiSmartCreateRequest(topic="Minimal test", user="llm_tester") assert request.topic == "Minimal test" assert request.include_web_research is True # default assert request.include_wiki_search is True # default diff --git a/tests/test_wiki_change_listener.py b/tests/test_wiki_change_listener.py index 2270dd9..c8c62b0 100644 --- a/tests/test_wiki_change_listener.py +++ b/tests/test_wiki_change_listener.py @@ -231,9 +231,8 @@ class TestWikiChangeListener: 'UPDATE:123:invaliduser' ) - # Should use default user - call_args = mock_process.call_args[1] - assert call_args['user'] == 'jpmschweitzer' + # No default tenant: change must be skipped entirely + mock_process.assert_not_awaited() @pytest.mark.asyncio async def test_process_page_delete_calls_cleanup(self, listener):