Skip to content

Commit 43b03b4

Browse files
committed
move opa-authorizer as separate package
1 parent e1d7583 commit 43b03b4

File tree

12 files changed

+5286
-331
lines changed

12 files changed

+5286
-331
lines changed
Lines changed: 0 additions & 1 deletion
This file was deleted.

superset/Dockerfile

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@
33

44
FROM stackable/image/statsd_exporter AS statsd_exporter-builder
55

6+
FROM stackable/image/vector AS opa-authorizer-builder
7+
8+
ARG PYTHON
9+
10+
RUN microdnf update \
11+
&& microdnf install \
12+
gcc \
13+
gcc-c++ \
14+
python${PYTHON} \
15+
python${PYTHON}-devel \
16+
python${PYTHON}-pip \
17+
&& microdnf clean all && \
18+
rm -rf /var/cache/yum
19+
20+
RUN pip install \
21+
poetry \
22+
pytest
23+
24+
COPY superset/stackable/opa-authorizer /tmp/opa-authorizer
25+
26+
WORKDIR /tmp/opa-authorizer
27+
28+
RUN poetry install && \
29+
poetry run pytest && \
30+
poetry build
31+
632
FROM stackable/image/vector AS builder
733

834
ARG PRODUCT
@@ -12,6 +38,7 @@ ARG TARGETARCH
1238
ARG TARGETOS
1339

1440
COPY superset/constraints-${PRODUCT}.txt /tmp/constraints.txt
41+
COPY --from=opa-authorizer-builder /tmp/opa-authorizer/dist/opa_authorizer-0.1.0-py3-none-any.whl /tmp/
1542

1643
RUN microdnf update \
1744
&& microdnf install \
@@ -82,7 +109,8 @@ RUN python3 -m venv /stackable/app \
82109
--upgrade \
83110
python-json-logger \
84111
cyclonedx-bom \
85-
&& if [ -n "$AUTHLIB" ]; then pip install Authlib==${AUTHLIB}; fi
112+
&& if [ -n "$AUTHLIB" ]; then pip install Authlib==${AUTHLIB}; fi && \
113+
pip install --no-cache-dir /tmp/opa_authorizer-0.1.0-py3-none-any.whl
86114

87115
COPY superset/stackable/patches /patches
88116
RUN /patches/apply_patches.sh ${PRODUCT}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
*/.pytest_cache
2+
dist

superset/stackable/opa-authorizer/README.md

Whitespace-only changes.

superset/stackable/opa-authorizer/opa_authorizer/__init__.py

Whitespace-only changes.
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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

Comments
 (0)