diff --git a/api/specs/web-server/_auth_api_keys.py b/api/specs/web-server/_auth_api_keys.py index bcebb042376..8b160f9df21 100644 --- a/api/specs/web-server/_auth_api_keys.py +++ b/api/specs/web-server/_auth_api_keys.py @@ -1,10 +1,12 @@ from typing import Annotated +from _common import as_query from fastapi import APIRouter, Depends, status from models_library.api_schemas_webserver.auth import ( ApiKeyCreateRequest, ApiKeyCreateResponse, ApiKeyGet, + ApiKeyListQueryParams, ) from models_library.generics import Envelope from models_library.rest_error import EnvelopedError @@ -39,7 +41,9 @@ async def create_api_key(_body: ApiKeyCreateRequest): response_model=Envelope[list[ApiKeyGet]], status_code=status.HTTP_200_OK, ) -async def list_api_keys(): +async def list_api_keys( + _query: Annotated[as_query(ApiKeyListQueryParams), Depends()], +): """lists API keys by this user""" 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 697867d93b8..a77dfe150b3 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 @@ -2,6 +2,7 @@ from typing import Annotated, Any from models_library.basic_types import IDStr +from models_library.rest_base import RequestParameters from pydantic import AliasGenerator, ConfigDict, Field, HttpUrl, SecretStr from pydantic.alias_generators import to_camel @@ -53,6 +54,17 @@ class UnregisterCheck(InputSchema): # +class ApiKeyListQueryParams(RequestParameters): + include_autogenerated: Annotated[ + bool, + Field( + alias="includeAutogenerated", + description="If True, then the list includes autogenerated API keys. " + "Otherwise, only user-created API keys are returned.", + ), + ] = False + + class ApiKeyCreateRequest(InputSchema): display_name: Annotated[str, Field(..., min_length=3)] expiration: Annotated[ diff --git a/packages/models-library/src/models_library/auth.py b/packages/models-library/src/models_library/auth.py new file mode 100644 index 00000000000..eead22ea84b --- /dev/null +++ b/packages/models-library/src/models_library/auth.py @@ -0,0 +1,8 @@ +from typing import Final + +from models_library.rpc.webserver.auth.api_keys import generate_api_key_prefix + +API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX: Final[str] = "__auto_" +API_KEY_AUTOGENERATED_KEY_PREFIX: Final[str] = generate_api_key_prefix( + API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX +) 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 index 80d248d9045..3b0da5ed78c 100644 --- 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 @@ -16,8 +16,12 @@ _SECRET_LEN: Final = 20 +def generate_api_key_prefix(name: str) -> str: + return _PUNCTUATION_REGEX.sub("_", name[:5]) + + def generate_unique_api_key(name: str, length: int = _KEY_LEN) -> str: - prefix = _PUNCTUATION_REGEX.sub("_", name[:5]) + prefix = generate_api_key_prefix(name) hashed = hashlib.sha256(name.encode()).hexdigest() return f"{prefix}_{hashed[:length]}" 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 18fb5f4ff17..7f6e2ad1925 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,6 +4,7 @@ from uuid import uuid5 from fastapi import FastAPI +from models_library.auth import API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX from models_library.products import ProductName from models_library.projects import ProjectID from models_library.projects_nodes_io import NodeID @@ -27,7 +28,7 @@ def create_unique_api_name_for( ) -> str: # NOTE: The namespace chosen doesn't significantly impact the resulting UUID # as long as it's consistently used across the same context - return f"__auto_{uuid5(uuid.NAMESPACE_DNS, f'{product_name}/{user_id}/{project_id}/{node_id}')}" + return f"{API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX}{uuid5(uuid.NAMESPACE_DNS, f'{product_name}/{user_id}/{project_id}/{node_id}')}" async def create_user_api_key( 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 034cb621eba..6bdd2034bc9 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 @@ -381,62 +381,70 @@ paths: schema: {} image/png: {} /v0/auth/api-keys: - get: + post: tags: - auth - summary: List Api Keys - description: lists API keys by this user - operationId: list_api_keys + summary: Create Api Key + description: creates API keys to access public API + operationId: create_api_key + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ApiKeyCreateRequest' responses: - '200': + '201': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/Envelope_list_ApiKeyGet__' + $ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_' '409': - description: Conflict content: application/json: schema: $ref: '#/components/schemas/EnvelopedError' + description: Conflict '404': - description: Not Found content: application/json: schema: $ref: '#/components/schemas/EnvelopedError' - post: + description: Not Found + get: tags: - auth - summary: Create Api Key - description: creates API keys to access public API - operationId: create_api_key - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/ApiKeyCreateRequest' - required: true + summary: List Api Keys + description: lists API keys by this user + operationId: list_api_keys + parameters: + - name: includeAutogenerated + in: query + required: false + schema: + type: boolean + default: false + title: Includeautogenerated responses: - '201': + '200': description: Successful Response content: application/json: schema: - $ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_' + $ref: '#/components/schemas/Envelope_list_ApiKeyGet__' '409': - description: Conflict content: application/json: schema: $ref: '#/components/schemas/EnvelopedError' + description: Conflict '404': - description: Not Found content: application/json: schema: $ref: '#/components/schemas/EnvelopedError' + description: Not Found /v0/auth/api-keys/{api_key_id}: get: tags: diff --git a/services/web/server/src/simcore_service_webserver/api_keys/_controller/rest.py b/services/web/server/src/simcore_service_webserver/api_keys/_controller/rest.py index 0cc879f2b2f..05a3b4cff14 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/_controller/rest.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/_controller/rest.py @@ -7,6 +7,7 @@ ApiKeyCreateRequest, ApiKeyCreateResponse, ApiKeyGet, + ApiKeyListQueryParams, ) from models_library.basic_types import IDStr from models_library.rest_base import StrictRequestParameters @@ -15,6 +16,7 @@ from servicelib.aiohttp.requests_validation import ( parse_request_body_as, parse_request_path_parameters_as, + parse_request_query_parameters_as, ) from ..._meta import API_VTAG @@ -68,10 +70,16 @@ async def create_api_key(request: web.Request): @handle_plugin_requests_exceptions async def list_api_keys(request: web.Request): req_ctx = AuthenticatedRequestContext.model_validate(request) + + query_params: ApiKeyListQueryParams = parse_request_query_parameters_as( + ApiKeyListQueryParams, request + ) + api_keys = await _service.list_api_keys( request.app, user_id=req_ctx.user_id, product_name=req_ctx.product_name, + include_autogenerated=query_params.include_autogenerated, ) return envelope_json_response( TypeAdapter(list[ApiKeyGet]).validate_python(api_keys) 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 index 72e58d7bcf1..d765d0a388b 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/_repository.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/_repository.py @@ -3,6 +3,7 @@ import sqlalchemy as sa from aiohttp import web +from models_library.auth import API_KEY_AUTOGENERATED_KEY_PREFIX from models_library.products import ProductName from models_library.users import UserID from simcore_postgres_database.models.api_keys import api_keys @@ -160,12 +161,18 @@ async def list_api_keys( *, user_id: UserID, product_name: ProductName, + include_autogenerated: bool = False, ) -> list[ApiKey]: async with pass_or_acquire_connection(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) ) + if not include_autogenerated: + stmt = stmt.where( + ~api_keys.c.api_key.like(f"{API_KEY_AUTOGENERATED_KEY_PREFIX}%") + ) + result = await conn.stream(stmt) rows = [row async for row in result] 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 index 93e8c361094..64f10969e93 100644 --- a/services/web/server/src/simcore_service_webserver/api_keys/_service.py +++ b/services/web/server/src/simcore_service_webserver/api_keys/_service.py @@ -2,9 +2,7 @@ from aiohttp import web from models_library.products import ProductName -from models_library.rpc.webserver.auth.api_keys import ( - generate_api_key_and_secret, -) +from models_library.rpc.webserver.auth.api_keys import generate_api_key_and_secret from models_library.users import UserID from . import _repository @@ -87,9 +85,13 @@ async def list_api_keys( *, product_name: ProductName, user_id: UserID, + include_autogenerated: bool = False, ) -> list[ApiKey]: api_keys: list[ApiKey] = await _repository.list_api_keys( - app, user_id=user_id, product_name=product_name + app, + user_id=user_id, + product_name=product_name, + include_autogenerated=include_autogenerated, ) return api_keys 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 0793762fada..ec8b987e750 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 @@ -13,6 +13,10 @@ import tenacity from aiohttp.test_utils import TestClient from faker import Faker +from models_library.auth import ( + API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX, + API_KEY_AUTOGENERATED_KEY_PREFIX, +) from models_library.products import ProductName from pytest_mock import MockerFixture, MockType from pytest_simcore.helpers.assert_checks import assert_status @@ -67,6 +71,39 @@ async def fake_user_api_keys( ) +@pytest.fixture +async def fake_auto_api_keys( + client: TestClient, + logged_user: UserInfoDict, + osparc_product_name: ProductName, + faker: Faker, +) -> AsyncIterable[list[ApiKey]]: + assert client.app + + api_keys: list[ApiKey] = [ + await _repository.create_api_key( + client.app, + user_id=logged_user["id"], + product_name=osparc_product_name, + display_name=API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX + faker.pystr(), + expiration=None, + api_key=API_KEY_AUTOGENERATED_KEY_PREFIX + faker.pystr(), + api_secret=faker.pystr(), + ) + for _ in range(5) + ] + + yield api_keys + + for api_key in api_keys: + await _repository.delete_api_key( + client.app, + api_key_id=api_key.id, + user_id=logged_user["id"], + product_name=osparc_product_name, + ) + + def _get_user_access_parametrizations(expected_authed_status_code): return [ pytest.param(UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED), @@ -86,6 +123,7 @@ def _get_user_access_parametrizations(expected_authed_status_code): async def test_list_api_keys( disabled_setup_garbage_collector: MockType, client: TestClient, + fake_user_api_keys: list[ApiKey], logged_user: UserInfoDict, user_role: UserRole, expected: HTTPStatus, @@ -93,6 +131,35 @@ async def test_list_api_keys( resp = await client.get("/v0/auth/api-keys") data, errors = await assert_status(resp, expected) + if not errors: + assert len(data) == len(fake_user_api_keys) + + +@pytest.mark.parametrize( + "user_role,expected", + _get_user_access_parametrizations(status.HTTP_200_OK), +) +async def test_list_auto_api_keys( + disabled_setup_garbage_collector: MockType, + client: TestClient, + fake_auto_api_keys: list[ApiKey], + logged_user: UserInfoDict, + user_role: UserRole, + expected: HTTPStatus, +): + resp = await client.get( + "/v0/auth/api-keys", params={"includeAutogenerated": "true"} + ) + data, errors = await assert_status(resp, expected) + + if not errors: + assert len(data) == len(fake_auto_api_keys) + + resp = await client.get( + "/v0/auth/api-keys", params={"includeAutogenerated": "false"} + ) + data, errors = await assert_status(resp, expected) + if not errors: assert not data