Skip to content

Commit 95a7233

Browse files
committed
Add error handling and configurability & refactor code
1 parent df4e169 commit 95a7233

File tree

4 files changed

+164
-88
lines changed

4 files changed

+164
-88
lines changed
Lines changed: 59 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,78 @@
1+
from http.client import HTTPException
2+
import os
13
from flask import g
24
from flask_appbuilder.security.sqla.models import (Role, User)
35
from opa_client.opa import OpaClient
46
from superset.security.manager import SupersetSecurityManager
5-
from typing import (Optional, List)
7+
from superset import conf
8+
from typing import (Optional, List, Tuple)
69

710
import logging
811

912
# logger = logging.get_logger(__name__)
10-
11-
"""
12-
We want OPA to sync roles.
13-
1. Role Sync via OPA
14-
2. Automated sync ( how and where to sync configurable [Decision Role sync policy] )
15-
3. CRD option to turn sync on, off. [ ~Decision~ Standardized through op-rs ]
16-
4. CRD option for auto delete roles from OPA and how ( Prefix maybe ) [ Decision ]
17-
--> Maybe we don't want that as we could reach permission states in dashboards, charts etc. which
18-
had been RBAC but now the role is gone. What now? Unsecure state.
19-
5. Come up with a patch process for such things ( @Lars )
20-
"""
2113
class OpaSupersetSecurityManager(SupersetSecurityManager):
2214

23-
"""
24-
This is called:
25-
as get_user_permissions() in FlaskApplicationBuilder
26-
- bootstrap_user_data() in superset views (REST APIs)
27-
as get_user_roles
28-
- get_rls_filter() -> row-level filter on tables
29-
- dashboard rbac filter
30-
- is_admin() -> used in many places as admin role in special
31-
32-
Important!
33-
user.roles can also be called directly, looks like you don't have to use the getter...
34-
35-
Seems to not use user.roles:
36-
- resource ownership (looks at owner attribute, not roles)
37-
"""
3815
def get_user_roles(self, user: Optional[User] = None) -> List[Role]:
3916
if not user:
4017
user = g.user
18+
19+
default_role = self.resolve_role(self.get_default_role())
4120

42-
# TODO: Let the operator configure host and port
43-
client = OpaClient(host = 'simple-opa', port=8081)
44-
response = client.query_rule(
45-
input_data = {'username': user.username},
46-
package_path = 'superset',
47-
rule_name = 'user_roles')
48-
logging.info(f'Query: {response}')
49-
role_names = response['result']
50-
logging.info(f'found opa roles: {role_names}')
51-
roles = list(map(self.find_role, role_names))
21+
opa_role_names = self.get_opa_user_roles(user.username)
22+
logging.info(f'OPA returned roles: {opa_role_names}')
5223

53-
# fairly primitive check if roles are already in database
54-
# TODO: Sophisticate
55-
for i, role in enumerate(roles):
56-
if role == None:
57-
logging.info(f'Found None: {role}, adding role {role_names[i]}')
58-
self.add_role(role_names[i])
24+
opa_roles = set(map(self.resolve_role, opa_role_names))
25+
# Ensure that in case of a bad or no reponse from OPA each user will have at least one role.
26+
opa_roles.add(default_role)
27+
28+
if set(user.roles) != opa_roles:
29+
logging.info(f'Found diff in {user.roles} vs. {opa_roles}')
30+
user.roles = list(opa_roles)
31+
self.update_user(user)
5932

60-
roles = list(map(self.find_role, role_names))
33+
return user.roles
34+
6135

62-
# TODO: See if you want to delete roles and how
63-
if set(user.roles) != set(roles):
64-
logging.info(f'found diff in {user.roles} vs. {roles}')
65-
user.roles = roles
66-
self.update_user(user)
36+
def get_opa_user_roles(self, username: str) -> set[str]:
37+
"""
38+
Queries an Open Policy Agent instance for the roles of a given user and returns a list of role names.
39+
Returns an empty list if an exception during the connection to Open Policy Agent is encountered or if the query result
40+
is not a list.
41+
"""
42+
host, port, tls = self.resolve_opa_endpoint()
43+
print(host)
44+
print(port)
45+
print(tls)
46+
client = OpaClient(host = host, port = port, ssl = tls)
47+
try:
48+
response = client.query_rule(
49+
input_data = {'username': username},
50+
package_path = 'superset',
51+
rule_name = 'user_roles')
52+
except HTTPException as e:
53+
logging.error(f'Encountered an exception while querying OPA:\n{e}')
54+
return []
55+
roles = response.get('result')
56+
# If OPA didn't return a result or if the result is not a list, return no roles.
57+
if roles is None or type(roles).__name__ != "list":
58+
logging.error(f'The OPA query didn\'t return a list: {response}')
59+
return []
60+
return roles
61+
6762

