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:
@@ -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,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"]
|
||||
|
||||
@@ -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) == []
|
||||
@@ -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() == []
|
||||
|
||||
@@ -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"] == {}
|
||||
|
||||
Reference in New Issue
Block a user