Frontend indicate require admin (#22272)

* Allow panels to indicate they are meant for admins

* Panels to indicate when they require admin access

* Do not return admin-only panels to non-admin users

* Fix flake8
This commit is contained in:
Paulus Schoutsen 2019-03-25 10:04:35 -07:00 committed by GitHub
parent b57d809dad
commit f1a0ad9e4a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 90 additions and 14 deletions

View file

@ -32,7 +32,7 @@ ON_DEMAND = ('zwave',)
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the config component.""" """Set up the config component."""
await hass.components.frontend.async_register_built_in_panel( await hass.components.frontend.async_register_built_in_panel(
'config', 'config', 'hass:settings') 'config', 'config', 'hass:settings', require_admin=True)
async def setup_panel(panel_name): async def setup_panel(panel_name):
"""Set up a panel.""" """Set up a panel."""

View file

@ -123,14 +123,18 @@ class Panel:
# Config to pass to the webcomponent # Config to pass to the webcomponent
config = None config = None
# If the panel should only be visible to admins
require_admin = False
def __init__(self, component_name, sidebar_title, sidebar_icon, def __init__(self, component_name, sidebar_title, sidebar_icon,
frontend_url_path, config): frontend_url_path, config, require_admin):
"""Initialize a built-in panel.""" """Initialize a built-in panel."""
self.component_name = component_name self.component_name = component_name
self.sidebar_title = sidebar_title self.sidebar_title = sidebar_title
self.sidebar_icon = sidebar_icon self.sidebar_icon = sidebar_icon
self.frontend_url_path = frontend_url_path or component_name self.frontend_url_path = frontend_url_path or component_name
self.config = config self.config = config
self.require_admin = require_admin
@callback @callback
def async_register_index_routes(self, router, index_view): def async_register_index_routes(self, router, index_view):
@ -150,16 +154,18 @@ class Panel:
'title': self.sidebar_title, 'title': self.sidebar_title,
'config': self.config, 'config': self.config,
'url_path': self.frontend_url_path, 'url_path': self.frontend_url_path,
'require_admin': self.require_admin,
} }
@bind_hass @bind_hass
async def async_register_built_in_panel(hass, component_name, async def async_register_built_in_panel(hass, component_name,
sidebar_title=None, sidebar_icon=None, sidebar_title=None, sidebar_icon=None,
frontend_url_path=None, config=None): frontend_url_path=None, config=None,
require_admin=False):
"""Register a built-in panel.""" """Register a built-in panel."""
panel = Panel(component_name, sidebar_title, sidebar_icon, panel = Panel(component_name, sidebar_title, sidebar_icon,
frontend_url_path, config) frontend_url_path, config, require_admin)
panels = hass.data.get(DATA_PANELS) panels = hass.data.get(DATA_PANELS)
if panels is None: if panels is None:
@ -247,9 +253,11 @@ async def async_setup(hass, config):
await asyncio.wait( await asyncio.wait(
[async_register_built_in_panel(hass, panel) for panel in ( [async_register_built_in_panel(hass, panel) for panel in (
'dev-event', 'dev-info', 'dev-service', 'dev-state', 'kiosk', 'states', 'profile')], loop=hass.loop)
'dev-template', 'dev-mqtt', 'kiosk', 'states', 'profile')], await asyncio.wait(
loop=hass.loop) [async_register_built_in_panel(hass, panel, require_admin=True)
for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state',
'dev-template', 'dev-mqtt')], loop=hass.loop)
hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel
@ -478,9 +486,11 @@ def websocket_get_panels(hass, connection, msg):
Async friendly. Async friendly.
""" """
user_is_admin = connection.user.is_admin
panels = { panels = {
panel: connection.hass.data[DATA_PANELS][panel].to_response() panel_key: panel.to_response()
for panel in connection.hass.data[DATA_PANELS]} for panel_key, panel in connection.hass.data[DATA_PANELS].items()
if user_is_admin or not panel.require_admin}
connection.send_message(websocket_api.result_message( connection.send_message(websocket_api.result_message(
msg['id'], panels)) msg['id'], panels))

View file

@ -189,6 +189,7 @@ async def async_setup(hass, config):
sidebar_icon='hass:home-assistant', sidebar_icon='hass:home-assistant',
js_url='/api/hassio/app/entrypoint.js', js_url='/api/hassio/app/entrypoint.js',
embed_iframe=True, embed_iframe=True,
require_admin=True,
) )
await hassio.update_hass_api(config.get('http', {}), refresh_token.token) await hassio.update_hass_api(config.get('http', {}), refresh_token.token)

View file

@ -23,6 +23,7 @@ CONF_MODULE_URL = 'module_url'
CONF_EMBED_IFRAME = 'embed_iframe' CONF_EMBED_IFRAME = 'embed_iframe'
CONF_TRUST_EXTERNAL_SCRIPT = 'trust_external_script' CONF_TRUST_EXTERNAL_SCRIPT = 'trust_external_script'
CONF_URL_EXCLUSIVE_GROUP = 'url_exclusive_group' CONF_URL_EXCLUSIVE_GROUP = 'url_exclusive_group'
CONF_REQUIRE_ADMIN = 'require_admin'
MSG_URL_CONFLICT = \ MSG_URL_CONFLICT = \
'Pass in only one of webcomponent_path, module_url or js_url' 'Pass in only one of webcomponent_path, module_url or js_url'
@ -52,6 +53,7 @@ CONFIG_SCHEMA = vol.Schema({
default=DEFAULT_EMBED_IFRAME): cv.boolean, default=DEFAULT_EMBED_IFRAME): cv.boolean,
vol.Optional(CONF_TRUST_EXTERNAL_SCRIPT, vol.Optional(CONF_TRUST_EXTERNAL_SCRIPT,
default=DEFAULT_TRUST_EXTERNAL): cv.boolean, default=DEFAULT_TRUST_EXTERNAL): cv.boolean,
vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean,
})]) })])
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)
@ -77,7 +79,9 @@ async def async_register_panel(
# Should user be asked for confirmation when loading external source # Should user be asked for confirmation when loading external source
trust_external=DEFAULT_TRUST_EXTERNAL, trust_external=DEFAULT_TRUST_EXTERNAL,
# Configuration to be passed to the panel # Configuration to be passed to the panel
config=None): config=None,
# If your panel should only be shown to admin users
require_admin=False):
"""Register a new custom panel.""" """Register a new custom panel."""
if js_url is None and html_url is None and module_url is None: if js_url is None and html_url is None and module_url is None:
raise ValueError('Either js_url, module_url or html_url is required.') raise ValueError('Either js_url, module_url or html_url is required.')
@ -115,7 +119,8 @@ async def async_register_panel(
sidebar_title=sidebar_title, sidebar_title=sidebar_title,
sidebar_icon=sidebar_icon, sidebar_icon=sidebar_icon,
frontend_url_path=frontend_url_path, frontend_url_path=frontend_url_path,
config=config config=config,
require_admin=require_admin,
) )
@ -134,6 +139,7 @@ async def async_setup(hass, config):
'config': panel.get(CONF_CONFIG), 'config': panel.get(CONF_CONFIG),
'trust_external': panel[CONF_TRUST_EXTERNAL_SCRIPT], 'trust_external': panel[CONF_TRUST_EXTERNAL_SCRIPT],
'embed_iframe': panel[CONF_EMBED_IFRAME], 'embed_iframe': panel[CONF_EMBED_IFRAME],
'require_admin': panel[CONF_REQUIRE_ADMIN],
} }
panel_path = panel.get(CONF_WEBCOMPONENT_PATH) panel_path = panel.get(CONF_WEBCOMPONENT_PATH)

View file

@ -12,6 +12,7 @@ CONF_TITLE = 'title'
CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required." CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required."
CONF_RELATIVE_URL_REGEX = r'\A/' CONF_RELATIVE_URL_REGEX = r'\A/'
CONF_REQUIRE_ADMIN = 'require_admin'
CONFIG_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({
DOMAIN: cv.schema_with_slug_keys( DOMAIN: cv.schema_with_slug_keys(
@ -19,6 +20,7 @@ CONFIG_SCHEMA = vol.Schema({
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
vol.Optional(CONF_TITLE): cv.string, vol.Optional(CONF_TITLE): cv.string,
vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_ICON): cv.icon,
vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean,
vol.Required(CONF_URL): vol.Any( vol.Required(CONF_URL): vol.Any(
vol.Match( vol.Match(
CONF_RELATIVE_URL_REGEX, CONF_RELATIVE_URL_REGEX,
@ -34,6 +36,7 @@ async def async_setup(hass, config):
for url_path, info in config[DOMAIN].items(): for url_path, info in config[DOMAIN].items():
await hass.components.frontend.async_register_built_in_panel( await hass.components.frontend.async_register_built_in_panel(
'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON),
url_path, {'url': info[CONF_URL]}) url_path, {'url': info[CONF_URL]},
require_admin=info[CONF_REQUIRE_ADMIN])
return True return True

View file

@ -249,7 +249,7 @@ async def test_get_panels(hass, hass_ws_client):
"""Test get_panels command.""" """Test get_panels command."""
await async_setup_component(hass, 'frontend') await async_setup_component(hass, 'frontend')
await hass.components.frontend.async_register_built_in_panel( await hass.components.frontend.async_register_built_in_panel(
'map', 'Map', 'mdi:tooltip-account') 'map', 'Map', 'mdi:tooltip-account', require_admin=True)
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json({ await client.send_json({
@ -266,6 +266,31 @@ async def test_get_panels(hass, hass_ws_client):
assert msg['result']['map']['url_path'] == 'map' assert msg['result']['map']['url_path'] == 'map'
assert msg['result']['map']['icon'] == 'mdi:tooltip-account' assert msg['result']['map']['icon'] == 'mdi:tooltip-account'
assert msg['result']['map']['title'] == 'Map' assert msg['result']['map']['title'] == 'Map'
assert msg['result']['map']['require_admin'] is True
async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user):
"""Test get_panels command."""
hass_admin_user.groups = []
await async_setup_component(hass, 'frontend')
await hass.components.frontend.async_register_built_in_panel(
'map', 'Map', 'mdi:tooltip-account', require_admin=True)
await hass.components.frontend.async_register_built_in_panel(
'history', 'History', 'mdi:history')
client = await hass_ws_client(hass)
await client.send_json({
'id': 5,
'type': 'get_panels',
})
msg = await client.receive_json()
assert msg['id'] == 5
assert msg['type'] == TYPE_RESULT
assert msg['success']
assert 'history' in msg['result']
assert 'map' not in msg['result']
async def test_get_translations(hass, hass_ws_client): async def test_get_translations(hass, hass_ws_client):

View file

@ -8,6 +8,7 @@ import pytest
from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.components.hassio import STORAGE_KEY from homeassistant.components.hassio import STORAGE_KEY
from homeassistant.components import frontend
from tests.common import mock_coro from tests.common import mock_coro
@ -44,6 +45,28 @@ def test_setup_api_ping(hass, aioclient_mock):
assert hass.components.hassio.is_hassio() assert hass.components.hassio.is_hassio()
async def test_setup_api_panel(hass, aioclient_mock):
"""Test setup with API ping."""
assert await async_setup_component(hass, 'frontend', {})
with patch.dict(os.environ, MOCK_ENVIRON):
result = await async_setup_component(hass, 'hassio', {})
assert result
panels = hass.data[frontend.DATA_PANELS]
assert panels.get('hassio').to_response() == {
'component_name': 'custom',
'icon': 'hass:home-assistant',
'title': 'Hass.io',
'url_path': 'hassio',
'require_admin': True,
'config': {'_panel_custom': {'embed_iframe': True,
'js_url': '/api/hassio/app/entrypoint.js',
'name': 'hassio-main',
'trust_external': False}},
}
@asyncio.coroutine @asyncio.coroutine
def test_setup_api_push_api_data(hass, aioclient_mock): def test_setup_api_push_api_data(hass, aioclient_mock):
"""Test setup with API push.""" """Test setup with API push."""

View file

@ -130,6 +130,7 @@ async def test_module_webcomponent(hass):
}, },
'embed_iframe': True, 'embed_iframe': True,
'trust_external_script': True, 'trust_external_script': True,
'require_admin': True,
} }
} }
@ -145,6 +146,7 @@ async def test_module_webcomponent(hass):
panel = panels['nice_url'] panel = panels['nice_url']
assert panel.require_admin
assert panel.config == { assert panel.config == {
'hello': 'world', 'hello': 'world',
'_panel_custom': { '_panel_custom': {

View file

@ -41,11 +41,13 @@ class TestPanelIframe(unittest.TestCase):
'icon': 'mdi:network-wireless', 'icon': 'mdi:network-wireless',
'title': 'Router', 'title': 'Router',
'url': 'http://192.168.1.1', 'url': 'http://192.168.1.1',
'require_admin': True,
}, },
'weather': { 'weather': {
'icon': 'mdi:weather', 'icon': 'mdi:weather',
'title': 'Weather', 'title': 'Weather',
'url': 'https://www.wunderground.com/us/ca/san-diego', 'url': 'https://www.wunderground.com/us/ca/san-diego',
'require_admin': True,
}, },
'api': { 'api': {
'icon': 'mdi:weather', 'icon': 'mdi:weather',
@ -67,7 +69,8 @@ class TestPanelIframe(unittest.TestCase):
'config': {'url': 'http://192.168.1.1'}, 'config': {'url': 'http://192.168.1.1'},
'icon': 'mdi:network-wireless', 'icon': 'mdi:network-wireless',
'title': 'Router', 'title': 'Router',
'url_path': 'router' 'url_path': 'router',
'require_admin': True,
} }
assert panels.get('weather').to_response() == { assert panels.get('weather').to_response() == {
@ -76,6 +79,7 @@ class TestPanelIframe(unittest.TestCase):
'icon': 'mdi:weather', 'icon': 'mdi:weather',
'title': 'Weather', 'title': 'Weather',
'url_path': 'weather', 'url_path': 'weather',
'require_admin': True,
} }
assert panels.get('api').to_response() == { assert panels.get('api').to_response() == {
@ -84,6 +88,7 @@ class TestPanelIframe(unittest.TestCase):
'icon': 'mdi:weather', 'icon': 'mdi:weather',
'title': 'Api', 'title': 'Api',
'url_path': 'api', 'url_path': 'api',
'require_admin': False,
} }
assert panels.get('ftp').to_response() == { assert panels.get('ftp').to_response() == {
@ -92,4 +97,5 @@ class TestPanelIframe(unittest.TestCase):
'icon': 'mdi:weather', 'icon': 'mdi:weather',
'title': 'FTP', 'title': 'FTP',
'url_path': 'ftp', 'url_path': 'ftp',
'require_admin': False,
} }