Skip to content

fix: standardize API token format across all creation paths#44

Open
wicky-zipstack wants to merge 3 commits intomainfrom
fix/consistent-api-token-format
Open

fix: standardize API token format across all creation paths#44
wicky-zipstack wants to merge 3 commits intomainfrom
fix/consistent-api-token-format

Conversation

@wicky-zipstack
Copy link
Copy Markdown
Contributor

@wicky-zipstack wicky-zipstack commented Apr 7, 2026

What

  • Standardize API token format to use vtk_ prefix across all 3 token creation paths
  • All auto-generated tokens now follow the same pattern as KeyManagement's create_api_key()

Why

  • user.py (get_or_create_valid_token) was generating tokens with uuid4().hex — plain hex, no vtk_ prefix, no label
  • views.py (update_user_token) stored raw frontend token with no format enforcement
  • api_tokens/views.py (generate_token legacy endpoint) generated vtk_ but never saved to DB
  • Inconsistent token formats cause issues when tokens are displayed in KeyManagement UI

How

  • user.py: Replaced uuid4().hex with generate_api_key(), added label="Default", generate_signature(), and API_KEY_EXPIRY_DAYS
  • views.py: Now generates fresh vtk_ key via generate_api_key() instead of storing raw frontend value
  • api_tokens/views.py: Legacy /token/generate now creates a proper APIToken DB record with label, signature, and expiry

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No — existing tokens in DB are unaffected, only new tokens will use the standardized format
  • Legacy uuid4().hex tokens already in DB will continue to work until they expire
  • KeyManagement create_api_key flow is completely unchanged

Database Migrations

  • None — no schema changes, only the token value format and additional fields populated on creation

Env Config

  • Uses existing API_KEY_EXPIRY_DAYS setting (default: 90 days)
  • No new env vars required

Relevant Docs

  • N/A

Related Issues or PRs

  • N/A

Dependencies Versions

  • None — uses existing api_key_service.py functions

Notes on Testing

  • Verify get_or_create_valid_token creates tokens with vtk_ prefix
  • Verify Profile page regenerate produces vtk_ token saved to DB
  • Verify update_user_token creates proper APIToken record
  • Verify tokens appear in KeyManagement list with "Default" label
  • Verify existing create_api_key flow (KeyManagement) still works unchanged

Screenshots

N/A — backend-only changes

Checklist

Three places were creating API tokens with inconsistent formats:
- user.py: used uuid4().hex (no vtk_ prefix, no label, no signature)
- views.py: stored raw frontend token (no format enforcement)
- api_tokens/views.py: generated vtk_ but never persisted to DB

All now use generate_api_key() for consistent vtk_ prefix, with label
"Default", proper signature, and configurable expiry via API_KEY_EXPIRY_DAYS.
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Apr 7, 2026

Greptile Summary

This PR standardizes API token creation across three paths (user.py, views.py, and the legacy generate_token endpoint) so that all auto-generated tokens use the vtk_ prefix, a "Default" label, a computed signature, and the API_KEY_EXPIRY_DAYS setting — matching the format already used by the KeyManagement create_api_key flow.

All three concerns from the previous review round have been properly addressed: the unscoped deletes are now scoped to label=\"Default\", update_user_token emits an audit log, and generate_token now persists a real APIToken record. The main gap introduced by this PR is that the two paths now producing real DB rows — the else branch of generate_token and the create path of update_user_token — do not enforce the MAX_KEYS_PER_USER cap that create_api_key enforces.

  • user.py: Clean replacement of uuid4().hex with generate_api_key() plus label, signature, and settings-driven expiry — no issues.
  • views.py: Token lookup correctly scoped to label=\"Default\" and audit log added; however, the new APIToken.objects.create(...) path lacks a MAX_KEYS_PER_USER check.
  • api_tokens/views.py: Upsert logic in generate_token is well-structured and includes audit logging; the else (new-record) branch bypasses the per-user key limit.

Confidence Score: 4/5

Safe to merge after addressing the MAX_KEYS_PER_USER enforcement gaps — the token format standardization itself is correct and all prior concerns are resolved.

All three previously raised issues (unscoped deletes, missing audit log, legacy endpoint not persisting) are properly fixed. Two P1 findings remain: both the generate_token else-branch and update_user_token now insert real DB rows without the per-user key-count guard present in create_api_key. These are straightforward fixes that don't require schema changes.

backend/backend/core/routers/api_tokens/views.py (generate_token else-branch, line ~212) and backend/backend/core/views.py (update_user_token create path, line ~70) both need the MAX_KEYS_PER_USER count check added before APIToken.objects.create().

Important Files Changed

Filename Overview
backend/backend/core/routers/api_tokens/views.py Legacy generate_token upgraded to upsert-based DB record creation with audit log and token_hash — but else branch bypasses MAX_KEYS_PER_USER
backend/backend/core/views.py Token lookup scoped to label='Default', fresh vtk_ key generated, audit log added — but create path bypasses MAX_KEYS_PER_USER
backend/backend/core/user.py Replaces uuid4().hex with generate_api_key(), adds label, signature, and API_KEY_EXPIRY_DAYS — clean and consistent

