Skip to content

Commit 51b894c

Browse files
committed
feat: track API key last usage timestamp
Add `last_used_at` field to track when API keys are used for security auditing. This enables identification of stale/unused API keys. - Add `last_used_at` nullable datetime field to ApiKey model - Update timestamp in `ApiKeyAuth.__call__()` on successful auth - Expose field in `ApiKeyResponse` schema - Add migration for the new column - Add test to verify timestamp is updated Closes SECURITY-REVIEW.md `#23`
1 parent a233ebc commit 51b894c

File tree

6 files changed

+83
-8
lines changed

6 files changed

+83
-8
lines changed

SECURITY-REVIEW.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -407,14 +407,18 @@
407407

408408
### 23. API Key last_used_at Never Updated
409409

410-
**Location**: `app/models/api_key.py:26` (field defined),
411-
`app/managers/api_key.py:155-248` (never updated)
412-
413-
- **Issue**: The `last_used_at` field exists in the model but is **never
414-
maintained**, making it impossible to identify stale/unused API keys for
415-
security audits.
416-
- **Fix**: Update `last_used_at` in `ApiKeyAuth.__call__()` after successful
417-
validation.
410+
> [!NOTE]
411+
> **Done**: Added `last_used_at` field to ApiKey model and update it in
412+
> `ApiKeyAuth.__call__()` after successful validation. Field is exposed in
413+
> `ApiKeyResponse` schema for security auditing.
414+
415+
**Location**: `app/models/api_key.py:27` (field defined),
416+
`app/managers/api_key.py:155-254` (updated on successful auth)
417+
418+
- **Issue**: The `last_used_at` field did not exist in the model, making it
419+
impossible to identify stale/unused API keys for security audits.
420+
- **Fix**: Added `last_used_at` field and update it in `ApiKeyAuth.__call__()`
421+
after successful validation.
418422

419423
### 24. No Password Required for Self-Service Password Change
420424

app/managers/api_key.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import hashlib
44
import hmac
55
import secrets
6+
from datetime import datetime, timezone
67
from uuid import UUID
78

89
from fastapi import Depends, HTTPException, Request, status
@@ -238,6 +239,9 @@ async def __call__( # noqa: C901, PLR0911, PLR0912
238239
request.state.user = user
239240
request.state.api_key = key
240241

242+
# Update last_used_at timestamp
243+
key.last_used_at = datetime.now(timezone.utc)
244+
241245
increment_api_key_validation("valid")
242246
category_logger.info(
243247
f"API key authenticated: '{key.name}' (ID: {key.id}) for user "
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Add last_used_at column to api_keys table.
2+
3+
Revision ID: add_last_used_at
4+
Revises: add_api_keys_table
5+
Create Date: 2026-01-23 21:15:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "add_last_used_at"
16+
down_revision: Union[str, None] = "add_api_keys_table"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Add last_used_at column to api_keys table."""
23+
op.add_column(
24+
"api_keys",
25+
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
26+
)
27+
28+
29+
def downgrade() -> None:
30+
"""Remove last_used_at column from api_keys table."""
31+
op.drop_column("api_keys", "last_used_at")

app/models/api_key.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ class ApiKey(Base):
2525
DateTime(timezone=True), default=datetime.now(timezone.utc)
2626
)
2727
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
28+
last_used_at: Mapped[datetime | None] = mapped_column(
29+
DateTime(timezone=True), nullable=True, default=None
30+
)
2831
scopes: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=True)
2932

3033
# Relationship to User

app/schemas/response/api_key.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ class ApiKeyResponse(BaseModel):
1515
name: str
1616
created_at: datetime
1717
is_active: bool
18+
last_used_at: datetime | None = None
1819
scopes: list[str] | None = None
1920

2021

tests/unit/test_api_key_auth.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,35 @@ async def test_api_key_auth_unverified_user_no_auto_error(
248248
result = await auth(request=mock_req, db=test_db)
249249

250250
assert result is None
251+
252+
async def test_api_key_auth_updates_last_used_at(
253+
self, test_db, mocker
254+
) -> None:
255+
"""Test that last_used_at is updated on successful authentication."""
256+
# Create a user and API key
257+
_ = await UserManager.register(self.test_user, test_db)
258+
user = await UserManager.get_user_by_email(
259+
self.test_user["email"], test_db
260+
)
261+
api_key, raw_key = await ApiKeyManager.create_key(
262+
user, "Test Key", None, test_db
263+
)
264+
265+
# Verify last_used_at is None initially
266+
assert api_key.last_used_at is None
267+
268+
# Authenticate with the API key
269+
mock_req = mocker.patch(self.mock_request_path)
270+
mock_req.headers = {"X-API-Key": raw_key}
271+
272+
auth = ApiKeyAuth()
273+
await auth(request=mock_req, db=test_db)
274+
275+
# Flush to ensure changes are persisted
276+
await test_db.flush()
277+
278+
# Refresh the api_key object from the database
279+
await test_db.refresh(api_key)
280+
281+
# Verify last_used_at is now set
282+
assert api_key.last_used_at is not None

0 commit comments

Comments
 (0)