Skip to content

Commit 5f3ccad

Browse files
authored
docs(encryption): Add docs for encrypted field usage (#15888)
Adds docs about usage of encryption fields at sentry. it also includes a more in-detail section on how things work under the hood (for SRE and infra teams) Closes [TET-523: Docs for adding encrypted field to the model](https://linear.app/getsentry/issue/TET-523/docs-for-adding-encrypted-field-to-the-model) and [TET-522: Docs for migrating existing fields to encrypted ones](https://linear.app/getsentry/issue/TET-522/docs-for-migrating-existing-fields-to-encrypted-ones)
1 parent 8992c62 commit 5f3ccad

File tree

2 files changed

+311
-0
lines changed

2 files changed

+311
-0
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
---
2+
title: "Administration"
3+
description: "Configuration, key management, and key rotation for encrypted fields."
4+
sidebar_order: 2
5+
---
6+
7+
This guide covers the administrative tasks for managing encrypted fields, including configuration, key management, and key rotation.
8+
9+
## Configuration
10+
11+
### Encryption Method
12+
13+
The `database.encryption.method` option controls which encryption method to use:
14+
15+
- `"plaintext"` - No encryption (default for development, base64-encoded only)
16+
- `"fernet"` - Fernet symmetric encryption (production)
17+
18+
```python
19+
# In your Sentry options
20+
options.set("database.encryption.method", "fernet")
21+
```
22+
23+
### Fernet Keys
24+
25+
Fernet encryption requires two settings in `DATABASE_ENCRYPTION_SETTINGS`:
26+
27+
```python
28+
DATABASE_ENCRYPTION_SETTINGS = {
29+
"fernet_keys_location": "/path/to/keys/directory",
30+
"fernet_primary_key_id": "key_2024_01"
31+
}
32+
```
33+
34+
- `fernet_keys_location`: Directory containing encryption key files
35+
- `fernet_primary_key_id`: The key ID to use for encrypting new data
36+
37+
### Keys Directory Structure
38+
39+
In Sentry SaaS, keys are stored as Kubernetes secrets and mounted as files to pods that have access to the database. Each secret is mounted as a separate file in the keys directory, with the filename serving as the key ID:
40+
41+
```
42+
/path/to/keys/
43+
├── key_2023_12
44+
├── key_2024_01 # Current primary key
45+
└── key_2024_02
46+
```
47+
48+
For self-hosted users, keys should be mounted to all the containers that interact with the database.
49+
50+
## Key Rotation
51+
52+
To rotate encryption keys:
53+
54+
1. Generate a new key and add it to the keys directory
55+
2. Update `fernet_primary_key_id` to point to the new key
56+
3. New/updated data will use the new key
57+
4. Old data can still be decrypted with previous keys
58+
59+
```python
60+
# Before rotation
61+
DATABASE_ENCRYPTION_SETTINGS = {
62+
"fernet_keys_location": "/path/to/keys",
63+
"fernet_primary_key_id": "key_2024_01"
64+
}
65+
66+
# After rotation
67+
DATABASE_ENCRYPTION_SETTINGS = {
68+
"fernet_keys_location": "/path/to/keys",
69+
"fernet_primary_key_id": "key_2024_02" # New key
70+
}
71+
```
72+
73+
Data will be gradually re-encrypted as records are updated.
74+
75+
### Generating Keys
76+
77+
Generate a Fernet key using Python:
78+
79+
```python
80+
from cryptography.fernet import Fernet
81+
82+
key = Fernet.generate_key()
83+
print(key.decode()) # Example: gAAAAABh...
84+
```
85+
86+
## Key Management
87+
88+
- **Never commit keys to version control**
89+
- Keys are stored as Kubernetes secrets and mounted to pods
90+
- Use different keys for different environments
91+
- Keep all historical keys—they're needed to decrypt old data
92+
- Rotate keys periodically (recommended: annually)
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
---
2+
title: "Encrypted Fields"
3+
description: "A replacement Django fields for encrypting sensitive data in Sentry."
4+
categories:
5+
- backend
6+
- encryption
7+
- django
8+
sidebar_order: 130
9+
---
10+
11+
Sentry provides encrypted database field types for storing sensitive data securely. The encryption system uses Fernet symmetric encryption with support for key rotation and backward compatibility.
12+
13+
## When to Use Encrypted Fields
14+
15+
Use encrypted fields for:
16+
17+
- API keys, tokens, and secrets
18+
- Passwords and credentials
19+
- OAuth tokens and refresh tokens
20+
- PII when required by compliance
21+
22+
Don't encrypt:
23+
24+
- Data you need to query or filter on
25+
- High-volume, low-sensitivity data
26+
- Data already encrypted at rest
27+
28+
## Basic Usage
29+
30+
Encrypted fields work as replacements for standard Django fields. Data is encrypted transparently on write and decrypted on read:
31+
32+
```python
33+
from sentry.db.models.fields.encryption import EncryptedCharField, EncryptedJSONField
34+
35+
class TempestCredentials(models.Model):
36+
client_id = models.CharField()
37+
client_secret = EncryptedCharField()
38+
metadata = EncryptedJSONField(default=dict)
39+
40+
# Using the model
41+
creds = TempestCredentials.objects.create(
42+
client_id="my-client",
43+
client_secret="super-secret-value",
44+
metadata={"api_version": "v2", "scopes": ["read", "write"]}
45+
)
46+
47+
# Reading works transparently
48+
print(creds.client_secret) # Prints: "super-secret-value"
49+
print(creds.metadata) # Prints: {"api_version": "v2", "scopes": ["read", "write"]}
50+
```
51+
52+
## Querying Encrypted Fields
53+
54+
You **cannot** query encrypted field values directly:
55+
56+
```python
57+
# This will NOT work
58+
MyModel.objects.filter(secret="my-value") # Won't find encrypted data
59+
```
60+
61+
If you need to query by these fields, consider keeping a separate hash field:
62+
63+
```python
64+
class MyModel(models.Model):
65+
secret = EncryptedCharField()
66+
secret_hash = models.CharField(max_length=64, db_index=True)
67+
68+
def save(self, *args, **kwargs):
69+
if self.secret:
70+
self.secret_hash = hashlib.sha256(self.secret.encode()).hexdigest()
71+
super().save(*args, **kwargs)
72+
73+
# Query by hash
74+
MyModel.objects.filter(secret_hash=hashlib.sha256(b"my-value").hexdigest())
75+
```
76+
77+
## Field Types
78+
79+
### EncryptedCharField
80+
81+
A replacement for Django's `CharField` that encrypts text data.
82+
83+
<Alert level="warning">
84+
**Important**: Do not set the `max_length` property on `EncryptedCharField`.
85+
The encrypted payload is larger than the original plaintext data.
86+
</Alert>
87+
88+
```python
89+
from sentry.db.models.fields.encryption import EncryptedCharField
90+
91+
class MyModel(models.Model):
92+
secret_token = EncryptedCharField()
93+
api_key = EncryptedCharField(null=True, blank=True)
94+
```
95+
96+
### EncryptedJSONField
97+
98+
A replacement for Django's `JSONField` that encrypts JSON data.
99+
100+
```python
101+
from sentry.db.models.fields.encryption import EncryptedJSONField
102+
103+
class MyModel(models.Model):
104+
credentials = EncryptedJSONField(null=True, blank=True)
105+
metadata = EncryptedJSONField(default=dict)
106+
```
107+
108+
## Migrations
109+
110+
### Adding New Encrypted Fields
111+
112+
Add the field to your model:
113+
```python
114+
class MyModel(models.Model):
115+
api_key = EncryptedCharField(null=True, blank=True)
116+
```
117+
118+
and generate a migration:
119+
120+
```bash
121+
sentry django makemigrations
122+
sentry upgrade
123+
```
124+
125+
### Converting Existing Fields
126+
127+
Change the field type in your model:
128+
129+
```python
130+
# Before
131+
class MyModel(models.Model):
132+
api_key = models.CharField(max_length=255)
133+
134+
# After
135+
class MyModel(models.Model):
136+
api_key = EncryptedCharField()
137+
```
138+
139+
Then follow the [regular migration procedure](/backend/application-domains/database-migrations/) to generate and deploy the migration.
140+
141+
<Alert level="info">
142+
This migration will be a SQL no-op—the field remains a text field in the
143+
database. The encryption is handled at the application layer, so no database
144+
schema changes occur.
145+
</Alert>
146+
147+
The encrypted field will automatically:
148+
149+
- Read unencrypted data as-is (backward compatibility)
150+
- Encrypt new data on write
151+
- Gradually encrypt existing data as records are updated
152+
153+
**Optional**: Force immediate encryption with a data migration:
154+
155+
```python
156+
from sentry.new_migrations.migrations import CheckedMigration
157+
from sentry.utils.query import RangeQuerySetWrapperWithProgressBar
158+
159+
def encrypt_existing_data(apps, schema_editor):
160+
MyModel = apps.get_model("myapp", "MyModel")
161+
for instance in RangeQuerySetWrapperWithProgressBar(MyModel.objects.all()):
162+
instance.save(update_fields=["api_key"])
163+
164+
class Migration(CheckedMigration):
165+
is_post_deployment = True
166+
167+
dependencies = [
168+
("myapp", "0002_alter_mymodel_api_key"),
169+
]
170+
171+
operations = [
172+
migrations.RunPython(encrypt_existing_data, migrations.RunPython.noop),
173+
]
174+
```
175+
176+
## Troubleshooting
177+
178+
**Problem**: `ValueError: Fernet primary key ID is not configured`
179+
180+
**Solution**: Set `DATABASE_ENCRYPTION_SETTINGS["fernet_primary_key_id"]` in your configuration. See [Administration](./administration/) for configuration details.
181+
182+
---
183+
184+
**Problem**: `ValueError: Encryption key with ID 'key_id' not found`
185+
186+
**Solution**: Add the missing key file to the keys directory or update the configuration.
187+
188+
---
189+
190+
**Problem**: Data is not being encrypted
191+
192+
**Solution**: Verify `database.encryption.method` is set to `"fernet"`, not `"plaintext"`.
193+
194+
---
195+
196+
**Problem**: Migration takes too long on large tables
197+
198+
**Solution**: Use a post-deployment data migration with `RangeQuerySetWrapperWithProgressBar`.
199+
200+
## How It Works
201+
202+
Encrypted data is stored with a marker prefix that identifies the encryption method:
203+
204+
```
205+
Plaintext: enc:plaintext:{base64_data}
206+
Fernet: enc:fernet:{key_id}:{base64_encrypted_data}
207+
```
208+
209+
The key ID in Fernet format enables key rotation—old data encrypted with previous keys can still be decrypted.
210+
211+
For `EncryptedJSONField`, the encrypted value is wrapped in a JSON object:
212+
213+
```json
214+
{
215+
"sentry_encrypted_field_value": "enc:fernet:key_2024_01:gAAAAABh..."
216+
}
217+
```
218+
219+
This allows the field to distinguish encrypted from unencrypted data during migrations and maintain backward compatibility.

0 commit comments

Comments
 (0)