mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Require admin for update install, skip and clear skipped actions (#178232)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
09892ba3b1
commit
2fd53a990b
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user