Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion api/specs/web-server/_auth_api_keys.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"""


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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[
Expand Down
3 changes: 3 additions & 0 deletions packages/models-library/src/models_library/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from typing import Final

API_KEY_AUTOGENERATED_PREFIX: Final[str] = "__auto_"
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from uuid import uuid5

from fastapi import FastAPI
from models_library.auth import API_KEY_AUTOGENERATED_PREFIX
from models_library.products import ProductName
from models_library.projects import ProjectID
from models_library.projects_nodes_io import NodeID
Expand All @@ -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_PREFIX}{uuid5(uuid.NAMESPACE_DNS, f'{product_name}/{user_id}/{project_id}/{node_id}')}"


async def create_user_api_key(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
openapi: 3.1.0
info:
title: simcore-service-webserver
Expand Down Expand Up @@ -381,62 +381,70 @@
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: include_autogenerated
in: query
required: false
schema:
type: boolean
default: false
title: Include Autogenerated
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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
ApiKeyCreateRequest,
ApiKeyCreateResponse,
ApiKeyGet,
ApiKeyListQueryParams,
)
from models_library.basic_types import IDStr
from models_library.rest_base import StrictRequestParameters
Expand All @@ -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
Expand Down Expand Up @@ -68,10 +70,15 @@ async def create_api_key(request: web.Request):
@handle_plugin_requests_exceptions
async def list_api_keys(request: web.Request):
req_ctx = RequestContext.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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import sqlalchemy as sa
from aiohttp import web
from models_library.auth import API_KEY_AUTOGENERATED_PREFIX
from models_library.products import ProductName
from models_library.users import UserID
from simcore_postgres_database.models.api_keys import api_keys
Expand Down Expand Up @@ -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.display_name.like(f"{API_KEY_AUTOGENERATED_PREFIX}%")
)

result = await conn.stream(stmt)
rows = [row async for row in result]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
64 changes: 64 additions & 0 deletions services/web/server/tests/unit/with_dbs/01/test_api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import tenacity
from aiohttp.test_utils import TestClient
from faker import Faker
from models_library.auth import API_KEY_AUTOGENERATED_PREFIX
from models_library.products import ProductName
from pytest_mock import MockerFixture, MockType
from pytest_simcore.helpers.assert_checks import assert_status
Expand Down Expand Up @@ -67,6 +68,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_PREFIX + faker.pystr(),
expiration=None,
api_key=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),
Expand All @@ -86,13 +120,43 @@ 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,
):
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

Expand Down
Loading