diff --git a/superset-frontend/src/pages/ApiKeyList/generateToken Modal.tsx b/superset-frontend/src/pages/ApiKeyList/generateToken Modal.tsx new file mode 100644 index 0000000000000..7b5f5b1cd948d --- /dev/null +++ b/superset-frontend/src/pages/ApiKeyList/generateToken Modal.tsx @@ -0,0 +1,151 @@ +import { css, styled, SupersetClient, t } from '@superset-ui/core'; +import { useCallback, useEffect, useState } from 'react'; +import { LabeledErrorBoundInput } from 'src/components/Form'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import Modal from 'src/components/Modal'; +import { createErrorHandler } from 'src/views/CRUD/utils'; + +const noMargins = css` + margin: 0; + + .ant-input { + margin: 0; + } +`; + +const StyledInputContainer = styled.div` + display: flex; + flex-direction: column; + margin: ${({ theme }) => theme.gridUnit}px; + margin-bottom: ${({ theme }) => theme.gridUnit * 4}px; + + .input-container { + display: flex; + align-items: center; + + > div { + width: 100%; + } + } + + input, + textarea { + flex: 1 1 auto; + } + + .required { + margin-left: ${({ theme }) => theme.gridUnit / 2}px; + color: ${({ theme }) => theme.colors.error.base}; + } +`; + +interface CreateTokenModalProps { + show: boolean; + onHide: () => void; + refetchData: () => void; +} + +export function GenerateTokenModal({ + show, + onHide, + refetchData, +}: CreateTokenModalProps) { + const [disableButton, setDisableButton] = useState(true); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const { addSuccessToast, addDangerToast } = useToasts(); + + useEffect(() => { + if(email.length === 0 || password.length === 0){ + setDisableButton(true) + } else { + setDisableButton(false) + } + }, [email, password]); + + const onClose = useCallback(() => { + onHide(); + setEmail(''); + setPassword(''); + }, [onHide, setEmail, setPassword]); + + function onCreateNewToken() { + SupersetClient.post({ + endpoint: `/api/v1/apikeys/`, + jsonPayload: { + username: email, + password: password, + }, + }).then( + () => { + addSuccessToast(t(`Generate new Token`)); + refetchData(); + }, + createErrorHandler(errMsg => { + let message = null; + if ( + typeof errMsg === 'string' && + errMsg?.startsWith('Error to generate new token:') + ) { + message = JSON.parse( + errMsg.split('Error to generate new token: ')[1], + ).error_description; + } else { + message = errMsg; + } + addDangerToast(t('There was an issue creating the token: %s', message)); + }), + ); + onClose(); + } + + return ( + Create new token} + primaryButtonName="Create" + onHandledPrimaryAction={onCreateNewToken} + disablePrimaryButton={disableButton} + > +
+ + + setEmail(target.value), + }} + css={noMargins} + label={t('Email')} + tooltipText={t('The email used to login.')} + hasTooltip + /> + + + + setPassword(target.value), + }} + css={noMargins} + label={t('Password')} + type="password" + tooltipText={t('The password used to login.')} + hasTooltip + /> + +
+
+ ); +} diff --git a/superset-frontend/src/pages/ApiKeyList/hooks.ts b/superset-frontend/src/pages/ApiKeyList/hooks.ts new file mode 100644 index 0000000000000..e2e8aeb90d5b1 --- /dev/null +++ b/superset-frontend/src/pages/ApiKeyList/hooks.ts @@ -0,0 +1,41 @@ +import { SupersetClient } from '@superset-ui/core'; +import { useCallback, useState } from 'react'; + +export interface ApiToken { + id: number; + token: string; + created_at: string; + expires_at: string; + status: string; + show: boolean; +} + +export function useGetApiKeysTokens() { + const [isLoading, setIsLoading] = useState(false); + const [result, setResult] = useState([]); + const [error, setError] = useState(null); + + const fetchData = useCallback(async function fetchData() { + try { + setIsLoading(true); + const response = await SupersetClient.get({ + endpoint: '/api/v1/apikeys/', + }); + + const result = response.json.result.map((item: any) => { + item.show = false; + const now = new Date(); + const expires = new Date(item.expires_at); + item.status = now > expires ? 'Expired' : 'Active'; + return item; + }); + setResult(result); + setIsLoading(false); + setError(null); + } catch (error) { + setError(error); + } + }, []); + + return { isLoading, result, error, fetchData }; +} diff --git a/superset-frontend/src/pages/ApiKeyList/index.tsx b/superset-frontend/src/pages/ApiKeyList/index.tsx new file mode 100644 index 0000000000000..7a12e306860da --- /dev/null +++ b/superset-frontend/src/pages/ApiKeyList/index.tsx @@ -0,0 +1,222 @@ +import { SupersetClient, t } from '@superset-ui/core'; +import Button from 'src/components/Button'; +import SubMenu from 'src/features/home/SubMenu'; +import withToasts, { useToasts } from 'src/components/MessageToasts/withToasts'; +import { + Card, + CenteredContainer, + Status, + Table, + TableItem, + Text, +} from './styled'; +import { useCallback, useEffect, useState } from 'react'; +import { createErrorHandler } from 'src/views/CRUD/utils'; +import { ApiToken, useGetApiKeysTokens } from './hooks'; +import Loading from 'src/components/Loading'; +import { GenerateTokenModal } from './generateToken Modal'; + +function ApiKeyList() { + const [showModal, setShowModal] = useState(false); + const [keys, setKeys] = useState([]); + const { isLoading, error, fetchData, result } = useGetApiKeysTokens(); + const { addSuccessToast, addDangerToast } = useToasts(); + + useEffect(() => { + fetchData(); + }, []); + + useEffect(() => { + setKeys(result); + }, [result]); + + useEffect(() => { + if (error) { + addDangerToast(error); + } + }, [error]); + + const current_plan = 'Free tier'; + + const copyText = (text: string) => { + navigator.clipboard.writeText(text); + addSuccessToast('Copied the API Key.'); + }; + + function showKey(index: number) { + const key = keys[index]; + key.show = !key.show; + const newKeys = [...keys]; + newKeys[index] = key; + setKeys(newKeys); + } + + function onRevokeToken(id: number) { + SupersetClient.post({ + endpoint: `/api/v1/apikeys/revoke/${id}`, + }).then( + () => { + addSuccessToast(t(`Revoked token`)); + }, + createErrorHandler(errMsg => { + addDangerToast(t('There was an issue revoking the token: %s', errMsg)); + }), + ); + fetchData(); + } + + function onDeleteToken(id: number) { + SupersetClient.delete({ + endpoint: `/api/v1/apikeys/${id}`, + }).then( + () => { + addSuccessToast(t(`Deleted token`)); + }, + createErrorHandler(errMsg => { + addDangerToast(t('There was an issue deleting the token: %s', errMsg)); + }), + ); + fetchData(); + } + + const onHide = useCallback(() => { + setShowModal(false); + }, [setShowModal]); + + return ( +
+ + + + + {t('Current plan')}: {current_plan} + + + {t('Generate New API key')} + + {t('Your API keys')} + + + + + + + + + + + + {!isLoading && + keys.map((key, index) => { + return ( + + + + + + + + ); + })} + +
+ Token + + Actions + + Created + + Expires + + Status +
+ + + {key.show + ? `${key.token.substring(0, 71)}...` + : '********'} + + + + + + + {key.status === 'Active' && ( + + )} + {key.status === 'Expired' && ( + + )} + + + + {new Date(key.created_at).toLocaleString()} + + + + {new Date(key.expires_at).toLocaleString()} + + + + + {key.status} + + +
+ {isLoading && ( + + + {t('Loading')} + + )} + {keys.length === 0 && !isLoading && ( + + {t('Not found.')} + + )} +
+
+ ); +} + +export default withToasts(ApiKeyList); diff --git a/superset-frontend/src/pages/ApiKeyList/styled.ts b/superset-frontend/src/pages/ApiKeyList/styled.ts new file mode 100644 index 0000000000000..dfe8380e7e887 --- /dev/null +++ b/superset-frontend/src/pages/ApiKeyList/styled.ts @@ -0,0 +1,69 @@ +import { styled } from '@superset-ui/core'; + +export const Card = styled.div` + margin: 0px 16px; + display: flex; + gap: 10px; + flex-direction: column; + background-color: white; + padding: 8px 16px; +`; + +export const Text = styled.span` + font-size: 16px; + font-weight: 800; +`; + +export const Table = styled.table` + th { + background: ${({ theme }) => theme.colors.grayscale.light4}; + span { + white-space: nowrap; + display: flex; + align-items: center; + line-height: 2; + font-size: 14px; + font-weight: 800; + color: ${({ theme }) => theme.colors.grayscale.base}; + padding: 8px; + } + } + + tr { + border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light4}; + + span { + font-weight: 600; + } + } +`; + +export const TableItem = styled.div` + display: flex; + align-items: center; + padding: 8px; + overflow: hidden; + text-overflow: ellipsis; + width: inherit; + white-space: nowrap; +`; + +export const Status = styled.div<{ active?: boolean }>` + padding: 4px; + border-radius: 4px; + width: 100px; + display: flex; + justify-content: center; + color: #fff; + + background-color: ${({ theme, active }) => + active ? theme.colors.success.base : theme.colors.error.base}; +`; + +export const CenteredContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 150px; +` \ No newline at end of file diff --git a/superset-frontend/src/views/routes.tsx b/superset-frontend/src/views/routes.tsx index 7044e8940af34..18010308b7e9a 100644 --- a/superset-frontend/src/views/routes.tsx +++ b/superset-frontend/src/views/routes.tsx @@ -123,6 +123,13 @@ const RowLevelSecurityList = lazy( ), ); +const ApiKeyList = lazy( + () => + import( + /* webpackChunkName: "ApiKeyList" */ 'src/pages/ApiKeyList' + ), +); + type Routes = { path: string; Component: ComponentType; @@ -225,6 +232,10 @@ export const routes: Routes = [ path: '/sqllab/', Component: SqlLab, }, + { + path: '/apikeys/', + Component: ApiKeyList, + }, ]; if (isFeatureEnabled(FeatureFlag.TaggingSystem)) { diff --git a/superset/api_key/api.py b/superset/api_key/api.py new file mode 100644 index 0000000000000..58e132fc0442e --- /dev/null +++ b/superset/api_key/api.py @@ -0,0 +1,322 @@ +import jwt +import logging +import requests + +from datetime import datetime +from flask import request, g, Response +from flask_appbuilder.api import expose, protect, safe +from flask_appbuilder.models.sqla.interface import SQLAInterface +from superset import app, db +from typing import Any, Dict + +from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP, RouteMethod +from superset.extensions import event_logger +from superset.api_key.models import ApiKeyToken, ApiKeyTokenFilter +from superset.views.base_api import ( + BaseSupersetModelRestApi, + requires_json, + statsd_metrics, +) + +logger = logging.getLogger(__name__) + +class ApiKeysRestApi(BaseSupersetModelRestApi): + resource_name = "apikeys" + class_permission_name = "ApiKeysRestApi" + openapi_spec_tag = "ApiKeys" + datamodel = SQLAInterface(ApiKeyToken) + base_filters = [["user_id", ApiKeyTokenFilter, lambda: []]] + + # Keycloak config + KEYCLOAK_CONFIG = { + "url": app.config.get("KEYCLOAK_URL"), + "realm": app.config.get("KEYCLOAK_REALM"), + "client_id": app.config.get("KEYCLOAK_CLIENT_ID"), + "client_secret": app.config.get("KEYCLOAK_CLIENT_SECRET") + } + + allow_browser_login = True + + include_route_methods = RouteMethod.REST_MODEL_VIEW_CRUD_SET | { + "revoke_token_post", + } + + method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP | { + "revoke_token_post": "write", + } + + list_columns = ["token", "created_at", "expires_at", "id"] + show_columns = ["token", "created_at", "expires_at", "id"] + order_columns = ["created_at", "expires_at"] + + def generate_token(self, username: str, password: str) -> tuple: + """Generate new Token for Keycloak.""" + try: + token_url = f"{self.KEYCLOAK_CONFIG['url']}/realms/{self.KEYCLOAK_CONFIG['realm']}/protocol/openid-connect/token" + + response = requests.post( + token_url, + data={ + "grant_type": "password", + "client_id": self.KEYCLOAK_CONFIG['client_id'], + "client_secret": self.KEYCLOAK_CONFIG['client_secret'], + "username": username, + "password": password, + "scope": "openid profile email roles" + } + ) + + if response.status_code != 200: + raise Exception(f"Error to generate new token: {response.text}") + + token_data = response.json() + decoded_token = jwt.decode( + token_data["access_token"], + options={"verify_signature": False} + ) + + return token_data["access_token"], decoded_token + + except Exception as e: + logger.error(f"Error to generate new token: {str(e)}") + raise + + def revoke_token(self, token, token_type_hint=None): + """Revoke a Keycloak access token.""" + try: + revoke_url = f"{self.KEYCLOAK_CONFIG['url']}/realms/{self.KEYCLOAK_CONFIG['realm']}/protocol/openid-connect/revoke" + + data = { + "token": token, + "client_id": self.KEYCLOAK_CONFIG['client_id'], + "client_secret": self.KEYCLOAK_CONFIG['client_secret'] + } + + if token_type_hint: + data["token_type_hint"] = token_type_hint + + response = requests.post(revoke_url, data=data) + + if response.status_code != 200: + error_message = f"Error revoking token: {response.text}" + logger.error(error_message) + raise Exception(error_message) + + logger.debug(f"Token revoked successfully.") + except Exception as e: + logger.exception(f"Error revoking token: {str(e)}") + raise + + @expose("/", methods=("GET",)) + @protect() + @safe + @statsd_metrics + def get(self, pk: int) -> Dict[str, Any]: + """Get specific API key details. + --- + get: + summary: Get specific API key details + parameters: + - in: path + name: pk + schema: + type: integer + required: true + responses: + 200: + description: API key details retrieved + content: + application/json: + schema: + $ref: '#/components/schemas/ApiKeyToken' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + token: ApiKeyToken = self.datamodel.get(pk) + + if not token: + return self.response_404() + + if token.user_id != g.user.id: + return self.response_403() + + return self.response(200, result=token.to_dict()) + except Exception as e: + return self.response_500(message=str(e)) + + @expose("/", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @requires_json + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.post", + log_to_statsd=False, + ) + def post(self) -> Response: + """Create new token. + --- + post: + summary: Create new API key token + requestBody: + content: + application/json: + schema: + type: object + required: + - username + - password + properties: + username: + type: string + password: + type: string + responses: + 200: + description: API key created + content: + application/json: + schema: + type: object + properties: + message: + type: string + result: + $ref: '#/components/schemas/ApiKeyToken' + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 422: + $ref: '#/components/responses/422' + 500: + $ref: '#/components/responses/500' + """ + if not request.is_json: + return self.response_400(message="Request should be JSON") + try: + data = request.json + required_fields = ["username", "password"] + + if not all(field in data for field in required_fields): + return self.response_400(message=f"Required fields: {', '.join(required_fields)}") + + # Generate token + token, decoded_token = self.generate_token( + data["username"], + data["password"] + ) + + # Create the record of token in the db + token_record = ApiKeyToken( + user_id=g.user.id, + token=token, + expires_at=datetime.fromtimestamp(decoded_token["exp"]) + ) + + db.session.add(token_record) + db.session.commit() + + response = { + "message": "Token created successfully.", + "result": token_record.to_dict() + } + + return self.response(200, **response) + except Exception as e: + db.session.rollback() + return self.response_500(message=str(e)) + + @expose("/", methods=("DELETE",)) + @protect() + @safe + @statsd_metrics + def delete(self, pk: int) -> Response: + """Delete an API key. + --- + delete: + summary: Delete an API key + parameters: + - in: path + name: pk + schema: + type: integer + required: true + responses: + 200: + description: API key deleted + content: + application/json: + schema: + type: object + properties: + message: + type: string + 401: + $ref: '#/components/responses/401' + 403: + $ref: '#/components/responses/403' + 404: + $ref: '#/components/responses/404' + 500: + $ref: '#/components/responses/500' + """ + try: + token: ApiKeyToken = self.datamodel.get(pk) + + if not token: + return self.response_404() + + if token.user_id != g.user.id: + return self.response_403() + + db.session.delete(token) + db.session.commit() + + response = { + "message": "Token deleted successfully" + } + + return self.response(200, **response) + except Exception as e: + db.session.rollback() + return self.response_500(message=str(e)) + + @expose("/revoke/", methods=("POST",)) + @protect() + @safe + @statsd_metrics + @event_logger.log_this_with_context( + action=lambda self, *args, **kwargs: f"{self.__class__.__name__}" + f".revoke_token_post", + log_to_statsd=False, + ) + def revoke_token_post(self, pk: int) -> Response: + try: + token: ApiKeyToken = self.datamodel.get(pk) + if not token: + return self.response_404() + + if token.user_id != g.user.id: + return self.response_403() + + self.revoke_token(token.token) + + db.session.delete(token) + db.session.commit() + + response = { + "message": "Token revoked successfully" + } + + return self.response(200, **response) + except Exception as e: + db.session.rollback() + return self.response_500(message=str(e)) diff --git a/superset/api_key/models.py b/superset/api_key/models.py new file mode 100644 index 0000000000000..3515ee1c939e8 --- /dev/null +++ b/superset/api_key/models.py @@ -0,0 +1,48 @@ +from datetime import datetime, timezone +from flask import g +from flask_appbuilder import Model +from flask_appbuilder.models.sqla.filters import BaseFilter +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from sqlalchemy.orm import relationship, Query +from typing import Any, Dict + +class ApiKeyToken(Model): + """Model para armazenar tokens do Keycloak""" + __tablename__ = 'apikey_tokens' + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey('ab_user.id'), nullable=False) + token = Column(String(2000), nullable=False) + created_at = Column(DateTime, default=datetime.now()) + expires_at = Column(DateTime, nullable=False) + + # Create a foreign key with the active user + user = relationship('User', foreign_keys=[user_id]) + + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "user_id": self.user_id, + "token": self.token, + "created_at": self.created_at.isoformat() if self.created_at else None, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + } + +class ApiKeyTokenFilter(BaseFilter): + """Filter that ensures users can only access their own API keys""" + + def apply(self, query: Query, value: Any) -> Query: + """ + Apply the filter to restrict API key access to the current user + + Args: + query (Query): The base query to filter + value (Any): Not used in this implementation + + Returns: + Query: The filtered query showing only the current user's API keys + """ + if not g.user: + return query.filter(self.model.id < 0) # Return empty if no user + + return query.filter(self.model.user_id == g.user.id) \ No newline at end of file diff --git a/superset/api_key/views.py b/superset/api_key/views.py new file mode 100644 index 0000000000000..a0acdcb775364 --- /dev/null +++ b/superset/api_key/views.py @@ -0,0 +1,17 @@ +from flask_appbuilder import expose +from flask_appbuilder.security.decorators import ( + has_access, + permission_name, +) +from superset.views.base import BaseSupersetView +from superset.superset_typing import FlaskResponse + +class ApiKeysView(BaseSupersetView): + route_base = "/apikeys" + class_permission_name = "ApiKeys" + + @expose("/") + @has_access + @permission_name("read") + def list(self) -> FlaskResponse: + return super().render_app_template() diff --git a/superset/initialization/__init__.py b/superset/initialization/__init__.py index bd24c5a57b327..43fc21205dedf 100644 --- a/superset/initialization/__init__.py +++ b/superset/initialization/__init__.py @@ -125,6 +125,8 @@ def init_views(self) -> None: from superset.advanced_data_type.api import AdvancedDataTypeRestApi from superset.annotation_layers.annotations.api import AnnotationRestApi from superset.annotation_layers.api import AnnotationLayerRestApi + from superset.api_key.views import ApiKeysView + from superset.api_key.api import ApiKeysRestApi from superset.async_events.api import AsyncEventsRestApi from superset.available_domains.api import AvailableDomainsRestApi from superset.cachekeys.api import CacheRestApi @@ -399,6 +401,17 @@ def init_views(self) -> None: category_label=__("Manage"), ) + appbuilder.add_view( + ApiKeysView, + "API Keys", + href="/apikeys/", + label=__("API Keys"), + category="Security", + category_label=__("Security"), + icon="fa-lock", + ) + appbuilder.add_api(ApiKeysRestApi) + appbuilder.add_view( RowLevelSecurityView, "Row Level Security", diff --git a/superset/migrations/versions/2024-12-13_16-39_03db8ec57506_create_apikey_tokens_table.py b/superset/migrations/versions/2024-12-13_16-39_03db8ec57506_create_apikey_tokens_table.py new file mode 100644 index 0000000000000..c485b9a25a39d --- /dev/null +++ b/superset/migrations/versions/2024-12-13_16-39_03db8ec57506_create_apikey_tokens_table.py @@ -0,0 +1,45 @@ +# 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. +"""Create apikey_tokens table + +Revision ID: 03db8ec57506 +Revises: 48cbb571fa3a +Create Date: 2024-12-13 16:39:44.000599 + +""" + +# revision identifiers, used by Alembic. +revision = '03db8ec57506' +down_revision = '48cbb571fa3a' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table( + 'apikey_tokens', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('user_id', sa.Integer(), sa.ForeignKey('ab_user.id'), nullable=False), + sa.Column('token', sa.String(2000), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + ) + + +def downgrade(): + op.drop_table("apikey_tokens")