33"""
44
55import logging
6+ from typing import Optional
67
7- from typing import List , Optional
8- from cachetools import cachedmethod , TTLCache
9- from flask import current_app , g
10- from flask_appbuilder .security .sqla .models import (
11- Role ,
12- User ,
13- )
148import requests
9+ from cachetools import TTLCache , cachedmethod
10+ from flask import current_app , g
11+ from flask_appbuilder .security .sqla .models import Role , User
12+ from overrides import override
1513from superset .security import SupersetSecurityManager
1614
15+ log = logging .getLogger (__name__ )
16+
1717
1818class OpaSupersetSecurityManager (SupersetSecurityManager ):
1919 """
@@ -29,20 +29,39 @@ class OpaSupersetSecurityManager(SupersetSecurityManager):
2929 AUTH_USER_REGISTRATION_ROLE_DEFAULT = "Public"
3030
3131 def __init__ (self , appbuilder ):
32- self .appbuilder = appbuilder
33- super ().__init__ (self .appbuilder )
34- config = self .appbuilder .get_app .config
35- self .opa_cache = self .opa_cache = TTLCache (
32+ super ().__init__ (appbuilder )
33+
34+ config = appbuilder .get_app .config
35+
36+ self .role_cache : TTLCache [str , set [Role ]] = TTLCache (
3637 maxsize = config .get (
3738 "AUTH_OPA_CACHE_MAXSIZE" , self .AUTH_OPA_CACHE_MAXSIZE_DEFAULT
3839 ),
3940 ttl = config .get (
4041 "AUTH_OPA_CACHE_TTL_IN_SEC" , self .AUTH_OPA_CACHE_TTL_IN_SEC_DEFAULT
4142 ),
4243 )
44+
45+ self .auth_opa_url : str = config .get (
46+ "AUTH_OPA_REQUEST_URL" , self .AUTH_OPA_REQUEST_URL_DEFAULT
47+ )
48+ self .auth_opa_package : str = config .get (
49+ "AUTH_OPA_PACKAGE" , self .AUTH_OPA_PACKAGE_DEFAULT
50+ )
51+ self .auth_opa_rule : str = config .get (
52+ "AUTH_OPA_RULE" , self .AUTH_OPA_RULE_DEFAULT
53+ )
54+ self .auth_opa_request_timeout : int = current_app .config .get (
55+ "AUTH_OPA_REQUEST_TIMEOUT" , self .AUTH_OPA_REQUEST_TIMEOUT_DEFAULT
56+ )
57+ # Cannot name this "auth_auth_user_registration_role" because it clashes with some super() property constraints
58+ self .user_registration_role : str = config .get (
59+ "AUTH_USER_REGISTRATION_ROLE" , self .AUTH_USER_REGISTRATION_ROLE_DEFAULT
60+ )
4361 self .opa_session = requests .Session ()
4462
45- def get_user_roles (self , user : Optional [User ] = None ) -> List [Role ]:
63+ @override
64+ def get_user_roles (self , user : Optional [User ] = None ) -> list [Role ]:
4665 """
4766 Retrieves a user's roles from an Open Policy Agent instance updating the
4867 user-role mapping in Superset's database in the process.
@@ -52,96 +71,132 @@ def get_user_roles(self, user: Optional[User] = None) -> List[Role]:
5271 if not user :
5372 user = g .user
5473
55- default_role = self .resolve_role (
56- current_app .config .get (
57- "AUTH_USER_REGISTRATION_ROLE" , self .AUTH_USER_REGISTRATION_ROLE_DEFAULT
58- )
59- )
60-
61- opa_role_names = self .get_opa_user_roles (user .username )
62- logging .debug (
63- "OPA returned roles for user %s: %s" , user .get_full_name (), opa_role_names
64- )
74+ resolved_opa_roles = self .resolve_opa_roles (user .username )
6575
66- opa_roles = set (map (self .resolve_role , opa_role_names ))
67- logging .debug (
68- "Resolved OPA Roles in database for user %s: %s" ,
69- user .get_full_name (),
70- opa_roles ,
71- )
72- # Ensure that in case of a bad or no response from OPA each user will have
73- # at least one role.
74- if opa_roles == {None } or opa_roles == set ():
75- opa_roles = {default_role }
76-
77- if set (user .roles ) != opa_roles :
78- logging .debug (
79- "Found a diff in roles for user %s: %s (Superset) and %s (OPA)." ,
80- user .get_full_name (),
81- user .roles ,
82- opa_roles ,
76+ if not resolved_opa_roles :
77+ log .error (
78+ f"No OPA roles for user [{ user .username } ], defaulting to role AUTH_USER_REGISTRATION_ROLE"
79+ )
80+ default_role = self .resolve_role (self .user_registration_role )
81+ if not default_role :
82+ log .error (
83+ f"Failed to resolve default role name { self .user_registration_role } for user [{ user .username } ]. User will have no roles."
84+ )
85+ return []
86+ else :
87+ log .info (
88+ f"User [{ user .username } ] will only have default role [{ self .user_registration_role } ]"
89+ )
90+ return [default_role ]
91+
92+ user_role_set = set (user .roles )
93+ log .debug (f"Superset roles for user [{ user .username } ]: { user_role_set } " )
94+
95+ if user_role_set != resolved_opa_roles :
96+ log .info (
97+ f"Superset and OPA roles diverge. Updating OPA roles for user [{ user .username } ]"
8398 )
84- user .roles = list (opa_roles )
85- self .update_user (user )
99+ user .roles = list (resolved_opa_roles )
100+ if not self .update_user (user ):
101+ log .error (f"Failed to update user roles for user { user .username } " )
86102
87103 return user .roles
88104
89- @cachedmethod (lambda self : self .opa_cache )
90105 def get_opa_user_roles (self , username : str ) -> list [str ]:
91106 """
92107 Queries an Open Policy Agent instance for the roles of a given user.
93108
94- :returns: A list of role names or an empty list if an exception during
95- the connection to OPA is encountered or if OPA didn't return a list.
109+ :returns: A list of Role objects assigned to the user or an empty list.
96110 """
97-
98- opa_url = current_app .config .get (
99- "AUTH_OPA_REQUEST_URL" , self .AUTH_OPA_REQUEST_URL_DEFAULT
100- )
101- package = current_app .config .get (
102- "AUTH_OPA_PACKAGE" , self .AUTH_OPA_PACKAGE_DEFAULT
103- )
104- rule = current_app .config .get ("AUTH_OPA_RULE" , self .AUTH_OPA_RULE_DEFAULT )
105- timeout = current_app .config .get (
106- "AUTH_OPA_REQUEST_TIMEOUT" , self .AUTH_OPA_REQUEST_TIMEOUT_DEFAULT
107- )
108111 input = {"input" : {"username" : username }}
109112 try :
113+ req_url = f"{ self .auth_opa_url } /v1/data/{ self .auth_opa_package } /{ self .auth_opa_rule } "
110114 response = self .call_opa (
111- url = f" { opa_url } /v1/data/ { package } / { rule } " ,
115+ url = req_url ,
112116 json = input ,
113- timeout = timeout ,
117+ timeout = self . auth_opa_request_timeout ,
114118 )
115119
116- if response .status_code is None or response .status_code != 200 :
117- logging .error ("OPA returned status code %s" , response .status_code )
120+ if response .status_code != 200 :
121+ log .error (
122+ f"OPA request [{ req_url } ] for user [{ username } ] failed with response code [{ response .status_code } ]"
123+ )
118124 return []
119125
120- roles = response .json ().get ("result" )
121- if roles is None or type (roles ). __name__ != " list" :
122- logging .error ("The OPA query didn't return a list: %s" , response . json () )
126+ roles : list [ str ] = response .json ().get ("result" )
127+ if type (roles ) is not list :
128+ log .error (f"Expected a list or role names from OPA but got [ { roles } ]" )
123129 return []
124130
131+ if not roles :
132+ log .error (
133+ "Expected a list or role names from OPA but got an empty list"
134+ )
135+ return []
136+
137+ log .debug (f"Retrieved OPA role names for user [{ username } ]: [{ roles } ]" )
125138 return roles
139+
126140 except Exception as e :
127- logging .error ("Request to OPA failed " , exc_info = e )
141+ log .error ("Failed to get OPA role names " , exc_info = e )
128142 return []
129143
144+ @cachedmethod (lambda self : self .role_cache )
145+ def resolve_opa_roles (self , username : str ) -> set [Role ]:
146+ """
147+ Queries an Open Policy Agent instance for the roles of a given user.
148+
149+ Then maps the OPA role names to Superset Role objects.
150+
151+ The result is cached.
152+
153+ :returns: A set of Role objects assigned to the user or an empty set.
154+ """
155+ try :
156+ roles = self .get_opa_user_roles (username )
157+ resolved_opa_roles = self .resolve_role_list (roles )
158+
159+ log .debug (
160+ f"Resolved OPA Roles for user [{ username } ]: [{ resolved_opa_roles } ]"
161+ )
162+
163+ return resolved_opa_roles
164+
165+ except Exception as e :
166+ log .error ("Failed to resolve OPA roles" , exc_info = e )
167+ return set ()
168+
130169 def call_opa (self , url : str , json : dict , timeout : int ) -> requests .Response :
131170 return self .opa_session .post (
132171 url = url ,
133172 json = json ,
134173 timeout = timeout ,
135174 )
136175
137- def resolve_role (self , role_name : str ) -> Role :
176+ def resolve_role (self , role_name : str ) -> Optional [ Role ] :
138177 """
139178 Finds a role by name creating it if it doesn't exist in Superset yet.
140179
141- :returns: A role.
180+ :returns: A role or None .
142181 """
143- role = self .find_role (role_name )
144- if role is None :
145- logging .debug ("Creating role %s as it doesn't already exist." , role_name )
146- self .add_role (role_name )
147- return self .find_role (role_name )
182+ role : Optional [Role ] = self .find_role (role_name )
183+ if not role :
184+ log .info (
185+ f"Failed to resolve role { role_name } , attempting to create it with no permissions"
186+ )
187+ role = self .add_role (role_name )
188+ if not role :
189+ log .error (f"Failed to create new role { role_name } " )
190+ else :
191+ log .info (f"Resolved role name [{ role_name } ] to new role." )
192+
193+ log .debug (f"Resolved role name [{ role_name } ] to existing role." )
194+
195+ return role
196+
197+ def resolve_role_list (self , roles : list [str ]) -> set [Role ]:
198+ result : set [Role ] = set ()
199+ for role_name in roles :
200+ if resolved_role := self .resolve_role (role_name ):
201+ result .add (resolved_role )
202+ return result
0 commit comments