From 90df932fe13d2686d99065e0baaedcbe805a0b22 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 13 Dec 2018 15:30:20 +0100 Subject: [PATCH] Check admin permission before able to manage config entries --- homeassistant/auth/permissions/const.py | 1 + .../components/config/config_entries.py | 37 +++++ homeassistant/exceptions.py | 6 + .../components/config/test_config_entries.py | 146 ++++++++++++++++++ 4 files changed, 190 insertions(+) diff --git a/homeassistant/auth/permissions/const.py b/homeassistant/auth/permissions/const.py index e60879881c1..d390d010dee 100644 --- a/homeassistant/auth/permissions/const.py +++ b/homeassistant/auth/permissions/const.py @@ -1,5 +1,6 @@ """Permission constants.""" CAT_ENTITIES = 'entities' +CAT_CONFIG_ENTRIES = 'config_entries' SUBCAT_ALL = 'all' POLICY_READ = 'read' diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 644990d7185..68890a79ca6 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,7 +1,9 @@ """Http views to control the config manager.""" from homeassistant import config_entries, data_entry_flow +from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES from homeassistant.components.http import HomeAssistantView +from homeassistant.exceptions import Unauthorized from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView) @@ -63,6 +65,9 @@ class ConfigManagerEntryResourceView(HomeAssistantView): async def delete(self, request, entry_id): """Delete a config entry.""" + if not request['hass_user'].is_admin: + raise Unauthorized(config_entry_id=entry_id, permission='remove') + hass = request.app['hass'] try: @@ -85,12 +90,26 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): Example of a non-user initiated flow is a discovered Hue hub that requires user interaction to finish setup. """ + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + hass = request.app['hass'] return self.json([ flw for flw in hass.config_entries.flow.async_progress() if flw['context']['source'] != config_entries.SOURCE_USER]) + # pylint: disable=arguments-differ + async def post(self, request): + """Handle a POST request.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + + # pylint: disable=no-value-for-parameter + return await super().post(request) + class ConfigManagerFlowResourceView(FlowManagerResourceView): """View to interact with the flow manager.""" @@ -98,6 +117,24 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): url = '/api/config/config_entries/flow/{flow_id}' name = 'api:config:config_entries:flow:resource' + async def get(self, request, flow_id): + """Get the current state of a data_entry_flow.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + + return await super().get(request, flow_id) + + # pylint: disable=arguments-differ + async def post(self, request, flow_id): + """Handle a POST request.""" + if not request['hass_user'].is_admin: + raise Unauthorized( + perm_category=CAT_CONFIG_ENTRIES, permission='add') + + # pylint: disable=no-value-for-parameter + return await super().post(request, flow_id) + class ConfigManagerAvailableFlowView(HomeAssistantView): """View to query available flows.""" diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index 5e2ab4988b1..aadee3e792b 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -47,12 +47,18 @@ class Unauthorized(HomeAssistantError): def __init__(self, context: Optional['Context'] = None, user_id: Optional[str] = None, entity_id: Optional[str] = None, + config_entry_id: Optional[str] = None, + perm_category: Optional[str] = None, permission: Optional[Tuple[str]] = None) -> None: """Unauthorized error.""" super().__init__(self.__class__.__name__) self.context = context self.user_id = user_id self.entity_id = entity_id + self.config_entry_id = config_entry_id + # Not all actions have an ID (like adding config entry) + # We then use this fallback to know what category was unauth + self.perm_category = perm_category self.permission = permission diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 0b36cc6bc87..709dbce3c16 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -84,6 +84,17 @@ def test_remove_entry(hass, client): assert len(hass.config_entries.async_entries()) == 0 +async def test_remove_entry_unauth(hass, client, hass_admin_user): + """Test removing an entry via the API.""" + hass_admin_user.groups = [] + entry = MockConfigEntry(domain='demo', state=core_ce.ENTRY_STATE_LOADED) + entry.add_to_hass(hass) + resp = await client.delete( + '/api/config/config_entries/entry/{}'.format(entry.entry_id)) + assert resp.status == 401 + assert len(hass.config_entries.async_entries()) == 1 + + @asyncio.coroutine def test_available_flows(hass, client): """Test querying the available flows.""" @@ -155,6 +166,35 @@ def test_initialize_flow(hass, client): } +async def test_initialize_flow_unauth(hass, client, hass_admin_user): + """Test we can initialize a flow.""" + hass_admin_user.groups = [] + + class TestFlow(core_ce.ConfigFlow): + @asyncio.coroutine + def async_step_user(self, user_input=None): + schema = OrderedDict() + schema[vol.Required('username')] = str + schema[vol.Required('password')] = str + + return self.async_show_form( + step_id='user', + data_schema=schema, + description_placeholders={ + 'url': 'https://example.com', + }, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = await client.post('/api/config/config_entries/flow', + json={'handler': 'test'}) + + assert resp.status == 401 + + @asyncio.coroutine def test_abort(hass, client): """Test a flow that aborts.""" @@ -273,6 +313,58 @@ def test_two_step_flow(hass, client): } +async def test_continue_flow_unauth(hass, client, hass_admin_user): + """Test we can't finish a two step flow.""" + set_component( + hass, 'test', + MockModule('test', async_setup_entry=mock_coro_func(True))) + + class TestFlow(core_ce.ConfigFlow): + VERSION = 1 + + @asyncio.coroutine + def async_step_user(self, user_input=None): + return self.async_show_form( + step_id='account', + data_schema=vol.Schema({ + 'user_title': str + })) + + @asyncio.coroutine + def async_step_account(self, user_input=None): + return self.async_create_entry( + title=user_input['user_title'], + data={'secret': 'account_token'}, + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = await client.post('/api/config/config_entries/flow', + json={'handler': 'test'}) + assert resp.status == 200 + data = await resp.json() + flow_id = data.pop('flow_id') + assert data == { + 'type': 'form', + 'handler': 'test', + 'step_id': 'account', + 'data_schema': [ + { + 'name': 'user_title', + 'type': 'string' + } + ], + 'description_placeholders': None, + 'errors': None + } + + hass_admin_user.groups = [] + + resp = await client.post( + '/api/config/config_entries/flow/{}'.format(flow_id), + json={'user_title': 'user-title'}) + assert resp.status == 401 + + @asyncio.coroutine def test_get_progress_index(hass, client): """Test querying for the flows that are in progress.""" @@ -305,6 +397,29 @@ def test_get_progress_index(hass, client): ] +async def test_get_progress_index_unauth(hass, client, hass_admin_user): + """Test we can't get flows that are in progress.""" + hass_admin_user.groups = [] + + class TestFlow(core_ce.ConfigFlow): + VERSION = 5 + + async def async_step_hassio(self, info): + return (await self.async_step_account()) + + async def async_step_account(self, user_input=None): + return self.async_show_form( + step_id='account', + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + form = await hass.config_entries.flow.async_init( + 'test', context={'source': 'hassio'}) + + resp = await client.get('/api/config/config_entries/flow') + assert resp.status == 401 + + @asyncio.coroutine def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" @@ -337,3 +452,34 @@ def test_get_progress_flow(hass, client): data2 = yield from resp2.json() assert data == data2 + + +async def test_get_progress_flow(hass, client, hass_admin_user): + """Test we can query the API for same result as we get from init a flow.""" + class TestFlow(core_ce.ConfigFlow): + async def async_step_user(self, user_input=None): + schema = OrderedDict() + schema[vol.Required('username')] = str + schema[vol.Required('password')] = str + + return self.async_show_form( + step_id='user', + data_schema=schema, + errors={ + 'username': 'Should be unique.' + } + ) + + with patch.dict(HANDLERS, {'test': TestFlow}): + resp = await client.post('/api/config/config_entries/flow', + json={'handler': 'test'}) + + assert resp.status == 200 + data = await resp.json() + + hass_admin_user.groups = [] + + resp2 = await client.get( + '/api/config/config_entries/flow/{}'.format(data['flow_id'])) + + assert resp2.status == 401