diff --git a/superset-frontend/src/dashboard/util/findPermission.test.ts b/superset-frontend/src/dashboard/util/findPermission.test.ts index f90c2800f4a37..8930549f4a7e9 100644 --- a/superset-frontend/src/dashboard/util/findPermission.test.ts +++ b/superset-frontend/src/dashboard/util/findPermission.test.ts @@ -75,6 +75,7 @@ describe('canUserEditDashboard', () => { email: 'user@example.com', firstName: 'Test', isActive: true, + isAnonymous: false, lastName: 'User', userId: 1, username: 'owner', diff --git a/superset-frontend/src/preamble.ts b/superset-frontend/src/preamble.ts index 0547e8a6abcca..7bd6dbe29b365 100644 --- a/superset-frontend/src/preamble.ts +++ b/superset-frontend/src/preamble.ts @@ -20,19 +20,24 @@ import { setConfig as setHotLoaderConfig } from 'react-hot-loader'; import 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'; import moment from 'moment'; // eslint-disable-next-line no-restricted-imports -import { configure, supersetTheme } from '@superset-ui/core'; +import { configure, makeApi, supersetTheme } from '@superset-ui/core'; import { merge } from 'lodash'; import setupClient from './setup/setupClient'; import setupColors from './setup/setupColors'; import setupFormatters from './setup/setupFormatters'; import setupDashboardComponents from './setup/setupDasboardComponents'; +import { User } from './types/bootstrapTypes'; if (process.env.WEBPACK_MODE === 'development') { setHotLoaderConfig({ logLevel: 'debug', trackTailUpdates: false }); } // eslint-disable-next-line import/no-mutable-exports -export let bootstrapData: any; +export let bootstrapData: { + user?: User | undefined; + common?: any; + config?: any; +} = {}; // Configure translation if (typeof window !== 'undefined') { const root = document.getElementById('app'); @@ -67,3 +72,24 @@ export const theme = merge( supersetTheme, bootstrapData?.common?.theme_overrides ?? {}, ); + +const getMe = makeApi({ + method: 'GET', + endpoint: '/api/v1/me/', +}); + +/** + * When you re-open the window, we check if you are still logged in. + * If your session expired or you signed out, we'll redirect to login. + * If you aren't logged in in the first place (!isActive), then we shouldn't do this. + */ +if (bootstrapData.user?.isActive) { + document.addEventListener('visibilitychange', () => { + // we only care about the tab becoming visible, not vice versa + if (document.visibilityState !== 'visible') return; + + getMe().catch(() => { + // ignore error, SupersetClient will redirect to login on a 401 + }); + }); +} diff --git a/superset-frontend/src/profile/components/fixtures.tsx b/superset-frontend/src/profile/components/fixtures.tsx index 90e97da22c762..e721b6dfa7510 100644 --- a/superset-frontend/src/profile/components/fixtures.tsx +++ b/superset-frontend/src/profile/components/fixtures.tsx @@ -40,6 +40,7 @@ export const user: UserWithPermissionsAndRoles = { userId: 5, email: 'alpha@alpha.com', isActive: true, + isAnonymous: false, permissions: { datasource_access: ['table1', 'table2'], database_access: ['db1', 'db2', 'db3'], diff --git a/superset-frontend/src/types/bootstrapTypes.ts b/superset-frontend/src/types/bootstrapTypes.ts index 15e7eba0a0313..dc41eb5878f81 100644 --- a/superset-frontend/src/types/bootstrapTypes.ts +++ b/superset-frontend/src/types/bootstrapTypes.ts @@ -23,6 +23,7 @@ export type User = { email: string; firstName: string; isActive: boolean; + isAnonymous: boolean; lastName: string; userId: number; username: string; diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index f2bce87afbc5b..d2b67d95b62af 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -50,6 +50,7 @@ ) from superset.security import SupersetSecurityManager from superset.typing import FlaskResponse +from superset.users.api import CurrentUserRestApi from superset.utils.core import pessimistic_connection_handling from superset.utils.log import DBEventLogger, get_event_logger_from_cfg_value @@ -205,6 +206,7 @@ def init_views(self) -> None: appbuilder.add_api(ChartRestApi) appbuilder.add_api(ChartDataRestApi) appbuilder.add_api(CssTemplateRestApi) + appbuilder.add_api(CurrentUserRestApi) appbuilder.add_api(DashboardFilterStateRestApi) appbuilder.add_api(DashboardRestApi) appbuilder.add_api(DatabaseRestApi) diff --git a/superset/users/api.py b/superset/users/api.py new file mode 100644 index 0000000000000..7d52056a8f070 --- /dev/null +++ b/superset/users/api.py @@ -0,0 +1,56 @@ +# 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 flask import g, Response +from flask_appbuilder.api import BaseApi, expose, safe + +from .schemas import UserResponseSchema + +user_response_schema = UserResponseSchema() + + +class CurrentUserRestApi(BaseApi): + """ An api to get information about the current user """ + + resource_name = "me" + openapi_spec_tag = "Current User" + openapi_spec_component_schemas = (UserResponseSchema,) + + @expose("/", methods=["GET"]) + @safe + def me(self) -> Response: + """Get the user object corresponding to the agent making the request + --- + get: + description: >- + Returns the user object corresponding to the agent making the request, + or returns a 401 error if the user is unauthenticated. + responses: + 200: + description: The current user + content: + application/json: + schema: + type: object + properties: + result: + $ref: '#/components/schemas/UserResponseSchema' + 401: + $ref: '#/components/responses/401' + """ + if g.user is None or g.user.is_anonymous: + return self.response_401() + return self.response(200, result=user_response_schema.dump(g.user)) diff --git a/superset/users/schemas.py b/superset/users/schemas.py new file mode 100644 index 0000000000000..021209edafb8e --- /dev/null +++ b/superset/users/schemas.py @@ -0,0 +1,28 @@ +# 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 marshmallow import Schema +from marshmallow.fields import Boolean, Integer, String + + +class UserResponseSchema(Schema): + id = Integer() + username = String() + email = String() + first_name = String() + last_name = String() + is_active = Boolean() + is_anonymous = Boolean() diff --git a/superset/views/utils.py b/superset/views/utils.py index eda24863e1059..c318c38cbf17d 100644 --- a/superset/views/utils.py +++ b/superset/views/utils.py @@ -81,6 +81,7 @@ def bootstrap_user_data(user: User, include_perms: bool = False) -> Dict[str, An "lastName": user.last_name, "userId": user.id, "isActive": user.is_active, + "isAnonymous": user.is_anonymous, "createdOn": user.created_on.isoformat(), "email": user.email, } diff --git a/tests/integration_tests/security_tests.py b/tests/integration_tests/security_tests.py index 9dca5ac51375c..0cdf0d786b0e2 100644 --- a/tests/integration_tests/security_tests.py +++ b/tests/integration_tests/security_tests.py @@ -907,6 +907,7 @@ def test_views_are_secured(self): ["LocaleView", "index"], ["AuthDBView", "login"], ["AuthDBView", "logout"], + ["CurrentUserRestApi", "me"], ["Dashboard", "embedded"], ["R", "index"], ["Superset", "log"], diff --git a/tests/integration_tests/users/api_tests.py b/tests/integration_tests/users/api_tests.py new file mode 100644 index 0000000000000..ee965f6f2bf01 --- /dev/null +++ b/tests/integration_tests/users/api_tests.py @@ -0,0 +1,49 @@ +# 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. +# type: ignore +"""Unit tests for Superset""" +import json +from unittest.mock import patch + +from superset import security_manager +from tests.integration_tests.base_tests import SupersetTestCase + +meUri = "/api/v1/me/" + + +class TestCurrentUserApi(SupersetTestCase): + def test_get_me_logged_in(self): + self.login(username="admin") + + rv = self.client.get(meUri) + + self.assertEqual(200, rv.status_code) + response = json.loads(rv.data.decode("utf-8")) + self.assertEqual("admin", response["result"]["username"]) + self.assertEqual(True, response["result"]["is_active"]) + self.assertEqual(False, response["result"]["is_anonymous"]) + + def test_get_me_unauthorized(self): + self.logout() + rv = self.client.get(meUri) + self.assertEqual(401, rv.status_code) + + @patch("superset.security.manager.g") + def test_get_me_anonymous(self, mock_g): + mock_g.user = security_manager.get_anonymous_user + rv = self.client.get(meUri) + self.assertEqual(401, rv.status_code)