Skip to content

Commit 0a8019d

Browse files
authored
🎨 webserver: cache authenticated calls (#7826)
1 parent ee22f37 commit 0a8019d

File tree

7 files changed

+130
-30
lines changed

7 files changed

+130
-30
lines changed

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,4 @@ This document provides guidelines and best practices for using GitHub Copilot in
5252
- [Python Coding Conventions](../docs/coding-conventions.md)
5353
- [Environment Variables Guide](../docs/env-vars.md)
5454
- [Steps to Upgrade Python](../docs/steps-to-upgrade-python.md)
55-
- [Node.js Installation Script](../scripts/install_nodejs_14.bash)
55+
- [Pydantic Annotated fields](../docs/llm-prompts/pydantic-annotated-fields.md)

services/web/server/src/simcore_service_webserver/security/_authz_db.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22
from typing import TypedDict
33

44
import sqlalchemy as sa
5-
from aiopg.sa import Engine
6-
from aiopg.sa.result import ResultProxy
75
from models_library.basic_types import IdInt
86
from models_library.products import ProductName
97
from models_library.users import UserID
108
from pydantic import TypeAdapter
119
from simcore_postgres_database.models.groups import user_to_groups
1210
from simcore_postgres_database.models.products import products
1311
from simcore_postgres_database.models.users import UserRole
12+
from sqlalchemy.ext.asyncio import AsyncEngine
1413

1514
from ..db.models import UserStatus, users
1615

@@ -22,33 +21,38 @@ class AuthInfoDict(TypedDict, total=True):
2221
role: UserRole
2322

2423

25-
async def get_active_user_or_none(engine: Engine, email: str) -> AuthInfoDict | None:
24+
async def get_active_user_or_none(
25+
engine: AsyncEngine, *, email: str
26+
) -> AuthInfoDict | None:
2627
"""Gets a user with email if ACTIVE othewise return None
2728
2829
Raises:
29-
DatabaseError: unexpected errors found in https://github.com/ITISFoundation/osparc-simcore/issues/880 and https://github.com/ITISFoundation/osparc-simcore/pull/1160
30+
DatabaseError: unexpected errors found in
31+
https://github.com/ITISFoundation/osparc-simcore/issues/880 and
32+
https://github.com/ITISFoundation/osparc-simcore/pull/1160
3033
"""
31-
async with engine.acquire() as conn:
32-
result: ResultProxy = await conn.execute(
34+
async with engine.connect() as conn:
35+
result = await conn.execute(
3336
sa.select(users.c.id, users.c.role).where(
3437
(users.c.email == email) & (users.c.status == UserStatus.ACTIVE)
3538
)
3639
)
37-
row = await result.fetchone()
38-
assert (
39-
row is None or TypeAdapter(IdInt).validate_python(row.id) is not None # nosec
40+
row = result.one_or_none()
41+
42+
assert ( # nosec
43+
row is None or TypeAdapter(IdInt).validate_python(row.id) is not None
4044
)
41-
assert (
42-
row is None or TypeAdapter(UserRole).validate_python(row.role) is not None # nosec
45+
assert ( # nosec
46+
row is None or TypeAdapter(UserRole).validate_python(row.role) is not None
4347
)
4448

4549
return AuthInfoDict(id=row.id, role=row.role) if row else None
4650

4751

4852
async def is_user_in_product_name(
49-
engine: Engine, user_id: UserID, product_name: ProductName
53+
engine: AsyncEngine, *, user_id: UserID, product_name: ProductName
5054
) -> bool:
51-
async with engine.acquire() as conn:
55+
async with engine.connect() as conn:
5256
return (
5357
await conn.scalar(
5458
sa.select(users.c.id)

services/web/server/src/simcore_service_webserver/security/_authz_policy.py

Lines changed: 26 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212
)
1313
from models_library.products import ProductName
1414
from models_library.users import UserID
15-
from simcore_postgres_database.aiopg_errors import DatabaseError
15+
from servicelib.aiohttp.db_asyncpg_engine import get_async_engine
16+
from servicelib.logging_errors import create_troubleshotting_log_kwargs
17+
from simcore_postgres_database.aiopg_errors import DatabaseError as AiopgDatabaseError
18+
from sqlalchemy.exc import DatabaseError as SQLAlchemyDatabaseError
1619

17-
from ..db.plugin import get_database_engine
1820
from ._authz_access_model import (
1921
AuthContextDict,
2022
OptionalContext,
@@ -27,17 +29,31 @@
2729

2830
_logger = logging.getLogger(__name__)
2931

30-
# Keeps a cache during bursts to avoid stress on the database
3132
_SECOND = 1 # in seconds
32-
_AUTHZ_BURST_CACHE_TTL: Final = 5 * _SECOND
33+
_MINUTE: Final = 60 * _SECOND
34+
_AUTHZ_BURST_CACHE_TTL: Final = (
35+
# WARNING: TLL=0 means it never expires
36+
# Rationale:
37+
# a user's access to a product does not change that frequently
38+
# Keeps a cache during bursts to avoid stress on the database
39+
30
40+
* _MINUTE
41+
)
3342

3443

3544
@contextlib.contextmanager
3645
def _handle_exceptions_as_503():
3746
try:
3847
yield
39-
except DatabaseError as err:
40-
_logger.exception("Auth unavailable due to database error")
48+
except (AiopgDatabaseError, SQLAlchemyDatabaseError) as err:
49+
_logger.exception(
50+
**create_troubleshotting_log_kwargs(
51+
"Auth unavailable due to database error",
52+
error=err,
53+
tip="Check database connection",
54+
)
55+
)
56+
4157
raise web.HTTPServiceUnavailable(text=MSG_AUTH_NOT_AVAILABLE) from err
4258

4359

@@ -57,7 +73,9 @@ async def _get_auth_or_none(self, *, email: str) -> AuthInfoDict | None:
5773
web.HTTPServiceUnavailable: if database raises an exception
5874
"""
5975
with _handle_exceptions_as_503():
60-
return await get_active_user_or_none(get_database_engine(self._app), email)
76+
return await get_active_user_or_none(
77+
get_async_engine(self._app), email=email
78+
)
6179

6280
@cached(
6381
ttl=_AUTHZ_BURST_CACHE_TTL,
@@ -73,7 +91,7 @@ async def _has_access_to_product(
7391
"""
7492
with _handle_exceptions_as_503():
7593
return await is_user_in_product_name(
76-
get_database_engine(self._app), user_id, product_name
94+
get_async_engine(self._app), user_id=user_id, product_name=product_name
7795
)
7896

7997
@property
@@ -117,11 +135,6 @@ async def permits(
117135
:return: True if user has permission to execute this operation within the given context
118136
"""
119137
if identity is None or permission is None:
120-
_logger.debug(
121-
"Invalid %s of %s. Denying access.",
122-
f"{identity=}",
123-
f"{permission=}",
124-
)
125138
return False
126139

127140
auth_info = await self._get_auth_or_none(email=identity)

services/web/server/tests/unit/isolated/test_security__authz.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ async def test_check_access_expressions(access_model: RoleBasedAccessModel):
243243
def mock_db(mocker: MockerFixture) -> MagicMock:
244244

245245
mocker.patch(
246-
"simcore_service_webserver.security._authz_policy.get_database_engine",
246+
"simcore_service_webserver.security._authz_policy.get_async_engine",
247247
autospec=True,
248248
return_value="FAKE-ENGINE",
249249
)

services/web/server/tests/unit/isolated/test_security_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ async def basic_db_funs_mocked(client: TestClient, mocker: MockerFixture) -> Non
243243
await clean_auth_policy_cache(client.app)
244244

245245
mocker.patch(
246-
"simcore_service_webserver.security._authz_policy.get_database_engine",
246+
"simcore_service_webserver.security._authz_policy.get_async_engine",
247247
autospec=True,
248248
)
249249

services/web/server/tests/unit/with_dbs/03/login/test_login_auth.py

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22
# pylint: disable=unused-argument
33
# pylint: disable=unused-variable
44

5+
import asyncio
56
import json
67
import time
8+
from collections.abc import AsyncIterator, Callable
9+
from contextlib import AsyncExitStack
710
from http import HTTPStatus
811

912
import pytest
10-
from aiohttp.test_utils import TestClient
13+
from aiohttp.test_utils import TestClient, TestServer
1114
from cryptography import fernet
1215
from faker import Faker
1316
from pytest_simcore.helpers.assert_checks import assert_status
@@ -198,3 +201,73 @@ def _build_proxy_session_cookie(identity: str):
198201

199202
if not error:
200203
assert data["login"] == user["email"]
204+
205+
206+
@pytest.fixture
207+
async def multiple_users(
208+
client: TestClient, num_users: int = 5
209+
) -> AsyncIterator[list[dict[str, str]]]:
210+
"""Fixture that creates multiple test users with an AsyncExitStack for cleanup."""
211+
async with AsyncExitStack() as exit_stack:
212+
users = []
213+
for _ in range(num_users):
214+
# Use enter_async_context to properly register each NewUser context manager
215+
user_ctx = await exit_stack.enter_async_context(NewUser(app=client.app))
216+
users.append(
217+
{
218+
"email": user_ctx["email"],
219+
"password": user_ctx["raw_password"],
220+
}
221+
)
222+
223+
yield users
224+
# AsyncExitStack will automatically clean up all users when exiting
225+
226+
227+
async def test_multiple_users_login_logout_concurrently(
228+
web_server: TestServer,
229+
client: TestClient,
230+
multiple_users: list[dict[str, str]],
231+
aiohttp_client: Callable,
232+
):
233+
"""Test multiple users can login concurrently and properly get logged out."""
234+
assert client.app
235+
236+
# URLs
237+
login_url = client.app.router["auth_login"].url_for().path
238+
profile_url = client.app.router["get_my_profile"].url_for().path
239+
logout_url = client.app.router["auth_logout"].url_for().path
240+
241+
async def user_session_flow(user_creds):
242+
# Create a new client for each user to ensure isolated sessions
243+
user_client = await aiohttp_client(web_server)
244+
245+
# Login
246+
login_resp = await user_client.post(
247+
login_url,
248+
json={"email": user_creds["email"], "password": user_creds["password"]},
249+
)
250+
login_data, _ = await assert_status(login_resp, status.HTTP_200_OK)
251+
assert MSG_LOGGED_IN in login_data["message"]
252+
253+
# Access profile (cookies are automatically sent by the client)
254+
profile_resp = await user_client.get(profile_url)
255+
profile_data, _ = await assert_status(profile_resp, status.HTTP_200_OK)
256+
assert profile_data["login"] == user_creds["email"]
257+
258+
# Logout
259+
logout_resp = await user_client.post(logout_url)
260+
await assert_status(logout_resp, status.HTTP_200_OK)
261+
262+
# Try to access profile after logout
263+
profile_after_logout_resp = await user_client.get(profile_url)
264+
_, error = await assert_status(
265+
profile_after_logout_resp, status.HTTP_401_UNAUTHORIZED
266+
)
267+
268+
# No need to manually close the client as aiohttp_client fixture handles cleanup
269+
270+
await user_session_flow(multiple_users[0])
271+
272+
# Run all user flows concurrently
273+
await asyncio.gather(*(user_session_flow(user) for user in multiple_users))

services/web/server/tests/unit/with_dbs/03/test_users_rest_profiles.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
from simcore_service_webserver.users._preferences_service import (
3737
get_frontend_user_preferences_aggregation,
3838
)
39+
from sqlalchemy.exc import OperationalError as SQLAlchemyOperationalError
40+
from sqlalchemy.ext.asyncio import AsyncConnection
3941

4042

4143
@pytest.fixture
@@ -554,6 +556,14 @@ def mock_failing_database_connection(mocker: Mock) -> MagicMock:
554556
conn_execute.side_effect = OperationalError(
555557
"MOCK: server closed the connection unexpectedly"
556558
)
559+
560+
aysncpg_conn_execute = mocker.patch.object(AsyncConnection, "execute")
561+
aysncpg_conn_execute.side_effect = SQLAlchemyOperationalError(
562+
statement="MOCK statement",
563+
params=(),
564+
orig=OperationalError("MOCK: server closed the connection unexpectedly"),
565+
)
566+
557567
return conn_execute
558568

559569

0 commit comments

Comments
 (0)