-
-
Notifications
You must be signed in to change notification settings - Fork 6
feat(superset): Role mapping from OPA #979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+4,694
−2
Merged
Changes from 45 commits
Commits
Show all changes
55 commits
Select commit
Hold shift + click to select a range
2761706
Fixing to 2.0.0
Maleware 7fe1305
Fixing typo
Maleware e138382
Fixing up requests dependency
Maleware 5c710d8
Adding superset Opa manager
Maleware 44c042a
Adding road map
Maleware 79af40d
Adding comment
Maleware 8b6c39d
fix indentation
Maleware d20cf01
adding wip and deployment setup
Maleware a55d625
fix opa rule
labrenbe df4e169
start investigation into role usage
labrenbe 95a7233
Add error handling and configurability & refactor code
labrenbe 1a15b1b
create patch for superset-opa
labrenbe eb18ffe
make manager a seperate file to load it only if necessary
Maleware 2549452
Updating manager, leaving todos
Maleware 0f52ea7
Manager works the way expected now
Maleware f8084bb
More sophisticated logs
Maleware b3de046
Adding better check. Only apply default role if user has none
Maleware d4e985d
Adding STACKABLE_OPA_BASE_URL
Maleware 82f8652
add first unit tests
labrenbe e1d7583
add more unit test and fix code style
labrenbe 43b03b4
move opa-authorizer as separate package
labrenbe 7954d3a
fix gitignore
labrenbe 6c83ba4
add caching to 'get_opa_user_roles'
labrenbe a04716f
fix caching
labrenbe 5a94dc9
remove supersetopa-integration directory
labrenbe 2b1f598
Merge remote-tracking branch 'origin/main' into feature/superset-opa-…
labrenbe 9e7f111
add dummy changelog entry
labrenbe a20d640
fix linting
labrenbe 44637a1
Merge branch 'main' into feature/superset-opa-integration
Maleware 99dfe75
remove opa client
labrenbe e387df6
fix typo
labrenbe 01e8950
Merge remote-tracking branch 'origin/main' into feature/superset-opa-…
labrenbe a3dc945
address feedback on PR
labrenbe 7cd8907
add readme and remove opa client
labrenbe d8ff055
fix changelog
labrenbe be486c9
Merge remote-tracking branch 'origin/main' into feature/superset-opa-…
labrenbe fc1b5b5
Merge remote-tracking branch 'origin/main' into feature/superset-opa-…
labrenbe a4e345f
Merge branch 'main' into feature/superset-opa-integration
razvan ea94470
refactor opa authorizer to cache resolved roles
razvan 95a92ad
poetry install doesn't find python. use sync instead
razvan 81b783a
do not default to the Public role
razvan 5a79599
pin poetry version and use heredoc syntax
razvan 6874127
do not mutate user roles anymore
razvan fc2372c
use the correct SQLAlchemy session to update user roles
razvan 31f7b1e
docs and silence some checker errors
razvan 8cc5029
Merge branch 'main' into feature/superset-opa-integration
razvan c0ef2f8
fix project dependencies
razvan 27d151c
change log level to debug
razvan 37689c2
clarify doc
razvan c1709c2
cleanup roles before updating
razvan 39035a8
do not raise exception if role doesn't exist
razvan 2fcacf6
update doc
razvan 889d9e4
Set user roles in "update_user_auth_stat" instead of "get_user_roles"
siegfriedweber 4c83924
remove auth_opa_package
razvan 49ef38d
Merge branch 'main' into feature/superset-opa-integration
razvan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| **/.pytest_cache | ||
| dist |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Superset OPA authorizer | ||
|
|
||
| Custom Superset security manager that syncs to an Open Policy | ||
| Agent | ||
|
|
||
| [Poetry](https://python-poetry.org/) is used to build the project: | ||
|
|
||
| poetry build | ||
|
|
||
| The unit tests can be run as follows: | ||
|
|
||
| poetry run pytest |
Empty file.
196 changes: 196 additions & 0 deletions
196
superset/stackable/opa-authorizer/opa_authorizer/opa_manager.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,196 @@ | ||
| """ | ||
| Custom security manager for Superset. | ||
|
|
||
| Assigns OPA roles to a user. The roles and their permissions must exist in the | ||
| Superset database. | ||
| """ | ||
|
|
||
| import logging | ||
| from dataclasses import dataclass | ||
| from typing import Optional | ||
|
|
||
| import requests | ||
| from cachetools import TTLCache, cachedmethod | ||
| from flask import current_app, g | ||
| from flask_appbuilder import AppBuilder | ||
| from flask_appbuilder.security.sqla.models import Role, User | ||
| from overrides import override | ||
| from sqlalchemy.orm.session import Session | ||
| from superset.security import SupersetSecurityManager | ||
|
|
||
| log = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class OpaError(Exception): | ||
| pass | ||
|
|
||
|
|
||
| class SupersetError(Exception): | ||
| pass | ||
|
|
||
|
|
||
| @dataclass | ||
| class OpaResponse: | ||
| roles: list[str] | ||
|
|
||
|
|
||
| def opa_response_from_json(json: dict[str, object]) -> OpaResponse: | ||
| """Converts a JSON object to an OpaResponse object.""" | ||
| if "result" in json: | ||
| if type(json["result"]) is list: | ||
| return OpaResponse(roles=json["result"]) | ||
|
|
||
| raise OpaError(f"Invalid OPA response: [{json}]") | ||
|
|
||
|
|
||
| class OpaSupersetSecurityManager(SupersetSecurityManager): | ||
| """ | ||
| Custom security manager that syncs role mappings from Open Policy Agent to Superset. | ||
| """ | ||
|
|
||
| AUTH_OPA_CACHE_MAXSIZE_DEFAULT: int = 1000 | ||
| AUTH_OPA_CACHE_TTL_IN_SEC_DEFAULT: int = 30 | ||
| AUTH_OPA_REQUEST_URL_DEFAULT: str = "http://opa:8081/" | ||
| AUTH_OPA_REQUEST_TIMEOUT_DEFAULT: int = 10 | ||
| AUTH_OPA_PACKAGE_DEFAULT: str = "superset" | ||
| AUTH_OPA_RULE_DEFAULT: str = "user_roles" | ||
|
|
||
| def __init__(self, appbuilder: AppBuilder): | ||
| super().__init__(appbuilder) | ||
|
|
||
| config = appbuilder.get_app.config | ||
|
|
||
| self.role_cache: TTLCache[str, set[Role]] = TTLCache( | ||
| maxsize=config.get( | ||
| "AUTH_OPA_CACHE_MAXSIZE", self.AUTH_OPA_CACHE_MAXSIZE_DEFAULT | ||
| ), | ||
| ttl=config.get( | ||
| "AUTH_OPA_CACHE_TTL_IN_SEC", self.AUTH_OPA_CACHE_TTL_IN_SEC_DEFAULT | ||
| ), | ||
| ) | ||
|
|
||
| self.auth_opa_url: str = config.get( | ||
| "AUTH_OPA_REQUEST_URL", self.AUTH_OPA_REQUEST_URL_DEFAULT | ||
| ) | ||
| self.auth_opa_package: str = config.get( | ||
| "AUTH_OPA_PACKAGE", self.AUTH_OPA_PACKAGE_DEFAULT | ||
| ) | ||
| self.auth_opa_rule: str = config.get( | ||
| "AUTH_OPA_RULE", self.AUTH_OPA_RULE_DEFAULT | ||
| ) | ||
| self.auth_opa_request_timeout: int = current_app.config.get( | ||
| "AUTH_OPA_REQUEST_TIMEOUT", self.AUTH_OPA_REQUEST_TIMEOUT_DEFAULT | ||
| ) | ||
|
|
||
| self.opa_session: requests.Session = requests.Session() | ||
|
|
||
| @override | ||
| def get_user_roles(self, user: Optional[User] = None) -> list[Role]: | ||
| """ | ||
| Retrieves a user's roles from an Open Policy Agent instance updating the | ||
| user-role mapping in Superset's database in the process. | ||
|
|
||
| :returns: A list of roles. | ||
| """ | ||
| if not user: | ||
| user = g.user | ||
|
|
||
| if user: | ||
| resolved_opa_roles = self.roles(user) | ||
|
|
||
| self.merge_user_roles(user, resolved_opa_roles) | ||
|
|
||
| return resolved_opa_roles | ||
| else: | ||
| raise Exception("Cannot get roles without a user.") | ||
|
|
||
| @cachedmethod(lambda self: self.role_cache) | ||
| def roles(self, user: User) -> list[Role]: | ||
| """ | ||
| Retrieves a user's role names from an Open Policy Agent instance. | ||
| Maps these names to existing Role objects in the Superset database and | ||
| possibly updates the user entity. | ||
siegfriedweber marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| The result is cached. | ||
| """ | ||
| opa_role_names = self.opa_get_user_roles(user.username) | ||
| result: list[Role] = self.resolve_user_roles(user, opa_role_names) | ||
| return result | ||
|
|
||
| def merge_user_roles(self, user: User, roles: list[Role]): | ||
| """ | ||
| Updates the roles of a user in the Superset database if neededd. | ||
| """ | ||
| if self.superset_roles_outdated(user.roles, roles): | ||
| user.roles = roles | ||
| # We need to use the same SQLA Session that was used to create the object | ||
| sqla_session = Session.object_session(user) | ||
| sqla_session.merge(user) | ||
siegfriedweber marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| sqla_session.commit() | ||
|
|
||
| def superset_roles_outdated( | ||
| self, superset_roles: list[Role], opa_roles: list[Role] | ||
| ) -> bool: | ||
| superset_role_set: set[str] = set([role.name for role in superset_roles]) | ||
| opa_role_set: set[str] = set([role.name for role in opa_roles]) | ||
| return superset_role_set != opa_role_set | ||
|
|
||
| def opa_get_user_roles(self, username: str) -> list[str]: | ||
| """ | ||
| Queries an Open Policy Agent instance for the roles of a given user. | ||
|
|
||
| :returns: A list of Role objects assigned to the user or an empty list. | ||
| """ | ||
| input = {"input": {"username": username}} | ||
| try: | ||
| req_url = f"{self.auth_opa_url}/v1/data/{self.auth_opa_package}/{self.auth_opa_rule}" | ||
| response = self.call_opa( | ||
| url=req_url, | ||
| json=input, | ||
| timeout=self.auth_opa_request_timeout, | ||
| ) | ||
|
|
||
| opa_response: OpaResponse = response.json( | ||
| object_hook=opa_response_from_json | ||
| ) | ||
|
|
||
| log.info(f"OPA role names for user [{username}]: [{opa_response.roles}]") | ||
|
|
||
| return opa_response.roles | ||
|
|
||
| except Exception as e: | ||
| log.error("Failed to get OPA role names", exc_info=e) | ||
| return [] | ||
|
|
||
| def call_opa(self, url: str, json: dict, timeout: int) -> requests.Response: | ||
| return self.opa_session.post( | ||
| url=url, | ||
| json=json, | ||
| timeout=timeout, | ||
| ) | ||
|
|
||
| def resolve_user_roles(self, user: User, roles: list[str]) -> list[Role]: | ||
| """ | ||
| Given a user object and a list of OPA role names, return the Role objects | ||
| that must be assigned to this user. | ||
|
|
||
| The user object is only needed to ensure that the Role objects are resolved | ||
| using the same SQLAlchemy session as the user object. | ||
|
|
||
| The Session object assigned to the SecurityManager is apparently not the same | ||
| Session as the one used by the FAB login. | ||
| """ | ||
| result: list[Role] = list() | ||
| sqla_session = Session.object_session(user) | ||
| superset_roles = sqla_session.query(Role).all() | ||
| for role_name in roles: | ||
| found = False | ||
|
|
||
| for role in superset_roles: | ||
| if role.name == role_name: | ||
| result.append(role) | ||
| log.info(f"Resolved Superset role [{role_name}].") | ||
siegfriedweber marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| found = True | ||
|
|
||
| if not found: | ||
| raise SupersetError(f"Superset role [{role_name}] does not exist.") | ||
siegfriedweber marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return result | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.