Skip to content

Commit c9079e7

Browse files
author
piptouque
committed
✨(api) modify basic auth credentials without reloading
Changes: - Cache the credentials file based on when it was last modified. - Also cache the users' decrypted data based on that information. Rationale: Right now, modifying the basic auth credentials file (adding a user, for instance) requires a server restart. This is because of caching at the credentials file level. Following the dicussion on [a previous PR] (#337 (comment)) we propose to add the last modified time of the credentials file to the cache key for the credentials file and the users' data both. If caching of the credentials file seems unnecessary (as was discussed in the above PR), it may be removed without breaking this feature.
1 parent 5e1558d commit c9079e7

3 files changed

Lines changed: 111 additions & 15 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ and this project adheres to
1212

1313
- Drop support for Python 3.8
1414

15+
### Changed
16+
17+
- Reload Basic Auth credentials file after change
18+
1519
### Fixed
1620

1721
- Fix type of `statement.result.score.scaled` from `int` to `Decimal`

src/ralph/api/auth/basic.py

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

33
import logging
44
import os
5-
from functools import lru_cache
65
from pathlib import Path
76
from threading import Lock
87
from typing import Any, Iterator, List, Optional
@@ -75,7 +74,21 @@ def ensure_unique_username(self) -> Any:
7574
return self
7675

7776

