diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py index 6498483a19a..21453f03379 100644 --- a/homeassistant/auth/permissions/__init__.py +++ b/homeassistant/auth/permissions/__init__.py @@ -2,7 +2,8 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable +from typing import TYPE_CHECKING import voluptuous as vol @@ -13,6 +14,9 @@ from .models import PermissionLookup from .types import PolicyType from .util import test_all +if TYPE_CHECKING: + from ..models import User + POLICY_SCHEMA = vol.Schema({vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA}) __all__ = [ @@ -22,10 +26,21 @@ __all__ = [ "PermissionLookup", "PolicyPermissions", "PolicyType", + "filter_entity_ids_by_permission", "merge_policies", ] +def filter_entity_ids_by_permission( + user: User, entity_ids: Iterable[str], key: str +) -> list[str]: + """Filter entity IDs to those the user can access for the given policy key.""" + if user.is_admin or user.permissions.access_all_entities(key): + return list(entity_ids) + check_entity = user.permissions.check_entity + return [entity_id for entity_id in entity_ids if check_entity(entity_id, key)] + + class AbstractPermissions: """Default permissions class.""" diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index b948060fe24..b5fc90f811e 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -9,8 +9,10 @@ from typing import cast from aiohttp import web import voluptuous as vol +from homeassistant.auth.permissions import filter_entity_ids_by_permission +from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components import frontend -from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView from homeassistant.components.recorder import get_instance, history from homeassistant.components.recorder.util import session_scope from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE @@ -83,6 +85,12 @@ class HistoryPeriodView(HomeAssistantView): "Invalid filter_entity_id", HTTPStatus.BAD_REQUEST ) + entity_ids = filter_entity_ids_by_permission( + request[KEY_HASS_USER], entity_ids, POLICY_READ + ) + if not entity_ids: + return self.json([]) + now = dt_util.utcnow() if datetime_: start_time = dt_util.as_utc(datetime_) diff --git a/homeassistant/components/history/websocket_api.py b/homeassistant/components/history/websocket_api.py index 3761c935992..b42d34385b1 100644 --- a/homeassistant/components/history/websocket_api.py +++ b/homeassistant/components/history/websocket_api.py @@ -11,6 +11,8 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.auth.permissions import filter_entity_ids_by_permission +from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance, history from homeassistant.components.websocket_api import ActiveConnection, messages @@ -138,6 +140,13 @@ async def ws_get_history_during_period( connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") return + entity_ids = filter_entity_ids_by_permission( + connection.user, entity_ids, POLICY_READ + ) + if not entity_ids: + connection.send_result(msg["id"], {}) + return + include_start_time_state = msg["include_start_time_state"] no_attributes = msg["no_attributes"] @@ -444,6 +453,13 @@ async def ws_stream( connection.send_error(msg["id"], "invalid_entity_ids", "Invalid entity_ids") return + entity_ids = filter_entity_ids_by_permission( + connection.user, entity_ids, POLICY_READ + ) + if not entity_ids: + _async_send_empty_response(connection, msg_id, start_time, end_time) + return + include_start_time_state = msg["include_start_time_state"] significant_changes_only = msg["significant_changes_only"] no_attributes = msg["no_attributes"] diff --git a/tests/auth/permissions/test_init.py b/tests/auth/permissions/test_init.py new file mode 100644 index 00000000000..20215068907 --- /dev/null +++ b/tests/auth/permissions/test_init.py @@ -0,0 +1,49 @@ +"""Tests for the permissions module.""" + +from homeassistant.auth.permissions import filter_entity_ids_by_permission +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.core import HomeAssistant + +from tests.common import MockUser + + +async def test_filter_entity_ids_by_permission_admin(hass: HomeAssistant) -> None: + """Test admins receive the input entity IDs unchanged.""" + user = MockUser(is_owner=True) + user.mock_policy({"entities": {"entity_ids": {"light.allowed": True}}}) + assert user.is_admin + + assert filter_entity_ids_by_permission( + user, ["light.allowed", "light.forbidden"], POLICY_READ + ) == ["light.allowed", "light.forbidden"] + + +async def test_filter_entity_ids_by_permission_access_all(hass: HomeAssistant) -> None: + """Test users with access_all_entities receive the input unchanged.""" + user = MockUser() + user.mock_policy({"entities": {"all": True}}) + assert not user.is_admin + + assert filter_entity_ids_by_permission( + user, ["light.a", "light.b"], POLICY_READ + ) == ["light.a", "light.b"] + + +async def test_filter_entity_ids_by_permission_filtered(hass: HomeAssistant) -> None: + """Test users without full access have entity IDs filtered.""" + user = MockUser() + user.mock_policy({"entities": {"entity_ids": {"light.allowed": True}}}) + assert not user.is_admin + + assert filter_entity_ids_by_permission( + user, ["light.allowed", "light.forbidden"], POLICY_READ + ) == ["light.allowed"] + + +async def test_filter_entity_ids_by_permission_empty(hass: HomeAssistant) -> None: + """Test users with no permitted entities receive an empty list.""" + user = MockUser() + user.mock_policy({}) + assert not user.is_admin + + assert filter_entity_ids_by_permission(user, ["light.forbidden"], POLICY_READ) == [] diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 4f2c072703a..c846f0ca979 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -17,6 +17,7 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from tests.common import MockUser from tests.components.recorder.common import ( assert_dict_of_states_equal_without_context_and_last_changed, assert_multiple_states_equal_without_context, @@ -770,3 +771,44 @@ async def test_history_with_invalid_entity_ids( response_json = await response.json() assert response_contains1 in str(response_json) assert response_contains2 in str(response_json) + + +@pytest.mark.usefixtures("recorder_mock") +async def test_fetch_period_api_filters_unauthorized_entities( + hass: HomeAssistant, + hass_read_only_user: MockUser, + hass_read_only_access_token: str, + hass_client: ClientSessionGenerator, +) -> None: + """Test history is filtered by per-entity read permissions for non-admins.""" + assert not hass_read_only_user.is_admin + hass_read_only_user.mock_policy( + {"entities": {"entity_ids": {"light.kitchen": True}}} + ) + await async_setup_component(hass, "history", {}) + + hass.states.async_set("light.kitchen", "on") + hass.states.async_set("light.cow", "on") + await async_wait_recording_done(hass) + + client = await hass_client(hass_read_only_access_token) + + now = dt_util.utcnow().isoformat() + response = await client.get( + f"/api/history/period/{now}", + params={"filter_entity_id": "light.kitchen,light.cow"}, + ) + assert response.status == HTTPStatus.OK + response_json = await response.json() + assert all( + state["entity_id"] == "light.kitchen" + for entity_states in response_json + for state in entity_states + ) + + response = await client.get( + f"/api/history/period/{now}", + params={"filter_entity_id": "light.cow"}, + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [] diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index a4d47f19c4d..0992ad6f94c 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -15,7 +15,7 @@ from homeassistant.helpers.event import async_track_state_change_event from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockUser, async_fire_time_changed from tests.components.recorder.common import ( async_recorder_block_till_done, async_wait_recording_done, @@ -2174,3 +2174,114 @@ async def test_history_stream_live_chained_events( "id": 1, "type": "event", } + + +@pytest.mark.usefixtures("recorder_mock") +async def test_history_during_period_filters_unauthorized_entities( + hass: HomeAssistant, + hass_read_only_user: MockUser, + hass_read_only_access_token: str, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test history_during_period filters by per-entity read permissions.""" + assert not hass_read_only_user.is_admin + hass_read_only_user.mock_policy( + {"entities": {"entity_ids": {"sensor.allowed": True}}} + ) + now = dt_util.utcnow() + + await async_setup_component(hass, "history", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.allowed", "on") + hass.states.async_set("sensor.forbidden", "on") + await async_wait_recording_done(hass) + + client = await hass_ws_client(access_token=hass_read_only_access_token) + + await client.send_json_auto_id( + { + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.allowed", "sensor.forbidden"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert "sensor.forbidden" not in response["result"] + assert "sensor.allowed" in response["result"] + + await client.send_json_auto_id( + { + "type": "history/history_during_period", + "start_time": now.isoformat(), + "entity_ids": ["sensor.forbidden"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + +@pytest.mark.usefixtures("recorder_mock") +async def test_history_stream_filters_unauthorized_entities( + hass: HomeAssistant, + hass_read_only_user: MockUser, + hass_read_only_access_token: str, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test history/stream filters by per-entity read permissions.""" + assert not hass_read_only_user.is_admin + hass_read_only_user.mock_policy( + {"entities": {"entity_ids": {"sensor.allowed": True}}} + ) + now = dt_util.utcnow() + + await async_setup_component(hass, "history", {}) + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.allowed", "on") + hass.states.async_set("sensor.forbidden", "on") + await async_wait_recording_done(hass) + + end_time = dt_util.utcnow() + timedelta(seconds=1) + client = await hass_ws_client(access_token=hass_read_only_access_token) + + await client.send_json_auto_id( + { + "type": "history/stream", + "start_time": now.isoformat(), + "end_time": end_time.isoformat(), + "entity_ids": ["sensor.allowed", "sensor.forbidden"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + response = await client.receive_json() + assert response["type"] == "event" + assert "sensor.forbidden" not in response["event"]["states"] + assert "sensor.allowed" in response["event"]["states"] + + await client.send_json_auto_id( + { + "type": "history/stream", + "start_time": now.isoformat(), + "end_time": end_time.isoformat(), + "entity_ids": ["sensor.forbidden"], + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + } + ) + response = await client.receive_json() + assert response["success"] + response = await client.receive_json() + assert response["type"] == "event" + assert response["event"]["states"] == {}