Skip to content

Commit e1d7583

Browse files
committed
add more unit test and fix code style
1 parent 82f8652 commit e1d7583

File tree

1 file changed

+188
-73
lines changed

1 file changed

+188
-73
lines changed
Lines changed: 188 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,112 +1,114 @@
11
diff --git a/superset/security/opa_manager.py b/superset/security/opa_manager.py
22
new file mode 100644
3-
index 000000000..771377a2b
3+
index 000000000..452d9d322
44
--- /dev/null
55
+++ b/superset/security/opa_manager.py
6-
@@ -0,0 +1,75 @@
6+
@@ -0,0 +1,95 @@
77
+import logging
88
+
9-
+from typing import List, Optional, Tuple
109
+from http.client import HTTPException
11-
+from opa_client.opa import OpaClient
12-
+from superset.security import SupersetSecurityManager
10+
+from typing import List, Optional, Tuple
1311
+from flask import current_app, g
14-
+
1512
+from flask_appbuilder.security.sqla.models import (
1613
+ Role,
1714
+ User,
1815
+)
19-
+
16+
+from opa_client.opa import OpaClient
17+
+from superset.security import SupersetSecurityManager
2018
+
2119
+class OpaSupersetSecurityManager(SupersetSecurityManager):
2220
+ def get_user_roles(self, user: Optional[User] = None) -> List[Role]:
21+
+ """
22+
+ Retrieves a user's roles from an Open Policy Agent instance updating the
23+
+ user-role mapping in Superset's database in the process.
24+
+
25+
+ :returns: A list of roles.
26+
+ """
2327
+ if not user:
2428
+ user = g.user
2529
+
26-
+ default_role = self.resolve_role(current_app.config.get("AUTH_USER_REGISTRATION_ROLE"))
30+
+ default_role = self.resolve_role(
31+
+ current_app.config.get("AUTH_USER_REGISTRATION_ROLE")
32+
+ )
2733
+
2834
+ opa_role_names = self.get_opa_user_roles(user.username)
29-
+ logging.info(f'OPA returned roles: {opa_role_names}')
35+
+ logging.info('OPA returned roles: %s', opa_role_names)
3036
+
3137
+ opa_roles = set(map(self.resolve_role, opa_role_names))
32-
+ logging.info(f'Resolved OPA Roles in Database: {opa_roles}')
33-
+ # Ensure that in case of a bad or no response from OPA each user will have at least one role.
38+
+ logging.info('Resolved OPA Roles in Database: %s', opa_roles)
39+
+ # Ensure that in case of a bad or no response from OPA each user will have
40+
+ # at least one role.
3441
+ if opa_roles == {None} or opa_roles == set():
35-
+ opa_roles = {default_role}
36-
+
42+
+ opa_roles = {default_role}
43+
+
3744
+ if set(user.roles) != opa_roles:
38-
+ logging.info(f'Found diff in {user.roles} vs. {opa_roles}')
39-
+ user.roles = list(opa_roles)
40-
+ self.update_user(user)
45+
+ logging.info('Found a diff between %s (Superset) and %s (OPA).',
46+
+ user.roles, opa_roles)
47+
+ user.roles = list(opa_roles)
48+
+ self.update_user(user)
4149
+
4250
+ return user.roles
4351
+
4452
+
4553
+ def get_opa_user_roles(self, username: str) -> set[str]:
4654
+ """
4755
+ Queries an Open Policy Agent instance for the roles of a given user.
48-
+
49-
+ :returns: A list of role names or an empty list if an exception during the connection to OPA
50-
+ is encountered or if OPA didn't return a list.
56+
+
57+
+ :returns: A list of role names or an empty list if an exception during
58+
+ the connection to OPA is encountered or if OPA didn't return a list.
5159
+ """
52-
+ host, port, tls = self.resolve_opa_base_path()
60+
+ host, port, tls = self.resolve_opa_base_url()
5361
+ client = OpaClient(host = host, port = port, ssl = tls)
5462
+ try:
55-
+ response = client.query_rule(
63+
+ response = client.query_rule(
5664
+ input_data = {'username': username},
5765
+ package_path = current_app.config.get('STACKABLE_OPA_PACKAGE'),
5866
+ rule_name = current_app.config.get('STACKABLE_OPA_RULE'))
59-
+ except HTTPException as e:
60-
+ logging.error(f'Encountered an exception while querying OPA:\n{e}')
61-
+ return []
67+
+ except HTTPException as exception:
68+
+ logging.error('Encountered an exception while querying OPA:%s', exception)
69+
+ return []
6270
+ roles = response.get('result')
6371
+ # If OPA didn't return a result or if the result is not a list, return no roles.
6472
+ if roles is None or type(roles).__name__ != "list":
65-
+ logging.error(f'The OPA query didn\'t return a list: {response}')
66-
+ return []
73+
+ logging.error('The OPA query didn\'t return a list: %s', response)
74+
+ return []
6775
+ return roles
6876
+
6977
+
70-
+ def resolve_opa_base_path(self) -> Tuple[str, int, bool]:
71-
+ opa_base_path = current_app.config.get('STACKABLE_OPA_BASE_URL')
72-
+ [protocol, host, port] = opa_base_path.split(":")
73-
+ return host.lstrip('/'), int(port.rstrip('/')), protocol == 'https'
78+
+ def resolve_opa_base_url(self) -> Tuple[str, int, bool]:
79+
+ """
80+
+ Extracts connection parameters of an Open Policy Agent instance from config.
81+
+
82+
+ :returns: Hostname, port and protocol (http/https).
83+
+ """
84+
+ opa_base_path = current_app.config.get('STACKABLE_OPA_BASE_URL')
85+
+ [protocol, host, port] = opa_base_path.split(":")
86+
+ # remove any path be appended to the base url
87+
+ port = int(port.split('/')[0])
88+
+ return host.lstrip('/'), port, protocol == 'https'
7489
+
7590
+
7691
+ def resolve_role(self, role_name: str) -> Role:
77-
+ role = self.find_role(role_name)
78-
+ if role is None:
79-
+ logging.info(f'Creating role {role_name} as it doesn\'t already exist.')
80-
+ self.add_role(role_name)
81-
+ return self.find_role(role_name)
92+
+ """
93+
+ Finds a role by name creating it if it doesn't exist in Superset yet.
94+
+
95+
+ :returns: A role.
96+
+ """
97+
+ role = self.find_role(role_name)
98+
+ if role is None:
99+
+ logging.info('Creating role %s as it doesn\'t already exist.', role_name)
100+
+ self.add_role(role_name)
101+
+ return self.find_role(role_name)
82102
diff --git a/tests/unit_tests/security/opa_manager_test.py b/tests/unit_tests/security/opa_manager_test.py
83103
new file mode 100644
84-
index 000000000..978d1ca1c
104+
index 000000000..b65d88b19
85105
--- /dev/null
86106
+++ b/tests/unit_tests/security/opa_manager_test.py
87-
@@ -0,0 +1,127 @@
88-
+# Licensed to the Apache Software Foundation (ASF) under one
89-
+# or more contributor license agreements. See the NOTICE file
90-
+# distributed with this work for additional information
91-
+# regarding copyright ownership. The ASF licenses this file
92-
+# to you under the Apache License, Version 2.0 (the
93-
+# "License"); you may not use this file except in compliance
94-
+# with the License. You may obtain a copy of the License at
95-
+#
96-
+# http://www.apache.org/licenses/LICENSE-2.0
97-
+#
98-
+# Unless required by applicable law or agreed to in writing,
99-
+# software distributed under the License is distributed on an
100-
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
101-
+# KIND, either express or implied. See the License for the
102-
+# specific language governing permissions and limitations
103-
+# under the License.
104-
+
107+
@@ -0,0 +1,222 @@
105108
+# pylint: disable=invalid-name, unused-argument, redefined-outer-name
106109
+
107110
+import pytest
108-
+from flask_appbuilder.security.sqla.models import Role, User
109-
+from flask import current_app
111+
+from flask_appbuilder.security.sqla.models import User
110112
+from pytest_mock import MockFixture
111113
+
112114
+from superset.extensions import appbuilder
@@ -145,10 +147,16 @@ index 000000000..978d1ca1c
145147
+ Test that roles are correctly added to a user.
146148
+ """
147149
+ sm = OpaSupersetSecurityManager(appbuilder)
148-
+ mocker.patch('flask_appbuilder.security.sqla.manager.SecurityManager.update_user', return_value = True)
149-
+
150150
+ opa_roles = ['Test1', 'Test2', 'Test3']
151-
+ mocker.patch('superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles', return_value = opa_roles)
151+
+ mocker.patch(
152+
+ 'flask_appbuilder.security.sqla.manager.SecurityManager.update_user',
153+
+ return_value = True
154+
+ )
155+
+ mocker.patch(
156+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles',
157+
+ return_value = opa_roles
158+
+ )
159+
+
152160
+ assert set(sm.get_user_roles(user)) == set(map(sm.resolve_role, opa_roles))
153161
+
154162
+
@@ -161,12 +169,18 @@ index 000000000..978d1ca1c
161169
+ Test that roles are correcty changed on a user.
162170
+ """
163171
+ sm = OpaSupersetSecurityManager(appbuilder)
164-
+ mocker.patch('flask_appbuilder.security.sqla.manager.SecurityManager.update_user', return_value = True)
165-
+
166-
+ user_roles = ['Test1', 'Test2', 'Test3']
167172
+ opa_roles = ['Test4']
173+
+ mocker.patch(
174+
+ 'flask_appbuilder.security.sqla.manager.SecurityManager.update_user',
175+
+ return_value = True
176+
+ )
177+
+ mocker.patch(
178+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles',
179+
+ return_value = opa_roles
180+
+ )
181+
+ user_roles = ['Test1', 'Test2', 'Test3']
168182
+ user.roles = list(map(sm.resolve_role, user_roles))
169-
+ mocker.patch('superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles', return_value = opa_roles)
183+
+
170184
+ assert set(sm.get_user_roles(user)) == set(map(sm.resolve_role, opa_roles))
171185
+
172186
+
@@ -179,11 +193,17 @@ index 000000000..978d1ca1c
179193
+ Test that only the default role is assigned if OPA returns no roles.
180194
+ """
181195
+ sm = OpaSupersetSecurityManager(appbuilder)
182-
+ mocker.patch('flask_appbuilder.security.sqla.manager.SecurityManager.update_user', return_value = True)
183-
+
184196
+ opa_roles = []
185-
+ mocker.patch('superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles', return_value = opa_roles)
197+
+ mocker.patch(
198+
+ 'flask_appbuilder.security.sqla.manager.SecurityManager.update_user',
199+
+ return_value = True
200+
+ )
201+
+ mocker.patch(
202+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles',
203+
+ return_value = opa_roles
204+
+ )
186205
+ default_role = sm.resolve_role('Public')
206+
+
187207
+ assert set(sm.get_user_roles(user)) == {default_role}
188208
+
189209
+
@@ -196,19 +216,114 @@ index 000000000..978d1ca1c
196216
+ Test that only the default role is assigned if a new role can't be created in the DB.
197217
+ """
198218
+ sm = OpaSupersetSecurityManager(appbuilder)
199-
+ mocker.patch('flask_appbuilder.security.sqla.manager.SecurityManager.update_user', return_value = True)
200-
+
201219
+ opa_roles = ['Test5', 'Test6']
202-
+ mocker.patch('superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles', return_value = opa_roles)
203-
+ mocker.patch('superset.security.opa_manager.OpaSupersetSecurityManager.add_role', return_value = None)
220+
+ mocker.patch(
221+
+ 'flask_appbuilder.security.sqla.manager.SecurityManager.update_user',
222+
+ return_value = True
223+
+ )
224+
+ mocker.patch(
225+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.get_opa_user_roles',
226+
+ return_value = opa_roles
227+
+ )
228+
+ mocker.patch(
229+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.add_role',
230+
+ return_value = None
231+
+ )
204232
+ default_role = sm.resolve_role('Public')
233+
+
205234
+ assert set(sm.get_user_roles(user)) == {default_role}
206235
+
207236
+
208-
+def test_resolve_opa_base_path(
237+
+def test_get_opa_roles(
209238
+ mocker: MockFixture,
210239
+ app_context: None,
211240
+) -> None:
241+
+ """
242+
+ Test that roles are correctly extracted from the OPA response.
243+
+ """
244+
+ sm = OpaSupersetSecurityManager(appbuilder)
245+
+ roles = ['Test1', 'Test2', 'Test3']
246+
+ response = {'result': roles}
247+
+ mocker.patch(
248+
+ 'opa_client.opa.OpaClient.query_rule',
249+
+ return_value = response
250+
+ )
251+
+ mocker.patch(
252+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.resolve_opa_base_url',
253+
+ return_value = ('opa-instance', 8081, False)
254+
+ )
255+
+
256+
+ assert sm.get_opa_user_roles('User1') == roles
257+
+
258+
+
259+
+def test_get_opa_roles_no_result(
260+
+ mocker: MockFixture,
261+
+ app_context: None,
262+
+) -> None:
263+
+ """
264+
+ Test that no roles are returned if the OPA response doesn't contain a query result.
265+
+ """
266+
+ sm = OpaSupersetSecurityManager(appbuilder)
267+
+ response = {'error': 'error occurred'}
268+
+ mocker.patch(
269+
+ 'opa_client.opa.OpaClient.query_rule',
270+
+ return_value = response
271+
+ )
272+
+ mocker.patch(
273+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.resolve_opa_base_url',
274+
+ return_value = ('opa-instance', 8081, False)
275+
+ )
276+
+
277+
+ assert sm.get_opa_user_roles('User1') == []
278+
+
279+
+
280+
+def test_get_opa_roles_not_a_list(
281+
+ mocker: MockFixture,
282+
+ app_context: None,
283+
+) -> None:
284+
+ """
285+
+ Test that no roles are returned if the query result doesn't contain a list.
286+
+ """
287+
+ sm = OpaSupersetSecurityManager(appbuilder)
288+
+ response = {'result': "['Test1', 'Test2', 'Test3']"} # type string not list
289+
+ mocker.patch(
290+
+ 'opa_client.opa.OpaClient.query_rule',
291+
+ return_value = response
292+
+ )
293+
+ mocker.patch(
294+
+ 'superset.security.opa_manager.OpaSupersetSecurityManager.resolve_opa_base_url',
295+
+ return_value = ('opa-instance', 8081, False)
296+
+ )
297+
+
298+
+ assert sm.get_opa_user_roles('User1') == []
299+
+
300+
+
301+
+def test_resolve_opa_base_url(
302+
+ mocker: MockFixture,
303+
+ app_context: None,
304+
+) -> None:
305+
+ """
306+
+ Test that only OPA base URL parts correctly resolved from the config.
307+
+ """
308+
+ sm = OpaSupersetSecurityManager(appbuilder)
309+
+ mocker.patch(
310+
+ 'flask.current_app.config.get',
311+
+ return_value = 'http://opa-instance:8081'
312+
+ )
313+
+ assert sm.resolve_opa_base_url() == ('opa-instance', 8081, False)
314+
+
315+
+
316+
+def test_resolve_opa_base_url_with_path(
317+
+ mocker: MockFixture,
318+
+ app_context: None,
319+
+) -> None:
320+
+ """
321+
+ Test that only OPA base URL parts correctly resolved from the config when the URL
322+
+ also contains a path.
323+
+ """
212324
+ sm = OpaSupersetSecurityManager(appbuilder)
213-
+ mocker.patch('flask.current_app.config.get', return_value = 'http://opa-instance:8081')
214-
+ assert sm.resolve_opa_base_path() == ('opa-instance', 8081, False)
325+
+ mocker.patch(
326+
+ 'flask.current_app.config.get',
327+
+ return_value = 'http://opa-instance:8081/v1/data/superset'
328+
+ )
329+
+ assert sm.resolve_opa_base_url() == ('opa-instance', 8081, False)

0 commit comments

Comments
 (0)