From 34374b4ac83285a7de3696d0e40fcd27c5633265 Mon Sep 17 00:00:00 2001 From: Giancarlo Romeo Date: Tue, 10 Dec 2024 10:13:43 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20API-keys=20serv?= =?UTF-8?q?ice=20(#6843)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: odeimaiz --- api/specs/web-server/_auth.py | 68 ------ api/specs/web-server/_auth_api_keys.py | 60 +++++ api/specs/web-server/openapi.py | 1 + .../api_schemas_webserver/auth.py | 79 +++++- .../src/models_library/rpc/__init__.py | 0 .../models_library/rpc/webserver/__init__.py | 0 .../rpc/webserver/auth/__init__.py | 0 .../rpc/webserver/auth/api_keys.py | 35 +++ .../rpc_interfaces/webserver/__init__.py | 0 .../rpc_interfaces/webserver/auth/__init__.py | 0 .../rpc_interfaces/webserver/auth/api_keys.py | 66 +++++ .../modules/osparc_variables/_api_auth.py | 11 +- .../modules/osparc_variables/_api_auth_rpc.py | 10 +- services/director-v2/tests/conftest.py | 6 +- .../source/class/osparc/data/Resources.js | 2 +- .../desktop/preferences/pages/TokensPage.js | 39 +-- .../desktop/preferences/window/ShowAPIKey.js | 51 ++-- .../api/v0/openapi.yaml | 221 ++++++++++++----- .../api_keys/_api.py | 117 --------- .../simcore_service_webserver/api_keys/_db.py | 155 ------------ .../api_keys/_exceptions_handlers.py | 26 ++ .../api_keys/_handlers.py | 81 ------ .../api_keys/_models.py | 11 + .../api_keys/_repository.py | 231 ++++++++++++++++++ .../api_keys/_rest.py | 112 +++++++++ .../api_keys/_rpc.py | 73 +++--- .../api_keys/_service.py | 123 ++++++++++ .../simcore_service_webserver/api_keys/api.py | 2 +- .../api_keys/errors.py | 13 + .../api_keys/plugin.py | 4 +- .../tests/unit/with_dbs/01/test_api_keys.py | 93 ++++--- .../unit/with_dbs/01/test_api_keys_rpc.py | 105 ++++---- tests/public-api/conftest.py | 18 +- 33 files changed, 1150 insertions(+), 663 deletions(-) create mode 100644 api/specs/web-server/_auth_api_keys.py create mode 100644 packages/models-library/src/models_library/rpc/__init__.py create mode 100644 packages/models-library/src/models_library/rpc/webserver/__init__.py create mode 100644 packages/models-library/src/models_library/rpc/webserver/auth/__init__.py create mode 100644 packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py create mode 100644 packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py delete mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_api.py delete mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_db.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py delete mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_handlers.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_models.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_repository.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_rest.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/_service.py create mode 100644 services/web/server/src/simcore_service_webserver/api_keys/errors.py diff --git a/api/specs/web-server/_auth.py b/api/specs/web-server/_auth.py index d47fe0aae5e..085e8d169c4 100644 --- a/api/specs/web-server/_auth.py +++ b/api/specs/web-server/_auth.py @@ -9,8 +9,6 @@ from fastapi import APIRouter, status from models_library.api_schemas_webserver.auth import ( AccountRequestInfo, - ApiKeyCreate, - ApiKeyGet, UnregisterCheck, ) from models_library.generics import Envelope @@ -264,72 +262,6 @@ async def email_confirmation(code: str): """email link sent to user to confirm an action""" -@router.get( - "/auth/api-keys", - operation_id="list_api_keys", - responses={ - status.HTTP_200_OK: { - "description": "returns the display names of API keys", - "model": list[str], - }, - status.HTTP_400_BAD_REQUEST: { - "description": "key name requested is invalid", - }, - status.HTTP_401_UNAUTHORIZED: { - "description": "requires login to list keys", - }, - status.HTTP_403_FORBIDDEN: { - "description": "not enough permissions to list keys", - }, - }, -) -async def list_api_keys(): - """lists display names of API keys by this user""" - - -@router.post( - "/auth/api-keys", - operation_id="create_api_key", - responses={ - status.HTTP_200_OK: { - "description": "Authorization granted returning API key", - "model": ApiKeyGet, - }, - status.HTTP_400_BAD_REQUEST: { - "description": "key name requested is invalid", - }, - status.HTTP_401_UNAUTHORIZED: { - "description": "requires login to list keys", - }, - status.HTTP_403_FORBIDDEN: { - "description": "not enough permissions to list keys", - }, - }, -) -async def create_api_key(_body: ApiKeyCreate): - """creates API keys to access public API""" - - -@router.delete( - "/auth/api-keys", - operation_id="delete_api_key", - status_code=status.HTTP_204_NO_CONTENT, - responses={ - status.HTTP_204_NO_CONTENT: { - "description": "api key successfully deleted", - }, - status.HTTP_401_UNAUTHORIZED: { - "description": "requires login to delete a key", - }, - status.HTTP_403_FORBIDDEN: { - "description": "not enough permissions to delete a key", - }, - }, -) -async def delete_api_key(_body: ApiKeyCreate): - """deletes API key by name""" - - @router.get( "/auth/captcha", operation_id="request_captcha", diff --git a/api/specs/web-server/_auth_api_keys.py b/api/specs/web-server/_auth_api_keys.py new file mode 100644 index 00000000000..0c6512eddda --- /dev/null +++ b/api/specs/web-server/_auth_api_keys.py @@ -0,0 +1,60 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, status +from models_library.api_schemas_webserver.auth import ( + ApiKeyCreateRequest, + ApiKeyCreateResponse, + ApiKeyGet, +) +from models_library.generics import Envelope +from models_library.rest_error import EnvelopedError +from simcore_service_webserver._meta import API_VTAG +from simcore_service_webserver.api_keys._exceptions_handlers import _TO_HTTP_ERROR_MAP +from simcore_service_webserver.api_keys._rest import ApiKeysPathParams + +router = APIRouter( + prefix=f"/{API_VTAG}", + tags=["auth"], + responses={ + i.status_code: {"model": EnvelopedError} for i in _TO_HTTP_ERROR_MAP.values() + }, +) + + +@router.post( + "/auth/api-keys", + operation_id="create_api_key", + status_code=status.HTTP_201_CREATED, + response_model=Envelope[ApiKeyCreateResponse], +) +async def create_api_key(_body: ApiKeyCreateRequest): + """creates API keys to access public API""" + + +@router.get( + "/auth/api-keys", + operation_id="list_api_keys", + response_model=Envelope[list[ApiKeyGet]], + status_code=status.HTTP_200_OK, +) +async def list_api_keys(): + """lists API keys by this user""" + + +@router.get( + "/auth/api-keys/{api_key_id}", + operation_id="get_api_key", + response_model=Envelope[ApiKeyGet], + status_code=status.HTTP_200_OK, +) +async def get_api_key(_path: Annotated[ApiKeysPathParams, Depends()]): + """returns the API Key with the given ID""" + + +@router.delete( + "/auth/api-keys/{api_key_id}", + operation_id="delete_api_key", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_api_key(_path: Annotated[ApiKeysPathParams, Depends()]): + """deletes the API key with the given ID""" diff --git a/api/specs/web-server/openapi.py b/api/specs/web-server/openapi.py index 77e656efdaa..b5fcb5fcb63 100644 --- a/api/specs/web-server/openapi.py +++ b/api/specs/web-server/openapi.py @@ -20,6 +20,7 @@ # # core --- "_auth", + "_auth_api_keys", "_groups", "_tags", "_tags_groups", # after _tags diff --git a/packages/models-library/src/models_library/api_schemas_webserver/auth.py b/packages/models-library/src/models_library/api_schemas_webserver/auth.py index c841056d40c..6fa33b3fdc4 100644 --- a/packages/models-library/src/models_library/api_schemas_webserver/auth.py +++ b/packages/models-library/src/models_library/api_schemas_webserver/auth.py @@ -1,10 +1,12 @@ from datetime import timedelta -from typing import Any +from typing import Annotated, Any -from pydantic import BaseModel, ConfigDict, Field, SecretStr +from models_library.basic_types import IDStr +from pydantic import AliasGenerator, ConfigDict, Field, HttpUrl, SecretStr +from pydantic.alias_generators import to_camel from ..emails import LowerCaseEmailStr -from ._base import InputSchema +from ._base import InputSchema, OutputSchema class AccountRequestInfo(InputSchema): @@ -51,42 +53,97 @@ class UnregisterCheck(InputSchema): # -class ApiKeyCreate(BaseModel): - display_name: str = Field(..., min_length=3) +class ApiKeyCreateRequest(InputSchema): + display_name: Annotated[str, Field(..., min_length=3)] expiration: timedelta | None = Field( None, description="Time delta from creation time to expiration. If None, then it does not expire.", ) model_config = ConfigDict( + alias_generator=AliasGenerator( + validation_alias=to_camel, + ), + from_attributes=True, + json_schema_extra={ + "examples": [ + { + "displayName": "test-api-forever", + }, + { + "displayName": "test-api-for-one-day", + "expiration": 60 * 60 * 24, + }, + { + "displayName": "test-api-for-another-day", + "expiration": "24:00:00", + }, + ] + }, + ) + + +class ApiKeyCreateResponse(OutputSchema): + id: IDStr + display_name: Annotated[str, Field(..., min_length=3)] + expiration: timedelta | None = Field( + None, + description="Time delta from creation time to expiration. If None, then it does not expire.", + ) + api_base_url: HttpUrl + api_key: str + api_secret: str + + model_config = ConfigDict( + alias_generator=AliasGenerator( + serialization_alias=to_camel, + ), + from_attributes=True, json_schema_extra={ "examples": [ { + "id": "42", "display_name": "test-api-forever", + "api_base_url": "http://api.osparc.io/v0", # NOSONAR + "api_key": "key", + "api_secret": "secret", }, { + "id": "48", "display_name": "test-api-for-one-day", "expiration": 60 * 60 * 24, + "api_base_url": "http://api.sim4life.io/v0", # NOSONAR + "api_key": "key", + "api_secret": "secret", }, { + "id": "54", "display_name": "test-api-for-another-day", "expiration": "24:00:00", + "api_base_url": "http://api.osparc-master.io/v0", # NOSONAR + "api_key": "key", + "api_secret": "secret", }, ] - } + }, ) -class ApiKeyGet(BaseModel): - display_name: str = Field(..., min_length=3) - api_key: str - api_secret: str +class ApiKeyGet(OutputSchema): + id: IDStr + display_name: Annotated[str, Field(..., min_length=3)] model_config = ConfigDict( + alias_generator=AliasGenerator( + serialization_alias=to_camel, + ), from_attributes=True, json_schema_extra={ "examples": [ - {"display_name": "myapi", "api_key": "key", "api_secret": "secret"}, + { + "id": "42", + "display_name": "myapi", + }, ] }, ) diff --git a/packages/models-library/src/models_library/rpc/__init__.py b/packages/models-library/src/models_library/rpc/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/models-library/src/models_library/rpc/webserver/__init__.py b/packages/models-library/src/models_library/rpc/webserver/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/models-library/src/models_library/rpc/webserver/auth/__init__.py b/packages/models-library/src/models_library/rpc/webserver/auth/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py b/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py new file mode 100644 index 00000000000..be36327abe7 --- /dev/null +++ b/packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py @@ -0,0 +1,35 @@ +import datetime as dt +from typing import Annotated + +from models_library.basic_types import IDStr +from pydantic import BaseModel, ConfigDict, Field + + +class ApiKeyCreate(BaseModel): + display_name: Annotated[str, Field(..., min_length=3)] + expiration: dt.timedelta | None = None + + model_config = ConfigDict( + from_attributes=True, + ) + + +class ApiKeyGet(BaseModel): + id: IDStr + display_name: Annotated[str, Field(..., min_length=3)] + api_key: str | None = None + api_secret: str | None = None + + model_config = ConfigDict( + from_attributes=True, + json_schema_extra={ + "examples": [ + { + "id": "42", + "display_name": "test-api-forever", + "api_key": "key", + "api_secret": "secret", + }, + ] + }, + ) diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py new file mode 100644 index 00000000000..e70889e3de1 --- /dev/null +++ b/packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/auth/api_keys.py @@ -0,0 +1,66 @@ +import logging + +from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE +from models_library.basic_types import IDStr +from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.rpc.webserver.auth.api_keys import ApiKeyCreate, ApiKeyGet +from pydantic import TypeAdapter +from servicelib.logging_utils import log_decorator +from servicelib.rabbitmq import RabbitMQRPCClient + +_logger = logging.getLogger(__name__) + + +@log_decorator(_logger, level=logging.DEBUG) +async def create_api_key( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: str, + product_name: str, + api_key: ApiKeyCreate, +) -> ApiKeyGet: + result: ApiKeyGet = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("create_api_key"), + user_id=user_id, + product_name=product_name, + api_key=api_key, + ) + assert isinstance(result, ApiKeyGet) + return result + + +@log_decorator(_logger, level=logging.DEBUG) +async def get_api_key( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: str, + product_name: str, + api_key_id: IDStr, +) -> ApiKeyGet: + result: ApiKeyGet = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("get_api_key"), + user_id=user_id, + product_name=product_name, + api_key_id=api_key_id, + ) + assert isinstance(result, ApiKeyGet) + return result + + +async def delete_api_key( + rabbitmq_rpc_client: RabbitMQRPCClient, + *, + user_id: str, + product_name: str, + api_key_id: IDStr, +) -> None: + result = await rabbitmq_rpc_client.request( + WEBSERVER_RPC_NAMESPACE, + TypeAdapter(RPCMethodName).validate_python("delete_api_key"), + user_id=user_id, + product_name=product_name, + api_key_id=api_key_id, + ) + assert result is None diff --git a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py index 65cb0934b35..a207df3aec3 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py @@ -4,8 +4,8 @@ from aiocache import cached # type: ignore[import-untyped] from fastapi import FastAPI -from models_library.api_schemas_webserver.auth import ApiKeyGet from models_library.products import ProductName +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from ._api_auth_rpc import get_or_create_api_key_and_secret @@ -30,10 +30,13 @@ async def _get_or_create_for( product_name: ProductName, user_id: UserID, ) -> ApiKeyGet: - - name = create_unique_api_name_for(product_name, user_id) + display_name = create_unique_api_name_for(product_name, user_id) return await get_or_create_api_key_and_secret( - app, product_name=product_name, user_id=user_id, name=name, expiration=None + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=None, ) diff --git a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py index c9edc8c0f1c..1a6da1f7382 100644 --- a/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py +++ b/services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth_rpc.py @@ -2,9 +2,9 @@ from fastapi import FastAPI from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE -from models_library.api_schemas_webserver.auth import ApiKeyGet from models_library.products import ProductName from models_library.rabbitmq_basic_types import RPCMethodName +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from pydantic import TypeAdapter @@ -20,16 +20,16 @@ async def get_or_create_api_key_and_secret( *, product_name: ProductName, user_id: UserID, - name: str, + display_name: str, expiration: timedelta | None = None, ) -> ApiKeyGet: rpc_client = get_rabbitmq_rpc_client(app) result = await rpc_client.request( WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("get_or_create_api_keys"), - product_name=product_name, + TypeAdapter(RPCMethodName).validate_python("get_or_create_api_key"), user_id=user_id, - name=name, + display_name=display_name, expiration=expiration, + product_name=product_name, ) return ApiKeyGet.model_validate(result) diff --git a/services/director-v2/tests/conftest.py b/services/director-v2/tests/conftest.py index 231debc371f..6337e7eea88 100644 --- a/services/director-v2/tests/conftest.py +++ b/services/director-v2/tests/conftest.py @@ -20,9 +20,9 @@ from asgi_lifespan import LifespanManager from faker import Faker from fastapi import FastAPI -from models_library.api_schemas_webserver.auth import ApiKeyGet from models_library.products import ProductName from models_library.projects import Node, NodesDict +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from pytest_mock import MockerFixture from pytest_simcore.helpers.monkeypatch_envs import ( @@ -347,7 +347,7 @@ async def _create( *, product_name: ProductName, user_id: UserID, - name: str, + display_name: str, expiration: timedelta, ): assert app @@ -355,7 +355,7 @@ async def _create( assert user_id assert expiration is None - fake_data.display_name = name + fake_data.display_name = display_name return fake_data # mocks RPC interface diff --git a/services/static-webserver/client/source/class/osparc/data/Resources.js b/services/static-webserver/client/source/class/osparc/data/Resources.js index de1d5aeddaa..b1c1d81ffa6 100644 --- a/services/static-webserver/client/source/class/osparc/data/Resources.js +++ b/services/static-webserver/client/source/class/osparc/data/Resources.js @@ -767,7 +767,7 @@ qx.Class.define("osparc.data.Resources", { }, delete: { method: "DELETE", - url: statics.API + "/auth/api-keys" + url: statics.API + "/auth/api-keys/{apiKeyId}" } } }, diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js index ddc9d94f60f..6d5ae0e5258 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js +++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/pages/TokensPage.js @@ -77,7 +77,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { const formData = e.getData(); const params = { data: { - "display_name": formData["name"] + "displayName": formData["name"] } }; if (formData["expiration"]) { @@ -90,14 +90,17 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { .then(data => { this.__rebuildAPIKeysList(); - const key = data["api_key"]; - const secret = data["api_secret"]; - const showAPIKeyWindow = new osparc.desktop.preferences.window.ShowAPIKey(key, secret); + const key = data["apiKey"]; + const secret = data["apiSecret"]; + const baseUrl = data["apiBaseUrl"]; + const showAPIKeyWindow = new osparc.desktop.preferences.window.ShowAPIKey(key, secret, baseUrl); showAPIKeyWindow.center(); showAPIKeyWindow.open(); }) .catch(err => { - osparc.FlashMessenger.getInstance().logAs(err.message, "ERROR"); + const errorMsg = err.message || this.tr("Cannot create API Key"); + osparc.FlashMessenger.getInstance().logAs(errorMsg, "ERROR"); + console.error(err); }) .finally(() => this.__requestAPIKeyBtn.setFetching(false)); }, this); @@ -109,26 +112,24 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { osparc.data.Resources.get("apiKeys") .then(apiKeys => { apiKeys.forEach(apiKey => { - const apiKeyForm = this.__createValidAPIKeyForm(apiKey); + const apiKeyForm = this.__createAPIKeyEntry(apiKey); this.__apiKeysList.add(apiKeyForm); }); }) .catch(err => console.error(err)); }, - __createValidAPIKeyForm: function(apiKeyLabel) { + __createAPIKeyEntry: function(apiKey) { const grid = this.__createValidEntryLayout(); - const nameLabel = new qx.ui.basic.Label(apiKeyLabel); + const nameLabel = new qx.ui.basic.Label(apiKey["displayName"]); grid.add(nameLabel, { row: 0, column: 0 }); const delAPIKeyBtn = new qx.ui.form.Button(null, "@FontAwesome5Solid/trash/14"); - delAPIKeyBtn.addListener("execute", e => { - this.__deleteAPIKey(apiKeyLabel); - }, this); + delAPIKeyBtn.addListener("execute", () => this.__deleteAPIKey(apiKey["id"]), this); grid.add(delAPIKeyBtn, { row: 0, column: 1 @@ -137,7 +138,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { return grid; }, - __deleteAPIKey: function(apiKeyLabel) { + __deleteAPIKey: function(apiKeyId) { if (!osparc.data.Permissions.getInstance().canDo("user.apikey.delete", true)) { return; } @@ -153,13 +154,17 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { win.addListener("close", () => { if (win.getConfirmed()) { const params = { - data: { - "display_name": apiKeyLabel + url: { + "apiKeyId": apiKeyId } }; osparc.data.Resources.fetch("apiKeys", "delete", params) .then(() => this.__rebuildAPIKeysList()) - .catch(err => console.error(err)); + .catch(err => { + const errorMsg = err.message || this.tr("Cannot delete API Key"); + osparc.FlashMessenger.getInstance().logAs(errorMsg, "ERROR"); + console.error(err) + }); } }, this); }, @@ -203,7 +208,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { const supportedExternalServices = osparc.utils.Utils.deepCloneObject(this.__supportedExternalServices()); tokensList.forEach(token => { - const tokenForm = this.__createValidTokenEntry(token); + const tokenForm = this.__createTokenEntry(token); this.__validTokensGB.add(tokenForm); const idx = supportedExternalServices.findIndex(srv => srv.name === token.service); if (idx > -1) { @@ -244,7 +249,7 @@ qx.Class.define("osparc.desktop.preferences.pages.TokensPage", { .catch(err => console.error(err)); }, - __createValidTokenEntry: function(token) { + __createTokenEntry: function(token) { const grid = this.__createValidEntryLayout(); const service = token["service"]; diff --git a/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js b/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js index 4c63579a70f..6c7209d3ecc 100644 --- a/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js +++ b/services/static-webserver/client/source/class/osparc/desktop/preferences/window/ShowAPIKey.js @@ -16,7 +16,7 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", { extend: osparc.desktop.preferences.window.APIKeyBase, - construct: function(key, secret) { + construct: function(key, secret, baseUrl) { const caption = this.tr("API Key"); const infoText = this.tr("For your protection, store your access keys securely and do not share them. You will not be able to access the key again once this window is closed."); this.base(arguments, caption, infoText); @@ -25,39 +25,60 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", { clickAwayClose: false }); - this.__populateTokens(key, secret); + this.__populateTokens(key, secret, baseUrl); }, members: { - __populateTokens: function(key, secret) { - const hBox1 = this.__createEntry(this.tr("Key:"), key); + __populateTokens: function(key, secret, baseUrl) { + const hBox1 = this.__createStarredEntry(this.tr("Key:"), key); this._add(hBox1); - const hBox2 = this.__createEntry(this.tr("Secret:"), secret); + const hBox2 = this.__createStarredEntry(this.tr("Secret:"), secret); this._add(hBox2); - const hBox3 = new qx.ui.container.Composite(new qx.ui.layout.HBox(10)).set({ + const hBox3 = this.__createEntry(this.tr("Base url:"), baseUrl); + this._add(hBox3); + + const buttonsLayout = new qx.ui.container.Composite(new qx.ui.layout.HBox(10)).set({ appearance: "margined-layout" }); - const copyAPIKeyBtn = new qx.ui.form.Button(this.tr("Copy API Key")); + const copyAPIKeyBtn = new qx.ui.form.Button(this.tr("API Key"), "@FontAwesome5Solid/copy/12"); copyAPIKeyBtn.addListener("execute", e => { if (osparc.utils.Utils.copyTextToClipboard(key)) { copyAPIKeyBtn.setIcon("@FontAwesome5Solid/check/12"); } }); - hBox3.add(copyAPIKeyBtn, { - width: "50%" + buttonsLayout.add(copyAPIKeyBtn, { + flex: 1 }); - const copyAPISecretBtn = new qx.ui.form.Button(this.tr("Copy API Secret")); + const copyAPISecretBtn = new qx.ui.form.Button(this.tr("API Secret"), "@FontAwesome5Solid/copy/12"); copyAPISecretBtn.addListener("execute", e => { if (osparc.utils.Utils.copyTextToClipboard(secret)) { copyAPISecretBtn.setIcon("@FontAwesome5Solid/check/12"); } }); - hBox3.add(copyAPISecretBtn, { - width: "50%" + buttonsLayout.add(copyAPISecretBtn, { + flex: 1 }); - this._add(hBox3); + const copyBaseUrlBtn = new qx.ui.form.Button(this.tr("Base URL"), "@FontAwesome5Solid/copy/12"); + copyBaseUrlBtn.addListener("execute", e => { + if (osparc.utils.Utils.copyTextToClipboard(baseUrl)) { + copyBaseUrlBtn.setIcon("@FontAwesome5Solid/check/12"); + } + }); + buttonsLayout.add(copyBaseUrlBtn, { + flex: 1 + }); + this._add(buttonsLayout); + }, + + __createStarredEntry: function(title, label) { + const hBox = this.__createEntry(title); + if (label) { + // partially hide the key and secret + hBox.getChildren()[1].setValue(label.substring(1, 8) + "****") + } + return hBox; }, __createEntry: function(title, label) { @@ -66,13 +87,13 @@ qx.Class.define("osparc.desktop.preferences.window.ShowAPIKey", { }); const sTitle = new qx.ui.basic.Label(title).set({ rich: true, - width: 40 + width: 60 }); hBox.add(sTitle); const sLabel = new qx.ui.basic.Label(); if (label) { // partially hide the key and secret - sLabel.setValue(label.substring(1, 8) + "****") + sLabel.setValue(label); } hBox.add(sLabel); return hBox; diff --git a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml index 0ab1f87a5c1..db975aa23c3 100644 --- a/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml +++ b/services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml @@ -369,29 +369,39 @@ paths: $ref: '#/components/schemas/Envelope_Log_' 3XX: description: redirection to specific ui application page + /v0/auth/captcha: + get: + tags: + - auth + summary: Request Captcha + operationId: request_captcha + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + image/png: {} /v0/auth/api-keys: get: tags: - auth summary: List Api Keys - description: lists display names of API keys by this user + description: lists API keys by this user operationId: list_api_keys responses: '200': - description: returns the display names of API keys + description: Successful Response content: application/json: schema: - items: - type: string - type: array - title: Response 200 List Api Keys - '400': - description: key name requested is invalid - '401': - description: requires login to list keys - '403': - description: not enough permissions to list keys + $ref: '#/components/schemas/Envelope_list_ApiKeyGet__' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' post: tags: - auth @@ -402,53 +412,74 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ApiKeyCreate' + $ref: '#/components/schemas/ApiKeyCreateRequest' required: true + responses: + '201': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_' + '404': + description: Not Found + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + /v0/auth/api-keys/{api_key_id}: + get: + tags: + - auth + summary: Get Api Key + description: returns the API Key with the given ID + operationId: get_api_key + parameters: + - name: api_key_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 100 + title: Api Key Id responses: '200': - description: Authorization granted returning API key + description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/ApiKeyGet' - '400': - description: key name requested is invalid - '401': - description: requires login to list keys - '403': - description: not enough permissions to list keys + $ref: '#/components/schemas/Envelope_ApiKeyGet_' + '404': + content: + application/json: + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found delete: tags: - auth summary: Delete Api Key - description: deletes API key by name + description: deletes the API key with the given ID operationId: delete_api_key - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyCreate' + parameters: + - name: api_key_id + in: path required: true + schema: + type: string + minLength: 1 + maxLength: 100 + title: Api Key Id responses: '204': - description: api key successfully deleted - '401': - description: requires login to delete a key - '403': - description: not enough permissions to delete a key - /v0/auth/captcha: - get: - tags: - - auth - summary: Request Captcha - operationId: request_captcha - responses: - '200': description: Successful Response + '404': content: application/json: - schema: {} - image/png: {} + schema: + $ref: '#/components/schemas/EnvelopedError' + description: Not Found /v0/groups: get: tags: @@ -6714,12 +6745,12 @@ components: - link - widgets title: Announcement - ApiKeyCreate: + ApiKeyCreateRequest: properties: - display_name: + displayName: type: string minLength: 3 - title: Display Name + title: Displayname expiration: anyOf: - type: string @@ -6730,25 +6761,59 @@ components: it does not expire. type: object required: - - display_name - title: ApiKeyCreate - ApiKeyGet: + - displayName + title: ApiKeyCreateRequest + ApiKeyCreateResponse: properties: - display_name: + displayName: type: string minLength: 3 - title: Display Name - api_key: + title: Displayname + expiration: + anyOf: + - type: string + format: duration + - type: 'null' + title: Expiration + description: Time delta from creation time to expiration. If None, then + it does not expire. + id: + type: string + maxLength: 100 + minLength: 1 + title: Id + apiBaseUrl: + type: string + title: Apibaseurl + apiKey: type: string - title: Api Key - api_secret: + title: Apikey + apiSecret: + type: string + title: Apisecret + type: object + required: + - displayName + - id + - apiBaseUrl + - apiKey + - apiSecret + title: ApiKeyCreateResponse + ApiKeyGet: + properties: + id: + type: string + maxLength: 100 + minLength: 1 + title: Id + displayName: type: string - title: Api Secret + minLength: 3 + title: Displayname type: object required: - - display_name - - api_key - - api_secret + - id + - displayName title: ApiKeyGet AppStatusCheck: properties: @@ -7575,6 +7640,32 @@ components: title: Error type: object title: Envelope[AnyUrl] + Envelope_ApiKeyCreateResponse_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/ApiKeyCreateResponse' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[ApiKeyCreateResponse] + Envelope_ApiKeyGet_: + properties: + data: + anyOf: + - $ref: '#/components/schemas/ApiKeyGet' + - type: 'null' + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[ApiKeyGet] Envelope_AppStatusCheck_: properties: data: @@ -8536,6 +8627,22 @@ components: title: Error type: object title: Envelope[list[Announcement]] + Envelope_list_ApiKeyGet__: + properties: + data: + anyOf: + - items: + $ref: '#/components/schemas/ApiKeyGet' + type: array + - type: 'null' + title: Data + error: + anyOf: + - {} + - type: 'null' + title: Error + type: object + title: Envelope[list[ApiKeyGet]] Envelope_list_DatasetMetaData__: properties: data: diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_api.py b/services/web/server/src/simcore_service_webserver/api_keys/_api.py deleted file mode 100644 index 6cdc15e2f24..00000000000 --- a/services/web/server/src/simcore_service_webserver/api_keys/_api.py +++ /dev/null @@ -1,117 +0,0 @@ -import re -import string -from datetime import timedelta -from typing import Final - -from aiohttp import web -from models_library.api_schemas_webserver.auth import ApiKeyCreate, ApiKeyGet -from models_library.products import ProductName -from models_library.users import UserID -from servicelib.utils_secrets import generate_token_secret_key - -from ._db import ApiKeyRepo - -_PUNCTUATION_REGEX = re.compile( - pattern="[" + re.escape(string.punctuation.replace("_", "")) + "]" -) - -_KEY_LEN: Final = 10 -_SECRET_LEN: Final = 20 - - -async def list_api_keys( - app: web.Application, - *, - user_id: UserID, - product_name: ProductName, -) -> list[str]: - repo = ApiKeyRepo.create_from_app(app) - names: list[str] = await repo.list_names(user_id=user_id, product_name=product_name) - return names - - -def _generate_api_key_and_secret(name: str): - prefix = _PUNCTUATION_REGEX.sub("_", name[:5]) - api_key = f"{prefix}_{generate_token_secret_key(_KEY_LEN)}" - api_secret = generate_token_secret_key(_SECRET_LEN) - return api_key, api_secret - - -async def create_api_key( - app: web.Application, - *, - new: ApiKeyCreate, - user_id: UserID, - product_name: ProductName, -) -> ApiKeyGet: - # generate key and secret - api_key, api_secret = _generate_api_key_and_secret(new.display_name) - - # raises if name exists already! - repo = ApiKeyRepo.create_from_app(app) - await repo.create( - user_id=user_id, - product_name=product_name, - display_name=new.display_name, - expiration=new.expiration, - api_key=api_key, - api_secret=api_secret, - ) - - return ApiKeyGet( - display_name=new.display_name, - api_key=api_key, - api_secret=api_secret, - ) - - -async def get_api_key( - app: web.Application, *, name: str, user_id: UserID, product_name: ProductName -) -> ApiKeyGet | None: - repo = ApiKeyRepo.create_from_app(app) - row = await repo.get(display_name=name, user_id=user_id, product_name=product_name) - return ApiKeyGet.model_validate(row) if row else None - - -async def get_or_create_api_key( - app: web.Application, - *, - name: str, - user_id: UserID, - product_name: ProductName, - expiration: timedelta | None = None, -) -> ApiKeyGet: - - api_key, api_secret = _generate_api_key_and_secret(name) - - repo = ApiKeyRepo.create_from_app(app) - row = await repo.get_or_create( - user_id=user_id, - product_name=product_name, - display_name=name, - expiration=expiration, - api_key=api_key, - api_secret=api_secret, - ) - return ApiKeyGet.model_construct( - display_name=row.display_name, api_key=row.api_key, api_secret=row.api_secret - ) - - -async def delete_api_key( - app: web.Application, - *, - name: str, - user_id: UserID, - product_name: ProductName, -) -> None: - repo = ApiKeyRepo.create_from_app(app) - await repo.delete_by_name( - display_name=name, user_id=user_id, product_name=product_name - ) - - -async def prune_expired_api_keys(app: web.Application) -> list[str]: - repo = ApiKeyRepo.create_from_app(app) - names: list[str] = await repo.prune_expired() - return names diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_db.py b/services/web/server/src/simcore_service_webserver/api_keys/_db.py deleted file mode 100644 index ec08ce5dd67..00000000000 --- a/services/web/server/src/simcore_service_webserver/api_keys/_db.py +++ /dev/null @@ -1,155 +0,0 @@ -import logging -from dataclasses import dataclass -from datetime import timedelta - -import sqlalchemy as sa -from aiohttp import web -from aiopg.sa.engine import Engine -from aiopg.sa.result import ResultProxy, RowProxy -from models_library.api_schemas_api_server.api_keys import ApiKeyInDB -from models_library.basic_types import IdInt -from models_library.products import ProductName -from models_library.users import UserID -from simcore_postgres_database.models.api_keys import api_keys -from sqlalchemy.dialects.postgresql import insert as pg_insert - -from ..db.plugin import get_database_engine - -_logger = logging.getLogger(__name__) - - -@dataclass -class ApiKeyRepo: - engine: Engine - - @classmethod - def create_from_app(cls, app: web.Application): - return cls(engine=get_database_engine(app)) - - async def list_names( - self, *, user_id: UserID, product_name: ProductName - ) -> list[str]: - async with self.engine.acquire() as conn: - stmt = sa.select(api_keys.c.display_name).where( - (api_keys.c.user_id == user_id) - & (api_keys.c.product_name == product_name) - ) - - result: ResultProxy = await conn.execute(stmt) - rows = await result.fetchall() or [] - return [r.display_name for r in rows] - - async def create( - self, - *, - user_id: UserID, - product_name: ProductName, - display_name: str, - expiration: timedelta | None, - api_key: str, - api_secret: str, - ) -> list[IdInt]: - async with self.engine.acquire() as conn: - stmt = ( - api_keys.insert() - .values( - display_name=display_name, - user_id=user_id, - product_name=product_name, - api_key=api_key, - api_secret=api_secret, - expires_at=(sa.func.now() + expiration) if expiration else None, - ) - .returning(api_keys.c.id) - ) - - result: ResultProxy = await conn.execute(stmt) - rows = await result.fetchall() or [] - return [r.id for r in rows] - - async def get( - self, *, display_name: str, user_id: UserID, product_name: ProductName - ) -> ApiKeyInDB | None: - async with self.engine.acquire() as conn: - stmt = sa.select(api_keys).where( - (api_keys.c.user_id == user_id) - & (api_keys.c.display_name == display_name) - & (api_keys.c.product_name == product_name) - ) - - result: ResultProxy = await conn.execute(stmt) - row: RowProxy | None = await result.fetchone() - return ApiKeyInDB.model_validate(row) if row else None - - async def get_or_create( - self, - *, - user_id: UserID, - product_name: ProductName, - display_name: str, - expiration: timedelta | None, - api_key: str, - api_secret: str, - ) -> ApiKeyInDB: - async with self.engine.acquire() as conn: - # Implemented as "create or get" - insert_stmt = ( - pg_insert(api_keys) - .values( - display_name=display_name, - user_id=user_id, - product_name=product_name, - api_key=api_key, - api_secret=api_secret, - expires_at=(sa.func.now() + expiration) if expiration else None, - ) - .on_conflict_do_update( - index_elements=["user_id", "display_name"], - set_={ - "product_name": product_name - }, # dummy enable returning since on_conflict_do_nothing returns None - # NOTE: use this entry for reference counting in https://github.com/ITISFoundation/osparc-simcore/issues/5875 - ) - .returning(api_keys) - ) - - result = await conn.execute(insert_stmt) - row = await result.fetchone() - assert row # nosec - return ApiKeyInDB.model_validate(row) - - async def delete_by_name( - self, *, display_name: str, user_id: UserID, product_name: ProductName - ) -> None: - async with self.engine.acquire() as conn: - stmt = api_keys.delete().where( - (api_keys.c.user_id == user_id) - & (api_keys.c.display_name == display_name) - & (api_keys.c.product_name == product_name) - ) - await conn.execute(stmt) - - async def delete_by_key( - self, *, api_key: str, user_id: UserID, product_name: ProductName - ) -> None: - async with self.engine.acquire() as conn: - stmt = api_keys.delete().where( - (api_keys.c.user_id == user_id) - & (api_keys.c.api_key == api_key) - & (api_keys.c.product_name == product_name) - ) - await conn.execute(stmt) - - async def prune_expired(self) -> list[str]: - async with self.engine.acquire() as conn: - stmt = ( - api_keys.delete() - .where( - (api_keys.c.expires_at.is_not(None)) - & (api_keys.c.expires_at < sa.func.now()) - ) - .returning(api_keys.c.display_name) - ) - result: ResultProxy = await conn.execute(stmt) - rows = await result.fetchall() or [] - return [r.display_name for r in rows] diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py b/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py new file mode 100644 index 00000000000..4cb6c84f824 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_exceptions_handlers.py @@ -0,0 +1,26 @@ +from servicelib.aiohttp import status + +from ..exception_handling import ( + ExceptionToHttpErrorMap, + HttpErrorInfo, + exception_handling_decorator, + to_exceptions_handlers_map, +) +from .errors import ApiKeyDuplicatedDisplayNameError, ApiKeyNotFoundError + +_TO_HTTP_ERROR_MAP: ExceptionToHttpErrorMap = { + ApiKeyDuplicatedDisplayNameError: HttpErrorInfo( + status.HTTP_409_CONFLICT, + "API key display name duplicated", + ), + ApiKeyNotFoundError: HttpErrorInfo( + status.HTTP_404_NOT_FOUND, + "API key was not found", + ), +} + + +handle_plugin_requests_exceptions = exception_handling_decorator( + to_exceptions_handlers_map(_TO_HTTP_ERROR_MAP) +) +# this is one decorator with a single exception handler diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py b/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py deleted file mode 100644 index 4a7a84fe742..00000000000 --- a/services/web/server/src/simcore_service_webserver/api_keys/_handlers.py +++ /dev/null @@ -1,81 +0,0 @@ -import logging - -from aiohttp import web -from aiohttp.web import RouteTableDef -from models_library.api_schemas_webserver.auth import ApiKeyCreate -from servicelib.aiohttp import status -from servicelib.aiohttp.requests_validation import parse_request_body_as -from servicelib.mimetype_constants import MIMETYPE_APPLICATION_JSON -from simcore_postgres_database.errors import DatabaseError - -from .._meta import API_VTAG -from ..login.decorators import login_required -from ..models import RequestContext -from ..security.decorators import permission_required -from ..utils_aiohttp import envelope_json_response -from . import _api - -_logger = logging.getLogger(__name__) - - -routes = RouteTableDef() - - -@routes.get(f"/{API_VTAG}/auth/api-keys", name="list_api_keys") -@login_required -@permission_required("user.apikey.*") -async def list_api_keys(request: web.Request): - req_ctx = RequestContext.model_validate(request) - api_keys_names = await _api.list_api_keys( - request.app, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - ) - return envelope_json_response(api_keys_names) - - -@routes.post(f"/{API_VTAG}/auth/api-keys", name="create_api_key") -@login_required -@permission_required("user.apikey.*") -async def create_api_key(request: web.Request): - req_ctx = RequestContext.model_validate(request) - new = await parse_request_body_as(ApiKeyCreate, request) - try: - data = await _api.create_api_key( - request.app, - new=new, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - ) - except DatabaseError as err: - raise web.HTTPBadRequest( - reason="Invalid API key name: already exists", - content_type=MIMETYPE_APPLICATION_JSON, - ) from err - - return envelope_json_response(data) - - -@routes.delete(f"/{API_VTAG}/auth/api-keys", name="delete_api_key") -@login_required -@permission_required("user.apikey.*") -async def delete_api_key(request: web.Request): - req_ctx = RequestContext.model_validate(request) - - # NOTE: SEE https://github.com/ITISFoundation/osparc-simcore/issues/4920 - body = await request.json() - name = body.get("display_name") - - try: - await _api.delete_api_key( - request.app, - name=name, - user_id=req_ctx.user_id, - product_name=req_ctx.product_name, - ) - except DatabaseError as err: - _logger.warning( - "Failed to delete API key %s. Ignoring error", name, exc_info=err - ) - - return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_models.py b/services/web/server/src/simcore_service_webserver/api_keys/_models.py new file mode 100644 index 00000000000..ee0ecec85c2 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_models.py @@ -0,0 +1,11 @@ +import datetime as dt +from dataclasses import dataclass + + +@dataclass +class ApiKey: + id: str + display_name: str + expiration: dt.timedelta | None = None + api_key: str | None = None + api_secret: str | None = None diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_repository.py b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py new file mode 100644 index 00000000000..1f4a8dbdc79 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py @@ -0,0 +1,231 @@ +import logging +from datetime import timedelta + +import sqlalchemy as sa +from aiohttp import web +from asyncpg.exceptions import UniqueViolationError +from models_library.products import ProductName +from models_library.users import UserID +from simcore_postgres_database.models.api_keys import api_keys +from simcore_postgres_database.utils_repos import transaction_context +from simcore_service_webserver.api_keys._models import ApiKey +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncConnection + +from ..db.plugin import get_asyncpg_engine +from .errors import ApiKeyDuplicatedDisplayNameError + +_logger = logging.getLogger(__name__) + + +async def create_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + display_name: str, + expiration: timedelta | None, + api_key: str, + api_secret: str, +) -> ApiKey: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + try: + stmt = ( + api_keys.insert() + .values( + display_name=display_name, + user_id=user_id, + product_name=product_name, + api_key=api_key, + api_secret=api_secret, + expires_at=(sa.func.now() + expiration) if expiration else None, + ) + .returning(api_keys.c.id) + ) + + result = await conn.stream(stmt) + row = await result.first() + + return ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=display_name, + expiration=expiration, + api_key=api_key, + api_secret=api_secret, + ) + except UniqueViolationError as exc: + raise ApiKeyDuplicatedDisplayNameError(display_name=display_name) from exc + + +async def get_or_create_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + display_name: str, + expiration: timedelta | None, + api_key: str, + api_secret: str, +) -> ApiKey: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + # Implemented as "create or get" + insert_stmt = ( + pg_insert(api_keys) + .values( + display_name=display_name, + user_id=user_id, + product_name=product_name, + api_key=api_key, + api_secret=api_secret, + expires_at=(sa.func.now() + expiration) if expiration else None, + ) + .on_conflict_do_update( + index_elements=["user_id", "display_name"], + set_={ + "product_name": product_name + }, # dummy enable returning since on_conflict_do_nothing returns None + # NOTE: use this entry for reference counting in https://github.com/ITISFoundation/osparc-simcore/issues/5875 + ) + .returning(api_keys) + ) + + result = await conn.stream(insert_stmt) + row = await result.first() + assert row # nosec + + return ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=row.display_name, + expiration=row.expires_at, + api_key=row.api_key, + api_secret=row.api_secret, + ) + + +async def list_api_keys( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, +) -> list[ApiKey]: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = sa.select(api_keys.c.id, api_keys.c.display_name).where( + (api_keys.c.user_id == user_id) & (api_keys.c.product_name == product_name) + ) + + result = await conn.stream(stmt) + rows = [row async for row in result] + + return [ + ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=row.display_name, + ) + for row in rows + ] + + +async def get_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + api_key_id: str, + user_id: UserID, + product_name: ProductName, +) -> ApiKey | None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = sa.select(api_keys).where( + ( + api_keys.c.id == int(api_key_id) + ) # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + & (api_keys.c.user_id == user_id) + & (api_keys.c.product_name == product_name) + ) + + result = await conn.stream(stmt) + row = await result.first() + + return ( + ApiKey( + id=f"{row.id}", # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + display_name=row.display_name, + expiration=row.expires_at, + api_key=row.api_key, + api_secret=row.api_secret, + ) + if row + else None + ) + + +async def delete_api_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + user_id: UserID, + product_name: ProductName, + api_key_id: str, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = api_keys.delete().where( + ( + api_keys.c.id == int(api_key_id) + ) # NOTE See: https://github.com/ITISFoundation/osparc-simcore/issues/6919 + & (api_keys.c.user_id == user_id) + & (api_keys.c.product_name == product_name) + ) + await conn.execute(stmt) + + +async def delete_by_name( + app: web.Application, + connection: AsyncConnection | None = None, + *, + display_name: str, + user_id: UserID, + product_name: ProductName, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = api_keys.delete().where( + (api_keys.c.user_id == user_id) + & (api_keys.c.display_name == display_name) + & (api_keys.c.product_name == product_name) + ) + await conn.execute(stmt) + + +async def delete_by_key( + app: web.Application, + connection: AsyncConnection | None = None, + *, + api_key: str, + user_id: UserID, + product_name: ProductName, +) -> None: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = api_keys.delete().where( + (api_keys.c.user_id == user_id) + & (api_keys.c.api_key == api_key) + & (api_keys.c.product_name == product_name) + ) + await conn.execute(stmt) + + +async def prune_expired( + app: web.Application, connection: AsyncConnection | None = None +) -> list[str]: + async with transaction_context(get_asyncpg_engine(app), connection) as conn: + stmt = ( + api_keys.delete() + .where( + (api_keys.c.expires_at.is_not(None)) + & (api_keys.c.expires_at < sa.func.now()) + ) + .returning(api_keys.c.display_name) + ) + result = await conn.stream(stmt) + rows = [row async for row in result] + return [r.display_name for r in rows] diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_rest.py b/services/web/server/src/simcore_service_webserver/api_keys/_rest.py new file mode 100644 index 00000000000..c3b81c63cd3 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_rest.py @@ -0,0 +1,112 @@ +import logging +from dataclasses import asdict + +from aiohttp import web +from aiohttp.web import RouteTableDef +from models_library.api_schemas_webserver.auth import ( + ApiKeyCreateRequest, + ApiKeyCreateResponse, + ApiKeyGet, +) +from models_library.basic_types import IDStr +from models_library.rest_base import StrictRequestParameters +from pydantic import TypeAdapter +from servicelib.aiohttp import status +from servicelib.aiohttp.requests_validation import ( + parse_request_body_as, + parse_request_path_parameters_as, +) + +from .._meta import API_VTAG +from ..login.decorators import login_required +from ..models import RequestContext +from ..security.decorators import permission_required +from ..utils_aiohttp import envelope_json_response +from . import _service +from ._exceptions_handlers import handle_plugin_requests_exceptions +from ._models import ApiKey + +_logger = logging.getLogger(__name__) + + +routes = RouteTableDef() + + +class ApiKeysPathParams(StrictRequestParameters): + api_key_id: IDStr + + +@routes.post(f"/{API_VTAG}/auth/api-keys", name="create_api_key") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def create_api_key(request: web.Request): + req_ctx = RequestContext.model_validate(request) + new_api_key = await parse_request_body_as(ApiKeyCreateRequest, request) + + created_api_key: ApiKey = await _service.create_api_key( + request.app, + display_name=new_api_key.display_name, + expiration=new_api_key.expiration, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + + api_key = ApiKeyCreateResponse.model_validate( + { + **asdict(created_api_key), + "api_base_url": "http://localhost:8000", + } # TODO: https://github.com/ITISFoundation/osparc-simcore/issues/6340 # @pcrespov + ) + + return envelope_json_response(api_key) + + +@routes.get(f"/{API_VTAG}/auth/api-keys", name="list_api_keys") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def list_api_keys(request: web.Request): + req_ctx = RequestContext.model_validate(request) + api_keys = await _service.list_api_keys( + request.app, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + return envelope_json_response( + TypeAdapter(list[ApiKeyGet]).validate_python(api_keys) + ) + + +@routes.get(f"/{API_VTAG}/auth/api-keys/{{api_key_id}}", name="get_api_key") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def get_api_key(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(ApiKeysPathParams, request) + api_key: ApiKey = await _service.get_api_key( + request.app, + api_key_id=path_params.api_key_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + return envelope_json_response(ApiKeyGet.model_validate(api_key)) + + +@routes.delete(f"/{API_VTAG}/auth/api-keys/{{api_key_id}}", name="delete_api_key") +@login_required +@permission_required("user.apikey.*") +@handle_plugin_requests_exceptions +async def delete_api_key(request: web.Request): + req_ctx = RequestContext.model_validate(request) + path_params = parse_request_path_parameters_as(ApiKeysPathParams, request) + + await _service.delete_api_key( + request.app, + api_key_id=path_params.api_key_id, + user_id=req_ctx.user_id, + product_name=req_ctx.product_name, + ) + + return web.json_response(status=status.HTTP_204_NO_CONTENT) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py b/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py index d54b24c1667..3dd04600cfa 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/_rpc.py @@ -2,71 +2,88 @@ from aiohttp import web from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE -from models_library.api_schemas_webserver.auth import ApiKeyCreate, ApiKeyGet +from models_library.api_schemas_webserver.auth import ApiKeyCreateRequest from models_library.products import ProductName +from models_library.rpc.webserver.auth.api_keys import ApiKeyGet from models_library.users import UserID from servicelib.rabbitmq import RPCRouter from ..rabbitmq import get_rabbitmq_rpc_server -from . import _api +from . import _service +from ._models import ApiKey +from .errors import ApiKeyNotFoundError router = RPCRouter() @router.expose() -async def create_api_keys( +async def create_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - new: ApiKeyCreate, + product_name: ProductName, + api_key: ApiKeyCreateRequest, ) -> ApiKeyGet: - return await _api.create_api_key( - app, new=new, user_id=user_id, product_name=product_name + created_api_key: ApiKey = await _service.create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=api_key.display_name, + expiration=api_key.expiration, ) + return ApiKeyGet.model_validate(created_api_key) -@router.expose() -async def delete_api_keys( + +@router.expose(reraise_if_error_type=(ApiKeyNotFoundError,)) +async def get_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - name: str, -) -> None: - await _api.delete_api_key( - app, name=name, user_id=user_id, product_name=product_name + product_name: ProductName, + api_key_id: str, +) -> ApiKeyGet: + api_key: ApiKey = await _service.get_api_key( + app, + user_id=user_id, + product_name=product_name, + api_key_id=api_key_id, ) + return ApiKeyGet.model_validate(api_key) @router.expose() -async def api_key_get( +async def get_or_create_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - name: str, -) -> ApiKeyGet | None: - return await _api.get_api_key( - app, name=name, user_id=user_id, product_name=product_name + product_name: ProductName, + display_name: str, + expiration: timedelta | None = None, +) -> ApiKeyGet: + api_key: ApiKey = await _service.get_or_create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=expiration, ) + return ApiKeyGet.model_validate(api_key) @router.expose() -async def get_or_create_api_keys( +async def delete_api_key( app: web.Application, *, - product_name: ProductName, user_id: UserID, - name: str, - expiration: timedelta | None = None, -) -> ApiKeyGet: - return await _api.get_or_create_api_key( + product_name: ProductName, + api_key_id: str, +) -> None: + await _service.delete_api_key( app, - name=name, user_id=user_id, product_name=product_name, - expiration=expiration, + api_key_id=api_key_id, ) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_service.py b/services/web/server/src/simcore_service_webserver/api_keys/_service.py new file mode 100644 index 00000000000..4d7cdcb43dc --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/_service.py @@ -0,0 +1,123 @@ +import datetime as dt +import re +import string +from typing import Final + +from aiohttp import web +from models_library.products import ProductName +from models_library.users import UserID +from servicelib.utils_secrets import generate_token_secret_key + +from . import _repository +from ._models import ApiKey +from .errors import ApiKeyNotFoundError + +_PUNCTUATION_REGEX = re.compile( + pattern="[" + re.escape(string.punctuation.replace("_", "")) + "]" +) + +_KEY_LEN: Final = 10 +_SECRET_LEN: Final = 20 + + +def _generate_api_key_and_secret(name: str): + prefix = _PUNCTUATION_REGEX.sub("_", name[:5]) + api_key = f"{prefix}_{generate_token_secret_key(_KEY_LEN)}" + api_secret = generate_token_secret_key(_SECRET_LEN) + return api_key, api_secret + + +async def create_api_key( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, + display_name=str, + expiration=dt.timedelta, +) -> ApiKey: + api_key, api_secret = _generate_api_key_and_secret(display_name) + + return await _repository.create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=expiration, + api_key=api_key, + api_secret=api_secret, + ) + + +async def list_api_keys( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, +) -> list[ApiKey]: + api_keys: list[ApiKey] = await _repository.list_api_keys( + app, user_id=user_id, product_name=product_name + ) + return api_keys + + +async def get_api_key( + app: web.Application, + *, + api_key_id: str, + user_id: UserID, + product_name: ProductName, +) -> ApiKey: + api_key: ApiKey | None = await _repository.get_api_key( + app, + api_key_id=api_key_id, + user_id=user_id, + product_name=product_name, + ) + if api_key is not None: + return api_key + + raise ApiKeyNotFoundError(api_key_id=api_key_id) + + +async def get_or_create_api_key( + app: web.Application, + *, + user_id: UserID, + product_name: ProductName, + display_name: str, + expiration: dt.timedelta | None = None, +) -> ApiKey: + + key, secret = _generate_api_key_and_secret(display_name) + + api_key: ApiKey = await _repository.get_or_create_api_key( + app, + user_id=user_id, + product_name=product_name, + display_name=display_name, + expiration=expiration, + api_key=key, + api_secret=secret, + ) + + return api_key + + +async def delete_api_key( + app: web.Application, + *, + api_key_id: str, + user_id: UserID, + product_name: ProductName, +) -> None: + await _repository.delete_api_key( + app, + api_key_id=api_key_id, + user_id=user_id, + product_name=product_name, + ) + + +async def prune_expired_api_keys(app: web.Application) -> list[str]: + names: list[str] = await _repository.prune_expired(app) + return names diff --git a/services/web/server/src/simcore_service_webserver/api_keys/api.py b/services/web/server/src/simcore_service_webserver/api_keys/api.py index df9cb9c8498..9c6a11d719d 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/api.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/api.py @@ -1,4 +1,4 @@ -from ._api import prune_expired_api_keys +from ._service import prune_expired_api_keys __all__: tuple[str, ...] = ("prune_expired_api_keys",) diff --git a/services/web/server/src/simcore_service_webserver/api_keys/errors.py b/services/web/server/src/simcore_service_webserver/api_keys/errors.py new file mode 100644 index 00000000000..5fbe6c38bd9 --- /dev/null +++ b/services/web/server/src/simcore_service_webserver/api_keys/errors.py @@ -0,0 +1,13 @@ +from ..errors import WebServerBaseError + + +class ApiKeysValueError(WebServerBaseError, ValueError): + ... + + +class ApiKeyDuplicatedDisplayNameError(ApiKeysValueError): + msg_template = "API Key with display name '{display_name}' already exists. {reason}" + + +class ApiKeyNotFoundError(ApiKeysValueError): + msg_template = "API Key with ID '{api_key_id}' not found. {reason}" diff --git a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py index b2871596094..9c8cc742c23 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/plugin.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/plugin.py @@ -8,7 +8,7 @@ from ..products.plugin import setup_products from ..rabbitmq import setup_rabbitmq from ..rest.plugin import setup_rest -from . import _handlers, _rpc +from . import _rest, _rpc _logger = logging.getLogger(__name__) @@ -26,7 +26,7 @@ def setup_api_keys(app: web.Application): # http api setup_rest(app) - app.router.add_routes(_handlers.routes) + app.router.add_routes(_rest.routes) # rpc api setup_rabbitmq(app) diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py index aa9e1a14065..85f63c42b96 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_api_keys.py +++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys.py @@ -7,19 +7,21 @@ from collections.abc import AsyncIterable from datetime import timedelta from http import HTTPStatus +from http.client import HTTPException import pytest from aiohttp.test_utils import TestClient +from faker import Faker from models_library.products import ProductName from pytest_simcore.helpers.assert_checks import assert_status from pytest_simcore.helpers.webserver_login import NewUser, UserInfoDict from servicelib.aiohttp import status -from simcore_service_webserver.api_keys._api import ( - get_api_key, +from simcore_service_webserver.api_keys import _repository as repo +from simcore_service_webserver.api_keys._models import ApiKey +from simcore_service_webserver.api_keys._service import ( get_or_create_api_key, prune_expired_api_keys, ) -from simcore_service_webserver.api_keys._db import ApiKeyRepo from simcore_service_webserver.db.models import UserRole @@ -28,26 +30,28 @@ async def fake_user_api_keys( client: TestClient, logged_user: UserInfoDict, osparc_product_name: ProductName, -) -> AsyncIterable[list[str]]: + faker: Faker, +) -> AsyncIterable[list[int]]: assert client.app - names = ["foo", "bar", "beta", "alpha"] - repo = ApiKeyRepo.create_from_app(app=client.app) - - for name in names: - await repo.create( + api_keys: list[ApiKey] = [ + await repo.create_api_key( + client.app, user_id=logged_user["id"], product_name=osparc_product_name, - display_name=name, + display_name=faker.pystr(), expiration=None, - api_key=f"{name}-key", - api_secret=f"{name}-secret", + api_key=faker.pystr(), + api_secret=faker.pystr(), ) + for _ in range(5) + ] - yield names + yield api_keys - for name in names: - await repo.delete_by_name( - display_name=name, + for api_key in api_keys: + await repo.delete_api_key( + client.app, + api_key_id=api_key.id, user_id=logged_user["id"], product_name=osparc_product_name, ) @@ -87,7 +91,7 @@ async def test_list_api_keys( "user_role,expected", _get_user_access_parametrizations(status.HTTP_200_OK), ) -async def test_create_api_keys( +async def test_create_api_key( client: TestClient, logged_user: UserInfoDict, user_role: UserRole, @@ -95,18 +99,18 @@ async def test_create_api_keys( disable_gc_manual_guest_users: None, ): display_name = "foo" - resp = await client.post("/v0/auth/api-keys", json={"display_name": display_name}) + resp = await client.post("/v0/auth/api-keys", json={"displayName": display_name}) data, errors = await assert_status(resp, expected) if not errors: - assert data["display_name"] == display_name - assert "api_key" in data - assert "api_secret" in data + assert data["displayName"] == display_name + assert "apiKey" in data + assert "apiSecret" in data resp = await client.get("/v0/auth/api-keys") data, _ = await assert_status(resp, expected) - assert sorted(data) == [display_name] + assert [d["displayName"] for d in data] == [display_name] @pytest.mark.parametrize( @@ -115,17 +119,17 @@ async def test_create_api_keys( ) async def test_delete_api_keys( client: TestClient, - fake_user_api_keys: list[str], + fake_user_api_keys: list[ApiKey], logged_user: UserInfoDict, user_role: UserRole, expected: HTTPStatus, disable_gc_manual_guest_users: None, ): - resp = await client.delete("/v0/auth/api-keys", json={"display_name": "foo"}) + resp = await client.delete("/v0/auth/api-keys/0") await assert_status(resp, expected) - for name in fake_user_api_keys: - resp = await client.delete("/v0/auth/api-keys", json={"display_name": name}) + for api_key in fake_user_api_keys: + resp = await client.delete(f"/v0/auth/api-keys/{api_key.id}") await assert_status(resp, expected) @@ -146,19 +150,19 @@ async def test_create_api_key_with_expiration( expiration_interval = timedelta(seconds=1) resp = await client.post( "/v0/auth/api-keys", - json={"display_name": "foo", "expiration": expiration_interval.seconds}, + json={"displayName": "foo", "expiration": expiration_interval.seconds}, ) data, errors = await assert_status(resp, expected) if not errors: - assert data["display_name"] == "foo" - assert "api_key" in data - assert "api_secret" in data + assert data["displayName"] == "foo" + assert "apiKey" in data + assert "apiSecret" in data # list created api-key resp = await client.get("/v0/auth/api-keys") data, _ = await assert_status(resp, expected) - assert data == ["foo"] + assert [d["displayName"] for d in data] == ["foo"] # wait for api-key for it to expire and force-run scheduled task await asyncio.sleep(expiration_interval.seconds) @@ -180,19 +184,34 @@ async def test_get_or_create_api_key( assert client.app options = { - "name": "repeated_name", "user_id": user["id"], "product_name": "osparc", + "display_name": "foo", } - # does not exist - assert await get_api_key(client.app, **options) is None - # create once created = await get_or_create_api_key(client.app, **options) - assert created.display_name == options["name"] + assert created.display_name == "foo" assert created.api_key != created.api_secret - # idempottent + # idempotent for _ in range(3): assert await get_or_create_api_key(client.app, **options) == created + + +@pytest.mark.parametrize( + "user_role,expected", + _get_user_access_parametrizations(status.HTTP_404_NOT_FOUND), +) +async def test_get_not_existing_api_key( + client: TestClient, + logged_user: UserInfoDict, + user_role: UserRole, + expected: HTTPException, + disable_gc_manual_guest_users: None, +): + resp = await client.get("/v0/auth/api-keys/42") + data, errors = await assert_status(resp, expected) + + if not errors: + assert data is None diff --git a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py index 51467f3c822..aa45fd9fd2e 100644 --- a/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py +++ b/services/web/server/tests/unit/with_dbs/01/test_api_keys_rpc.py @@ -8,19 +8,23 @@ import pytest from aiohttp.test_utils import TestServer from faker import Faker -from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE -from models_library.api_schemas_webserver.auth import ApiKeyCreate from models_library.products import ProductName -from models_library.rabbitmq_basic_types import RPCMethodName -from pydantic import TypeAdapter +from models_library.rpc.webserver.auth.api_keys import ApiKeyCreate from pytest_mock import MockerFixture from pytest_simcore.helpers.monkeypatch_envs import setenvs_from_dict from pytest_simcore.helpers.typing_env import EnvVarsDict from pytest_simcore.helpers.webserver_login import UserInfoDict from servicelib.rabbitmq import RabbitMQRPCClient +from servicelib.rabbitmq.rpc_interfaces.webserver.auth.api_keys import ( + create_api_key, + delete_api_key, + get_api_key, +) from settings_library.rabbit import RabbitSettings from simcore_postgres_database.models.users import UserRole -from simcore_service_webserver.api_keys._db import ApiKeyRepo +from simcore_service_webserver.api_keys import _repository as repo +from simcore_service_webserver.api_keys._models import ApiKey +from simcore_service_webserver.api_keys.errors import ApiKeyNotFoundError from simcore_service_webserver.application_settings import ApplicationSettings pytest_simcore_core_services_selection = [ @@ -63,25 +67,29 @@ async def fake_user_api_keys( web_server: TestServer, logged_user: UserInfoDict, osparc_product_name: ProductName, -) -> AsyncIterable[list[str]]: - names = ["foo", "bar", "beta", "alpha"] - repo = ApiKeyRepo.create_from_app(app=web_server.app) + faker: Faker, +) -> AsyncIterable[list[ApiKey]]: + assert web_server.app - for name in names: - await repo.create( + api_keys: list[ApiKey] = [ + await repo.create_api_key( + web_server.app, user_id=logged_user["id"], product_name=osparc_product_name, - display_name=name, + display_name=faker.pystr(), expiration=None, - api_key=f"{name}-key", - api_secret=f"{name}-secret", + api_key=faker.pystr(), + api_secret=faker.pystr(), ) + for _ in range(5) + ] - yield names + yield api_keys - for name in names: - await repo.delete_by_name( - display_name=name, + for api_key in api_keys: + await repo.delete_api_key( + web_server.app, + api_key_id=api_key.id, user_id=logged_user["id"], product_name=osparc_product_name, ) @@ -95,21 +103,20 @@ async def rpc_client( return await rabbitmq_rpc_client("client") -async def test_api_key_get( - fake_user_api_keys: list[str], +async def test_get_api_key( + fake_user_api_keys: list[ApiKey], rpc_client: RabbitMQRPCClient, osparc_product_name: ProductName, logged_user: UserInfoDict, ): - for api_key_name in fake_user_api_keys: - result = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("api_key_get"), - product_name=osparc_product_name, + for api_key in fake_user_api_keys: + result = await get_api_key( + rpc_client, user_id=logged_user["id"], - name=api_key_name, + product_name=osparc_product_name, + api_key_id=api_key.id, ) - assert result.display_name == api_key_name + assert result.id == api_key.id async def test_api_keys_workflow( @@ -122,43 +129,39 @@ async def test_api_keys_workflow( key_name = faker.pystr() # creating a key - created_api_key = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("create_api_keys"), - product_name=osparc_product_name, + created_api_key = await create_api_key( + rpc_client, user_id=logged_user["id"], - new=ApiKeyCreate(display_name=key_name, expiration=None), + product_name=osparc_product_name, + api_key=ApiKeyCreate(display_name=key_name, expiration=None), ) assert created_api_key.display_name == key_name # query the key is still present - queried_api_key = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("api_key_get"), + queried_api_key = await get_api_key( + rpc_client, product_name=osparc_product_name, user_id=logged_user["id"], - name=key_name, + api_key_id=created_api_key.id, ) assert queried_api_key.display_name == key_name - assert created_api_key == queried_api_key + assert created_api_key.id == queried_api_key.id + assert created_api_key.display_name == queried_api_key.display_name # remove the key - delete_key_result = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("delete_api_keys"), - product_name=osparc_product_name, + await delete_api_key( + rpc_client, user_id=logged_user["id"], - name=key_name, - ) - assert delete_key_result is None - - # key no longer present - query_missing_query = await rpc_client.request( - WEBSERVER_RPC_NAMESPACE, - TypeAdapter(RPCMethodName).validate_python("api_key_get"), product_name=osparc_product_name, - user_id=logged_user["id"], - name=key_name, + api_key_id=created_api_key.id, ) - assert query_missing_query is None + + with pytest.raises(ApiKeyNotFoundError): + # key no longer present + await get_api_key( + rpc_client, + product_name=osparc_product_name, + user_id=logged_user["id"], + api_key_id=created_api_key.id, + ) diff --git a/tests/public-api/conftest.py b/tests/public-api/conftest.py index 3b4a0b27b9c..b8f40710e08 100644 --- a/tests/public-api/conftest.py +++ b/tests/public-api/conftest.py @@ -147,25 +147,23 @@ def registered_user( resp.raise_for_status() # create a key via web-api - resp = client.post("/auth/api-keys", json={"display_name": "test-public-api"}) + resp = client.post("/auth/api-keys", json={"displayName": "test-public-api"}) print(resp.text) resp.raise_for_status() data = resp.json()["data"] - assert data["display_name"] == "test-public-api" + assert data["displayName"] == "test-public-api" - assert "api_key" in data - assert "api_secret" in data + assert "apiKey" in data + assert "apiSecret" in data - user["api_key"] = data["api_key"] - user["api_secret"] = data["api_secret"] + user["api_key"] = data["apiKey"] + user["api_secret"] = data["apiSecret"] yield user - resp = client.request( - "DELETE", "/auth/api-keys", json={"display_name": "test-public-api"} - ) + resp = client.delete(f"/auth/api-keys/{data['id']}") @pytest.fixture(scope="module") @@ -283,7 +281,7 @@ def api_client( def as_dict(obj: object): return { attr: getattr(obj, attr) - for attr in obj.__dict__.keys() + for attr in obj.__dict__ if not attr.startswith("_") }