Skip to content
Open
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
13 changes: 7 additions & 6 deletions docs/customization/encrypted_model_fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,13 @@ As such, the model *should not define a* ``user`` *property of its own*.
Some Explanations
-----------------

EncryptableModelMixin (`source <https://github.com/TOMToolkit/tom_base/blob/069024f954e5540c1441c5186378de538f7d606f/tom_common/models.py#L100>`__)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The User's data is encrypted using (among other things) their password (i.e the
password they use to login to your TOM). When the User changes their password,
their encrypted data re-encrypted accordingly. The ``EncryptableModelMixin`` adds
method for this to your otherwise normal Django model.
EncryptableModelMixin
~~~~~~~~~~~~~~~~~~~~~
An abstract Django model mixin that provides a standardized ``user`` OneToOneField.
Any model that stores encrypted data via ``EncryptedProperty`` should inherit from
this mixin. The ``user`` field ties encrypted data to its owner, allowing the
helper functions in ``session_utils`` to look up the user's Data Encryption Key
(DEK) and build the cipher needed for encryption and decryption.

EncryptedProperty (`source <https://github.com/TOMToolkit/tom_base/blob/069024f954e5540c1441c5186378de538f7d606f/tom_common/models.py#L39>`__)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Expand Down
25 changes: 18 additions & 7 deletions tom_base/settings.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
"""
Django settings for tom_base project.
"""Django settings for the tom_base repository itself.

THIS IS NOT YOUR TOM's `settings.py`.

Generated by 'django-admin startproject' using Django 2.0.6.
This file is used when running commands directly from the tom_base repo —
for example, ``python manage.py test`` within the tom_base repo. It is NOT
the settings file that individual TOM projects use.

For more information on this file, see
https://docs.djangoproject.com/en/2.0/topics/settings/
Each TOM project gets its own standalone ``settings.py``, generated by
``tom_setup`` from the template ``tom_setup/templates/tom_setup/settings.tmpl``.
That project-level settings file is what a TOM runs in production.

For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.0/ref/settings/
This file exists only so that the tom_base repo has a working
Django configuration for development, testing, and CI.
"""
import logging.config
import os
Expand All @@ -27,6 +31,13 @@
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

# Encryption key for protecting sensitive user data (API keys, credentials) at rest.
# This is a Fernet key — a 44-character URL-safe base64 string encoding 32 random bytes.
# Treat this like SECRET_KEY. See the TOM Toolkit encryption documentation.
TOMTOOLKIT_DEK_ENCRYPTION_KEY = os.getenv(
'TOMTOOLKIT_DEK_ENCRYPTION_KEY',
'UlUYyKsGzQVwjpTbvhtgCihKaj07H1voc-V4pmb7NN4=') # 44-char URL-safe base64 string

ALLOWED_HOSTS = ['']

# Application definition
Expand Down
36 changes: 35 additions & 1 deletion tom_common/apps.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
import plotly.io as pio


class TomCommonConfig(AppConfig):
name = 'tom_common'

def ready(self):
# Import signals for automatically saving profiles when updating User objects
# Import signals so their @receiver decorators are executed, which
# registers the signal handlers. Without this import, signal handlers
# in signals.py would never fire.
# https://docs.djangoproject.com/en/5.1/topics/signals/#connecting-receiver-functions
import tom_common.signals # noqa

self._check_dek_encryption_key()

# Set default plotly theme on startup
valid_themes = ['plotly', 'plotly_white', 'plotly_dark', 'ggplot2', 'seaborn', 'simple_white', 'none']

Expand All @@ -21,6 +26,35 @@ def ready(self):

pio.templates.default = plotly_theme

def _check_dek_encryption_key(self) -> None:
"""Verify that the DEK encryption master key is configured.

