Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
8 changes: 8 additions & 0 deletions packages/models-library/src/models_library/auth.py
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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]}"

Expand Down
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_DISPLAY_NAME_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_DISPLAY_NAME_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
Expand Up @@ -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:
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_KEY_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.api_key.like(f"{API_KEY_AUTOGENERATED_KEY_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
67 changes: 67 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,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
Expand Down Expand Up @@ -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),
Expand All @@ -86,13 +123,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