Sequence Diagram

sequenceDiagram
    participant FE as Frontend
    participant VP as views.py<br/>(update_user_token)
    participant UP as user.py<br/>(get_or_create_valid_token)
    participant LG as api_tokens/views.py<br/>(generate_token)
    participant DB as APIToken DB
    participant AUD as Audit Log

    FE->>VP: PUT /profile {token: "vtk_..."}
    VP->>DB: filter(user, label='Default').first()
    DB-->>VP: existing | None
    alt token unchanged
        VP-->>FE: return early
    else token changed
        VP->>DB: existing.delete()
        note over VP,DB: ⚠ No MAX_KEYS check
        VP->>DB: create(vtk_, sig, label='Default', expiry)
        VP->>AUD: log(action='create')
        VP-->>FE: 200 OK
    end

    Note over UP: Called during auth/session init
    UP->>DB: filter(user, org).select_for_update()
    DB-->>UP: token | None
    alt token invalid/expired
        UP->>DB: token.delete()
    end
    UP->>DB: create(vtk_, sig, label='Default', expiry)

    FE->>LG: POST /token/generate
    LG->>DB: filter(user, label='Default').first()
    DB-->>LG: existing | None
    alt existing Default token
        LG->>DB: existing.save(update_fields=[token,token_hash,sig,expiry])
        LG->>AUD: log(action='regenerate')
    else no Default token
        note over LG,DB: ⚠ No MAX_KEYS check
        LG->>DB: create(vtk_, sig, label='Default', expiry)
        LG->>AUD: log(action='create')
    end
    LG-->>FE: {token: "vtk_..."}
Loading
Prompt To Fix All With AI
This is a comment left during a code review.
Path: backend/backend/core/routers/api_tokens/views.py
Line: 211-219

Comment:
**`MAX_KEYS_PER_USER` limit not enforced on new-token creation path**

The upsert logic correctly updates an existing `"Default"` token in-place, but when no `"Default"` token exists the `else` branch creates a brand-new `APIToken` record without first checking `MAX_KEYS_PER_USER`. A user who already holds the maximum number of non-Default keys can call this legacy endpoint to silently create an extra token that exceeds the per-user cap enforced by `create_api_key`.

`create_api_key` (lines 66–72 of the same file) shows the guard pattern:
```python
max_keys = django_settings.MAX_KEYS_PER_USER
existing_count = APIToken.objects.filter(user=request.user).count()
if existing_count >= max_keys:
    return Response({"message": f"Maximum {max_keys} API keys allowed per user."}, status=status.HTTP_429_TOO_MANY_REQUESTS)
```
The same check should be added to `generate_token` before the `else` branch creates a new record.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: backend/backend/core/views.py
Line: 62-80

Comment:
**`MAX_KEYS_PER_USER` limit not enforced in `update_user_token`**

Before this PR `update_user_token` stored the raw frontend string — no real DB row was inserted. Now it calls `APIToken.objects.create(...)` (line 70), creating a real row that counts against the per-user key cap. However, no `MAX_KEYS_PER_USER` guard is applied before the create call, unlike `create_api_key`. A user who has reached the maximum via the KeyManagement UI can get an extra token simply by saving their profile page.

Consider adding the same count check:
```python
max_keys = django_settings.MAX_KEYS_PER_USER
if APIToken.objects.filter(user=user).count() >= max_keys:
    return  # or surface an appropriate error
```

How can I resolve this? If you propose a fix, please make it concise.

Reviews (3): Last reviewed commit: "fix: address Greptile round 2 — scope fi..." | Re-trigger Greptile

