Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions backend/apps/accounts/encryption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""
Encryption utilities for sensitive data storage.

Uses Fernet symmetric encryption with a key derived from Django's SECRET_KEY.
This ensures that encrypted data in the database cannot be read without
the application's secret key.
"""
import base64
import hashlib

from cryptography.fernet import Fernet, InvalidToken
from django.conf import settings


def _get_fernet_key() -> bytes:
"""
Derive a Fernet-compatible key from Django's SECRET_KEY.

Uses SHA-256 to produce a 32-byte digest, then base64url-encodes it
to create a valid Fernet key (44 URL-safe base64-encoded bytes).
"""
secret = settings.SECRET_KEY.encode()
digest = hashlib.sha256(secret).digest()
return base64.urlsafe_b64encode(digest)


def _get_fernet() -> Fernet:
"""Return a Fernet instance using the derived key."""
return Fernet(_get_fernet_key())


def encrypt_value(plaintext: str) -> str:
"""
Encrypt a plaintext string and return a URL-safe base64-encoded token.

Args:
plaintext: The string to encrypt.

Returns:
The encrypted value as a URL-safe base64 string.
"""
if not plaintext:
return ""
f = _get_fernet()
return f.encrypt(plaintext.encode()).decode()


def decrypt_value(token: str) -> str:
"""
Decrypt a Fernet token back to the original plaintext string.

Args:
token: The encrypted value (URL-safe base64 string).

Returns:
The decrypted plaintext string.

Raises:
InvalidToken: If the token is invalid or was encrypted with
a different key.
"""
if not token:
return ""
f = _get_fernet()
return f.decrypt(token.encode()).decode()
61 changes: 61 additions & 0 deletions backend/apps/accounts/migrations/0003_add_userllmconfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Generated by Django 6.0.1 on 2026-02-26 05:34

import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("accounts", "0002_userapikey"),
]

operations = [
migrations.CreateModel(
name="UserLLMConfig",
fields=[
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Created at")),
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Updated at")),
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
(
"provider",
models.CharField(
choices=[("gemini", "Gemini"), ("openrouter", "OpenRouter")],
help_text="LLM provider name",
max_length=20,
verbose_name="Provider",
),
),
(
"api_key_encrypted",
models.TextField(help_text="Fernet-encrypted API key", verbose_name="Encrypted API key"),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Whether this provider configuration is active",
verbose_name="Is Active",
),
),
(
"user",
models.ForeignKey(
help_text="User who owns this LLM configuration",
on_delete=django.db.models.deletion.CASCADE,
related_name="llm_configs",
to=settings.AUTH_USER_MODEL,
verbose_name="User",
),
),
],
options={
"verbose_name": "User LLM configuration",
"verbose_name_plural": "User LLM configurations",
"ordering": ["-created_at"],
"constraints": [models.UniqueConstraint(fields=("user", "provider"), name="unique_user_provider")],
},
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 6.0.1 on 2026-02-26 06:20

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("accounts", "0003_add_userllmconfig"),
]

operations = [
migrations.AddField(
model_name="userllmconfig",
name="is_default",
field=models.BooleanField(
default=False, help_text="Whether this is the default provider for the user", verbose_name="Is Default"
),
),
]
79 changes: 79 additions & 0 deletions backend/apps/accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,82 @@ def update_last_used(self):

self.last_used_at = timezone.now()
self.save(update_fields=['last_used_at'])


class UserLLMConfig(TimeStampedModel):
"""
Per-user LLM provider configuration.

Each user can configure one entry per provider with their own API key.
API keys are encrypted at rest using Fernet symmetric encryption.
"""

PROVIDER_CHOICES = [
('gemini', _('Gemini')),
('openrouter', _('OpenRouter')),
]

id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False
)
user = models.ForeignKey(
'accounts.User',
on_delete=models.CASCADE,
related_name='llm_configs',
verbose_name=_('User'),
help_text=_('User who owns this LLM configuration')
)
provider = models.CharField(
max_length=20,
choices=PROVIDER_CHOICES,
verbose_name=_('Provider'),
help_text=_('LLM provider name')
)
api_key_encrypted = models.TextField(
verbose_name=_('Encrypted API key'),
help_text=_('Fernet-encrypted API key')
)
is_active = models.BooleanField(
default=True,
verbose_name=_('Is Active'),
help_text=_('Whether this provider configuration is active')
)
is_default = models.BooleanField(
default=False,
verbose_name=_('Is Default'),
help_text=_('Whether this is the default provider for the user')
)

class Meta:
verbose_name = _('User LLM configuration')
verbose_name_plural = _('User LLM configurations')
ordering = ['-created_at']
constraints = [
models.UniqueConstraint(
fields=['user', 'provider'],
name='unique_user_provider'
)
]

def __str__(self):
return f'{self.user.email} - {self.get_provider_display()}'

def save(self, *args, **kwargs):
# Ensure only one default config per user
if self.is_default:
UserLLMConfig.objects.filter(
user=self.user, is_default=True
).exclude(pk=self.pk).update(is_default=False)
super().save(*args, **kwargs)

def set_api_key(self, plaintext_key: str):
"""Encrypt and store the API key."""
from .encryption import encrypt_value
self.api_key_encrypted = encrypt_value(plaintext_key)

def get_api_key(self) -> str:
"""Decrypt and return the API key."""
from .encryption import decrypt_value
return decrypt_value(self.api_key_encrypted)
67 changes: 66 additions & 1 deletion backend/apps/accounts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from rest_framework import serializers
from rest_framework.validators import UniqueValidator

from .models import UserAPIKey
from .models import UserAPIKey, UserLLMConfig

User = get_user_model()

Expand Down Expand Up @@ -124,3 +124,68 @@ class Meta:
model = UserAPIKey
fields = ['id', 'name', 'key', 'key_prefix', 'expires_at', 'created_at']
read_only_fields = ['id', 'key', 'key_prefix', 'created_at']


class UserLLMConfigSerializer(serializers.ModelSerializer):
"""
Serializer for UserLLMConfig.
Accepts a plaintext ``api_key`` on write and returns a masked
version on read. The actual encrypted blob is never exposed.
"""

api_key = serializers.CharField(
write_only=True,
required=True,
help_text=_('Plaintext API key (write-only)')
)
api_key_display = serializers.SerializerMethodField()
provider_display = serializers.CharField(
source='get_provider_display',
read_only=True
)

class Meta:
model = UserLLMConfig
fields = [
'id', 'provider', 'provider_display',
'api_key', 'api_key_display',
'is_active', 'is_default', 'created_at', 'updated_at',
]
read_only_fields = ['id', 'created_at', 'updated_at']

def get_api_key_display(self, obj):
"""Return a masked version of the API key."""
try:
key = obj.get_api_key()
if len(key) > 8:
return f"{key[:4]}{'*' * (len(key) - 8)}{key[-4:]}"
return '****'
except Exception:
return '****'

def validate(self, attrs):
# On create, check uniqueness of (user, provider)
if self.instance is None:
user = self.context['request'].user
provider = attrs.get('provider')
if UserLLMConfig.objects.filter(user=user, provider=provider).exists():
raise serializers.ValidationError({
'provider': _('A configuration for this provider already exists.')
})
return attrs

def create(self, validated_data):
api_key = validated_data.pop('api_key')
instance = UserLLMConfig(**validated_data)
instance.set_api_key(api_key)
instance.save()
return instance

def update(self, instance, validated_data):
api_key = validated_data.pop('api_key', None)
if api_key:
instance.set_api_key(api_key)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
Loading