Require admin for update install, skip and clear skipped actions (#178232)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Paulus Schoutsen
2026-08-06 04:21:01 -04:00
committed by GitHub
co-authored by Claude
parent 09892ba3b1
commit 2fd53a990b
5 changed files with 128 additions and 23 deletions
@@ -101,17 +101,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
},
async_install,
[UpdateEntityFeature.INSTALL],
admin_only=True,
)
component.async_register_entity_service(
SERVICE_SKIP,
None,
async_skip,
admin_only=True,
)
component.async_register_entity_service(
"clear_skipped",
None,
async_clear_skipped,
admin_only=True,
)
websocket_api.async_register_command(hass, websocket_release_notes)
@@ -225,6 +225,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
required_features: list[int] | None = None,
supports_response: SupportsResponse = SupportsResponse.NONE,
*,
admin_only: bool = False,
description_placeholders: Mapping[str, str] | None = None,
) -> None:
"""Register an entity service."""
@@ -232,6 +233,7 @@ class EntityComponent[_EntityT: entity.Entity = entity.Entity]:
self.hass,
self.domain,
name,
admin_only=admin_only,
entities=self._entities,
func=func,
job_type=HassJobType.Coroutinefunction,
+21 -20
View File
@@ -679,6 +679,7 @@ async def _resolve_entity_service_call_entities(
call: ServiceCall,
required_features: Iterable[int] | None = None,
entity_device_classes: Iterable[str | None] | None = None,
admin_only: bool = False,
) -> list[Entity] | None:
"""Resolve and filter entities for an entity service call."""
entity_perms: Callable[[str, str], bool] | None = None
@@ -688,6 +689,8 @@ async def _resolve_entity_service_call_entities(
if user is None:
raise UnknownUser(context=call.context)
if not user.is_admin:
if admin_only:
raise Unauthorized(context=call.context)
entity_perms = user.permissions.check_entity
target_all_entities = call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL
@@ -884,6 +887,7 @@ async def entity_service_call(
call: ServiceCall,
required_features: Iterable[int] | None = None,
*,
admin_only: bool = False,
entity_device_classes: Iterable[str | None] | None = None,
) -> EntityServiceResponse | None:
"""Handle an entity service call.
@@ -891,7 +895,12 @@ async def entity_service_call(
Calls all platforms simultaneously.
"""
entities = await _resolve_entity_service_call_entities(
hass, registered_entities, call, required_features, entity_device_classes
hass,
registered_entities,
call,
required_features,
entity_device_classes,
admin_only=admin_only,
)
if entities is None:
return None
@@ -1153,6 +1162,7 @@ def async_register_entity_service(
domain: str,
name: str,
*,
admin_only: bool = False,
description_placeholders: Mapping[str, str] | None = None,
entity_device_classes: Iterable[str | None] | None = None,
entities: Mapping[str, Entity],
@@ -1181,6 +1191,7 @@ def async_register_entity_service(
hass,
entities,
service_func,
admin_only=admin_only,
entity_device_classes=entity_device_classes,
required_features=required_features,
),
@@ -1270,29 +1281,19 @@ def async_register_platform_entity_service(
service_func: str | HassJob[..., Any]
service_func = func if isinstance(func, str) else HassJob(func)
entity_handler = partial(
entity_service_call,
hass,
partial(_get_platform_entities, hass, entity_domain, service_domain),
service_func,
entity_device_classes=entity_device_classes,
required_features=required_features,
)
service_handler = (
partial(
_async_admin_handler,
hass,
HassJob(entity_handler, f"admin service {service_domain}.{service_name}"),
)
if admin_only
else entity_handler
)
hass.services.async_register(
service_domain,
service_name,
service_handler,
partial(
entity_service_call,
hass,
partial(_get_platform_entities, hass, entity_domain, service_domain),
service_func,
admin_only=admin_only,
entity_device_classes=entity_device_classes,
required_features=required_features,
),
schema,
supports_response,
job_type=HassJobType.Coroutinefunction,
+53 -2
View File
@@ -42,8 +42,8 @@ from homeassistant.const import (
EntityCategory,
Platform,
)
from homeassistant.core import HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.core import Context, HomeAssistant, State, callback
from homeassistant.exceptions import HomeAssistantError, Unauthorized
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.event import async_track_state_change_event
from homeassistant.setup import async_setup_component
@@ -55,6 +55,7 @@ from tests.common import (
MockEntityPlatform,
MockModule,
MockPlatform,
MockUser,
mock_config_flow,
mock_integration,
mock_platform,
@@ -387,6 +388,56 @@ async def test_entity_with_updates_available(
assert "Installed latest update" in caplog.text
@pytest.mark.parametrize(
("service", "state_after_admin_call"),
[
pytest.param(SERVICE_INSTALL, STATE_OFF, id="install"),
pytest.param(SERVICE_SKIP, STATE_OFF, id="skip"),
pytest.param("clear_skipped", STATE_ON, id="clear_skipped"),
],
)
async def test_services_require_admin(
hass: HomeAssistant,
hass_admin_user: MockUser,
hass_read_only_user: MockUser,
mock_update_entities: list[MockUpdateEntity],
service: str,
state_after_admin_call: str,
) -> None:
"""Test the update services require an admin user."""
# Grant control of all entities, so the call is only rejected for not being admin
hass_read_only_user.mock_policy({"entities": {"all": {"control": True}}})
setup_test_component_platform(hass, DOMAIN, mock_update_entities)
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
await hass.async_block_till_done()
with pytest.raises(Unauthorized):
await hass.services.async_call(
DOMAIN,
service,
{ATTR_ENTITY_ID: "update.update_available"},
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
state = hass.states.get("update.update_available")
assert state
assert state.state == STATE_ON
await hass.services.async_call(
DOMAIN,
service,
{ATTR_ENTITY_ID: "update.update_available"},
blocking=True,
context=Context(user_id=hass_admin_user.id),
)
state = hass.states.get("update.update_available")
assert state
assert state.state == state_after_admin_call
async def test_entity_with_unknown_version(
hass: HomeAssistant,
mock_update_entities: list[MockUpdateEntity],
+49 -1
View File
@@ -17,6 +17,7 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import (
Context,
EntityServiceResponse,
HomeAssistant,
ServiceCall,
@@ -24,7 +25,7 @@ from homeassistant.core import (
SupportsResponse,
callback,
)
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady, Unauthorized
from homeassistant.helpers import config_validation as cv, discovery
from homeassistant.helpers.entity_component import EntityComponent, async_update_entity
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -38,6 +39,7 @@ from tests.common import (
MockEntity,
MockModule,
MockPlatform,
MockUser,
async_fire_time_changed,
mock_integration,
mock_platform,
@@ -577,6 +579,52 @@ async def test_register_entity_service(
assert len(calls) == 2
async def test_register_entity_service_admin_only(
hass: HomeAssistant,
hass_admin_user: MockUser,
hass_read_only_user: MockUser,
) -> None:
"""Test an admin-only entity service."""
# Grant control of all entities, so the call is only rejected for not being admin
hass_read_only_user.mock_policy({"entities": {"all": {"control": True}}})
entity = MockEntity(entity_id=f"{DOMAIN}.entity")
calls: list[MockEntity] = []
@callback
def handle_service(target: MockEntity, call: ServiceCall) -> None:
calls.append(target)
component = EntityComponent(_LOGGER, DOMAIN, hass)
await component.async_setup({})
await component.async_add_entities([entity])
component.async_register_entity_service(
"hello",
None,
handle_service,
admin_only=True,
)
with pytest.raises(Unauthorized):
await hass.services.async_call(
DOMAIN,
"hello",
{"entity_id": entity.entity_id},
blocking=True,
context=Context(user_id=hass_read_only_user.id),
)
assert calls == []
await hass.services.async_call(
DOMAIN,
"hello",
{"entity_id": entity.entity_id},
blocking=True,
context=Context(user_id=hass_admin_user.id),
)
assert calls == [entity]
async def test_register_entity_service_response_data(hass: HomeAssistant) -> None:
"""Test an entity service that does support response data."""
entity = MockEntity(entity_id=f"{DOMAIN}.entity")