Skip to content

Commit 1ce595e

Browse files
🎨 Filter autogenerated api-keys when listing (#7855)
1 parent 9c3a929 commit 1ce595e

File tree

10 files changed

+150
-29
lines changed

10 files changed

+150
-29
lines changed

api/specs/web-server/_auth_api_keys.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
from typing import Annotated
22

3+
from _common import as_query
34
from fastapi import APIRouter, Depends, status
45
from models_library.api_schemas_webserver.auth import (
56
ApiKeyCreateRequest,
67
ApiKeyCreateResponse,
78
ApiKeyGet,
9+
ApiKeyListQueryParams,
810
)
911
from models_library.generics import Envelope
1012
from models_library.rest_error import EnvelopedError
@@ -39,7 +41,9 @@ async def create_api_key(_body: ApiKeyCreateRequest):
3941
response_model=Envelope[list[ApiKeyGet]],
4042
status_code=status.HTTP_200_OK,
4143
)
42-
async def list_api_keys():
44+
async def list_api_keys(
45+
_query: Annotated[as_query(ApiKeyListQueryParams), Depends()],
46+
):
4347
"""lists API keys by this user"""
4448

4549

packages/models-library/src/models_library/api_schemas_webserver/auth.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from typing import Annotated, Any
33

44
from models_library.basic_types import IDStr
5+
from models_library.rest_base import RequestParameters
56
from pydantic import AliasGenerator, ConfigDict, Field, HttpUrl, SecretStr
67
from pydantic.alias_generators import to_camel
78

@@ -53,6 +54,17 @@ class UnregisterCheck(InputSchema):
5354
#
5455

5556

