1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-08 17:49:37 +01:00

Filter history API responses by per-entity read permissions (#169236)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-04-29 06:16:49 -04:00
committed by GitHub
parent 32743fcf8d
commit 57d9e8ea6f
6 changed files with 244 additions and 3 deletions
+16 -1
View File
@@ -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."""
+9 -1
View File
@@ -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_)
@@ -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"]
+49
View File
@@ -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) == []
+42
View File
@@ -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() == []
+112 -1
View File
@@ -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"] == {}