Add permission checking to all RainMachine services ()

* Add permission checking to all RainMachine services

* Linting

* Some initial work

* Owner comments

* Test in place (I think)

* Linting

* Update conftest.py
This commit is contained in:
Aaron Bach 2019-03-31 20:32:55 -06:00 committed by Paulus Schoutsen
parent 50a0504e07
commit 3d8efd4200
3 changed files with 137 additions and 21 deletions
homeassistant/components/rainmachine
tests/components/rainmachine

View file

@ -1,15 +1,18 @@
"""Support for RainMachine devices.""" """Support for RainMachine devices."""
import logging import logging
from datetime import timedelta from datetime import timedelta
from functools import wraps
import voluptuous as vol import voluptuous as vol
from homeassistant.auth.permissions.const import POLICY_CONTROL
from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD, ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD,
CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL,
CONF_MONITORED_CONDITIONS, CONF_SWITCHES) CONF_MONITORED_CONDITIONS, CONF_SWITCHES)
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import (
ConfigEntryNotReady, Unauthorized, UnknownUser)
from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
@ -128,6 +131,44 @@ CONFIG_SCHEMA = vol.Schema({
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
def _check_valid_user(hass):
"""Ensure the user of a service call has proper permissions."""
def decorator(service):
"""Decorate."""
@wraps(service)
async def check_permissions(call):
"""Check user permission and raise before call if unauthorized."""
if not call.context.user_id:
return
user = await hass.auth.async_get_user(call.context.user_id)
if user is None:
raise UnknownUser(
context=call.context,
permission=POLICY_CONTROL
)
# RainMachine services don't interact with specific entities.
# Therefore, we examine _all_ RainMachine entities and if the user
# has permission to control _any_ of them, the user has permission
# to call the service:
en_reg = await hass.helpers.entity_registry.async_get_registry()
rainmachine_entities = [
entity.entity_id for entity in en_reg.entities.values()
if entity.platform == DOMAIN
]
for entity_id in rainmachine_entities:
if user.permissions.check_entity(entity_id, POLICY_CONTROL):
return await service(call)
raise Unauthorized(
context=call.context,
permission=POLICY_CONTROL,
)
return check_permissions
return decorator
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the RainMachine component.""" """Set up the RainMachine component."""
hass.data[DOMAIN] = {} hass.data[DOMAIN] = {}
@ -197,59 +238,70 @@ async def async_setup_entry(hass, config_entry):
refresh, refresh,
timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]))
async def disable_program(service): @_check_valid_user(hass)
async def disable_program(call):
"""Disable a program.""" """Disable a program."""
await rainmachine.client.programs.disable( await rainmachine.client.programs.disable(
service.data[CONF_PROGRAM_ID]) call.data[CONF_PROGRAM_ID])
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
async def disable_zone(service): @_check_valid_user(hass)
async def disable_zone(call):
"""Disable a zone.""" """Disable a zone."""
await rainmachine.client.zones.disable(service.data[CONF_ZONE_ID]) await rainmachine.client.zones.disable(call.data[CONF_ZONE_ID])
async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
async def enable_program(service): @_check_valid_user(hass)
async def enable_program(call):
"""Enable a program.""" """Enable a program."""
await rainmachine.client.programs.enable(service.data[CONF_PROGRAM_ID]) await rainmachine.client.programs.enable(call.data[CONF_PROGRAM_ID])
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
async def enable_zone(service): @_check_valid_user(hass)
async def enable_zone(call):
"""Enable a zone.""" """Enable a zone."""
await rainmachine.client.zones.enable(service.data[CONF_ZONE_ID]) await rainmachine.client.zones.enable(call.data[CONF_ZONE_ID])
async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
async def pause_watering(service): @_check_valid_user(hass)
async def pause_watering(call):
"""Pause watering for a set number of seconds.""" """Pause watering for a set number of seconds."""
await rainmachine.client.watering.pause_all(service.data[CONF_SECONDS]) await rainmachine.client.watering.pause_all(call.data[CONF_SECONDS])
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
async def start_program(service): @_check_valid_user(hass)
async def start_program(call):
"""Start a particular program.""" """Start a particular program."""
await rainmachine.client.programs.start(service.data[CONF_PROGRAM_ID]) await rainmachine.client.programs.start(call.data[CONF_PROGRAM_ID])
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
async def start_zone(service): @_check_valid_user(hass)
async def start_zone(call):
"""Start a particular zone for a certain amount of time.""" """Start a particular zone for a certain amount of time."""
await rainmachine.client.zones.start( await rainmachine.client.zones.start(
service.data[CONF_ZONE_ID], service.data[CONF_ZONE_RUN_TIME]) call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME])
async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
async def stop_all(service): @_check_valid_user(hass)
async def stop_all(call):
"""Stop all watering.""" """Stop all watering."""
await rainmachine.client.watering.stop_all() await rainmachine.client.watering.stop_all()
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
async def stop_program(service): @_check_valid_user(hass)
async def stop_program(call):
"""Stop a program.""" """Stop a program."""
await rainmachine.client.programs.stop(service.data[CONF_PROGRAM_ID]) await rainmachine.client.programs.stop(call.data[CONF_PROGRAM_ID])
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
async def stop_zone(service): @_check_valid_user(hass)
async def stop_zone(call):
"""Stop a zone.""" """Stop a zone."""
await rainmachine.client.zones.stop(service.data[CONF_ZONE_ID]) await rainmachine.client.zones.stop(call.data[CONF_ZONE_ID])
async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
async def unpause_watering(service): @_check_valid_user(hass)
async def unpause_watering(call):
"""Unpause watering.""" """Unpause watering."""
await rainmachine.client.watering.unpause_all() await rainmachine.client.watering.unpause_all()
async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)

View file

@ -0,0 +1,23 @@
"""Configuration for Rainmachine tests."""
import pytest
from homeassistant.components.rainmachine.const import DOMAIN
from homeassistant.const import (
CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL)
from tests.common import MockConfigEntry
@pytest.fixture(name="config_entry")
def config_entry_fixture():
"""Create a mock RainMachine config entry."""
return MockConfigEntry(
domain=DOMAIN,
title='192.168.1.101',
data={
CONF_IP_ADDRESS: '192.168.1.101',
CONF_PASSWORD: '12345',
CONF_PORT: 8080,
CONF_SSL: True,
CONF_SCAN_INTERVAL: 60,
})

View file

@ -0,0 +1,41 @@
"""Define tests for permissions on RainMachine service calls."""
import asynctest
import pytest
from homeassistant.components.rainmachine.const import DOMAIN
from homeassistant.core import Context
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.setup import async_setup_component
from tests.common import mock_coro
async def setup_platform(hass, config_entry):
"""Set up the media player platform for testing."""
with asynctest.mock.patch('regenmaschine.login') as mock_login:
mock_client = mock_login.return_value
mock_client.restrictions.current.return_value = mock_coro()
mock_client.restrictions.universal.return_value = mock_coro()
config_entry.add_to_hass(hass)
assert await async_setup_component(hass, DOMAIN)
await hass.async_block_till_done()
async def test_services_authorization(
hass, config_entry, hass_read_only_user):
"""Test that a RainMachine service is halted on incorrect permissions."""
await setup_platform(hass, config_entry)
with pytest.raises(UnknownUser):
await hass.services.async_call(
'rainmachine',
'unpause_watering', {},
blocking=True,
context=Context(user_id='fake_user_id'))
with pytest.raises(Unauthorized):
await hass.services.async_call(
'rainmachine',
'unpause_watering', {},
blocking=True,
context=Context(user_id=hass_read_only_user.id))