Skip to content

Commit

Permalink
feat: Guest token (for embedded dashboard auth) (#17517)
Browse files Browse the repository at this point in the history
* generate an embed token

* improve existing tests

* add some auth setup, and rename token

* fix the stuff for compatibility with external request loaders

* docs, standard jwt claims, tweaks

* black

* lint

* tests, and safer token decoding

* linting

* type annotation

* prettier

* add feature flag

* quiet pylint

* apparently typing is a problem again

* Make guest role name configurable

* fake being a non-anonymous user

* just one log entry

* customizable algo

* lint

* lint again

* 403 works now!

* get guest token from header instead of cookie

* Revert "403 works now!"

This reverts commit df2f49a.

* fix tests

* Revert "Revert "403 works now!""

This reverts commit 883dff3.

* rename method
  • Loading branch information
suddjian committed Dec 14, 2021
1 parent ee71eb8 commit d705236
Show file tree
Hide file tree
Showing 7 changed files with 357 additions and 27 deletions.
31 changes: 15 additions & 16 deletions superset-frontend/src/components/UiConfigContext/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,19 @@ export const UiConfigContext = createContext<UiConfigType>({

export const useUiConfig = () => useContext(UiConfigContext);

export const EmbeddedUiConfigProvider: React.FC<EmbeddedUiConfigProviderProps> = ({
children,
}) => {
const config = getUrlParam(URL_PARAMS.uiConfig);
const [embeddedConfig] = useState({
hideTitle: (config & 1) !== 0,
hideTab: (config & 2) !== 0,
hideNav: (config & 4) !== 0,
hideChartControls: (config & 8) !== 0,
});
export const EmbeddedUiConfigProvider: React.FC<EmbeddedUiConfigProviderProps> =
({ children }) => {
const config = getUrlParam(URL_PARAMS.uiConfig);
const [embeddedConfig] = useState({
hideTitle: (config & 1) !== 0,
hideTab: (config & 2) !== 0,
hideNav: (config & 4) !== 0,
hideChartControls: (config & 8) !== 0,
});

return (
<UiConfigContext.Provider value={embeddedConfig}>
{children}
</UiConfigContext.Provider>
);
};
return (
<UiConfigContext.Provider value={embeddedConfig}>
{children}
</UiConfigContext.Provider>
);
};
8 changes: 8 additions & 0 deletions superset/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ def _try_json_readsha(filepath: str, length: int) -> Optional[str]:
# a custom security config could potentially give access to setting filters on
# tables that users do not have access to.
"ROW_LEVEL_SECURITY": True,
"EMBEDDED_SUPERSET": False,
# Enables Alerts and reports new implementation
"ALERT_REPORTS": False,
# Enable experimental feature to search for other dashboards
Expand Down Expand Up @@ -1257,6 +1258,13 @@ def SQL_QUERY_MUTATOR( # pylint: disable=invalid-name,unused-argument
)
GLOBAL_ASYNC_QUERIES_WEBSOCKET_URL = "ws://127.0.0.1:8080/"

# Embedded config options
GUEST_ROLE_NAME = "Public"
GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me"
GUEST_TOKEN_JWT_ALGO = "HS256"
GUEST_TOKEN_HEADER_NAME = "X-GuestToken"
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes

# A SQL dataset health check. Note if enabled it is strongly advised that the callable
# be memoized to aid with performance, i.e.,
#
Expand Down
69 changes: 68 additions & 1 deletion superset/security/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,38 @@
# under the License.
import logging

from flask import Response
from flask import request, Response
from flask_appbuilder import expose
from flask_appbuilder.api import BaseApi, safe
from flask_appbuilder.security.decorators import permission_name, protect
from flask_wtf.csrf import generate_csrf
from marshmallow import fields, Schema, ValidationError

from superset.extensions import event_logger

logger = logging.getLogger(__name__)


class UserSchema(Schema):
username = fields.String()
first_name = fields.String()
last_name = fields.String()


class ResourceSchema(Schema):
type = fields.String(required=True)
id = fields.String(required=True)
rls = fields.String()


class GuestTokenCreateSchema(Schema):
user = fields.Nested(UserSchema)
resource = fields.Nested(ResourceSchema, required=True)


guest_token_create_schema = GuestTokenCreateSchema()


class SecurityRestApi(BaseApi):
resource_name = "security"
allow_browser_login = True
Expand Down Expand Up @@ -60,3 +81,49 @@ def csrf_token(self) -> Response:
$ref: '#/components/responses/500'
"""
return self.response(200, result=generate_csrf())

@expose("/guest_token/", methods=["POST"])
@event_logger.log_this
@protect()
@safe
@permission_name("grant_guest_token")
def guest_token(self) -> Response:
"""Response
Returns a guest token that can be used for auth in embedded Superset
---
post:
description: >-
Fetches a guest token
requestBody:
description: Parameters for the guest token
required: true
content:
application/json:
schema: GuestTokenCreateSchema
responses:
200:
description: Result contains the guest token
content:
application/json:
schema:
type: object
properties:
token:
type: string
401:
$ref: '#/components/responses/401'
500:
$ref: '#/components/responses/500'
"""
try:
body = guest_token_create_schema.load(request.json)
# validate stuff:
# make sure the resource id is valid
# make sure username doesn't reference an existing user
# check rls rules for validity?
token = self.appbuilder.sm.create_guest_access_token(
body["user"], [body["resource"]]
)
return self.response(200, token=token)
except ValidationError as error:
return self.response_400(message=error.messages)
73 changes: 73 additions & 0 deletions superset/security/guest_token.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from typing import List, Optional, TypedDict, Union

from flask_appbuilder.security.sqla.models import Role
from flask_login import AnonymousUserMixin


class GuestTokenUser(TypedDict, total=False):
username: str
first_name: str
last_name: str


class GuestTokenResource(TypedDict):
type: str
id: Union[str, int]
rls: Optional[str]


class GuestToken(TypedDict):
iat: float
exp: float
user: GuestTokenUser
resources: List[GuestTokenResource]


class GuestUser(AnonymousUserMixin):
"""
Used as the "anonymous" user in case of guest authentication (embedded)
"""

is_guest_user = True

@property
def is_authenticated(self) -> bool:
"""
This is set to true because guest users should be considered authenticated,
at least in most places. The treatment of this flag is pretty inconsistent.
"""
return True

@property
def is_anonymous(self) -> bool:
"""
This is set to false because lots of code assumes that
role = Public if user.is_anonymous == false.
But guest users need to have their own role independent of Public.
"""
return False

def __init__(self, token: GuestToken, roles: List[Role]):
user = token["user"]
self.guest_token = token
self.username = user.get("username", "guest_user")
self.first_name = user.get("first_name", "Guest")
self.last_name = user.get("last_name", "User")
self.roles = roles
self.resources = token["resources"]
95 changes: 93 additions & 2 deletions superset/security/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"""A set of constants and methods to manage permissions and security"""
import logging
import re
import time
from collections import defaultdict
from typing import (
Any,
Expand All @@ -32,7 +33,8 @@
Union,
)

from flask import current_app, g
import jwt
from flask import current_app, Flask, g, Request
from flask_appbuilder import Model
from flask_appbuilder.models.sqla.interface import SQLAInterface
from flask_appbuilder.security.sqla.manager import SecurityManager
Expand All @@ -51,7 +53,7 @@
ViewMenuModelView,
)
from flask_appbuilder.widgets import ListWidget
from flask_login import AnonymousUserMixin
from flask_login import AnonymousUserMixin, LoginManager
from sqlalchemy import and_, or_
from sqlalchemy.engine.base import Connection
from sqlalchemy.orm import Session
Expand All @@ -63,6 +65,12 @@
from superset.constants import RouteMethod
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import SupersetSecurityException
from superset.security.guest_token import (
GuestToken,
GuestTokenResource,
GuestTokenUser,
GuestUser,
)
from superset.utils.core import DatasourceName, RowLevelSecurityFilterType

if TYPE_CHECKING:
Expand Down Expand Up @@ -172,6 +180,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"can_approve",
"can_update_role",
"all_query_access",
"can_grant_guest_token",
}

READ_ONLY_PERMISSION = {
Expand Down Expand Up @@ -221,6 +230,17 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
"all_query_access",
)

guest_user_cls = GuestUser

def create_login_manager(self, app: Flask) -> LoginManager:
# pylint: disable=import-outside-toplevel
from superset.extensions import feature_flag_manager

lm = super().create_login_manager(app)
if feature_flag_manager.is_feature_enabled("EMBEDDED_SUPERSET"):
lm.request_loader(self.get_guest_user_from_request)
return lm

def get_schema_perm( # pylint: disable=no-self-use
self, database: Union["Database", str], schema: Optional[str] = None
) -> Optional[str]:
Expand Down Expand Up @@ -1228,3 +1248,74 @@ def can_access_based_on_dashboard(datasource: "BaseDatasource") -> bool:

exists = db.session.query(query.exists()).scalar()
return exists

@staticmethod
def _get_current_epoch_time() -> float:
""" This is used so the tests can mock time """
return time.time()

def create_guest_access_token(
self, user: GuestTokenUser, resources: List[GuestTokenResource]
) -> bytes:
secret = current_app.config["GUEST_TOKEN_JWT_SECRET"]
algo = current_app.config["GUEST_TOKEN_JWT_ALGO"]
exp_seconds = current_app.config["GUEST_TOKEN_JWT_EXP_SECONDS"]

# calculate expiration time
now = self._get_current_epoch_time()
exp = now + (exp_seconds * 1000)
claims = {
"user": user,
"resources": resources,
# standard jwt claims:
"iat": now, # issued at
"exp": exp, # expiration time
}
token = jwt.encode(claims, secret, algorithm=algo)
return token

def get_guest_user_from_request(self, req: Request) -> Optional[GuestUser]:
"""
If there is a guest token in the request (used for embedded),
parses the token and returns the guest user.
This is meant to be used as a request loader for the LoginManager.
The LoginManager will only call this if an active session cannot be found.
:return: A guest user object
"""
raw_token = req.headers.get(current_app.config["GUEST_TOKEN_HEADER_NAME"])
if raw_token is None:
return None

try:
token = self.parse_jwt_guest_token(raw_token)
except Exception: # pylint: disable=broad-except
# The login manager will handle sending 401s.
# We don't need to send a special error message.
logger.warning("Invalid guest token", exc_info=True)
return None
else:
return self.guest_user_cls(
token=token,
roles=[self.find_role(current_app.config["GUEST_ROLE_NAME"])],
)

@staticmethod
def parse_jwt_guest_token(raw_token: str) -> GuestToken:
"""
Parses and validates a guest token.
Raises an error if the jwt is invalid:
if it is not signed with our secret,
or if required claims are not present.
:param raw_token: the token gotten from the request
:return: the same token that was passed in, tested but unchanged
"""
secret = current_app.config["GUEST_TOKEN_JWT_SECRET"]
algo = current_app.config["GUEST_TOKEN_JWT_ALGO"]

token = jwt.decode(raw_token, secret, algorithms=[algo])
if token.get("user") is None:
raise ValueError("Guest token does not contain a user claim")
if token.get("resources") is None:
raise ValueError("Guest token does not contain a resources claim")
return cast(GuestToken, token)
Loading

0 comments on commit d705236

Please sign in to comment.