57+
class ApiKeyListQueryParams(RequestParameters):
58+
include_autogenerated: Annotated[
59+
bool,
60+
Field(
61+
alias="includeAutogenerated",
62+
description="If True, then the list includes autogenerated API keys. "
63+
"Otherwise, only user-created API keys are returned.",
64+
),
65+
] = False
66+
67+
5668
class ApiKeyCreateRequest(InputSchema):
5769
display_name: Annotated[str, Field(..., min_length=3)]
5870
expiration: Annotated[
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from typing import Final
2+
3+
from models_library.rpc.webserver.auth.api_keys import generate_api_key_prefix
4+
5+
API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX: Final[str] = "__auto_"
6+
API_KEY_AUTOGENERATED_KEY_PREFIX: Final[str] = generate_api_key_prefix(
7+
API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX
8+
)

packages/models-library/src/models_library/rpc/webserver/auth/api_keys.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@
1616
_SECRET_LEN: Final = 20
1717

1818

19+
def generate_api_key_prefix(name: str) -> str:
20+
return _PUNCTUATION_REGEX.sub("_", name[:5])
21+
22+
1923
def generate_unique_api_key(name: str, length: int = _KEY_LEN) -> str:
20-
prefix = _PUNCTUATION_REGEX.sub("_", name[:5])
24+
prefix = generate_api_key_prefix(name)
2125
hashed = hashlib.sha256(name.encode()).hexdigest()
2226
return f"{prefix}_{hashed[:length]}"
2327

services/director-v2/src/simcore_service_director_v2/modules/osparc_variables/_api_auth.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from uuid import uuid5
55

66
from fastapi import FastAPI
7+
from models_library.auth import API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX
78
from models_library.products import ProductName
89
from models_library.projects import ProjectID
910
from models_library.projects_nodes_io import NodeID
@@ -27,7 +28,7 @@ def create_unique_api_name_for(
2728
) -> str:
2829
# NOTE: The namespace chosen doesn't significantly impact the resulting UUID
2930
# as long as it's consistently used across the same context
30-
return f"__auto_{uuid5(uuid.NAMESPACE_DNS, f'{product_name}/{user_id}/{project_id}/{node_id}')}"
31+
return f"{API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX}{uuid5(uuid.NAMESPACE_DNS, f'{product_name}/{user_id}/{project_id}/{node_id}')}"
3132

3233

3334
async def create_user_api_key(

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

Lines changed: 30 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -381,62 +381,70 @@ paths:
381381
schema: {}
382382
image/png: {}
383383
/v0/auth/api-keys:
384-
get:
384+
post:
385385
tags:
386386
- auth
387-
summary: List Api Keys
388-
description: lists API keys by this user
389-
operationId: list_api_keys
387+
summary: Create Api Key
388+
description: creates API keys to access public API
389+
operationId: create_api_key
390+
requestBody:
391+
required: true
392+
content:
393+
application/json:
394+
schema:
395+
$ref: '#/components/schemas/ApiKeyCreateRequest'
390396
responses:
391-
'200':
397+
'201':
392398
description: Successful Response
393399
content:
394400
application/json:
395401
schema:
396-
$ref: '#/components/schemas/Envelope_list_ApiKeyGet__'
402+
$ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_'
397403
'409':
398-
description: Conflict
399404
content:
400405
application/json:
401406
schema:
402407
$ref: '#/components/schemas/EnvelopedError'
408+
description: Conflict
403409
'404':
404-
description: Not Found
405410
content:
406411
application/json:
407412
schema:
408413
$ref: '#/components/schemas/EnvelopedError'
409-
post:
414+
description: Not Found
415+
get:
410416
tags:
411417
- auth
412-
summary: Create Api Key
413-
description: creates API keys to access public API
414-
operationId: create_api_key
415-
requestBody:
416-
content:
417-
application/json:
418-
schema:
419-
$ref: '#/components/schemas/ApiKeyCreateRequest'
420-
required: true
418+
summary: List Api Keys
419+
description: lists API keys by this user
420+
operationId: list_api_keys
421+
parameters:
422+
- name: includeAutogenerated
423+
in: query
424+
required: false
425+
schema:
426+
type: boolean
427+
default: false
428+
title: Includeautogenerated
421429
responses:
422-
'201':
430+
'200':
423431
description: Successful Response
424432
content:
425433
application/json:
426434
schema:
427-
$ref: '#/components/schemas/Envelope_ApiKeyCreateResponse_'
435+
$ref: '#/components/schemas/Envelope_list_ApiKeyGet__'
428436
'409':
429-
description: Conflict
430437
content:
431438
application/json:
432439
schema:
433440
$ref: '#/components/schemas/EnvelopedError'
441+
description: Conflict
434442
'404':
435-
description: Not Found
436443
content:
437444
application/json:
438445
schema:
439446
$ref: '#/components/schemas/EnvelopedError'
447+
description: Not Found
440448
/v0/auth/api-keys/{api_key_id}:
441449
get:
442450
tags:

services/web/server/src/simcore_service_webserver/api_keys/_controller/rest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
ApiKeyCreateRequest,
88
ApiKeyCreateResponse,
99
ApiKeyGet,
10+
ApiKeyListQueryParams,
1011
)
1112
from models_library.basic_types import IDStr
1213
from models_library.rest_base import StrictRequestParameters
@@ -15,6 +16,7 @@
1516
from servicelib.aiohttp.requests_validation import (
1617
parse_request_body_as,
1718
parse_request_path_parameters_as,
19+
parse_request_query_parameters_as,
1820
)
1921

2022
from ..._meta import API_VTAG
@@ -68,10 +70,16 @@ async def create_api_key(request: web.Request):
6870
@handle_plugin_requests_exceptions
6971
async def list_api_keys(request: web.Request):
7072
req_ctx = AuthenticatedRequestContext.model_validate(request)
73+
74+
query_params: ApiKeyListQueryParams = parse_request_query_parameters_as(
75+
ApiKeyListQueryParams, request
76+
)
77+
7178
api_keys = await _service.list_api_keys(
7279
request.app,
7380
user_id=req_ctx.user_id,
7481
product_name=req_ctx.product_name,
82+
include_autogenerated=query_params.include_autogenerated,
7583
)
7684
return envelope_json_response(
7785
TypeAdapter(list[ApiKeyGet]).validate_python(api_keys)

services/web/server/src/simcore_service_webserver/api_keys/_repository.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import sqlalchemy as sa
55
from aiohttp import web
6+
from models_library.auth import API_KEY_AUTOGENERATED_KEY_PREFIX
67
from models_library.products import ProductName
78
from models_library.users import UserID
89
from simcore_postgres_database.models.api_keys import api_keys
@@ -160,12 +161,18 @@ async def list_api_keys(
160161
*,
161162
user_id: UserID,
162163
product_name: ProductName,
164+
include_autogenerated: bool = False,
163165
) -> list[ApiKey]:
164166
async with pass_or_acquire_connection(get_asyncpg_engine(app), connection) as conn:
165167
stmt = sa.select(api_keys.c.id, api_keys.c.display_name).where(
166168
(api_keys.c.user_id == user_id) & (api_keys.c.product_name == product_name)
167169
)
168170

171+
if not include_autogenerated:
172+
stmt = stmt.where(
173+
~api_keys.c.api_key.like(f"{API_KEY_AUTOGENERATED_KEY_PREFIX}%")
174+
)
175+
169176
result = await conn.stream(stmt)
170177
rows = [row async for row in result]
171178

services/web/server/src/simcore_service_webserver/api_keys/_service.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,7 @@
22

33
from aiohttp import web
44
from models_library.products import ProductName
5-
from models_library.rpc.webserver.auth.api_keys import (
6-
generate_api_key_and_secret,
7-
)
5+
from models_library.rpc.webserver.auth.api_keys import generate_api_key_and_secret
86
from models_library.users import UserID
97

108
from . import _repository
@@ -87,9 +85,13 @@ async def list_api_keys(
8785
*,
8886
product_name: ProductName,
8987
user_id: UserID,
88+
include_autogenerated: bool = False,
9089
) -> list[ApiKey]:
9190
api_keys: list[ApiKey] = await _repository.list_api_keys(
92-
app, user_id=user_id, product_name=product_name
91+
app,
92+
user_id=user_id,
93+
product_name=product_name,
94+
include_autogenerated=include_autogenerated,
9395
)
9496
return api_keys
9597

services/web/server/tests/unit/with_dbs/01/test_api_keys.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
import tenacity
1414
from aiohttp.test_utils import TestClient
1515
from faker import Faker
16+
from models_library.auth import (
17+
API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX,
18+
API_KEY_AUTOGENERATED_KEY_PREFIX,
19+
)
1620
from models_library.products import ProductName
1721
from pytest_mock import MockerFixture, MockType
1822
from pytest_simcore.helpers.assert_checks import assert_status
@@ -67,6 +71,39 @@ async def fake_user_api_keys(
6771
)
6872

6973

74+
@pytest.fixture
75+
async def fake_auto_api_keys(
76+
client: TestClient,
77+
logged_user: UserInfoDict,
78+
osparc_product_name: ProductName,
79+
faker: Faker,
80+
) -> AsyncIterable[list[ApiKey]]:
81+
assert client.app
82+
83+
api_keys: list[ApiKey] = [
84+
await _repository.create_api_key(
85+
client.app,
86+
user_id=logged_user["id"],
87+
product_name=osparc_product_name,
88+
display_name=API_KEY_AUTOGENERATED_DISPLAY_NAME_PREFIX + faker.pystr(),
89+
expiration=None,
90+
api_key=API_KEY_AUTOGENERATED_KEY_PREFIX + faker.pystr(),
91+
api_secret=faker.pystr(),
92+
)
93+
for _ in range(5)
94+
]
95+
96+
yield api_keys
97+
98+
for api_key in api_keys:
99+
await _repository.delete_api_key(
100+
client.app,
101+
api_key_id=api_key.id,
102+
user_id=logged_user["id"],
103+
product_name=osparc_product_name,
104+
)
105+
106+
70107
def _get_user_access_parametrizations(expected_authed_status_code):
71108
return [
72109
pytest.param(UserRole.ANONYMOUS, status.HTTP_401_UNAUTHORIZED),
@@ -86,13 +123,43 @@ def _get_user_access_parametrizations(expected_authed_status_code):
86123
async def test_list_api_keys(
87124
disabled_setup_garbage_collector: MockType,
88125
client: TestClient,
126+
fake_user_api_keys: list[ApiKey],
89127
logged_user: UserInfoDict,
90128
user_role: UserRole,
91129
expected: HTTPStatus,
92130
):
93131
resp = await client.get("/v0/auth/api-keys")
94132
data, errors = await assert_status(resp, expected)
95133

134+
if not errors:
135+
assert len(data) == len(fake_user_api_keys)
136+
137+
138+
@pytest.mark.parametrize(
139+
"user_role,expected",
140+
_get_user_access_parametrizations(status.HTTP_200_OK),
141+
)
142+
async def test_list_auto_api_keys(
143+
disabled_setup_garbage_collector: MockType,
144+
client: TestClient,
145+
fake_auto_api_keys: list[ApiKey],
146+
logged_user: UserInfoDict,
147+
user_role: UserRole,
148+
expected: HTTPStatus,
149+
):
150+
resp = await client.get(
151+
"/v0/auth/api-keys", params={"includeAutogenerated": "true"}
152+
)
153+
data, errors = await assert_status(resp, expected)
154+
155+
if not errors:
156+
assert len(data) == len(fake_auto_api_keys)
157+
158+
resp = await client.get(
159+
"/v0/auth/api-keys", params={"includeAutogenerated": "false"}
160+
)
161+
data, errors = await assert_status(resp, expected)
162+
96163
if not errors:
97164
assert not data
98165

0 commit comments

Comments
 (0)