Skip to content

Commit 9cc0d26

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 chache 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 9cc0d26

2 files changed

Lines changed: 111 additions & 14 deletions

File tree

src/ralph/api/auth/basic.py

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,23 @@ def ensure_unique_username(self) -> Any:
7575
return self
7676

7777

78-
@lru_cache()
78+
def basic_auth_file_cache_key(
79+
auth_file_path: os.PathLike = settings.AUTH_FILE
80+
):
81+
"""Key used by cachetools to index cached user credentials results."""
82+
if not os.path.exists(auth_file_path):
83+
return None
84+
return (
85+
auth_file_path,
86+
os.path.getmtime(auth_file_path),
87+
)
88+
89+
90+
@cached(
91+
TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL),
92+
lock=Lock(),
93+
key=basic_auth_file_cache_key
94+
)
7995
def get_stored_credentials(auth_file: os.PathLike) -> ServerUsersCredentials:
8096
"""Helper to read the credentials/scopes file.
8197
@@ -98,21 +114,31 @@ def get_stored_credentials(auth_file: os.PathLike) -> ServerUsersCredentials:
98114
with open(auth_file, encoding=settings.LOCALE_ENCODING) as f:
99115
return ServerUsersCredentials.model_validate_json(f.read())
100116

117+
def basic_auth_user_cache_key(
118+
credentials: Optional[HTTPBasicCredentials] = Depends(security),
119+
auth_file_path: os.PathLike = settings.AUTH_FILE
120+
):
121+
"""Key used by cachetools to index cached user credentials results."""
122+
if credentials is None:
123+
return None
124+
if not os.path.exists(auth_file_path):
125+
return None
126+
return (
127+
auth_file_path,
128+
os.path.getmtime(auth_file_path),
129+
credentials.username,
130+
credentials.password,
131+
)
132+
101133

102134
@cached(
103135
TTLCache(maxsize=settings.AUTH_CACHE_MAX_SIZE, ttl=settings.AUTH_CACHE_TTL),
104136
lock=Lock(),
105-
key=lambda credentials: (
106-
(
107-
credentials.username,
108-
credentials.password,
109-
)
110-
if credentials is not None
111-
else None
112-
),
137+
key=basic_auth_user_cache_key
113138
)
114139
def get_basic_auth_user(
115140
credentials: Optional[HTTPBasicCredentials] = Depends(security),
141+
auth_file_path: os.PathLike = settings.AUTH_FILE
116142
) -> AuthenticatedUser:
117143
"""Check valid auth parameters.
118144
@@ -121,22 +147,25 @@ def get_basic_auth_user(
121147
122148
Args:
123149
credentials (iterator): auth parameters from the Authorization header
150+
auth_file_path (os.PathLike): path to credentials file
124151
125152
Raises:
126153
HTTPException
127154
"""
155+
if auth_file_path is None:
156+
auth_file_path = settings.AUTH_FILE
128157
if not credentials:
129158
logger.debug("No credentials were found for Basic auth")
130159
return None
131-
132160
try:
133161
user = next(
134162
filter(
135163
lambda u: u.username == credentials.username,
136-
get_stored_credentials(settings.AUTH_FILE),
164+
get_stored_credentials(auth_file_path),
137165
)
138166
)
139167
hashed_password = user.hash
168+
140169
except StopIteration:
141170
# next() gets the first item in the enumerable; if there is none, it
142171
# raises a StopIteration error as it is out of bounds.

tests/api/auth/test_basic.py

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

33
import base64
44
import json
5+
import os
6+
from pathlib import Path
57

68
import bcrypt
79
import pytest
@@ -12,7 +14,6 @@
1214
ServerUsersCredentials,
1315
UserCredentials,
1416
get_basic_auth_user,
15-
get_stored_credentials,
1617
)
1718
from ralph.api.auth.user import AuthenticatedUser, UserScopes
1819
from ralph.conf import AuthBackend, Settings, settings
@@ -31,6 +32,25 @@
3132
]
3233
)
3334

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

3555
def test_api_auth_basic_model_serveruserscredentials():
3656
"""Test api.auth ServerUsersCredentials model."""
@@ -101,23 +121,71 @@ def test_api_auth_basic_caching_credentials(fs):
101121

102122
auth_file_path = settings.APP_DIR / "auth.json"
103123
fs.create_file(auth_file_path, contents=STORED_CREDENTIALS)
124+
auth_file_modified_time = os.path.getmtime(auth_file_path)
104125
get_basic_auth_user.cache_clear()
105-
get_stored_credentials.cache_clear()
106126

107127
credentials = HTTPBasicCredentials(username="ralph", password="admin")
108128

109129
# Call function as in a first request with these credentials
110130
get_basic_auth_user(credentials=credentials)
111131

112132
assert get_basic_auth_user.cache.popitem() == (
113-
("ralph", "admin"),
133+
(auth_file_path, auth_file_modified_time, "ralph", "admin"),
114134
AuthenticatedUser(
115135
agent={"mbox": "mailto:ralph@example.com"},
116136
scopes=UserScopes(["statements/read/mine", "statements/write"]),
117137
target="custom_target",
118138
),
119139
)
120140

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

122190
def test_api_auth_basic_with_wrong_password(fs):
123191
"""Test the authentication with a wrong password."""

0 commit comments

Comments
 (0)