|
| 1 | +# pylint: disable=missing-module-docstring |
| 2 | +import logging |
| 3 | + |
| 4 | +from http.client import HTTPException |
| 5 | +from typing import List, Optional, Tuple |
| 6 | +from flask import current_app, g |
| 7 | +from flask_appbuilder.security.sqla.models import ( |
| 8 | + Role, |
| 9 | + User, |
| 10 | +) |
| 11 | +from opa_client.opa import OpaClient |
| 12 | +from superset.security import SupersetSecurityManager |
| 13 | + |
| 14 | + |
| 15 | +class OpaSupersetSecurityManager(SupersetSecurityManager): |
| 16 | + """ |
| 17 | + Custom security manager that syncs user-role mappings from Open Policy Agent to Superset. |
| 18 | + """ |
| 19 | + def get_user_roles(self, user: Optional[User] = None) -> List[Role]: |
| 20 | + """ |
| 21 | + Retrieves a user's roles from an Open Policy Agent instance updating the |
| 22 | + user-role mapping in Superset's database in the process. |
| 23 | +
|
| 24 | + :returns: A list of roles. |
| 25 | + """ |
| 26 | + if not user: |
| 27 | + user = g.user |
| 28 | + |
| 29 | + default_role = self.resolve_role( |
| 30 | + current_app.config.get("AUTH_USER_REGISTRATION_ROLE") |
| 31 | + ) |
| 32 | + |
| 33 | + opa_role_names = self.get_opa_user_roles(user.username) |
| 34 | + logging.info('OPA returned roles: %s', opa_role_names) |
| 35 | + |
| 36 | + opa_roles = set(map(self.resolve_role, opa_role_names)) |
| 37 | + logging.info('Resolved OPA Roles in Database: %s', opa_roles) |
| 38 | + # Ensure that in case of a bad or no response from OPA each user will have |
| 39 | + # at least one role. |
| 40 | + if opa_roles == {None} or opa_roles == set(): |
| 41 | + opa_roles = {default_role} |
| 42 | + |
| 43 | + if set(user.roles) != opa_roles: |
| 44 | + logging.info('Found a diff between %s (Superset) and %s (OPA).', |
| 45 | + user.roles, opa_roles) |
| 46 | + user.roles = list(opa_roles) |
| 47 | + self.update_user(user) |
| 48 | + |
| 49 | + return user.roles |
| 50 | + |
| 51 | + |
| 52 | + def get_opa_user_roles(self, username: str) -> set[str]: |
| 53 | + """ |
| 54 | + Queries an Open Policy Agent instance for the roles of a given user. |
| 55 | +
|
| 56 | + :returns: A list of role names or an empty list if an exception during |
| 57 | + the connection to OPA is encountered or if OPA didn't return a list. |
| 58 | + """ |
| 59 | + host, port, tls = self.resolve_opa_base_url() |
| 60 | + client = OpaClient(host = host, port = port, ssl = tls) |
| 61 | + try: |
| 62 | + response = client.query_rule( |
| 63 | + input_data = {'username': username}, |
| 64 | + package_path = current_app.config.get('STACKABLE_OPA_PACKAGE'), |
| 65 | + rule_name = current_app.config.get('STACKABLE_OPA_RULE')) |
| 66 | + except HTTPException as exception: |
| 67 | + logging.error('Encountered an exception while querying OPA:%s', exception) |
| 68 | + return [] |
| 69 | + roles = response.get('result') |
| 70 | + # If OPA didn't return a result or if the result is not a list, return no roles. |
| 71 | + if roles is None or type(roles).__name__ != "list": |
| 72 | + logging.error('The OPA query didn\'t return a list: %s', response) |
| 73 | + return [] |
| 74 | + return roles |
| 75 | + |
| 76 | + |
| 77 | + def resolve_opa_base_url(self) -> Tuple[str, int, bool]: |
| 78 | + """ |
| 79 | + Extracts connection parameters of an Open Policy Agent instance from config. |
| 80 | +
|
| 81 | + :returns: Hostname, port and protocol (http/https). |
| 82 | + """ |
| 83 | + opa_base_path = current_app.config.get('STACKABLE_OPA_BASE_URL') |
| 84 | + [protocol, host, port] = opa_base_path.split(":") |
| 85 | + # remove any path be appended to the base url |
| 86 | + port = int(port.split('/')[0]) |
| 87 | + return host.lstrip('/'), port, protocol == 'https' |
| 88 | + |
| 89 | + |
| 90 | + def resolve_role(self, role_name: str) -> Role: |
| 91 | + """ |
| 92 | + Finds a role by name creating it if it doesn't exist in Superset yet. |
| 93 | +
|
| 94 | + :returns: A role. |
| 95 | + """ |
| 96 | + role = self.find_role(role_name) |
| 97 | + if role is None: |
| 98 | + logging.info('Creating role %s as it doesn\'t already exist.', role_name) |
| 99 | + self.add_role(role_name) |
| 100 | + return self.find_role(role_name) |
0 commit comments