From c3b1121d7761c9816c5544eaa0429e9802ec6e68 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 8 Oct 2018 16:35:38 +0200 Subject: [PATCH] Add group foundation (#16935) Add group foundation --- homeassistant/auth/__init__.py | 3 + homeassistant/auth/auth_store.py | 59 +++++++++++++++++- homeassistant/auth/models.py | 10 +++ homeassistant/components/config/auth.py | 1 + tests/auth/test_auth_store.py | 82 +++++++++++++++++++++++++ tests/common.py | 27 +++++++- tests/components/config/test_auth.py | 9 ++- 7 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 tests/auth/test_auth_store.py diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index c6f978640f6..e19498026d1 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -130,13 +130,16 @@ class AuthManager: name=name, system_generated=True, is_active=True, + groups=[], ) async def async_create_user(self, name: str) -> models.User: """Create a user.""" + group = (await self._store.async_get_groups())[0] kwargs = { 'name': name, 'is_active': True, + 'groups': [group] } # type: Dict[str, Any] if await self._user_should_be_owner(): diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 54c34d8ec2c..572393dc444 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -13,6 +13,7 @@ from . import models STORAGE_VERSION = 1 STORAGE_KEY = 'auth' +INITIAL_GROUP_NAME = 'All Access' class AuthStore: @@ -28,9 +29,18 @@ class AuthStore: """Initialize the auth store.""" self.hass = hass self._users = None # type: Optional[Dict[str, models.User]] + self._groups = None # type: Optional[Dict[str, models.Group]] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) + async def async_get_groups(self) -> List[models.Group]: + """Retrieve all users.""" + if self._groups is None: + await self._async_load() + assert self._groups is not None + + return list(self._groups.values()) + async def async_get_users(self) -> List[models.User]: """Retrieve all users.""" if self._users is None: @@ -51,14 +61,20 @@ class AuthStore: self, name: Optional[str], is_owner: Optional[bool] = None, is_active: Optional[bool] = None, system_generated: Optional[bool] = None, - credentials: Optional[models.Credentials] = None) -> models.User: + credentials: Optional[models.Credentials] = None, + groups: Optional[List[models.Group]] = None) -> models.User: """Create a new user.""" if self._users is None: await self._async_load() - assert self._users is not None + + assert self._users is not None + assert self._groups is not None kwargs = { - 'name': name + 'name': name, + # Until we get group management, we just put everyone in the + # same group. + 'groups': groups or [], } # type: Dict[str, Any] if is_owner is not None: @@ -219,19 +235,36 @@ class AuthStore: return users = OrderedDict() # type: Dict[str, models.User] + groups = OrderedDict() # type: Dict[str, models.Group] # When creating objects we mention each attribute explicetely. This # prevents crashing if user rolls back HA version after a new property # was added. + for group_dict in data.get('groups', []): + groups[group_dict['id']] = models.Group( + name=group_dict['name'], + id=group_dict['id'], + ) + + migrate_group = None + + if not groups: + migrate_group = models.Group(name=INITIAL_GROUP_NAME) + groups[migrate_group.id] = migrate_group + for user_dict in data['users']: users[user_dict['id']] = models.User( name=user_dict['name'], + groups=[groups[group_id] for group_id + in user_dict.get('group_ids', [])], id=user_dict['id'], is_owner=user_dict['is_owner'], is_active=user_dict['is_active'], system_generated=user_dict['system_generated'], ) + if migrate_group is not None and not user_dict['system_generated']: + users[user_dict['id']].groups = [migrate_group] for cred_dict in data['credentials']: users[cred_dict['user_id']].credentials.append(models.Credentials( @@ -286,6 +319,7 @@ class AuthStore: ) users[rt_dict['user_id']].refresh_tokens[token.id] = token + self._groups = groups self._users = users @callback @@ -300,10 +334,12 @@ class AuthStore: def _data_to_save(self) -> Dict: """Return the data to store.""" assert self._users is not None + assert self._groups is not None users = [ { 'id': user.id, + 'group_ids': [group.id for group in user.groups], 'is_owner': user.is_owner, 'is_active': user.is_active, 'name': user.name, @@ -312,6 +348,14 @@ class AuthStore: for user in self._users.values() ] + groups = [ + { + 'name': group.name, + 'id': group.id, + } + for group in self._groups.values() + ] + credentials = [ { 'id': credential.id, @@ -348,6 +392,7 @@ class AuthStore: return { 'users': users, + 'groups': groups, 'credentials': credentials, 'refresh_tokens': refresh_tokens, } @@ -355,3 +400,11 @@ class AuthStore: def _set_defaults(self) -> None: """Set default values for auth store.""" self._users = OrderedDict() # type: Dict[str, models.User] + + # Add default group + all_access_group = models.Group(name=INITIAL_GROUP_NAME) + + groups = OrderedDict() # type: Dict[str, models.Group] + groups[all_access_group.id] = all_access_group + + self._groups = groups diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index bd00ca72b83..7305e0e77b2 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -14,6 +14,14 @@ TOKEN_TYPE_SYSTEM = 'system' TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token' +@attr.s(slots=True) +class Group: + """A group.""" + + name = attr.ib(type=str) # type: Optional[str] + id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) + + @attr.s(slots=True) class User: """A user.""" @@ -24,6 +32,8 @@ class User: is_active = attr.ib(type=bool, default=False) system_generated = attr.ib(type=bool, default=False) + groups = attr.ib(type=List, factory=list, cmp=False) # type: List[Group] + # List of credentials of a user. credentials = attr.ib( type=list, factory=list, cmp=False diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index fb60b4075ef..ec83918e9f0 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -92,6 +92,7 @@ def _user_info(user): 'is_owner': user.is_owner, 'is_active': user.is_active, 'system_generated': user.system_generated, + 'group_ids': [group.id for group in user.groups], 'credentials': [ { 'type': c.auth_provider_type, diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py new file mode 100644 index 00000000000..a3bdbab93d7 --- /dev/null +++ b/tests/auth/test_auth_store.py @@ -0,0 +1,82 @@ +"""Tests for the auth store.""" +from homeassistant.auth import auth_store + + +async def test_loading_old_data_format(hass, hass_storage): + """Test we correctly load an old data format.""" + hass_storage[auth_store.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'credentials': [], + 'users': [ + { + "id": "user-id", + "is_active": True, + "is_owner": True, + "name": "Paulus", + "system_generated": False, + }, + { + "id": "system-id", + "is_active": True, + "is_owner": True, + "name": "Hass.io", + "system_generated": True, + } + ], + "refresh_tokens": [ + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "user-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": None, + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "system-token-id", + "jwt_key": "some-key", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "system-id" + }, + { + "access_token_expiration": 1800.0, + "client_id": "http://localhost:8123/", + "created_at": "2018-10-03T13:43:19.774637+00:00", + "id": "hidden-because-no-jwt-id", + "last_used_at": "2018-10-03T13:43:19.774712+00:00", + "token": "some-token", + "user_id": "user-id" + }, + ] + } + } + + store = auth_store.AuthStore(hass) + groups = await store.async_get_groups() + assert len(groups) == 1 + group = groups[0] + assert group.name == "All Access" + + users = await store.async_get_users() + assert len(users) == 2 + + owner, system = users + + assert owner.system_generated is False + assert owner.groups == [group] + assert len(owner.refresh_tokens) == 1 + owner_token = list(owner.refresh_tokens.values())[0] + assert owner_token.id == 'user-token-id' + + assert system.system_generated is True + assert system.groups == [] + assert len(system.refresh_tokens) == 1 + system_token = list(system.refresh_tokens.values())[0] + assert system_token.id == 'system-token-id' diff --git a/tests/common.py b/tests/common.py index ee181cfa2e9..cfc29a7f441 100644 --- a/tests/common.py +++ b/tests/common.py @@ -345,17 +345,42 @@ def mock_device_registry(hass, mock_entries=None): return registry +class MockGroup(auth_models.Group): + """Mock a group in Home Assistant.""" + + def __init__(self, id=None, name='Mock Group'): + """Mock a group.""" + kwargs = { + 'name': name + } + if id is not None: + kwargs['id'] = id + + super().__init__(**kwargs) + + def add_to_hass(self, hass): + """Test helper to add entry to hass.""" + return self.add_to_auth_manager(hass.auth) + + def add_to_auth_manager(self, auth_mgr): + """Test helper to add entry to hass.""" + ensure_auth_manager_loaded(auth_mgr) + auth_mgr._store._groups[self.id] = self + return self + + class MockUser(auth_models.User): """Mock a user in Home Assistant.""" def __init__(self, id=None, is_owner=False, is_active=True, - name='Mock User', system_generated=False): + name='Mock User', system_generated=False, groups=None): """Initialize mock user.""" kwargs = { 'is_owner': is_owner, 'is_active': is_active, 'name': name, 'system_generated': system_generated, + 'groups': groups or [], } if id is not None: kwargs['id'] = id diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index cd04eedf08e..f7e348e8476 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -6,7 +6,7 @@ import pytest from homeassistant.auth import models as auth_models from homeassistant.components.config import auth as auth_config -from tests.common import MockUser, CLIENT_ID +from tests.common import MockGroup, MockUser, CLIENT_ID @pytest.fixture(autouse=True) @@ -39,10 +39,13 @@ async def test_list_requires_owner(hass, hass_ws_client, hass_access_token): async def test_list(hass, hass_ws_client): """Test get users.""" + group = MockGroup().add_to_hass(hass) + owner = MockUser( id='abc', name='Test Owner', is_owner=True, + groups=[group], ).add_to_hass(hass) owner.credentials.append(auth_models.Credentials( @@ -61,6 +64,7 @@ async def test_list(hass, hass_ws_client): id='hij', name='Inactive User', is_active=False, + groups=[group], ).add_to_hass(hass) refresh_token = await hass.auth.async_create_refresh_token( @@ -83,6 +87,7 @@ async def test_list(hass, hass_ws_client): 'is_owner': True, 'is_active': True, 'system_generated': False, + 'group_ids': [group.id for group in owner.groups], 'credentials': [{'type': 'homeassistant'}] } assert data[1] == { @@ -91,6 +96,7 @@ async def test_list(hass, hass_ws_client): 'is_owner': False, 'is_active': True, 'system_generated': True, + 'group_ids': [], 'credentials': [], } assert data[2] == { @@ -99,6 +105,7 @@ async def test_list(hass, hass_ws_client): 'is_owner': False, 'is_active': False, 'system_generated': False, + 'group_ids': [group.id for group in inactive.groups], 'credentials': [], }