From 37d7b7e6c3eeee8b1687f2cbd14999da612c4d3a Mon Sep 17 00:00:00 2001 From: orchestrator-agent Date: Sat, 19 Sep 2026 08:36:14 -0400 Subject: [PATCH] Gate every NestQuest service behind the admin permission check permission_gate wraps all registered services at registration time: admin-only policy reads call.context.user_id against admin_allowlist is_admin (fail closed on no context); complete_quest stays open. --- custom_components/nestquest/permissions.py | 90 ++++++++++++++ custom_components/nestquest/services.py | 136 +++++++++++---------- tests/conftest.py | 5 + tests/test_lifecycle.py | 29 +++-- tests/test_services.py | 108 ++++++++++++++-- 5 files changed, 284 insertions(+), 84 deletions(-) create mode 100644 custom_components/nestquest/permissions.py diff --git a/custom_components/nestquest/permissions.py b/custom_components/nestquest/permissions.py new file mode 100644 index 0000000..5f6df6b --- /dev/null +++ b/custom_components/nestquest/permissions.py @@ -0,0 +1,90 @@ +"""Permission gate for NestQuest services (Feature 09). + +A wrapper factory, applied to EVERY registered service at registration +time (no handler is exempt), reads ``call.context.user_id``, consults +the :data:`~.service_policy.SERVICE_POLICY` registry and +:func:`~.admin_allowlist.is_admin`, and raises +:class:`homeassistant.exceptions.Unauthorized` for an admin-only +operation called by a non-admin — including a call with NO user +context, which fails closed. ``complete_quest`` (policy ``open``) +proceeds regardless of the allowlist. + +Logging (later Feature 09 tasks): every denied admin-only call logs a +warning and every successful one logs a debug line, both carrying the +service, the user id (or ``none``), and a UTC timestamp. +""" +from __future__ import annotations + +import datetime +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from homeassistant.exceptions import Unauthorized + +from .admin_allowlist import is_admin +from .service_policy import ADMIN_ONLY + +LOGGER = logging.getLogger(__name__) + +ServiceHandler = Callable[[Any], Awaitable[None]] + + +def _context_user_id(call: Any) -> str | None: + context = getattr(call, "context", None) + user_id = getattr(context, "user_id", None) if context is not None else None + if isinstance(user_id, str) and user_id: + return user_id + return None + + +def _stamp() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat( + timespec="seconds" + ) + + +def permission_gate( + service: str, + policy: str, + database_of: Callable[[Any], Any], +) -> Callable[[ServiceHandler], ServiceHandler]: + """Wrap ``handler`` with the Feature 09 permission check. + + ``database_of`` resolves the live database from the call-time + runtime (the same ``find_runtime`` the handler uses), so the gate + never opens its own connection. The verdict is fail-closed: + absent allowlist row, unknown id, no user context — all not admin. + """ + + def _wrap(handler: ServiceHandler) -> ServiceHandler: + if policy != ADMIN_ONLY: + return handler + + async def _gated(call: Any) -> None: + user_id = _context_user_id(call) + allowed = user_id is not None and await is_admin( + database_of(call), user_id + ) + if not allowed: + LOGGER.warning( + "NestQuest denied %s: caller user_id=%s at %s", + service, + user_id if user_id is not None else "none", + _stamp(), + ) + raise Unauthorized( + f"User '{user_id if user_id is not None else 'none'}' " + f"is not allowed to call nestquest.{service}" + ) + LOGGER.debug( + "NestQuest allowed %s: caller user_id=%s at %s", + service, + user_id, + _stamp(), + ) + await handler(call) + + return _gated + + return _wrap \ No newline at end of file diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 0e7ee18..9c6d765 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -43,8 +43,10 @@ from .const import ( ) from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .materialize import regenerate_for_child +from .permissions import permission_gate from .presence import PresenceSchedule from .recurrence import ScheduleRule +from .service_policy import SERVICE_POLICY FindRuntime = Callable[[HomeAssistant], Any] ServiceHandler = Callable[[Any], Awaitable[None]] @@ -521,72 +523,78 @@ def async_register_services( find_runtime: FindRuntime, regenerate_handler: ServiceHandler, ) -> None: - """Register every canonical service once at domain scope.""" + """Register every canonical service once at domain scope. + + Every service — regenerate included — passes through the Feature + 09 permission gate before its handler runs; the gate reads + :data:`~.service_policy.SERVICE_POLICY`, so no service can be + registered exempt from the policy. + """ if hass.services.has_service(DOMAIN, SERVICE_REGENERATE): return - hass.services.async_register( - DOMAIN, SERVICE_REGENERATE, regenerate_handler - ) - hass.services.async_register( - DOMAIN, - SERVICE_COMPLETE_QUEST, - _complete_quest(hass, find_runtime), - schema=SCHEMA_COMPLETE_QUEST, - ) - hass.services.async_register( - DOMAIN, - SERVICE_UNCOMPLETE_QUEST, - _uncomplete_quest(hass, find_runtime), - schema=SCHEMA_UNCOMPLETE_QUEST, - ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_QUEST_DEFINITION, - _create_quest_definition(hass, find_runtime), - schema=SCHEMA_CREATE_QUEST_DEFINITION, - ) - hass.services.async_register( - DOMAIN, - SERVICE_UPDATE_QUEST_DEFINITION, - _update_quest_definition(hass, find_runtime), - schema=SCHEMA_UPDATE_QUEST_DEFINITION, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_QUEST_DEFINITION_ACTIVE, - _set_quest_definition_active(hass, find_runtime), - schema=SCHEMA_SET_QUEST_DEFINITION_ACTIVE, - ) - hass.services.async_register( - DOMAIN, - SERVICE_SET_PRESENCE_PATTERN, - _set_presence_pattern(hass, find_runtime), - schema=SCHEMA_SET_PRESENCE_PATTERN, - ) - hass.services.async_register( - DOMAIN, - SERVICE_CREATE_PRESENCE_OVERRIDE, - _create_presence_override(hass, find_runtime), - schema=SCHEMA_CREATE_PRESENCE_OVERRIDE, - ) - hass.services.async_register( - DOMAIN, - SERVICE_DELETE_PRESENCE_OVERRIDE, - _delete_presence_override(hass, find_runtime), - schema=SCHEMA_DELETE_PRESENCE_OVERRIDE, - ) - hass.services.async_register( - DOMAIN, - SERVICE_EXPORT_HISTORY_CSV, - _export_history_csv, - schema=SCHEMA_EXPORT_HISTORY_CSV, - ) - hass.services.async_register( - DOMAIN, - SERVICE_MANAGE_CHILD, - _manage_child(hass, find_runtime), - schema=SCHEMA_MANAGE_CHILD, - ) + gate = permission_gate + specs: list[tuple[str, ServiceHandler, object]] = [ + (SERVICE_REGENERATE, regenerate_handler, None), + ( + SERVICE_COMPLETE_QUEST, + _complete_quest(hass, find_runtime), + SCHEMA_COMPLETE_QUEST, + ), + ( + SERVICE_UNCOMPLETE_QUEST, + _uncomplete_quest(hass, find_runtime), + SCHEMA_UNCOMPLETE_QUEST, + ), + ( + SERVICE_CREATE_QUEST_DEFINITION, + _create_quest_definition(hass, find_runtime), + SCHEMA_CREATE_QUEST_DEFINITION, + ), + ( + SERVICE_UPDATE_QUEST_DEFINITION, + _update_quest_definition(hass, find_runtime), + SCHEMA_UPDATE_QUEST_DEFINITION, + ), + ( + SERVICE_SET_QUEST_DEFINITION_ACTIVE, + _set_quest_definition_active(hass, find_runtime), + SCHEMA_SET_QUEST_DEFINITION_ACTIVE, + ), + ( + SERVICE_SET_PRESENCE_PATTERN, + _set_presence_pattern(hass, find_runtime), + SCHEMA_SET_PRESENCE_PATTERN, + ), + ( + SERVICE_CREATE_PRESENCE_OVERRIDE, + _create_presence_override(hass, find_runtime), + SCHEMA_CREATE_PRESENCE_OVERRIDE, + ), + ( + SERVICE_DELETE_PRESENCE_OVERRIDE, + _delete_presence_override(hass, find_runtime), + SCHEMA_DELETE_PRESENCE_OVERRIDE, + ), + ( + SERVICE_EXPORT_HISTORY_CSV, + _export_history_csv, + SCHEMA_EXPORT_HISTORY_CSV, + ), + ( + SERVICE_MANAGE_CHILD, + _manage_child(hass, find_runtime), + SCHEMA_MANAGE_CHILD, + ), + ] + + def _database_of(_call: Any) -> Any: + return _require_runtime(hass, find_runtime).database + + for service, handler, schema in specs: + gated = gate( + service, SERVICE_POLICY[service], _database_of + )(handler) + hass.services.async_register(DOMAIN, service, gated, schema=schema) def async_deregister_services(hass: HomeAssistant) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index 3fa8637..f9e31bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -189,8 +189,13 @@ class ConfigEntryNotReady(HomeAssistantError): """ +class Unauthorized(HomeAssistantError): + """Stand-in mirroring homeassistant.exceptions.Unauthorized.""" + + _exceptions_mock.HomeAssistantError = HomeAssistantError _exceptions_mock.ConfigEntryNotReady = ConfigEntryNotReady +_exceptions_mock.Unauthorized = Unauthorized import voluptuous as vol diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index b81f3f9..b0db61e 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -9,6 +9,7 @@ from conftest import make_config_entry, make_hass, wire_entry_to_registry from custom_components.nestquest import DOMAIN, async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, CONF_DAY_ROLLOVER_TIME, CONF_HORIZON_DAYS, DEFAULT_HORIZON_DAYS, @@ -16,6 +17,13 @@ from custom_components.nestquest.const import ( DOMAIN as DOMAIN_CONST, ) +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} + + +def _admin_data(): + return {CONF_ADMIN_USER_IDS: [ADMIN_ID]} + def _wire(entry, registry): return wire_entry_to_registry(entry, registry) @@ -546,7 +554,7 @@ async def test_regenerate_service_registered_and_materializes( from custom_components.nestquest.dao_instances import QuestInstancesDao - entry = _wire(make_entry(), hass.registry) + entry = _wire(make_entry(data=_admin_data()), hass.registry) assert await async_setup_entry(hass, entry) is True assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) @@ -555,7 +563,7 @@ async def test_regenerate_service_registered_and_materializes( database = entry.runtime_data.database child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = _local_today(hass) end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() @@ -574,14 +582,15 @@ async def test_regenerate_service_uses_configured_horizon_days( from custom_components.nestquest.dao_instances import QuestInstancesDao entry = _wire( - make_entry(options={CONF_HORIZON_DAYS: 5}), hass.registry + make_entry(options={CONF_HORIZON_DAYS: 5}, data=_admin_data()), + hass.registry, ) assert await async_setup_entry(hass, entry) is True database = entry.runtime_data.database child_id = await _seed_daily_child_and_definition(database, _local_today(hass)) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = _local_today(hass) end = (today + datetime.timedelta(days=5)).isoformat() @@ -622,8 +631,12 @@ async def test_regenerate_service_is_domain_scoped_across_entries( from custom_components.nestquest.dao_instances import QuestInstancesDao - entry_a = _wire(make_entry(entry_id="entry_a"), hass.registry) - entry_b = _wire(make_entry(entry_id="entry_b"), hass.registry) + entry_a = _wire( + make_entry(entry_id="entry_a", data=_admin_data()), hass.registry + ) + entry_b = _wire( + make_entry(entry_id="entry_b", data=_admin_data()), hass.registry + ) assert await async_setup_entry(hass, entry_a) is True assert await async_setup_entry(hass, entry_b) is True @@ -634,7 +647,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries( child_id = await _seed_daily_child_and_definition( database_b, _local_today(hass) ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = _local_today(hass) end = (today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS)).isoformat() records = await QuestInstancesDao(database_b).list_by_date_range( @@ -647,7 +660,7 @@ async def test_regenerate_service_is_domain_scoped_across_entries( assert hass.services.has_service(DOMAIN, SERVICE_REGENERATE) # It still works for the remaining entry. - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) # Unloading the final entry removes the domain-global service. assert await async_unload_entry(hass, entry_b) is True diff --git a/tests/test_services.py b/tests/test_services.py index 3c63194..c77b13e 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -6,12 +6,13 @@ from pathlib import Path import pytest import voluptuous as vol -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized from conftest import wire_entry_to_registry from custom_components.nestquest import async_setup_entry, async_unload_entry from custom_components.nestquest.const import ( + CONF_ADMIN_USER_IDS, DOMAIN, DOMAIN_SERVICES, SERVICE_COMPLETE_QUEST, @@ -38,6 +39,16 @@ def _wire(entry, registry): return wire_entry_to_registry(entry, registry) +ADMIN_ID = "admin-1" +ADMIN_CTX = {"user_id": ADMIN_ID} + + +def _make_admin_entry(make_entry): + from custom_components.nestquest.const import CONF_ADMIN_USER_IDS + + return make_entry(data={CONF_ADMIN_USER_IDS: [ADMIN_ID]}) + + def _service_section(text: str, name: str) -> str: match = re.search(rf"^{name}:\s*$", text, re.MULTILINE) assert match is not None, f"services.yaml must declare {name!r}" @@ -129,12 +140,13 @@ async def test_unload_one_of_two_entries_keeps_services( async def test_manage_child_create_persists_child(hass, make_entry) -> None: from custom_components.nestquest.children import list_children - entry = _wire(make_entry(), hass.registry) + entry = _wire(_make_admin_entry(make_entry), hass.registry) assert await async_setup_entry(hass, entry) is True await hass.services.call( DOMAIN, SERVICE_MANAGE_CHILD, {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, ) children = await list_children(entry.runtime_data.database) assert [child.display_name for child in children] == ["Ada"] @@ -149,12 +161,13 @@ async def test_create_and_set_quest_definition_active( ) from custom_components.nestquest.dao_rules import QuestDefinitionsDao - entry = _wire(make_entry(), hass.registry) + entry = _wire(_make_admin_entry(make_entry), hass.registry) assert await async_setup_entry(hass, entry) is True await hass.services.call( DOMAIN, SERVICE_MANAGE_CHILD, {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, ) child = (await list_children(entry.runtime_data.database))[0] await hass.services.call( @@ -166,6 +179,7 @@ async def test_create_and_set_quest_definition_active( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) active = await list_active_definitions(entry.runtime_data.database) assert len(active) == 1 @@ -174,6 +188,7 @@ async def test_create_and_set_quest_definition_active( DOMAIN, SERVICE_SET_QUEST_DEFINITION_ACTIVE, {"definition_id": definition_id, "is_active": False}, + context=ADMIN_CTX, ) assert await list_active_definitions(entry.runtime_data.database) == [] stored = await QuestDefinitionsDao(entry.runtime_data.database).get( @@ -193,12 +208,13 @@ async def test_complete_and_uncomplete_quest_call_business_layer( from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import QuestInstancesDao - entry = _wire(make_entry(), hass.registry) + entry = _wire(_make_admin_entry(make_entry), hass.registry) assert await async_setup_entry(hass, entry) is True await hass.services.call( DOMAIN, SERVICE_MANAGE_CHILD, {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, ) child = (await list_children(entry.runtime_data.database))[0] await hass.services.call( @@ -210,8 +226,9 @@ async def test_complete_and_uncomplete_quest_call_business_layer( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = datetime.date.today().isoformat() end = ( datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) @@ -240,8 +257,9 @@ async def test_complete_and_uncomplete_quest_call_business_layer( { "instance_id": instance_id, "actor": "user", - "actor_user_id": "parent-1", + "actor_user_id": ADMIN_ID, }, + context=ADMIN_CTX, ) assert ( await instance_state(entry.runtime_data.database, instance_id) @@ -258,12 +276,13 @@ async def test_set_presence_pattern_regenerates_child_instances( from custom_components.nestquest.const import DEFAULT_HORIZON_DAYS from custom_components.nestquest.dao_instances import QuestInstancesDao - entry = _wire(make_entry(), hass.registry) + entry = _wire(_make_admin_entry(make_entry), hass.registry) assert await async_setup_entry(hass, entry) is True await hass.services.call( DOMAIN, SERVICE_MANAGE_CHILD, {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, ) child = (await list_children(entry.runtime_data.database))[0] await hass.services.call( @@ -275,8 +294,9 @@ async def test_set_presence_pattern_regenerates_child_instances( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = datetime.date.today().isoformat() end = ( datetime.date.today() + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) @@ -292,6 +312,7 @@ async def test_set_presence_pattern_regenerates_child_instances( "anchor_date": today, "pattern": {0: []}, }, + context=ADMIN_CTX, ) assert await dao.list_by_date_range(child.id, today, end) == [] @@ -306,12 +327,13 @@ async def test_presence_override_create_and_delete_regenerate( from custom_components.nestquest.dao_instances import QuestInstancesDao from custom_components.nestquest.dao_presence import PresenceOverridesDao - entry = _wire(make_entry(), hass.registry) + entry = _wire(_make_admin_entry(make_entry), hass.registry) assert await async_setup_entry(hass, entry) is True await hass.services.call( DOMAIN, SERVICE_MANAGE_CHILD, {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, ) child = (await list_children(entry.runtime_data.database))[0] await hass.services.call( @@ -323,8 +345,9 @@ async def test_presence_override_create_and_delete_regenerate( "assignee_child_ids": [child.id], "windows": ["morning"], }, + context=ADMIN_CTX, ) - await hass.services.call(DOMAIN, SERVICE_REGENERATE) + await hass.services.call(DOMAIN, SERVICE_REGENERATE, context=ADMIN_CTX) today = datetime.date.today() end = today + datetime.timedelta(days=DEFAULT_HORIZON_DAYS) dao = QuestInstancesDao(entry.runtime_data.database) @@ -341,6 +364,7 @@ async def test_presence_override_create_and_delete_regenerate( "end_date": end.isoformat(), "is_present": False, }, + context=ADMIN_CTX, ) assert ( await dao.list_by_date_range( @@ -359,6 +383,7 @@ async def test_presence_override_create_and_delete_regenerate( DOMAIN, SERVICE_DELETE_PRESENCE_OVERRIDE, {"override_id": override.id}, + context=ADMIN_CTX, ) after = await dao.list_by_date_range( child.id, today.isoformat(), end.isoformat() @@ -369,10 +394,12 @@ async def test_presence_override_create_and_delete_regenerate( async def test_export_history_csv_raises_home_assistant_error( hass, make_entry ) -> None: - entry = _wire(make_entry(), hass.registry) + entry = _wire(_make_admin_entry(make_entry), hass.registry) assert await async_setup_entry(hass, entry) is True with pytest.raises(HomeAssistantError, match="Feature 13"): - await hass.services.call(DOMAIN, SERVICE_EXPORT_HISTORY_CSV) + await hass.services.call( + DOMAIN, SERVICE_EXPORT_HISTORY_CSV, context=ADMIN_CTX + ) async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: @@ -392,3 +419,60 @@ async def test_invalid_schema_data_surfaces_as_error(hass, make_entry) -> None: SERVICE_MANAGE_CHILD, {"action": "create"}, ) + + +async def test_admin_only_service_denies_non_admin(hass, make_entry) -> None: + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + + +async def test_admin_only_service_denies_missing_user_context( + hass, make_entry +) -> None: + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + ) + + +async def test_admin_only_service_denies_admin_on_empty_allowlist( + hass, make_entry +) -> None: + """Even the allowlisted id is denied when the allowlist is empty.""" + entry = _wire(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context=ADMIN_CTX, + ) + + +async def test_unauthorized_is_raised_before_handler_runs( + hass, make_entry +) -> None: + from custom_components.nestquest.children import list_children + + entry = _wire(_make_admin_entry(make_entry), hass.registry) + assert await async_setup_entry(hass, entry) is True + with pytest.raises(Unauthorized): + await hass.services.call( + DOMAIN, + SERVICE_MANAGE_CHILD, + {"action": "create", "display_name": "Ada"}, + context={"user_id": "kiosk-panel"}, + ) + assert await list_children(entry.runtime_data.database) == []