68-
logging.info(f'found user roles: {user.roles}')
63+
def resolve_opa_endpoint(self) -> Tuple[str, int, bool]:
64+
opa_endpoint = os.getenv('STACKABLE_OPA_ENDPOINT')
65+
[protocol, host, port] = opa_endpoint.split(":")
66+
return host.lstrip('/'), int(port.rstrip('/')), protocol == 'https'
67+
6968

70-
return roles
69+
def resolve_role(self, role_name: str) -> Role:
70+
role = self.find_role(role_name)
71+
if role is None:
72+
logging.info(f'Creating role {role_name} as it doesn\'t already exist.')
73+
self.add_role(role_name)
74+
return self.find_role(role_name)
75+
76+
77+
def get_default_role(self) -> str:
78+
return conf["AUTH_USER_REGISTRATION_ROLE"] if conf["AUTH_USER_REGISTRATION_ROLE"] else "Public"

superset-opa-integration/TODO.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# ToDos
2+
3+
- Test with UIF + caching in OPA (+ document this)
4+
- Implement changes in operator and CRD
5+
- Documentation (how to write rego rules for this)
6+
- Create a patch for superset image with OPA integration behind feature flag
7+
- Tests
8+
- "Sync interval" mechanism in superset to improve latency and not spam OPA
9+
- Mount OPA service discovery configMap
10+
11+
# What is working
12+
- User-role sync OPA -> Superset
13+
- Role-sync OPA -> Superset during user-role sync
14+
- OPA rego rules returning user-to-role mappings
15+
- Error handling in case OPA is not available or the API call returns a non 200 code or if the result data is garbage (e.g. the UIF source system is not available)
16+
- Make OPA address etc configurable via service discovery
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
apiVersion: v1
3+
kind: ConfigMap
4+
metadata:
5+
name: test
6+
labels:
7+
opa.stackable.tech/bundle: "true"
8+
data:
9+
roles.rego: |
10+
package superset
11+
12+
import rego.v1
13+
14+
default user_roles := []
15+
16+
user_roles := "ABC"
17+
18+
users := [
19+
{"username": "admin", "roles": ["Admin", "Test"]},
20+
{"username": "testuser", "roles": ["El_Testos", "Custom2"]}
21+
]