78-
@lru_cache()
77+
def basic_auth_file_cache_key(auth_file_path: os.PathLike = settings.AUTH_FILE):
78+
"""Key used by cachetools to index cached user credentials results."""
79+
if not os.path.exists(auth_file_path):
80+
return None
81+
return (
82+
auth_file_path,
83+
os.path.getmtime(auth_file_path),
84+
)
85+
86+
87+
@cached(
88+
TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL),
89+
lock=Lock(),
90+
key=basic_auth_file_cache_key,
91+
)
7992
def get_stored_credentials(auth_file: os.PathLike) -> ServerUsersCredentials:
8093
"""Helper to read the credentials/scopes file.
8194
@@ -99,20 +112,31 @@ def get_stored_credentials(auth_file: os.PathLike) -> ServerUsersCredentials:
99112
return ServerUsersCredentials.model_validate_json(f.read())
100113

101114

115+
def basic_auth_user_cache_key(
116+
credentials: Optional[HTTPBasicCredentials] = Depends(security),
117+
auth_file_path: os.PathLike = settings.AUTH_FILE,
118+
):
119+
"""Key used by cachetools to index cached user credentials results."""
120+
if credentials is None:
121+
return None
122+
if not os.path.exists(auth_file_path):
123+
return None
124+
return (
125+
auth_file_path,
126+
os.path.getmtime(auth_file_path),
127+
credentials.username,
128+
credentials.password,
129+
)
130+
131+
102132
@cached(
103133
TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL),
104134
lock=Lock(),
105-
key=lambda credentials: (
106-
(
107-
credentials.username,
108-
credentials.password,
109-
)
110-
if credentials is not None
111-
else None
112-
),
135+
key=basic_auth_user_cache_key,
113136
)
114137
def get_basic_auth_user(
115138
credentials: Optional[HTTPBasicCredentials] = Depends(security),
139+
auth_file_path: os.PathLike = settings.AUTH_FILE,
116140
) -> AuthenticatedUser:
117141
"""Check valid auth parameters.
118142
@@ -121,22 +145,23 @@ def get_basic_auth_user(
121145
122146
Args:
123147
credentials (iterator): auth parameters from the Authorization header
148+
auth_file_path (os.PathLike): path to credentials file
124149
125150
Raises:
126151
HTTPException
127152
"""
128153
if not credentials:
129154
logger.debug("No credentials were found for Basic auth")
130155
return None
131-
132156
try:
133157
user = next(
134158
filter(
135159
lambda u: u.username == credentials.username,
136-
get_stored_credentials(settings.AUTH_FILE),
160+
get_stored_credentials(auth_file_path),
137161
)
138162
)
139163
hashed_password = user.hash
164+
140165
except StopIteration:
141166
# next() gets the first item in the enumerable; if there is none, it
142167
# raises a StopIteration error as it is out of bounds.

tests/api/auth/test_basic.py

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

33
import base64
44
import json
5+
import os
56

67
import bcrypt
78
import pytest
@@ -12,7 +13,6 @@
1213
ServerUsersCredentials,
1314
UserCredentials,
1415
get_basic_auth_user,
15-
get_stored_credentials,
1616
)
1717
from ralph.api.auth.user import AuthenticatedUser, UserScopes
1818
from ralph.conf import AuthBackend, Settings, settings
@@ -31,6 +31,25 @@
3131
]
3232
)
3333

34+
STORED_CREDENTIALS_MORE = json.dumps(
35+
[
36+
{
37+
"username": "ralph",
38+
"hash": bcrypt.hashpw(b"admin", bcrypt.gensalt()).decode("UTF-8"),
39+
"scopes": ["statements/read/mine", "statements/write"],
40+
"agent": {"mbox": "mailto:ralph@example.com"},
41+
"target": "custom_target",
42+
},
43+
{
44+
"username": "foo",
45+
"hash": bcrypt.hashpw(b"bar", bcrypt.gensalt()).decode("UTF-8"),
46+
"scopes": ["statements/read/mine", "statements/write"],
47+
"agent": {"mbox": "mailto:foo@example.com"},
48+
"target": "custom_target",
49+
},
50+
]
51+
)
52+
3453

3554
def test_api_auth_basic_model_serveruserscredentials():
3655
"""Test api.auth ServerUsersCredentials model."""
@@ -101,16 +120,16 @@ def test_api_auth_basic_caching_credentials(fs):
101120

102121
auth_file_path = settings.APP_DIR / "auth.json"
103122
fs.create_file(auth_file_path, contents=STORED_CREDENTIALS)
123+
auth_file_modified_time = os.path.getmtime(auth_file_path)
104124
get_basic_auth_user.cache_clear()
105-
get_stored_credentials.cache_clear()
106125

107126
credentials = HTTPBasicCredentials(username="ralph", password="admin")
108127

109128
# Call function as in a first request with these credentials
110129
get_basic_auth_user(credentials=credentials)
111130

112131
assert get_basic_auth_user.cache.popitem() == (
113-
("ralph", "admin"),
132+
(auth_file_path, auth_file_modified_time, "ralph", "admin"),
114133
AuthenticatedUser(
115134
agent={"mbox": "mailto:ralph@example.com"},
116135
scopes=UserScopes(["statements/read/mine", "statements/write"]),
@@ -119,6 +138,54 @@ def test_api_auth_basic_caching_credentials(fs):
119138
)
120139

121140

141+
def test_api_auth_basic_new_credentials(fs):
142+
"""Test the authentication with new credentials and without clearing cache"""
143+
144+
auth_file_path = settings.APP_DIR / "auth.json"
145+
fs.create_file(auth_file_path, contents=STORED_CREDENTIALS)
146+
get_basic_auth_user.cache_clear()
147+
148+
credentials = HTTPBasicCredentials(username="ralph", password="admin")
149+
# Try to authenticate with known user, create cache entry
150+
get_basic_auth_user(credentials)
151+
# Add a new user to credentials file
152+
# In order to test this, we do NOT clear cache
153+
auth_file_mtime = os.path.getmtime(auth_file_path)
154+
# FIX: Force update of modification time.
155+
# It does not seem to be updated by setting the content of the pyfakefs file
156+
os.remove(auth_file_path)
157+
fs.create_file(auth_file_path, contents=STORED_CREDENTIALS_MORE)
158+
assert os.path.getmtime(auth_file_path) > auth_file_mtime
159+
credentials_new = HTTPBasicCredentials(username="foo", password="bar")
160+
161+
# Try to authenticate with new user, should succeed
162+
get_basic_auth_user(credentials_new)
163+
164+
165+
def test_api_auth_basic_deleted_credentials(fs):
166+
"""Test the authentication with deleted credentials and without clearing cache."""
167+
168+
auth_file_path = settings.APP_DIR / "auth.json"
169+
fs.create_file(auth_file_path, contents=STORED_CREDENTIALS_MORE)
170+
get_basic_auth_user.cache_clear()
171+
172+
credentials = HTTPBasicCredentials(username="foo", password="bar")
173+
# Try to authenticate with known user, create cache entry
174+
get_basic_auth_user(credentials)
175+
176+
# In order to test this, we do NOT clear cache
177+
auth_file_mtime = os.path.getmtime(auth_file_path)
178+
# FIX: Force update of modification time.
179+
# It does not seem to be updated by setting the content of the pyfakefs file
180+
os.remove(auth_file_path)
181+
fs.create_file(auth_file_path, contents=STORED_CREDENTIALS)
182+
assert os.path.getmtime(auth_file_path) > auth_file_mtime
183+
184+
# Try to authenticate with a deleted user, should fail
185+
with pytest.raises(HTTPException):
186+
get_basic_auth_user(credentials)
187+
188+
122189
def test_api_auth_basic_with_wrong_password(fs):
123190
"""Test the authentication with a wrong password."""
124191

0 commit comments

Comments
 (0)