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("_")
}