superset-opa-integration/superset-custom-opa.yaml

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,65 +20,96 @@ spec:
2020
config:
2121
rowLimit: 10000
2222
webserverTimeout: 300
23+
podOverrides:
24+
spec:
25+
containers:
26+
- name: superset
27+
env:
28+
- name: STACKABLE_OPA_ENDPOINT
29+
valueFrom:
30+
configMapKeyRef:
31+
key: OPA
32+
name: simple-opa
2333
configOverrides:
2434
superset_config.py:
2535
EXPERIMENTAL_FILE_HEADER: |
36+
from http.client import HTTPException
37+
import os
2638
from flask import g
2739
from flask_appbuilder.security.sqla.models import (Role, User)
2840
from opa_client.opa import OpaClient
2941
from superset.security.manager import SupersetSecurityManager
30-
from typing import (Optional, List)
42+
from superset import conf
43+
from typing import (Optional, Tuple, List)
3144
3245
import logging
3346
3447
# logger = logging.get_logger(__name__)
35-
36-
"""
37-
We want OPA to sync roles.
38-
1. Role Sync via OPA
39-
2. Automated sync ( how and where to sync configurable [Decision Role sync policy] )
40-
3. CRD option to turn sync on, off. [ ~Decision~ Standardized through op-rs ]
41-
4. CRD option for auto delete roles from OPA and how ( Prefix maybe ) [ Decision ]
42-
--> Maybe we don't want that as we could reach permission states in dashboards, charts etc. which
43-
had been RBAC but now the role is gone. What now? Unsecure state.
44-
5. Come up with a patch process for such things ( @Lars )
45-
"""
4648
class OpaSupersetSecurityManager(SupersetSecurityManager):
4749
4850
def get_user_roles(self, user: Optional[User] = None) -> List[Role]:
4951
if not user:
5052
user = g.user
53+
54+
default_role = self.resolve_role(self.get_default_role())
5155
52-
# TODO: Let the operator configure host and port
53-
client = OpaClient(host = 'simple-opa', port=8081)
54-
response = client.query_rule(
55-
input_data = {'username': user.username},
56-
package_path = 'superset',
57-
rule_name = 'user_roles')
58-
logging.info(f'Query: {response}')
59-
role_names = response['result']
60-
logging.info(f'found opa roles: {role_names}')
61-
roles = list(map(self.find_role, role_names))
62-
63-
# fairly primitive check if roles are already in database
64-
# TODO: Sophisticate
65-
for i, role in enumerate(roles):
66-
if role == None:
67-
logging.info(f'Found None: {role}, adding role {role_names[i]}')
68-
self.add_role(role_names[i])
56+
opa_role_names = self.get_opa_user_roles(user.username)
57+
logging.info(f'OPA returned roles: {opa_role_names}')
6958
70-
roles = list(map(self.find_role, role_names))
71-
72-
# TODO: See if you want to delete roles and how
73-
if set(user.roles) != set(roles):
74-
logging.info(f'found diff in {user.roles} vs. {roles}')
75-
user.roles = roles
59+
opa_roles = set(map(self.resolve_role, opa_role_names))
60+
# Ensure that in case of a bad or no reponse from OPA each user will have at least one role.
61+
opa_roles.add(default_role)
62+
63+
if set(user.roles) != opa_roles:
64+
logging.info(f'Found diff in {user.roles} vs. {opa_roles}')
65+
user.roles = list(opa_roles)
7666
self.update_user(user)
7767
78-
logging.info(f'found user roles: {user.roles}')
68+
return user.roles
69+
70+
71+
def get_opa_user_roles(self, username: str) -> set[str]:
72+
"""
73+
Queries an Open Policy Agent instance for the roles of a given user and returns a list of role names.
74+
Returns an empty list if an exception during the connection to Open Policy Agent is encountered or if the query result
75+
is not a list.
76+
"""
7977
78+
host, port, tls = self.resolve_opa_endpoint()
79+
client = OpaClient(host = host, port = port, ssl = tls)
80+
try:
81+
response = client.query_rule(
82+
input_data = {'username': username},
83+
package_path = 'superset',
84+
rule_name = 'user_roles')
85+
except HTTPException as e:
86+
logging.error(f'Encountered an exception while querying OPA:\n{e}')
87+
return []
88+
roles = response.get('result')
89+
# If OPA didn't return a result or if the result is not a list, return no roles.
90+
if roles is None or type(roles).__name__ != "list":
91+
logging.error(f'The OPA query didn\'t return a list: {response}')
92+
return []
8093
return roles
81-
94+
95+
96+
def resolve_opa_endpoint(self) -> Tuple[str, int, bool]:
97+
opa_endpoint = os.getenv('STACKABLE_OPA_ENDPOINT')
98+
[protocol, host, port] = opa_endpoint.split(":")
99+
return host.lstrip('/'), int(port.rstrip('/')), protocol == 'https'
100+
101+
102+
def resolve_role(self, role_name: str) -> Role:
103+
role = self.find_role(role_name)
104+
if role is None:
105+
logging.info(f'Creating role {role_name} as it doesn\'t already exist.')
106+
self.add_role(role_name)
107+
return self.find_role(role_name)
108+
109+
110+
def get_default_role(self) -> str:
111+
return conf["AUTH_USER_REGISTRATION_ROLE"] if conf["AUTH_USER_REGISTRATION_ROLE"] else "Public"
112+
AUTH_USER_REGISTRATION_ROLE: Gamma
82113
# Maybe also ENABLE_TEMPLATE_PROCESSING
83114
CUSTOM_SECURITY_MANAGER: OpaSupersetSecurityManager
84115
FEATURE_FLAGS: |-

0 commit comments

Comments
 (0)