fix(housekeeping): require a caller on reads; writes unchanged (T-1)

api.schweitz.net is internet-facing with no forward-auth, and every
GET under /housekeeping/* (health, devices, areas, scenes, scripts,
automations, history) was unauthenticated. It answers 500 today only
because Home Assistant's token is dead; once that token is restored it
would serve the house's device list to anyone.

Reads now go through require_caller("office", "tatlock", "user").
tatlock is in the list because tatlock's Housekeeper
(tatlock/src/agents/housekeeper/client.py) is the only caller of these
routes today and already sends Bearer CORE_API_KEY when it is set.

Writes are untouched — control_device, activate_scene, run_script and
toggle_automation still depend on get_admin_user. Whether tatlock may
write is a separate decision, not this one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 11:14:35 +02:00
co-authored by Claude Sonnet 5
parent 04ec54b635
commit 3f77cc2926
2 changed files with 65 additions and 8 deletions
+46
View File
@@ -3,6 +3,7 @@ import pytest
from fastapi.testclient import TestClient
from unittest.mock import patch, AsyncMock
from src.domains.auth.oidc import oidc_config
from src.main import app
@@ -12,6 +13,51 @@ def client():
return TestClient(app)
class TestHousekeepingReadsAreLocked:
"""
T-1 / D-68: api.schweitz.net carries no forward-auth, so an
unauthenticated read used to reach Home Assistant directly. These
assert the caller gate in front of the GET routes, independent of
whatever Home Assistant itself returns.
"""
@pytest.fixture(autouse=True)
def oidc_enabled(self):
original = (oidc_config.enabled, oidc_config.issuers, oidc_config.audiences)
oidc_config.configure(enabled=True, issuers=[], audiences=[])
yield
oidc_config.enabled, oidc_config.issuers, oidc_config.audiences = original
def test_no_auth_is_401_on_devices(self, client):
response = client.get("/housekeeping/devices")
assert response.status_code == 401
def test_no_auth_is_401_on_health(self, client):
response = client.get("/housekeeping/health")
assert response.status_code == 401
@patch("src.domains.housekeeping.controller.get_homeassistant_client")
def test_tatlock_key_passes_auth_on_devices(self, mock_get_ha, client):
"""The one caller this lock exists to keep working (dossier §2)."""
from src.shared.config import get_settings
settings = get_settings()
original = settings.tatlock_service_key
settings.tatlock_service_key = "test-tatlock-key"
try:
mock_client = AsyncMock()
mock_client.get_states.return_value = []
mock_get_ha.return_value = mock_client
response = client.get(
"/housekeeping/devices",
headers={"Authorization": "Bearer test-tatlock-key"},
)
assert response.status_code == 200
finally:
settings.tatlock_service_key = original
@pytest.fixture
def mock_ha_client():
"""Create a mock Home Assistant client."""