Skip to content

Commit 3e495e1

Browse files
authored
Feat: authdb tables cleanup (#815)
* feat: add functions to cleanup the AuthDB tables
1 parent 4fd5ce8 commit 3e495e1

8 files changed

Lines changed: 277 additions & 3 deletions

File tree

diracx-core/src/diracx/core/settings.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
SecretStr,
3333
TypeAdapter,
3434
UrlConstraints,
35+
model_validator,
3536
)
3637
from pydantic_settings import BaseSettings, SettingsConfigDict
3738

@@ -151,6 +152,21 @@ def create(cls) -> Self:
151152
class AuthSettings(ServiceSettingsBase):
152153
"""Settings for the authentication service."""
153154

155+
@model_validator(mode="after")
156+
def check_retention_greater_than_expiration(self) -> Self:
157+
"""Ensure retention times are bigger than expiration times to avoid deleting valid flows."""
158+
if self.completed_flow_retention_minutes <= (
159+
self.device_flow_expiration_seconds / 60
160+
) or self.completed_flow_retention_minutes <= (
161+
self.authorization_flow_expiration_seconds / 60
162+
):
163+
raise ValueError(
164+
f"completed_flow_retention_minutes ({self.completed_flow_retention_minutes} minutes) must be bigger"
165+
f" than device_flow_expiration_seconds ({self.device_flow_expiration_seconds / 60} minutes) and"
166+
f" authorization_flow_expiration_seconds: ({self.authorization_flow_expiration_seconds / 60} minutes)"
167+
)
168+
return self
169+
154170
model_config = SettingsConfigDict(
155171
env_prefix="DIRACX_SERVICE_AUTH_", use_attribute_docstrings=True
156172
)
@@ -183,6 +199,13 @@ class AuthSettings(ServiceSettingsBase):
183199
before it must be exchanged for tokens. Default: 5 minutes.
184200
"""
185201

202+
completed_flow_retention_minutes: int = 60
203+
"""Retention time in minutes for completed flow.
204+
205+
The maximum retention time of flow after being completed
206+
and before they are deleted. Default: 60 minutes.
207+
"""
208+
186209
state_key: FernetKey
187210
"""Encryption key used to encrypt/decrypt the state parameter passed to the IAM.
188211
@@ -226,6 +249,13 @@ class AuthSettings(ServiceSettingsBase):
226249
through a new authentication flow. Default: 60 minutes.
227250
"""
228251

252+
revoked_refresh_token_retention_minutes: int = 43200
253+
"""Retention time in minutes for revoked refresh tokens.
254+
255+
The maximum retention time of refresh tokens after being
256+
revoked and before they are deleted. Default: 43200 minutes (30 days).
257+
"""
258+
229259
available_properties: set[SecurityProperty] = Field(
230260
default_factory=SecurityProperty.available_properties
231261
)

diracx-db/src/diracx/db/sql/auth/db.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from itertools import pairwise
77

88
from dateutil.rrule import MONTHLY, rrule
9-
from sqlalchemy import insert, select, text, update
9+
from sqlalchemy import delete, insert, select, text, update
1010
from sqlalchemy.exc import IntegrityError, NoResultFound
1111
from sqlalchemy.ext.asyncio import AsyncConnection
1212
from uuid_utils import UUID, uuid7
@@ -339,3 +339,61 @@ async def revoke_user_refresh_tokens(self, subject):
339339
.where(RefreshTokens.sub == subject)
340340
.values(status=RefreshTokenStatus.REVOKED)
341341
)
342+
343+
async def clean_expired_refresh_tokens(self, max_validity: int) -> int:
344+
"""Delete expired refresh tokens.
345+
346+
max_validity: Maximum validity time in minutes for refresh tokens.
347+
"""
348+
expired_date = str(
349+
uuid7_from_datetime(substract_date(minutes=max_validity), randomize=False)
350+
)
351+
stmt_expired = delete(RefreshTokens).where(
352+
RefreshTokens.status == RefreshTokenStatus.CREATED,
353+
RefreshTokens.jti < expired_date,
354+
)
355+
res_expired = await self.conn.execute(stmt_expired)
356+
357+
return res_expired.rowcount
358+
359+
async def clean_revoked_refresh_tokens(self, max_retention: int) -> int:
360+
"""Delete old revoked refresh tokens.
361+
362+
max_retention: Maximum retention time in minutes for revoked refresh tokens.
363+
"""
364+
revoked_date = str(
365+
uuid7_from_datetime(substract_date(minutes=max_retention), randomize=False)
366+
)
367+
stmt_revoked = delete(RefreshTokens).where(
368+
RefreshTokens.status == RefreshTokenStatus.REVOKED,
369+
RefreshTokens.jti < revoked_date,
370+
)
371+
res_revoked = await self.conn.execute(stmt_revoked)
372+
373+
return res_revoked.rowcount
374+
375+
async def clean_expired_authorization_flows(self, max_retention: int) -> int:
376+
"""Delete old authorization flows.
377+
378+
max_retention: Maximum retention time in minutes for expired authorization flows.
379+
Must be bigger than authorization_flow_expiration_seconds.
380+
"""
381+
stmt_auth = delete(AuthorizationFlows).where(
382+
AuthorizationFlows.creation_time < substract_date(minutes=max_retention),
383+
)
384+
res_auth = await self.conn.execute(stmt_auth)
385+
386+
return res_auth.rowcount
387+
388+
async def clean_expired_device_flows(self, max_retention: int) -> int:
389+
"""Delete old device flows.
390+
391+
max_retention: Maximum retention time in minutes for expired device flows.
392+
Must be bigger than device_flow_expiration_seconds.
393+
"""
394+
stmt_device = delete(DeviceFlows).where(
395+
DeviceFlows.creation_time < substract_date(minutes=max_retention),
396+
)
397+
res_device = await self.conn.execute(stmt_device)
398+
399+
return res_device.rowcount

diracx-db/tests/auth/test_authorization_flow.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from diracx.core.exceptions import AuthorizationError
77
from diracx.db.sql.auth.db import AuthDB
8+
from diracx.db.sql.auth.schema import FlowStatus
89

910
MAX_VALIDITY = 2
1011
EXPIRED = 0
@@ -74,3 +75,46 @@ async def test_insert(auth_db: AuthDB):
7475
)
7576

7677
assert uuid1 != uuid2
78+
79+
80+
async def test_clean_authorization_flows(auth_db: AuthDB):
81+
# Insert two authorization flows
82+
async with auth_db as auth_db:
83+
_ = await auth_db.insert_authorization_flow(
84+
"client_id", "scope", "code_challenge", "S256", "redirect_uri"
85+
)
86+
uuid2 = await auth_db.insert_authorization_flow(
87+
"client_id2", "scope2", "code_challenge2", "S256", "redirect_uri2"
88+
)
89+
uuid3 = await auth_db.insert_authorization_flow(
90+
"client_id3", "scope3", "code_challenge3", "S256", "redirect_uri3"
91+
)
92+
uuid4 = await auth_db.insert_authorization_flow(
93+
"client_id4", "scope4", "code_challenge4", "S256", "redirect_uri4"
94+
)
95+
96+
id_token = {"sub": "myIdToken"}
97+
98+
async with auth_db as auth_db:
99+
_, _ = await auth_db.authorization_flow_insert_id_token(uuid2, id_token, 1)
100+
code3, _ = await auth_db.authorization_flow_insert_id_token(uuid3, id_token, 1)
101+
code4, _ = await auth_db.authorization_flow_insert_id_token(uuid4, id_token, 1)
102+
103+
async with auth_db as auth_db:
104+
await auth_db.update_authorization_flow_status(code3, FlowStatus.DONE)
105+
await auth_db.update_authorization_flow_status(code4, FlowStatus.ERROR)
106+
107+
# Check the number of deleted authorization flow (should be 0)
108+
async with auth_db as auth_db:
109+
deleted_auth = await auth_db.clean_expired_authorization_flows(max_retention=30)
110+
assert deleted_auth == 0
111+
112+
# Check the number of deleted authorization flow (should be 4: 1 PENDING, 1 READY, 1 DONE, 1 ERROR)
113+
async with auth_db as auth_db:
114+
deleted_auth = await auth_db.clean_expired_authorization_flows(max_retention=0)
115+
assert deleted_auth == 4
116+
117+
# Check the number of deleted authorization flow (should be 0 because there is nothing left to delete)
118+
async with auth_db as auth_db:
119+
deleted_auth = await auth_db.clean_expired_authorization_flows(max_retention=0)
120+
assert deleted_auth == 0

diracx-db/tests/auth/test_device_flow.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
from diracx.core.exceptions import AuthorizationError
1010
from diracx.db.sql.auth.db import AuthDB
11-
from diracx.db.sql.auth.schema import USER_CODE_LENGTH
11+
from diracx.db.sql.auth.schema import USER_CODE_LENGTH, FlowStatus
1212
from diracx.db.sql.utils.functions import substract_date
1313

1414
MAX_VALIDITY = 2
@@ -139,3 +139,35 @@ async def test_device_flow_insert_id_token(auth_db: AuthDB):
139139
async with auth_db as auth_db:
140140
res = await auth_db.get_device_flow(device_code)
141141
assert res["IDToken"] == id_token
142+
143+
144+
async def test_clean_device_flows(auth_db: AuthDB):
145+
# Insert two device flows
146+
async with auth_db as auth_db:
147+
_, _ = await auth_db.insert_device_flow("client_id1", "scope1")
148+
user_code2, _ = await auth_db.insert_device_flow("client_id2", "scope2")
149+
_, device_code3 = await auth_db.insert_device_flow("client_id3", "scope3")
150+
_, device_code4 = await auth_db.insert_device_flow("client_id4", "scope4")
151+
152+
async with auth_db as auth_db:
153+
await auth_db.device_flow_validate_user_code(user_code2, MAX_VALIDITY)
154+
await auth_db.device_flow_insert_id_token(
155+
user_code2, {"sub": "myIdToken"}, MAX_VALIDITY
156+
)
157+
await auth_db.update_device_flow_status(device_code3, FlowStatus.DONE)
158+
await auth_db.update_device_flow_status(device_code4, FlowStatus.ERROR)
159+
160+
# Check the number of deleted device flows (should be 0)
161+
async with auth_db as auth_db:
162+
deleted_device = await auth_db.clean_expired_device_flows(max_retention=30)
163+
assert deleted_device == 0
164+
165+
# Check the number of deleted device flows (should be 4: 1 PENDING, 1 READY, 1 DONE, 1 ERROR)
166+
async with auth_db as auth_db:
167+
deleted_device = await auth_db.clean_expired_device_flows(max_retention=0)
168+
assert deleted_device == 4
169+
170+
# Check the number of deleted device flow (should be 0 because there is nothing left to delete)
171+
async with auth_db as auth_db:
172+
deleted_device = await auth_db.clean_expired_device_flows(max_retention=0)
173+
assert deleted_device == 0

diracx-db/tests/auth/test_refresh_token.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,3 +257,46 @@ async def test_get_refresh_tokens(auth_db: AuthDB):
257257

258258
# Check the number of retrieved refresh tokens (should be 3 refresh tokens)
259259
assert len(refresh_tokens) == 2
260+
261+
262+
async def test_clean_refresh_tokens(auth_db: AuthDB):
263+
# Insert two refresh tokens
264+
jtis = []
265+
async with auth_db as auth_db:
266+
for _ in range(2):
267+
jti = uuid7()
268+
await auth_db.insert_refresh_token(
269+
jti,
270+
"subject",
271+
"scope",
272+
)
273+
jtis.append(jti)
274+
275+
# Revoke one of the refresh token
276+
async with auth_db as auth_db:
277+
await auth_db.revoke_refresh_token(jtis[0])
278+
279+
# Check the number of deleted refresh tokens (should be 0)
280+
async with auth_db as auth_db:
281+
deleted_expired = await auth_db.clean_expired_refresh_tokens(max_validity=10)
282+
assert deleted_expired == 0
283+
284+
async with auth_db as auth_db:
285+
deleted_revoked = await auth_db.clean_revoked_refresh_tokens(max_retention=30)
286+
assert deleted_revoked == 0
287+
288+
# Check the number of deleted refresh tokens (should be 1 of each)
289+
async with auth_db as auth_db:
290+
deleted_expired = await auth_db.clean_expired_refresh_tokens(max_validity=0)
291+
assert deleted_expired == 1
292+
293+
async with auth_db as auth_db:
294+
deleted_revoked = await auth_db.clean_revoked_refresh_tokens(max_retention=0)
295+
assert deleted_revoked == 1
296+
297+
# Get all refresh tokens (Admin)
298+
async with auth_db as auth_db:
299+
refresh_tokens = await auth_db.get_user_refresh_tokens()
300+
301+
# Check the number of retrieved refresh tokens (should be 0)
302+
assert len(refresh_tokens) == 0

diracx-logic/src/diracx/logic/__main__.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,23 @@ async def delete_jwk(args):
9090
save_jwks(path, jwks)
9191

9292

93+
async def cleanup_authdb(args):
94+
"""Delete expired tokens and flows from the AuthDB."""
95+
logger.info("Deleting expired tokens and flows")
96+
import os
97+
98+
from diracx.core.settings import AuthSettings
99+
from diracx.db.sql import AuthDB
100+
from diracx.logic.auth.management import cleanup_expired_data
101+
102+
settings = AuthSettings()
103+
db_url = os.environ["DIRACX_DB_URL_AUTHDB"]
104+
db = AuthDB(db_url)
105+
async with db.engine_context():
106+
async with db:
107+
await cleanup_expired_data(db, settings)
108+
109+
93110
def parse_args():
94111
parser = argparse.ArgumentParser()
95112
subparsers = parser.add_subparsers(dest="command", required=True)
@@ -120,8 +137,13 @@ def parse_args():
120137
)
121138
delete_jwk_parser.set_defaults(func=delete_jwk)
122139

140+
cleanup_authdb_parser = subparsers.add_parser(
141+
"cleanup-authdb", help="Delete expired tokens and flows from the AuthDB"
142+
)
143+
cleanup_authdb_parser.set_defaults(func=cleanup_authdb)
144+
123145
args = parser.parse_args()
124-
logger.setLevel(logging.INFO)
146+
logging.basicConfig(level=logging.INFO)
125147
asyncio.run(args.func(args))
126148

127149

diracx-logic/src/diracx/logic/auth/management.py

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

33
from __future__ import annotations
44

5+
import logging
6+
57
from uuid_utils import UUID
68

79
from diracx.core.exceptions import InvalidCredentialsError
@@ -10,6 +12,8 @@
1012
from diracx.db.sql import AuthDB
1113
from diracx.logic.auth.utils import verify_dirac_refresh_token
1214

15+
logger = logging.getLogger(__name__)
16+
1317

1418
async def get_refresh_tokens(
1519
auth_db: AuthDB,
@@ -57,3 +61,26 @@ async def revoke_refresh_token_by_refresh_token(
5761
# Decode and verify the refresh token
5862
jti, _, _ = await verify_dirac_refresh_token(token, settings)
5963
return await revoke_refresh_token_by_jti(auth_db=auth_db, subject=subject, jti=jti)
64+
65+
66+
async def cleanup_expired_data(auth_db: AuthDB, settings: AuthSettings) -> None:
67+
"""Remove expired data from the auth database."""
68+
expired_tokens = await auth_db.clean_expired_refresh_tokens(
69+
max_validity=settings.refresh_token_expire_minutes,
70+
)
71+
logger.info("Deleted %d expired refresh tokens", expired_tokens)
72+
73+
revoked_tokens = await auth_db.clean_revoked_refresh_tokens(
74+
max_retention=settings.revoked_refresh_token_retention_minutes,
75+
)
76+
logger.info("Deleted %d revoked refresh tokens", revoked_tokens)
77+
78+
auth = await auth_db.clean_expired_authorization_flows(
79+
max_retention=settings.completed_flow_retention_minutes,
80+
)
81+
logger.info("Deleted %d expired authorization flows", auth)
82+
83+
device = await auth_db.clean_expired_device_flows(
84+
max_retention=settings.completed_flow_retention_minutes,
85+
)
86+
logger.info("Deleted %d expired device flows", device)

docs/admin/reference/env-variables.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ Expiration time in seconds for authorization code flow.
4242
The time window during which the authorization code remains valid
4343
before it must be exchanged for tokens. Default: 5 minutes.
4444

45+
### `DIRACX_SERVICE_AUTH_COMPLETED_FLOW_RETENTION_MINUTES`
46+
47+
*Optional*, default value: `60`
48+
49+
Retention time in minutes for completed flow.
50+
51+
The maximum retention time of flow after being completed
52+
and before they are deleted. Default: 60 minutes.
53+
4554
### `DIRACX_SERVICE_AUTH_STATE_KEY`
4655

4756
**Required**
@@ -96,6 +105,15 @@ Expiration time in minutes for refresh tokens.
96105
The maximum lifetime of refresh tokens before they must be re-issued
97106
through a new authentication flow. Default: 60 minutes.
98107

108+
### `DIRACX_SERVICE_AUTH_REVOKED_REFRESH_TOKEN_RETENTION_MINUTES`
109+
110+
*Optional*, default value: `43200`
111+
112+
Retention time in minutes for revoked refresh tokens.
113+
114+
The maximum retention time of refresh tokens after being
115+
revoked and before they are deleted. Default: 43200 minutes (30 days).
116+
99117
### `DIRACX_SERVICE_AUTH_AVAILABLE_PROPERTIES`
100118

101119
*Optional*

0 commit comments

Comments
 (0)