This key is required for encrypting sensitive user data (API keys,
observatory credentials) at rest in the database. Without it, the
TOM is prevented from starting.
"""
key = getattr(settings, 'TOMTOOLKIT_DEK_ENCRYPTION_KEY', '')
if not key:
raise ImproperlyConfigured(
"\n\n"
"TOMTOOLKIT_DEK_ENCRYPTION_KEY is not set.\n\n"
"This setting is required for encrypting sensitive user data at rest.\n"
"To fix this:\n\n"
" 1. Generate a key (requires the 'cryptography' package, which is\n"
" installed as a dependency of tom-base):\n\n"
" python -c \"from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())\"\n\n"
" 2. Set the key as an environment variable:\n\n"
" export TOMTOOLKIT_DEK_ENCRYPTION_KEY='<paste the generated key>'\n\n"
" Then reference it in your settings.py:\n\n"
" TOMTOOLKIT_DEK_ENCRYPTION_KEY = os.getenv(\n"
" 'TOMTOOLKIT_DEK_ENCRYPTION_KEY')\n\n"
" 3. Restart your TOM.\n\n"
"Treat this key like SECRET_KEY — keep it secret, do not commit it\n"
"to source control, and back it up. If this key is lost, users will\n"
"need to re-enter their saved external service credentials.\n"
)

def profile_details(self):
"""
Integration point for adding items to the user profile page.
Expand Down
Empty file.
Empty file.
73 changes: 73 additions & 0 deletions tom_common/management/commands/rotate_dek_encryption_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
"""Management command to rotate the TOMTOOLKIT_DEK_ENCRYPTION_KEY.

This is a thin CLI wrapper around ``session_utils.rotate_master_key()``.
See that function for the actual rotation logic.

Usage:
1. Generate a new Fernet key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
2. Run the rotation:
python manage.py rotate_dek_encryption_key --new-key <new_key>
3. Update your environment / settings.py with the new key.
4. Restart the server.
"""
from __future__ import annotations

from django.core.management.base import BaseCommand, CommandError

from tom_common.session_utils import rotate_master_key


class Command(BaseCommand):
help = (
'Re-encrypts all per-user Data Encryption Keys (DEKs) with a new master key. '
'Run this when rotating TOMTOOLKIT_DEK_ENCRYPTION_KEY.'
)

def add_arguments(self, parser) -> None:
parser.add_argument(
'--new-key',
required=True,
help='The new Fernet master key (URL-safe base64-encoded, 32 bytes). '
'Generate with: python -c "from cryptography.fernet import Fernet; '
'print(Fernet.generate_key().decode())"',
)

def handle(self, *args, **options) -> None:
new_key: str = options['new_key']

try:
result = rotate_master_key(new_key)
except ValueError as e:
raise CommandError(str(e))
except Exception as e:
raise CommandError(f"Cannot access current master key: {e}")

if result.total == 0:
self.stdout.write(self.style.WARNING(
"No profiles with encryption keys found. Nothing to rotate."
))
return

self.stdout.write(f"Re-encrypting DEKs for {result.total} profile(s)...")

if result.success_count:
self.stdout.write(self.style.SUCCESS(
f"Done. {result.success_count} re-encrypted successfully."
))

for error in result.errors:
self.stderr.write(self.style.ERROR(
f" FAILED: Profile pk={error.profile_pk} (user={error.username}) — {error.error}"
))

if result.error_count:
self.stdout.write(self.style.ERROR(
f"{result.error_count} failed — see errors above."
))

self.stdout.write("")
self.stdout.write(self.style.WARNING(
"IMPORTANT: Update TOMTOOLKIT_DEK_ENCRYPTION_KEY in your environment / "
"settings.py with the new key, then restart the server."
))
18 changes: 18 additions & 0 deletions tom_common/migrations/0003_profile_encrypted_dek.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-27 22:29

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('tom_common', '0002_usersession'),
]

operations = [
migrations.AddField(
model_name='profile',
name='encrypted_dek',
field=models.BinaryField(blank=True, null=True),
),
]
16 changes: 16 additions & 0 deletions tom_common/migrations/0004_delete_usersession.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Generated by Django 5.2.12 on 2026-03-27 22:29

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('tom_common', '0003_profile_encrypted_dek'),
]

operations = [
migrations.DeleteModel(
name='UserSession',
),
]
Loading
Loading