diff --git a/CHANGELOG.md b/CHANGELOG.md index bdc4134..a68e93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Weekly quality report** — `POST /maintenance/quality-report {user}` runs the duplicate scan, flags stale pages (not updated in N days AND ≤ M SearchQuery hits from the graph data), lists pages missing tags/description, folds in the latest integrity-check results (Redis-cached or run inline), and writes a dated report page to `users/{user}/system/quality-reports/YYYY-MM-DD` (same-day reruns update the same page — the page id is remembered in Redis because the Wiki.js listing lags page creation). Response returns the full report content + page path. Verified end-to-end against the local dev server as `llm_tester`. - **Nightly integrity check** — `POST /maintenance/integrity-check {user}` (read-only: reports, never auto-fixes) reports per tenant: wiki pages with ZERO vectors in Qdrant (silent-skip reindex victims), orphaned vectors whose wiki page no longer exists, unexpected Qdrant collections (test-tenant residue and unknown namespaces flagged; other services' collections counted as foreign), Neo4j Document nodes without wiki counterparts, plus counts and duration. The latest report is cached in Redis (30 days) so the weekly quality report can fold it in. +### Fixed (Phase D review batch) + +- **Registrar authenticates to the Scheduler API** - `scripts/register_scheduler_tasks.py --execute` sent no `Authorization` header while the Scheduler's task-management endpoints are Bearer-guarded (`verify_api_key`: 401 on missing key), so the existence probes 401'd (misread as "task absent") and every registration failed; only the public `/health` gate passed. `--execute` now requires `SCHEDULER_API_KEY` in the environment (refuses to run without it, key is never stored) and sends `Authorization: Bearer $SCHEDULER_API_KEY` on all of its own HTTP calls. Deploy note updated alongside the existing `LIBRARY_API_KEY` requirement. + ### Fixed (hazards batch) - **CORS wildcard + credentials removed** - `allow_origins=["*"]` combined with `allow_credentials=True` told browsers to attach credentials for any site. Credentials are now disabled (all real callers are server-to-server and use the `Authorization` header, which wildcard-origin CORS without credentials still permits) and the origin list is configurable via `CORS_ALLOW_ORIGINS` (comma-separated, default `*`). diff --git a/docs/scheduler-tasks.md b/docs/scheduler-tasks.md index 6edaaba..25ffd7a 100644 --- a/docs/scheduler-tasks.md +++ b/docs/scheduler-tasks.md @@ -12,11 +12,19 @@ SCHEDULER_URL=http://:8090 \ # Actually register/update the tasks (deploy checklist step): SCHEDULER_URL=http://:8090 \ +SCHEDULER_API_KEY= \ .venv/bin/python scripts/register_scheduler_tasks.py --execute ``` Conventions: +- The Scheduler's task-management endpoints (`GET`/`POST /tasks`, + `PUT /tasks/{name}`) require `Authorization: Bearer $SCHEDULER_API_KEY`. + The registrar reads `SCHEDULER_API_KEY` from the environment for its + own HTTP calls (`--execute` refuses to run without it); the key is + never stored. This is separate from `LIBRARY_API_KEY` below, which the + Scheduler container needs at task **execution** time. + - All tasks call the **production** library-desk container (`http://library-desk:8089`) with the explicit production tenant `user=jpmschweitzer` (there is no default tenant — Phase B). diff --git a/scripts/register_scheduler_tasks.py b/scripts/register_scheduler_tasks.py index a9f47c8..abdecf7 100644 --- a/scripts/register_scheduler_tasks.py +++ b/scripts/register_scheduler_tasks.py @@ -17,6 +17,11 @@ SAFETY MODEL if present, and disable test_example_task. - The Scheduler API location comes from the environment (SCHEDULER_URL); there is no hardcoded production default. +- The Scheduler's task-management endpoints are themselves guarded by + Bearer auth (verify_api_key). --execute therefore requires + SCHEDULER_API_KEY in the environment; the registrar sends it as + ``Authorization: Bearer `` on its own HTTP calls. It is read from + the environment only and never stored anywhere. - NO SECRET IS EVER STORED: the library-desk API key is referenced as the literal placeholder ``${LIBRARY_API_KEY}`` inside the task's ``auth.token`` field. The Scheduler's rest_api_executor substitutes @@ -32,6 +37,7 @@ Usage: # Register for real (deploy checklist step) SCHEDULER_URL=http://scheduler-host:8090 \ + SCHEDULER_API_KEY= \ python scripts/register_scheduler_tasks.py --execute """ @@ -156,9 +162,16 @@ def dry_run(scheduler_url: str) -> None: f"{len(TASKS)} task definition(s), {len(TASK_UPDATES)} update(s).") -def execute(scheduler_url: str) -> int: +def execute(scheduler_url: str, scheduler_api_key: str) -> int: failures = 0 - with httpx.Client(base_url=scheduler_url, timeout=30.0) as client: + # The Scheduler's task-management endpoints require Bearer auth + # (verify_api_key: 401 when missing, 403 when wrong). Without this + # header the existence probes 401 (misread as "task absent") and + # every POST/PUT fails. + auth_headers = {"Authorization": f"Bearer {scheduler_api_key}"} + with httpx.Client( + base_url=scheduler_url, timeout=30.0, headers=auth_headers + ) as client: health = client.get("/health") if health.status_code != 200: print(f"ERROR: Scheduler health check failed: {health.status_code}") @@ -224,7 +237,13 @@ def main() -> int: print("ERROR: SCHEDULER_URL must be set for --execute") return 1 - return execute(scheduler_url) + scheduler_api_key = os.environ.get("SCHEDULER_API_KEY", "") + if not scheduler_api_key: + print("ERROR: SCHEDULER_API_KEY must be set for --execute " + "(the Scheduler's task endpoints require Bearer auth)") + return 1 + + return execute(scheduler_url, scheduler_api_key) if __name__ == "__main__": diff --git a/tests/test_job_plumbing.py b/tests/test_job_plumbing.py index b16bbc6..b9a044a 100644 --- a/tests/test_job_plumbing.py +++ b/tests/test_job_plumbing.py @@ -128,3 +128,54 @@ class TestSchedulerTaskDefinitions: assert "DRY RUN" in out assert "library_integrity_check" in out assert mod.API_KEY_PLACEHOLDER in out # placeholder, never a real key + + def test_execute_requires_scheduler_api_key(self, capsys, monkeypatch): + """--execute must refuse to run without SCHEDULER_API_KEY (the + Scheduler's task endpoints are Bearer-guarded; without the key + every probe 401s and registration silently fails).""" + mod = self._load_module() + monkeypatch.setattr( + "sys.argv", ["register_scheduler_tasks.py", "--execute"] + ) + monkeypatch.setenv("SCHEDULER_URL", "http://scheduler.test:8090") + monkeypatch.delenv("SCHEDULER_API_KEY", raising=False) + + def _boom(*args, **kwargs): + raise AssertionError("must not contact the Scheduler without a key") + + monkeypatch.setattr(mod.httpx, "Client", _boom) + + assert mod.main() == 1 + assert "SCHEDULER_API_KEY" in capsys.readouterr().out + + def test_execute_sends_scheduler_bearer_auth(self, monkeypatch): + """The registrar's own HTTP client must carry + Authorization: Bearer $SCHEDULER_API_KEY on every call.""" + import httpx as real_httpx + + mod = self._load_module() + seen = {"auth_headers": [], "paths": []} + + def handler(request: real_httpx.Request) -> real_httpx.Response: + seen["auth_headers"].append(request.headers.get("Authorization")) + path = request.url.path + seen["paths"].append(f"{request.method} {path}") + if path == "/health": + return real_httpx.Response(200, json={"status": "healthy"}) + if request.method == "GET" and path.startswith("/tasks/"): + return real_httpx.Response(404) # not registered yet + return real_httpx.Response(200, json={"ok": True}) + + real_client = real_httpx.Client + + def client_factory(**kwargs): + kwargs["transport"] = real_httpx.MockTransport(handler) + return real_client(**kwargs) + + monkeypatch.setattr(mod.httpx, "Client", client_factory) + + assert mod.execute("http://scheduler.test:8090", "sched-key-123") == 0 + assert seen["auth_headers"], "no HTTP calls were made" + assert all(h == "Bearer sched-key-123" for h in seen["auth_headers"]) + # All three tasks created (404 probe -> POST /tasks) + assert seen["paths"].count("POST /tasks") == len(mod.TASKS)