to maintain consistency with the api-keys/create endpoint.
"""
# Delete any existing default token for this user
APIToken.objects.filter(user=request.user, label="Default").delete()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deletes ALL 'Default'-labeled tokens, including user-managed ones

APIToken.objects.filter(user=request.user, label="Default").delete() removes every token labeled "Default" for the user — not only ones generated by this legacy endpoint. A user who created a key via the KeyManagement UI and chose to name it "Default" will have it silently destroyed the next time this endpoint is called.

Consider a more precise scoping strategy, for example:

  • Adding a source field to APIToken to distinguish auto-generated vs. user-managed keys, or
  • Switching to an upsert pattern (update token value in place) rather than delete-then-create, or
  • At minimum, limiting deletion to a single record ([:1].delete()) to reduce blast radius.
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/backend/core/routers/api_tokens/views.py
Line: 191

Comment:
**Deletes ALL 'Default'-labeled tokens, including user-managed ones**

`APIToken.objects.filter(user=request.user, label="Default").delete()` removes every token labeled `"Default"` for the user — not only ones generated by this legacy endpoint. A user who created a key via the KeyManagement UI and chose to name it `"Default"` will have it silently destroyed the next time this endpoint is called.

Consider a more precise scoping strategy, for example:
- Adding a `source` field to `APIToken` to distinguish auto-generated vs. user-managed keys, or
- Switching to an upsert pattern (update token value in place) rather than delete-then-create, or
- At minimum, limiting deletion to a single record (`[:1].delete()`) to reduce blast radius.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced the DELETE ... label='Default' with an upsert pattern. Now finds the existing auto-generated token and updates it in place, instead of deleting all matching tokens. Also added log_api_key_event() audit call.

- P1: Replace delete-all-by-label with upsert in generate_token to
  avoid destroying user-created keys named "Default"
- P2: Add log_api_key_event() to generate_token and update_user_token
- P2: Rename new_token to token_value with comment clarifying it is
  only used as an "unchanged" gate, not stored
- P1: Scope update_user_token filter to label="Default" to avoid
  deleting user-managed keys
- P2: Use "regenerate" audit action when updating existing token in
  generate_token, "create" only for new tokens
- P2: Refresh token_hash (SHA-256) on upsert update and regenerate
  to keep DB hash consistent with new token value
Comment on lines +211 to +219
else:
token = APIToken.objects.create(
user=request.user,
token=api_key,
signature=sig,
label="Default",
expires_at=new_expiry,
)
audit_action = "create"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 MAX_KEYS_PER_USER limit not enforced on new-token creation path

The upsert logic correctly updates an existing "Default" token in-place, but when no "Default" token exists the else branch creates a brand-new APIToken record without first checking MAX_KEYS_PER_USER. A user who already holds the maximum number of non-Default keys can call this legacy endpoint to silently create an extra token that exceeds the per-user cap enforced by create_api_key.

create_api_key (lines 66–72 of the same file) shows the guard pattern:

max_keys = django_settings.MAX_KEYS_PER_USER
existing_count = APIToken.objects.filter(user=request.user).count()
if existing_count >= max_keys:
    return Response({"message": f"Maximum {max_keys} API keys allowed per user."}, status=status.HTTP_429_TOO_MANY_REQUESTS)

The same check should be added to generate_token before the else branch creates a new record.

Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/backend/core/routers/api_tokens/views.py
Line: 211-219

Comment:
**`MAX_KEYS_PER_USER` limit not enforced on new-token creation path**

The upsert logic correctly updates an existing `"Default"` token in-place, but when no `"Default"` token exists the `else` branch creates a brand-new `APIToken` record without first checking `MAX_KEYS_PER_USER`. A user who already holds the maximum number of non-Default keys can call this legacy endpoint to silently create an extra token that exceeds the per-user cap enforced by `create_api_key`.

`create_api_key` (lines 66–72 of the same file) shows the guard pattern:
```python
max_keys = django_settings.MAX_KEYS_PER_USER
existing_count = APIToken.objects.filter(user=request.user).count()
if existing_count >= max_keys:
    return Response({"message": f"Maximum {max_keys} API keys allowed per user."}, status=status.HTTP_429_TOO_MANY_REQUESTS)
```
The same check should be added to `generate_token` before the `else` branch creates a new record.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +62 to +80
if token_value:
# Skip regeneration if the token hasn't changed
if existing_token and existing_token.token == token_value:
return
if existing_token:
existing_token.delete()

APIToken.objects.create(user=user, token=new_token, expires_at= now() + timedelta(days=90))
api_key = generate_api_key()
token = APIToken.objects.create(
user=user,
token=api_key,
signature=generate_signature(api_key),
label="Default",
expires_at=now() + timedelta(days=django_settings.API_KEY_EXPIRY_DAYS),
)
log_api_key_event(
request, action="create", key_id=token.id,
key_label="Default", key_masked=token.masked_token,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 MAX_KEYS_PER_USER limit not enforced in update_user_token

Before this PR update_user_token stored the raw frontend string — no real DB row was inserted. Now it calls APIToken.objects.create(...) (line 70), creating a real row that counts against the per-user key cap. However, no MAX_KEYS_PER_USER guard is applied before the create call, unlike create_api_key. A user who has reached the maximum via the KeyManagement UI can get an extra token simply by saving their profile page.

Consider adding the same count check:

max_keys = django_settings.MAX_KEYS_PER_USER
if APIToken.objects.filter(user=user).count() >= max_keys:
    return  # or surface an appropriate error
Prompt To Fix With AI
This is a comment left during a code review.
Path: backend/backend/core/views.py
Line: 62-80

Comment:
**`MAX_KEYS_PER_USER` limit not enforced in `update_user_token`**

Before this PR `update_user_token` stored the raw frontend string — no real DB row was inserted. Now it calls `APIToken.objects.create(...)` (line 70), creating a real row that counts against the per-user key cap. However, no `MAX_KEYS_PER_USER` guard is applied before the create call, unlike `create_api_key`. A user who has reached the maximum via the KeyManagement UI can get an extra token simply by saving their profile page.

Consider adding the same count check:
```python
max_keys = django_settings.MAX_KEYS_PER_USER
if APIToken.objects.filter(user=user).count() >= max_keys:
    return  # or surface an appropriate error
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — low risk in practice since both paths only create label="Default" tokens (one per user via upsert). The only edge case is if the Default token was manually deleted and the user already has MAX_KEYS_PER_USER keys from KeyManagement. Will add the guard in a follow-up if needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant