Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions homeassistant/components/calendar/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
from dateutil.rrule import rrulestr
import voluptuous as vol

from homeassistant.auth.models import User
from homeassistant.auth.permissions.const import POLICY_CONTROL, POLICY_READ
from homeassistant.components import frontend, http, websocket_api
from homeassistant.components.http import KEY_HASS_USER
from homeassistant.components.websocket_api import (
ERR_INVALID_FORMAT,
ERR_NOT_FOUND,
Expand All @@ -32,7 +35,7 @@
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.entity import Entity, EntityDescription
Expand Down Expand Up @@ -786,6 +789,10 @@ def __init__(self, component: EntityComponent[CalendarEntity]) -> None:

async def get(self, request: web.Request, entity_id: str) -> web.Response:
"""Return calendar events."""
user: User = request[KEY_HASS_USER]
if not user.permissions.check_entity(entity_id, POLICY_READ):
raise Unauthorized(entity_id=entity_id)

if not (entity := self.component.get_entity(entity_id)) or not isinstance(
entity, CalendarEntity
):
Expand Down Expand Up @@ -837,10 +844,14 @@ def __init__(self, component: EntityComponent[CalendarEntity]) -> None:

async def get(self, request: web.Request) -> web.Response:
"""Retrieve calendar list."""
user: User = request[KEY_HASS_USER]
hass = request.app[http.KEY_HASS]
entity_perm = user.permissions.check_entity
calendar_list: list[dict[str, str]] = []

for entity in self.component.entities:
if not entity_perm(entity.entity_id, POLICY_READ):
continue
state = hass.states.get(entity.entity_id)
assert state
calendar_list.append({"name": state.name, "entity_id": entity.entity_id})
Expand All @@ -860,6 +871,9 @@ async def handle_calendar_event_create(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle creation of a calendar event."""
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
raise Unauthorized(entity_id=msg["entity_id"])

if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
return
Expand Down Expand Up @@ -899,6 +913,8 @@ async def handle_calendar_event_delete(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle delete of a calendar event."""
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
raise Unauthorized(entity_id=msg["entity_id"])

if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
Expand Down Expand Up @@ -944,7 +960,10 @@ async def handle_calendar_event_delete(
async def handle_calendar_event_update(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Handle creation of a calendar event."""
"""Handle update of a calendar event."""
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
raise Unauthorized(entity_id=msg["entity_id"])

if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
return
Expand Down Expand Up @@ -989,6 +1008,9 @@ async def handle_calendar_event_subscribe(
"""Subscribe to calendar event updates."""
entity_id: str = msg["entity_id"]

if not connection.user.permissions.check_entity(entity_id, POLICY_READ):
raise Unauthorized(entity_id=entity_id)

Comment on lines 1008 to +1013
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extend the permission enforcement to account for policy changes after the subscription is created (currently it’s only checked at subscribe time), e.g., re-check before sending each event update and stop/unsubscribe if access is revoked.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Although maybe all subscriptions should be stopped centrally if access rights are changed?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's something we should consider for a future audit task. Probably disconnect all users connection when their permissions change.

if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
connection.send_error(
msg["id"],
Expand Down
112 changes: 111 additions & 1 deletion tests/components/calendar/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

from .conftest import MockCalendarEntity, MockConfigEntry

from tests.common import async_fire_time_changed
from tests.common import MockUser, async_fire_time_changed
from tests.typing import ClientSessionGenerator, WebSocketGenerator


Expand Down Expand Up @@ -952,3 +952,113 @@ async def test_websocket_subscribe_debounces_rapid_updates(

# The final message has all events
assert len(messages[-1]["event"]["events"]) == 6


async def test_events_http_api_unauthorized(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_admin_user: MockUser,
) -> None:
"""Test that the events HTTP API enforces per-entity read permission."""
hass_admin_user.groups = []
hass_admin_user.mock_policy(
{"entities": {"entity_ids": {"calendar.calendar_2": True}}}
)
client = await hass_client()
start = dt_util.now()
end = start + timedelta(days=1)
response = await client.get(
f"/api/calendars/calendar.calendar_1?start={start.isoformat()}&end={end.isoformat()}"
)
assert response.status == HTTPStatus.UNAUTHORIZED
# Allowed entity still works
response = await client.get(
f"/api/calendars/calendar.calendar_2?start={start.isoformat()}&end={end.isoformat()}"
)
assert response.status == HTTPStatus.OK


async def test_calendars_http_api_filters_unauthorized(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_admin_user: MockUser,
) -> None:
"""Test that the calendar list filters out entities the user cannot read."""
hass_admin_user.groups = []
hass_admin_user.mock_policy(
{"entities": {"entity_ids": {"calendar.calendar_2": True}}}
)
client = await hass_client()
response = await client.get("/api/calendars")
assert response.status == HTTPStatus.OK
data = await response.json()
assert data == [{"entity_id": "calendar.calendar_2", "name": "Calendar 2"}]


@pytest.mark.parametrize(
"command",
[
{
"type": "calendar/event/create",
"entity_id": "calendar.calendar_1",
"event": {
"summary": "Bastille Day Party",
"dtstart": "1997-07-14T17:00:00+00:00",
"dtend": "1997-07-15T04:00:00+00:00",
},
},
{
"type": "calendar/event/delete",
"entity_id": "calendar.calendar_1",
"uid": "some-uid",
},
{
"type": "calendar/event/update",
"entity_id": "calendar.calendar_1",
"uid": "some-uid",
"event": {
"summary": "Bastille Day Party",
"dtstart": "1997-07-14T17:00:00+00:00",
"dtend": "1997-07-15T04:00:00+00:00",
},
},
],
)
async def test_websocket_event_mutate_unauthorized(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
command: dict[str, Any],
) -> None:
"""Test that mutating event WS commands enforce per-entity control permission."""
hass_admin_user.groups = []
hass_admin_user.mock_policy({})
client = await hass_ws_client(hass)
await client.send_json_auto_id(command)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "unauthorized"


async def test_websocket_subscribe_unauthorized(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
hass_admin_user: MockUser,
) -> None:
"""Test calendar event subscription enforces per-entity read permission."""
hass_admin_user.groups = []
hass_admin_user.mock_policy({})
client = await hass_ws_client(hass)
start = dt_util.now()
end = start + timedelta(days=1)
await client.send_json_auto_id(
{
"type": "calendar/event/subscribe",
"entity_id": "calendar.calendar_1",
"start": start.isoformat(),
"end": end.isoformat(),
}
)
msg = await client.receive_json()
assert not msg["success"]
assert msg["error"]["code"] == "unauthorized"
Loading