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
+19 -8
View File
@@ -13,9 +13,15 @@ from src.shared.base import BaseController
from src.shared.clients import get_homeassistant_client
from src.shared.logging import get_logger
from src.domains.auth.oidc import get_admin_user
from src.domains.auth.callers import Caller, require_caller
logger = get_logger(__name__)
# api.schweitz.net carries no forward-auth, so these reads were otherwise
# open to the internet (T-1 / D-68). Writes are untouched — see controller
# comments at each `get_admin_user` dependency below.
require_housekeeping_reader = require_caller("office", "tatlock", "user")
# Pydantic Schemas
class Device(BaseModel):
@@ -188,7 +194,7 @@ class HousekeepingController(BaseController):
response_model=HealthResponse,
summary="Home automation health check"
)
async def get_health():
async def get_health(caller: Caller = Depends(require_housekeeping_reader)):
"""Check Home Assistant connection health"""
ha = get_homeassistant_client()
return await ha.health_check()
@@ -200,7 +206,8 @@ class HousekeepingController(BaseController):
)
async def list_devices(
domain: Optional[str] = Query(None, description="Filter by domain (light, switch, climate, etc.)"),
area: Optional[str] = Query(None, description="Filter by area/room name")
area: Optional[str] = Query(None, description="Filter by area/room name"),
caller: Caller = Depends(require_housekeeping_reader),
):
"""List all available devices with optional filtering"""
ha = get_homeassistant_client()
@@ -256,7 +263,10 @@ class HousekeepingController(BaseController):
response_model=DeviceDetailResponse,
responses={404: {"model": ErrorResponse}}
)
async def get_device(entity_id: str):
async def get_device(
entity_id: str,
caller: Caller = Depends(require_housekeeping_reader),
):
"""Get detailed state of a specific device"""
ha = get_homeassistant_client()
@@ -296,7 +306,7 @@ class HousekeepingController(BaseController):
response_model=AreaListResponse,
summary="List areas/rooms"
)
async def list_areas():
async def list_areas(caller: Caller = Depends(require_housekeeping_reader)):
"""List all configured areas/rooms in Home Assistant"""
ha = get_homeassistant_client()
@@ -389,7 +399,7 @@ class HousekeepingController(BaseController):
response_model=SceneListResponse,
summary="List available scenes"
)
async def list_scenes():
async def list_scenes(caller: Caller = Depends(require_housekeeping_reader)):
"""List all available scenes in Home Assistant"""
ha = get_homeassistant_client()
@@ -459,7 +469,7 @@ class HousekeepingController(BaseController):
response_model=ScriptListResponse,
summary="List available scripts"
)
async def list_scripts():
async def list_scripts(caller: Caller = Depends(require_housekeeping_reader)):
"""List all available scripts/sequences in Home Assistant"""
ha = get_homeassistant_client()
@@ -531,7 +541,7 @@ class HousekeepingController(BaseController):
response_model=AutomationListResponse,
summary="List automations"
)
async def list_automations():
async def list_automations(caller: Caller = Depends(require_housekeeping_reader)):
"""List all automations with their enabled/disabled status"""
ha = get_homeassistant_client()
@@ -609,7 +619,8 @@ class HousekeepingController(BaseController):
)
async def get_history(
entity_id: str = Query(..., description="Entity ID to get history for"),
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)")
hours: int = Query(24, ge=1, le=168, description="Hours of history (1-168)"),
caller: Caller = Depends(require_housekeeping_reader),
):
"""Get state history for a device"""
ha = get_homeassistant_client()