From 3823edda32589a083e3fe7d7edb75c7d7ce667a7 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 21 Jun 2022 12:17:29 -0400 Subject: [PATCH] Add Permission checking for UniFi Protect (#73765) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/button.py | 4 ++- .../components/unifiprotect/entity.py | 7 +++- .../components/unifiprotect/light.py | 11 +++--- .../components/unifiprotect/models.py | 9 +++++ .../components/unifiprotect/number.py | 17 +++++++-- .../components/unifiprotect/select.py | 12 ++++++- .../components/unifiprotect/switch.py | 27 +++++++++++++- tests/components/unifiprotect/test_switch.py | 35 +++++++++++++++++++ 8 files changed, 109 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 9ed5ecc4967..01714868261 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T @dataclass @@ -40,6 +40,7 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, name="Reboot Device", ufp_press="reboot", + ufp_perm=PermRequired.WRITE, ), ) @@ -49,6 +50,7 @@ SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( name="Clear Tamper", icon="mdi:notification-clear-all", ufp_press="clear_tamper", + ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index e06d297ef33..155fa49f078 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData -from .models import ProtectRequiredKeysMixin +from .models import PermRequired, ProtectRequiredKeysMixin from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) @@ -46,6 +46,11 @@ def _async_device_entities( for device in data.get_by_types({model_type}): assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime)) for description in descs: + if description.ufp_perm is not None: + can_write = device.can_write(data.api.bootstrap.auth_user) + if description.ufp_perm == PermRequired.WRITE and not can_write: + continue + if description.ufp_required_field: required_field = get_nested_attr(device, description.ufp_required_field) if not required_field: diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index d84c8406acf..b200fb85e03 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -25,13 +25,10 @@ async def async_setup_entry( ) -> None: """Set up lights for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - entities = [ - ProtectLight( - data, - device, - ) - for device in data.api.bootstrap.lights.values() - ] + entities = [] + for device in data.api.bootstrap.lights.values(): + if device.can_write(data.api.bootstrap.auth_user): + entities.append(ProtectLight(data, device)) if not entities: return diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index c28e1757722..81ad8438dd7 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +from enum import Enum import logging from typing import Any, Generic, TypeVar @@ -17,6 +18,13 @@ _LOGGER = logging.getLogger(__name__) T = TypeVar("T", bound=ProtectDeviceModel) +class PermRequired(int, Enum): + """Type of permission level required for entity.""" + + NO_WRITE = 1 + WRITE = 2 + + @dataclass class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" @@ -25,6 +33,7 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): ufp_value: str | None = None ufp_value_fn: Callable[[T], Any] | None = None ufp_enabled: str | None = None + ufp_perm: PermRequired | None = None def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 5a3b048e623..7bd6ce5b3d8 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -8,7 +8,7 @@ from pyunifiprotect.data import Camera, Doorlock, Light, ProtectModelWithId from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TIME_SECONDS +from homeassistant.const import PERCENTAGE, TIME_SECONDS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T @dataclass @@ -63,30 +63,35 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_required_field="feature_flags.has_wdr", ufp_value="isp_settings.wdr", ufp_set_method="set_wdr_level", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( key="mic_level", name="Microphone Level", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field="feature_flags.has_mic", ufp_value="mic_volume", ufp_set_method="set_mic_volume", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( key="zoom_position", name="Zoom Level", icon="mdi:magnify-plus-outline", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field="feature_flags.can_optical_zoom", ufp_value="isp_settings.zoom_position", ufp_set_method="set_camera_zoom", + ufp_perm=PermRequired.WRITE, ), ) @@ -96,12 +101,14 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Motion Sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field=None, ufp_value="light_device_settings.pir_sensitivity", ufp_set_method="set_sensitivity", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( key="duration", @@ -115,6 +122,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_required_field=None, ufp_value_fn=_get_pir_duration, ufp_set_method_fn=_set_pir_duration, + ufp_perm=PermRequired.WRITE, ), ) @@ -124,12 +132,14 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Motion Sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field=None, ufp_value="motion_settings.sensitivity", ufp_set_method="set_motion_sensitivity", + ufp_perm=PermRequired.WRITE, ), ) @@ -146,6 +156,7 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_required_field=None, ufp_value_fn=_get_auto_close, ufp_set_method_fn=_set_auto_close, + ufp_perm=PermRequired.WRITE, ), ) @@ -155,11 +166,13 @@ CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Volume", icon="mdi:speaker", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_value="volume", ufp_set_method="set_volume", + ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 6f5c2cfd0d7..4432e77ac26 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -38,7 +38,7 @@ from homeassistant.util.dt import utcnow from .const import ATTR_DURATION, ATTR_MESSAGE, DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -208,6 +208,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=RecordingMode, ufp_value="recording_settings.mode", ufp_set_method="set_recording_mode", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription( key="infrared", @@ -219,6 +220,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=IRLEDMode, ufp_value="isp_settings.ir_led_mode", ufp_set_method="set_ir_led_model", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Camera]( key="doorbell_text", @@ -230,6 +232,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value_fn=_get_doorbell_current, ufp_options_fn=_get_doorbell_options, ufp_set_method_fn=_set_doorbell_message, + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription( key="chime_type", @@ -241,6 +244,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=ChimeType, ufp_value="chime_type", ufp_set_method="set_chime_type", + ufp_perm=PermRequired.WRITE, ), ) @@ -253,6 +257,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_options=MOTION_MODE_TO_LIGHT_MODE, ufp_value_fn=_get_light_motion_current, ufp_set_method_fn=_set_light_mode, + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Light]( key="paired_camera", @@ -262,6 +267,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -275,6 +281,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=MountType, ufp_value="mount_type", ufp_set_method="set_mount_type", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Sensor]( key="paired_camera", @@ -284,6 +291,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -296,6 +304,7 @@ DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -308,6 +317,7 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_options_fn=_get_viewer_options, ufp_value_fn=_get_viewer_current, ufp_set_method_fn=_set_liveview, + ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index d8542da2f7f..06bf9f7251b 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T _LOGGER = logging.getLogger(__name__) @@ -56,6 +56,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="status_light", @@ -65,6 +66,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_led_status", ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="hdr_mode", @@ -74,6 +76,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_hdr", ufp_value="hdr_mode", ufp_set_method="set_hdr", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription[Camera]( key="high_fps", @@ -83,6 +86,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_highfps", ufp_value_fn=_get_is_highfps, ufp_set_method_fn=_set_highfps, + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key=_KEY_PRIVACY_MODE, @@ -91,6 +95,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_privacy_mask", ufp_value="is_privacy_on", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="system_sounds", @@ -100,6 +105,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_speaker", ufp_value="speaker_settings.are_system_sounds_enabled", ufp_set_method="set_system_sounds", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_name", @@ -108,6 +114,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_name_enabled", ufp_set_method="set_osd_name", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_date", @@ -116,6 +123,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_date_enabled", ufp_set_method="set_osd_date", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_logo", @@ -124,6 +132,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_logo_enabled", ufp_set_method="set_osd_logo", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_bitrate", @@ -132,6 +141,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_debug_enabled", ufp_set_method="set_osd_bitrate", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="motion", @@ -140,6 +150,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="recording_settings.enable_motion_detection", ufp_set_method="set_motion_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_person", @@ -149,6 +160,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_person", ufp_value="is_person_detection_on", ufp_set_method="set_person_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_vehicle", @@ -158,6 +170,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_vehicle", ufp_value="is_vehicle_detection_on", ufp_set_method="set_vehicle_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_face", @@ -167,6 +180,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_face", ufp_value="is_face_detection_on", ufp_set_method="set_face_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_package", @@ -176,6 +190,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_package", ufp_value="is_package_detection_on", ufp_set_method="set_package_detection", + ufp_perm=PermRequired.WRITE, ), ) @@ -187,6 +202,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="motion", @@ -195,6 +211,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="motion_settings.is_enabled", ufp_set_method="set_motion_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="temperature", @@ -203,6 +220,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="temperature_settings.is_enabled", ufp_set_method="set_temperature_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="humidity", @@ -211,6 +229,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="humidity_settings.is_enabled", ufp_set_method="set_humidity_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="light", @@ -219,6 +238,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="light_settings.is_enabled", ufp_set_method="set_light_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="alarm", @@ -226,6 +246,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="alarm_settings.is_enabled", ufp_set_method="set_alarm_status", + ufp_perm=PermRequired.WRITE, ), ) @@ -239,6 +260,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="status_light", @@ -247,6 +269,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="light_device_settings.is_indicator_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ) @@ -258,6 +281,7 @@ DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ) @@ -270,6 +294,7 @@ VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 1bd6dbeb349..6c9340af5d5 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -8,6 +8,7 @@ import pytest from pyunifiprotect.data import ( Camera, Light, + Permission, RecordingMode, SmartDetectObjectType, VideoMode, @@ -214,6 +215,40 @@ async def camera_privacy_fixture( Camera.__config__.validate_assignment = True +async def test_switch_setup_no_perm( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_light: Light, + mock_camera: Camera, +): + """Test switch entity setup for light devices.""" + + light_obj = mock_light.copy() + light_obj._api = mock_entry.api + + camera_obj = mock_camera.copy() + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + + reset_objects(mock_entry.api.bootstrap) + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + mock_entry.api.bootstrap.auth_user.all_permissions = [ + Permission.unifi_dict_to_dict({"rawPermission": "light:read:*"}) + ] + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + + async def test_switch_setup_light( hass: HomeAssistant, mock_entry: MockEntityFixture,