11diff --git a/superset/security/opa_manager.py b/superset/security/opa_manager.py
22new 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)
82102diff --git a/tests/unit_tests/security/opa_manager_test.py b/tests/unit_tests/security/opa_manager_test.py
83103new 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