Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f90cdb4f | ||
|
|
f5983c379f | ||
|
|
90515e0f6d | ||
|
|
83d9ade910 |
+5
-5
@@ -1,13 +1,13 @@
|
|||||||
# Service URLs for local dev (pointing to your server)
|
# Service URLs for local dev (pointing to your server)
|
||||||
TEST_HOST=192.168.86.149
|
TEST_HOST=192.168.86.149
|
||||||
WIKIJS_URL=http://192.168.86.149:8088
|
WIKIJS_URL=http://wiki:3000
|
||||||
NEO4J_URI=bolt://192.168.86.149:7687
|
NEO4J_URI=bolt://neo4j:7687
|
||||||
QDRANT_HOST=192.168.86.149
|
QDRANT_HOST=192.168.86.149
|
||||||
QDRANT_PORT=6333
|
QDRANT_PORT=6333
|
||||||
OLLAMA_URL=http://192.168.86.149:11434
|
OLLAMA_URL=http://ollama:11434
|
||||||
SEARXNG_URL=http://192.168.86.149:8080
|
SEARXNG_URL=http://searxng:8080
|
||||||
REDIS_HOST=192.168.86.149
|
REDIS_HOST=192.168.86.149
|
||||||
PAPERLESS_URL=http://192.168.86.149:8091
|
PAPERLESS_URL=http://paperless:8000
|
||||||
|
|
||||||
OLLAMA_LLM_MODEL=gemma4:e2b
|
OLLAMA_LLM_MODEL=gemma4:e2b
|
||||||
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
- name: Login to Gitea Registry
|
- name: Login to Gitea Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.schweitz.internal
|
registry: git.schweitz.net
|
||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
@@ -36,8 +36,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.schweitz.internal/jpmschweitzer/library-desk:latest
|
git.schweitz.net/jpmschweitzer/library-desk:latest
|
||||||
git.schweitz.internal/jpmschweitzer/library-desk:${{ github.ref_name }}
|
git.schweitz.net/jpmschweitzer/library-desk:${{ github.ref_name }}
|
||||||
|
|
||||||
- name: Trigger Watchtower update
|
- name: Trigger Watchtower update
|
||||||
if: success()
|
if: success()
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ When changes are ready for deployment:
|
|||||||
5. **CI/CD triggers automatically**:
|
5. **CI/CD triggers automatically**:
|
||||||
- Gitea CI builds Docker image on new tag
|
- Gitea CI builds Docker image on new tag
|
||||||
- Watchtower pulls and deploys to production
|
- Watchtower pulls and deploys to production
|
||||||
- Verify deployment: `curl http://192.168.86.149:8000/health`
|
- Verify deployment: `curl http://192.168.86.149:8089/health`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-07-20
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- The Wiki.js integration buttons no longer embed an API key in the browser. The re-index and entity-link endpoints now authenticate via the NPM `/library-desk/` proxy (Authentik session for external users, LAN bypass for internal), verified by a trusted proxy marker header. The previously-embedded key was a full-privilege key served to every wiki visitor; it has been rotated out of service.
|
||||||
|
- `verify_api_key` now uses a constant-time comparison.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `static/wikijs-integration.js` calls library-desk same-origin (`/library-desk/...`) with `credentials: same-origin` and no `Authorization` header. Update the Wiki.js code-injection snippet to `<script src="/library-desk/static/wikijs-integration.js">`.
|
||||||
|
- Machine callers (the Scheduler) continue to use the Bearer API key against the container-network endpoints; only the two browser endpoints switched to proxy auth.
|
||||||
|
|
||||||
|
## [1.8.1] - 2026-07-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Container images are now pushed via the `git.schweitz.net` registry endpoint (the `.internal` registry domain is being retired); no change to the image name consumed by Watchtower.
|
||||||
|
- `.env.example` service URL defaults now use docker-dataplane container names (`wiki:3000`, `neo4j:7687`, `searxng:8080`, `paperless:8000`, `ollama:11434`) instead of host IP + published port, ahead of the Phase 4 port lockdown; AGENTS.md deploy health-check URL corrected from port 8000 to 8089.
|
||||||
|
|
||||||
## [1.8.0] - 2026-07-14
|
## [1.8.0] - 2026-07-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "library-desk"
|
name = "library-desk"
|
||||||
version = "1.8.0"
|
version = "1.9.0"
|
||||||
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
|
description = "Coordination service for The Library system - HybridRAG queries, document ingestion, entity extraction, and knowledge consolidation"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@@ -765,7 +765,8 @@ def get_volatile_cache_service() -> "VolatileCacheService":
|
|||||||
|
|
||||||
|
|
||||||
# Authentication
|
# Authentication
|
||||||
from fastapi import Security, HTTPException
|
import secrets
|
||||||
|
from fastapi import Security, HTTPException, Request
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
@@ -788,7 +789,7 @@ async def verify_api_key(
|
|||||||
Raises:
|
Raises:
|
||||||
HTTPException: If API key is invalid
|
HTTPException: If API key is invalid
|
||||||
"""
|
"""
|
||||||
if credentials.credentials != settings.library_api_key:
|
if not secrets.compare_digest(credentials.credentials, settings.library_api_key):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
detail="Invalid API key"
|
detail="Invalid API key"
|
||||||
@@ -796,6 +797,42 @@ async def verify_api_key(
|
|||||||
return credentials.credentials
|
return credentials.credentials
|
||||||
|
|
||||||
|
|
||||||
|
# Header set by NPM only on the authenticated /library-desk/ proxy location.
|
||||||
|
# library-desk is bound to loopback (127.0.0.1:8089), so NPM is the only path
|
||||||
|
# that can reach it and set this header — a client cannot forge it. NPM also
|
||||||
|
# overwrites any client-supplied value via proxy_set_header.
|
||||||
|
_PROXY_MARKER_HEADER = "x-library-desk-proxy"
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_browser_request(
|
||||||
|
request: Request,
|
||||||
|
settings: SettingsDep,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Auth for browser-facing endpoints (the Wiki.js integration buttons).
|
||||||
|
|
||||||
|
Accepts the request when it arrives through the authenticated NPM proxy
|
||||||
|
location (Authentik session for external users, or the LAN bypass for
|
||||||
|
internal ones) — identified by the trusted proxy marker header. No secret
|
||||||
|
is carried in the browser. Machine callers may still authenticate with the
|
||||||
|
Bearer API key. Returns the acting user's identity.
|
||||||
|
"""
|
||||||
|
if request.headers.get(_PROXY_MARKER_HEADER) == "1":
|
||||||
|
# Authentik injects the identity for externally-authenticated users;
|
||||||
|
# on the LAN bypass these are empty and the endpoint falls back to the
|
||||||
|
# user supplied in the request body.
|
||||||
|
return request.headers.get("x-authentik-email") or "lan"
|
||||||
|
|
||||||
|
# Fallback: server-to-server Bearer API key.
|
||||||
|
auth = request.headers.get("authorization", "")
|
||||||
|
if auth.startswith("Bearer ") and secrets.compare_digest(
|
||||||
|
auth[len("Bearer "):], settings.library_api_key
|
||||||
|
):
|
||||||
|
return auth[len("Bearer "):]
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthenticated")
|
||||||
|
|
||||||
|
|
||||||
# Service type aliases for FastAPI endpoint dependencies
|
# Service type aliases for FastAPI endpoint dependencies
|
||||||
# These are defined after the factory functions
|
# These are defined after the factory functions
|
||||||
from src.services.vector_service import VectorService
|
from src.services.vector_service import VectorService
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from src.core.dependencies import (
|
|||||||
get_wiki_service,
|
get_wiki_service,
|
||||||
get_graph_service,
|
get_graph_service,
|
||||||
get_ingestion_service,
|
get_ingestion_service,
|
||||||
verify_api_key
|
verify_browser_request,
|
||||||
)
|
)
|
||||||
from src.services.wiki_service import WikiService
|
from src.services.wiki_service import WikiService
|
||||||
from src.services.graph_service import GraphService
|
from src.services.graph_service import GraphService
|
||||||
@@ -57,7 +57,7 @@ async def link_entities_in_page(
|
|||||||
wiki_service: WikiService = Depends(get_wiki_service),
|
wiki_service: WikiService = Depends(get_wiki_service),
|
||||||
graph_service: GraphService = Depends(get_graph_service),
|
graph_service: GraphService = Depends(get_graph_service),
|
||||||
ingestion_service: IngestionService = Depends(get_ingestion_service),
|
ingestion_service: IngestionService = Depends(get_ingestion_service),
|
||||||
api_key: str = Depends(verify_api_key)
|
actor: str = Depends(verify_browser_request)
|
||||||
) -> EntityLinkingResult:
|
) -> EntityLinkingResult:
|
||||||
"""
|
"""
|
||||||
Find and link entities mentioned in a wiki page.
|
Find and link entities mentioned in a wiki page.
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ from src.models.ingestion import (
|
|||||||
BatchIngestionResult
|
BatchIngestionResult
|
||||||
)
|
)
|
||||||
from src.core.dependencies import (
|
from src.core.dependencies import (
|
||||||
get_ingestion_service, verify_api_key, RequiredUserQuery, JobManagerDep
|
get_ingestion_service, verify_api_key, verify_browser_request,
|
||||||
|
RequiredUserQuery, JobManagerDep
|
||||||
)
|
)
|
||||||
from src.jobs.job_manager import JobManager, JobStatus, JobType
|
from src.jobs.job_manager import JobManager, JobStatus, JobType
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ async def ingest_page(
|
|||||||
request: IngestionRequest,
|
request: IngestionRequest,
|
||||||
ingestion: IngestionService = Depends(get_ingestion_service),
|
ingestion: IngestionService = Depends(get_ingestion_service),
|
||||||
job_manager: JobManagerDep = None,
|
job_manager: JobManagerDep = None,
|
||||||
api_key: str = Depends(verify_api_key)
|
actor: str = Depends(verify_browser_request)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Ingest a single wiki page into the knowledge base.
|
Ingest a single wiki page into the knowledge base.
|
||||||
|
|||||||
@@ -2,21 +2,28 @@
|
|||||||
* Library Desk Integration for Wiki.js
|
* Library Desk Integration for Wiki.js
|
||||||
* Combined re-index and entity linking buttons
|
* Combined re-index and entity linking buttons
|
||||||
*
|
*
|
||||||
* Usage: Add to Wiki.js Code Injection:
|
* Usage: Add to Wiki.js Code Injection (served same-origin behind Authentik):
|
||||||
* <script src="http://192.168.86.149:8089/static/wikijs-integration.js"></script>
|
* <script src="/library-desk/static/wikijs-integration.js"></script>
|
||||||
|
*
|
||||||
|
* Auth: none in the browser. Requests go same-origin through the NPM
|
||||||
|
* /library-desk/ location, which is gated by Authentik forward-auth with the
|
||||||
|
* LAN bypass — external users are authenticated, LAN users pass through, and
|
||||||
|
* library-desk trusts the proxy marker header. No API key is embedded here.
|
||||||
*/
|
*/
|
||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Auto-detect Library Desk URL
|
// Same-origin base: the script is served from <origin>/library-desk/static/...,
|
||||||
|
// so strip '/static/...' to get the library-desk mount point on this origin.
|
||||||
const scriptTag = document.currentScript;
|
const scriptTag = document.currentScript;
|
||||||
const scriptUrl = scriptTag ? scriptTag.src : '';
|
const scriptUrl = scriptTag ? scriptTag.src : '';
|
||||||
const libraryDeskUrl = scriptUrl ? scriptUrl.split('/static/')[0] : 'http://192.168.86.149:8089';
|
const libraryDeskUrl = scriptUrl
|
||||||
|
? scriptUrl.replace(/^https?:\/\/[^/]+/, '').split('/static/')[0]
|
||||||
|
: '/library-desk';
|
||||||
|
|
||||||
// Shared configuration
|
// Shared configuration
|
||||||
const CONFIG = window.LIBRARY_DESK_CONFIG || {
|
const CONFIG = window.LIBRARY_DESK_CONFIG || {
|
||||||
libraryDeskUrl: libraryDeskUrl,
|
libraryDeskUrl: libraryDeskUrl,
|
||||||
apiKey: 'af88ed8f44bed81bdb20d0534f1c4547340b29e2aba4963f61a71b993d7eb6e5',
|
|
||||||
user: 'jpmschweitzer',
|
user: 'jpmschweitzer',
|
||||||
buttonPosition: 'toolbar', // 'toolbar' or 'floating'
|
buttonPosition: 'toolbar', // 'toolbar' or 'floating'
|
||||||
debug: true
|
debug: true
|
||||||
@@ -229,8 +236,8 @@
|
|||||||
// Re-index directly
|
// Re-index directly
|
||||||
const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', {
|
const response = await fetch(CONFIG.libraryDeskUrl + '/ingest/page', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + CONFIG.apiKey,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -334,8 +341,8 @@
|
|||||||
// Call entity linking endpoint
|
// Call entity linking endpoint
|
||||||
const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', {
|
const response = await fetch(CONFIG.libraryDeskUrl + '/entity-linking/link-page', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + CONFIG.apiKey,
|
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
Tests for verify_browser_request — the session/proxy auth used by the
|
||||||
|
Wiki.js integration endpoints (no secret in the browser).
|
||||||
|
"""
|
||||||
|
import pytest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from src.core.dependencies import verify_browser_request
|
||||||
|
|
||||||
|
|
||||||
|
def _request(headers: dict) -> Request:
|
||||||
|
raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()]
|
||||||
|
return Request({"type": "http", "method": "POST", "path": "/ingest/page", "headers": raw})
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS = SimpleNamespace(library_api_key="server-secret-key")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_marker_with_authentik_identity_is_accepted():
|
||||||
|
req = _request({"X-Library-Desk-Proxy": "1", "X-Authentik-Email": "user@example.com"})
|
||||||
|
assert await verify_browser_request(req, SETTINGS) == "user@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_proxy_marker_on_lan_bypass_falls_back_to_lan():
|
||||||
|
# LAN bypass: proxy marker present, no Authentik identity headers.
|
||||||
|
req = _request({"X-Library-Desk-Proxy": "1"})
|
||||||
|
assert await verify_browser_request(req, SETTINGS) == "lan"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_valid_api_key_is_accepted_for_machine_callers():
|
||||||
|
req = _request({"Authorization": "Bearer server-secret-key"})
|
||||||
|
assert await verify_browser_request(req, SETTINGS) == "server-secret-key"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_marker_and_no_key_is_rejected():
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
await verify_browser_request(_request({}), SETTINGS)
|
||||||
|
assert exc.value.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_forged_marker_value_is_rejected():
|
||||||
|
# Only the exact NPM-set value "1" is trusted.
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await verify_browser_request(_request({"X-Library-Desk-Proxy": "yes"}), SETTINGS)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wrong_api_key_is_rejected():
|
||||||
|
with pytest.raises(HTTPException):
|
||||||
|
await verify_browser_request(_request({"Authorization": "Bearer wrong"}), SETTINGS)
|
||||||
@@ -12,19 +12,21 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from src.main import app
|
from src.main import app
|
||||||
from src.core.dependencies import verify_api_key
|
from src.core.dependencies import verify_api_key, verify_browser_request
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def client():
|
def client():
|
||||||
"""TestClient with API-key auth stubbed out (no lifespan startup)."""
|
"""TestClient with auth stubbed out (no lifespan startup)."""
|
||||||
app.dependency_overrides[verify_api_key] = lambda: "test-key"
|
app.dependency_overrides[verify_api_key] = lambda: "test-key"
|
||||||
|
app.dependency_overrides[verify_browser_request] = lambda: "test-user"
|
||||||
try:
|
try:
|
||||||
# No context manager: startup/lifespan events are NOT triggered,
|
# No context manager: startup/lifespan events are NOT triggered,
|
||||||
# so no connections to external services are attempted.
|
# so no connections to external services are attempted.
|
||||||
yield TestClient(app)
|
yield TestClient(app)
|
||||||
finally:
|
finally:
|
||||||
app.dependency_overrides.pop(verify_api_key, None)
|
app.dependency_overrides.pop(verify_api_key, None)
|
||||||
|
app.dependency_overrides.pop(verify_browser_request, None)
|
||||||
|
|
||||||
|
|
||||||
QUERY_PARAM_ENDPOINTS = [
|
QUERY_PARAM_ENDPOINTS = [
|
||||||
|
|||||||
Reference in New Issue
Block a user