diff --git a/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx b/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx index 44b193e5535..d4ba20d3421 100644 --- a/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx +++ b/superset-frontend/src/components/IconTooltip/IconTooltip.stories.tsx @@ -39,19 +39,21 @@ const PLACEMENTS = [ 'topRight', ]; -const theme = useTheme(); +export const InteractiveIconTooltip = (args: Props) => { + const theme = useTheme(); -export const InteractiveIconTooltip = (args: Props) => ( -
- - - -
-); + return ( +
+ + + +
+ ); +}; InteractiveIconTooltip.args = { tooltip: 'Tooltip', diff --git a/superset-frontend/src/features/groups/types.ts b/superset-frontend/src/features/groups/types.ts new file mode 100644 index 00000000000..4bfab858bc6 --- /dev/null +++ b/superset-frontend/src/features/groups/types.ts @@ -0,0 +1,31 @@ +/** + * 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. + */ +export interface BaseGroupListModalProps { + show: boolean; + onHide: () => void; + onSave: () => void; +} + +export interface FormValues { + name: string; + label?: string; + description?: string; + roles: number[]; + users: { value: number; label: string }[]; +} diff --git a/superset-frontend/src/features/groups/utils.ts b/superset-frontend/src/features/groups/utils.ts new file mode 100644 index 00000000000..89e3dc05ccd --- /dev/null +++ b/superset-frontend/src/features/groups/utils.ts @@ -0,0 +1,74 @@ +/** + * 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. + */ +import { SupersetClient, t } from '@superset-ui/core'; +import rison from 'rison'; +import { FormValues } from './types'; + +export const createGroup = async (values: FormValues) => { + await SupersetClient.post({ + endpoint: '/api/v1/security/groups/', + jsonPayload: { ...values, users: values.users.map(user => user.value) }, + }); +}; + +export const updateGroup = async (groupId: number, values: FormValues) => { + await SupersetClient.put({ + endpoint: `/api/v1/security/groups/${groupId}`, + jsonPayload: { ...values, users: values.users.map(user => user.value) }, + }); +}; + +export const deleteGroup = async (groupId: number) => + SupersetClient.delete({ + endpoint: `/api/v1/security/groups/${groupId}`, + }); + +export const fetchUserOptions = async ( + filterValue: string, + page: number, + pageSize: number, + addDangerToast: (msg: string) => void, +) => { + const query = rison.encode({ + filter: filterValue, + page, + page_size: pageSize, + order_column: 'username', + order_direction: 'asc', + }); + + try { + const response = await SupersetClient.get({ + endpoint: `/api/v1/security/users/?q=${query}`, + }); + + const results = response.json?.result || []; + + return { + data: results.map((user: any) => ({ + value: user.id, + label: user.username, + })), + totalCount: response.json?.count ?? 0, + }; + } catch (error) { + addDangerToast(t('There was an error while fetching users')); + return { data: [], totalCount: 0 }; + } +}; diff --git a/superset-frontend/src/pages/ActionLog/index.tsx b/superset-frontend/src/pages/ActionLog/index.tsx new file mode 100644 index 00000000000..7c2f708ddb7 --- /dev/null +++ b/superset-frontend/src/pages/ActionLog/index.tsx @@ -0,0 +1,282 @@ +/** + * 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. + */ +import { useMemo } from 'react'; +import { t, css } from '@superset-ui/core'; +import SubMenu, { SubMenuProps } from 'src/features/home/SubMenu'; +import { useListViewResource } from 'src/views/CRUD/hooks'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import ListView, { Filters, FilterOperator } from 'src/components/ListView'; +// eslint-disable-next-line no-restricted-imports +import { Typography } from 'antd-v5'; +import { fetchUserOptions } from 'src/features/groups/utils'; + +export type ActionLogObject = { + user: { + username: string; + }; + action: string; + dttm: string | null; + dashboard_id?: number; + slice_id?: number; + json?: string; + duration_ms?: number; + referrer?: string; +}; + +const PAGE_SIZE = 25; + +function ActionLogList() { + const { addDangerToast, addSuccessToast } = useToasts(); + const initialSort = [{ id: 'dttm', desc: true }]; + const subMenuButtons: SubMenuProps['buttons'] = []; + + const { + state: { + loading, + resourceCount: LogsCount, + resourceCollection: Logs, + bulkSelectEnabled, + }, + fetchData, + refreshData, + toggleBulkSelect, + } = useListViewResource( + 'log', + t('Log'), + addDangerToast, + false, + ); + const filters: Filters = useMemo( + () => [ + { + Header: t('Users'), + key: 'user', + id: 'user', + input: 'select', + operator: FilterOperator.RelationOneMany, + unfilteredLabel: t('All'), + fetchSelects: async (filterValue, page, pageSize) => + fetchUserOptions(filterValue, page, pageSize, addDangerToast), + }, + { + Header: t('Dashboard Id'), + key: 'dashboard_id', + id: 'dashboard_id', + input: 'search', + operator: FilterOperator.Equals, + }, + { + Header: t('Slice Id'), + key: 'slice_id', + id: 'slice_id', + input: 'search', + operator: FilterOperator.Equals, + }, + { + Header: t('Action'), + key: 'action', + id: 'action', + input: 'search', + operator: FilterOperator.Contains, + }, + { + Header: t('JSON'), + key: 'json', + id: 'json', + input: 'search', + operator: FilterOperator.Contains, + }, + { + Header: t('dttm'), + key: 'dttm', + id: 'dttm', + input: 'datetime_range', + operator: FilterOperator.Between, + dateFilterValueType: 'iso', + }, + { + Header: t('Referrer'), + key: 'referrer', + id: 'referrer', + input: 'search', + operator: FilterOperator.Equals, + }, + { + Header: t('Duration Ms'), + key: 'duration_ms', + id: 'duration_ms', + input: 'search', + operator: FilterOperator.Equals, + }, + ], + [], + ); + + const columns = useMemo( + () => [ + { + accessor: 'action', + Header: t('Action'), + Cell: ({ + row: { + original: { action }, + }, + }: any) => {action}, + }, + { + accessor: 'user', + Header: t('User'), + Cell: ({ + row: { + original: { user }, + }, + }: any) => {user?.username}, + }, + + { + accessor: 'duration_ms', + Header: t('Duration Ms'), + + Cell: ({ + row: { + original: { duration_ms }, + }, + }: any) => {duration_ms}, + }, + { + accessor: 'dashboard_id', + Header: t('Dashboard Id'), + hidden: false, + Cell: ({ + row: { + original: { dashboard_id }, + }, + }: any) => {dashboard_id}, + }, + { + accessor: 'slice_id', + Header: t('Slice Id'), + hidden: false, + Cell: ({ + row: { + original: { slice_id }, + }, + }: any) => {slice_id}, + }, + { + accessor: 'json', + Header: t('JSON'), + + Cell: ({ + row: { + original: { json }, + }, + }: any) => ( + + {json} + + ), + }, + + { + accessor: 'referrer', + Header: t('Referrer'), + + Cell: ({ + row: { + original: { referrer }, + }, + }: any) => ( + + {referrer} + + ), + }, + { + accessor: 'dttm', + Header: t('Dttm'), + Cell: ({ + row: { + original: { dttm }, + }, + }: any) => {dttm}, + }, + ], + [], + ); + + const emptyState = { + title: t('No Logs yet'), + image: 'filter-results.svg', + }; + + return ( + <> + + + className="action-log-view" + columns={columns} + count={LogsCount} + data={Logs} + fetchData={fetchData} + filters={filters} + initialSort={initialSort} + loading={loading} + pageSize={PAGE_SIZE} + bulkSelectEnabled={bulkSelectEnabled} + disableBulkSelect={toggleBulkSelect} + addDangerToast={addDangerToast} + addSuccessToast={addSuccessToast} + emptyState={emptyState} + refreshData={refreshData} + /> + + ); +} + +export default ActionLogList; diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 521bbee0ddf..8380ac58f0b 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -137,6 +137,9 @@ const RolesList = lazy( const UsersList: LazyExoticComponent = lazy( () => import(/* webpackChunkName: "UsersList" */ 'src/pages/UsersList'), ); +const ActionLogList: LazyExoticComponent = lazy( + () => import(/* webpackChunkName: "ActionLogList" */ 'src/pages/ActionLog'), +); type Routes = { path: string; @@ -240,6 +243,10 @@ export const routes: Routes = [ path: '/sqllab/', Component: SqlLab, }, + { + path: '/actionlog/list', + Component: ActionLogList, + }, ]; if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index 4022c653ff0..427fd699254 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -175,7 +175,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods from superset.views.error_handling import set_app_error_handlers from superset.views.explore import ExplorePermalinkView, ExploreView from superset.views.log.api import LogRestApi - from superset.views.log.views import LogModelView + from superset.views.logs import ActionLogView from superset.views.roles import RolesListView from superset.views.sql_lab.views import ( SavedQueryView, @@ -224,6 +224,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods appbuilder.add_api(TagRestApi) appbuilder.add_api(SqlLabRestApi) appbuilder.add_api(SqlLabPermalinkRestApi) + appbuilder.add_api(LogRestApi) # # Setup regular views # @@ -289,6 +290,14 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods category_label=__("Security"), ) + appbuilder.add_view( + ActionLogView, + "Action Logs", + label=__("Action Logs"), + category="Security", + category_label=__("Security"), + ) + appbuilder.add_view( DynamicPluginsView, "Plugins", @@ -369,19 +378,6 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods category="Manage", menu_cond=lambda: feature_flag_manager.is_feature_enabled("TAGGING_SYSTEM"), ) - appbuilder.add_api(LogRestApi) - appbuilder.add_view( - LogModelView, - "Action Log", - label=__("Action Log"), - category="Security", - category_label=__("Security"), - icon="fa-list-ol", - menu_cond=lambda: ( - self.config["FAB_ADD_SECURITY_VIEWS"] - and self.config["SUPERSET_LOG_VIEW"] - ), - ) appbuilder.add_api(SecurityRestApi) # # Conditionally setup email views diff --git a/superset/security/manager.py b/superset/security/manager.py index 962e0d9c5c9..3eba380929b 100644 --- a/superset/security/manager.py +++ b/superset/security/manager.py @@ -263,7 +263,7 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods ADMIN_ONLY_VIEW_MENUS = { "Access Requests", - "Action Log", + "Action Logs", "Log", "List Users", "UsersListView", diff --git a/superset/views/log/api.py b/superset/views/log/api.py index ffa3a860060..53a7e08d7f7 100644 --- a/superset/views/log/api.py +++ b/superset/views/log/api.py @@ -19,6 +19,7 @@ from typing import Any, Optional from flask import current_app as app from flask_appbuilder.api import expose, protect, rison, safe from flask_appbuilder.hooks import before_request +from flask_appbuilder.models.sqla.filters import FilterRelationOneToManyEqual from flask_appbuilder.models.sqla.interface import SQLAInterface import superset.models.core as models @@ -45,7 +46,8 @@ class LogRestApi(LogMixin, BaseSupersetModelRestApi): resource_name = "log" allow_browser_login = True list_columns = [ - "user.username", + "user", + "user_id", "action", "dttm", "json", @@ -55,6 +57,21 @@ class LogRestApi(LogMixin, BaseSupersetModelRestApi): "duration_ms", "referrer", ] + search_columns = [ + "user", + "user_id", + "action", + "dttm", + "json", + "slice_id", + "dashboard_id", + "user_id", + "duration_ms", + "referrer", + ] + search_filters = { + "user": [FilterRelationOneToManyEqual], + } show_columns = list_columns page_size = 20 apispec_parameter_schemas = { diff --git a/tests/integration_tests/log_model_view_tests.py b/superset/views/logs.py similarity index 51% rename from tests/integration_tests/log_model_view_tests.py rename to superset/views/logs.py index e347f39e9a4..39fe10d0a3e 100644 --- a/tests/integration_tests/log_model_view_tests.py +++ b/superset/views/logs.py @@ -14,24 +14,21 @@ # KIND, either express or implied. See the License for the # specific language governing permissions and limitations # under the License. -from unittest.mock import patch +from flask_appbuilder import permission_name +from flask_appbuilder.api import expose +from flask_appbuilder.security.decorators import has_access -from superset.views.log.views import LogModelView -from tests.integration_tests.base_tests import SupersetTestCase -from tests.integration_tests.constants import ADMIN_USERNAME +from superset.superset_typing import FlaskResponse + +from .base import BaseSupersetView -class TestLogModelView(SupersetTestCase): - def test_disabled(self): - with patch.object(LogModelView, "is_enabled", return_value=False): - self.login(ADMIN_USERNAME) - uri = "/logmodelview/list/" - rv = self.client.get(uri) - self.assert404(rv) +class ActionLogView(BaseSupersetView): + route_base = "/" + class_permission_name = "security" - def test_enabled(self): - with patch.object(LogModelView, "is_enabled", return_value=True): - self.login(ADMIN_USERNAME) - uri = "/logmodelview/list/" - rv = self.client.get(uri) - self.assert200(rv) + @expose("/actionlog/list") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/tests/integration_tests/log_api_tests.py b/tests/integration_tests/log_api_tests.py index ddec917cb4a..a08068defab 100644 --- a/tests/integration_tests/log_api_tests.py +++ b/tests/integration_tests/log_api_tests.py @@ -98,7 +98,7 @@ class TestLogApi(SupersetTestCase): response = json.loads(rv.data.decode("utf-8")) assert list(response["result"][0].keys()) == EXPECTED_COLUMNS assert response["result"][0]["action"] == "some_action" - assert response["result"][0]["user"] == {"username": "admin"} + assert response["result"][0]["user"]["username"] == "admin" db.session.delete(log) db.session.commit() @@ -132,7 +132,7 @@ class TestLogApi(SupersetTestCase): assert list(response["result"].keys()) == EXPECTED_COLUMNS assert response["result"]["action"] == "some_action" - assert response["result"]["user"] == {"username": "admin"} + assert response["result"]["user"]["username"] == "admin" db.session.delete(log) db.session.commit()