From 5ba2b235cd19c88de98f7809012edd968c30b546 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 15:41:34 +0100 Subject: [PATCH 01/14] Add django-fernet-encrypted-fields --- pyproject.toml | 1 + uv.lock | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bf02fbb..fa5378c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "cryptography>=44.0.2", "django==5.2b1", "django-allauth>=65.5.0", + "django-fernet-encrypted-fields>=0.3.0", "django-scopes>=2.0.0", "django-template-partials>=24.4", "pillow>=11.1.0", diff --git a/uv.lock b/uv.lock index 418d307..74c5511 100644 --- a/uv.lock +++ b/uv.lock @@ -287,6 +287,19 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/66/f8/b58f84c29bcbca3798939279a98e2423e6e53a38c29e3fed7700ff3d6984/django_allauth-65.5.0.tar.gz", hash = "sha256:1a564fd2f5413054559078c2b7146796b517c1e7a38c6312e9de7c9bb708325d", size = 1624216 } +[[package]] +name = "django-fernet-encrypted-fields" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/70/b8/b6725f1207693ba9e76223abf87eb9e8de5114cccad8ddd1bce29a195273/django-fernet-encrypted-fields-0.3.0.tar.gz", hash = "sha256:38031bdaf1724a6e885ee137cc66a2bd7dc3726c438e189ea7e44799ec0ba9b3", size = 4021 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/8a/2c5d88cd540d83ceaa1cb3191ed35dfed0caacc6fe2ff5fe74c9ecc7776f/django_fernet_encrypted_fields-0.3.0-py3-none-any.whl", hash = "sha256:a17cca5bf3638ee44674e64f30792d5960b1d4d4b291ec478c27515fc4860612", size = 5400 }, +] + [[package]] name = "django-scopes" version = "2.0.0" @@ -731,6 +744,7 @@ dependencies = [ { name = "cryptography" }, { name = "django" }, { name = "django-allauth" }, + { name = "django-fernet-encrypted-fields" }, { name = "django-scopes" }, { name = "django-template-partials" }, { name = "pillow" }, @@ -761,6 +775,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=44.0.2" }, { name = "django", specifier = "==5.2b1" }, { name = "django-allauth", specifier = ">=65.5.0" }, + { name = "django-fernet-encrypted-fields", specifier = ">=0.3.0" }, { name = "django-scopes", specifier = ">=2.0.0" }, { name = "django-template-partials", specifier = ">=24.4" }, { name = "pillow", specifier = ">=11.1.0" }, -- 2.47.2 From 00703807d6ad894dcb25c62a185d08b2a07a72f6 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 15:56:55 +0100 Subject: [PATCH 02/14] Add encryption settings --- .env.example | 6 +++++- src/servala/settings.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 940a6d0..2b7ce27 100644 --- a/.env.example +++ b/.env.example @@ -4,10 +4,14 @@ # When the environment is "development", DEBUG is set to True. SERVALA_ENVIRONMENT='development' -# Set PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions +# Set SERVALA_PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions and to remain able to read encrypted fields! # SERVALA_PREVIOUS_SECRET_KEY='' SERVALA_SECRET_KEY='django-insecure-8sl^1&1f-$3%w7cf)q(rcvi4jo(#s3ug-@be0ooc2ioep*&%7@' +# Set SERVALA_PREVIOUS_SALT_KEY when rotating to a new salt in order to remain able to read encrypted fields! +# SERVALA_PREVIOUS_SALT_KEY='' +SERVALA_SALT_KEY='eed6UaCi3euZojai5Iequ8ochookun1o' + # Set the allowed hosts as comma-separated list. # Use a leading dot to match a domain and all subdomains. # Leave or unset in the development environment in order to accept localhost names. diff --git a/src/servala/settings.py b/src/servala/settings.py index d8e5e54..c4c2627 100644 --- a/src/servala/settings.py +++ b/src/servala/settings.py @@ -21,6 +21,10 @@ SECRET_KEY = os.environ.get("SERVALA_SECRET_KEY") if previous_secret_key := os.environ.get("SERVALA_PREVIOUS_SECRET_KEY"): SECRET_KEY_FALLBACKS = [previous_secret_key] +SALT_KEY = os.environ.get("SERVALA_SALT_KEY") +if previous_salt := os.environ.get("SERVALA_PREVIOUS_SALT_KEY"): + SALT_KEY = [SALT_KEY, previous_salt] + BASE_DIR = Path(__file__).resolve().parent.parent ALLOWED_HOSTS = [] -- 2.47.2 From 899bffb974445e620c74d4f3ef51810009393063 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 15:57:54 +0100 Subject: [PATCH 03/14] Turn ControlPlane.api_credentials into encrypted field --- .../0004_encrypt_api_credentials.py | 46 +++++++++++++++++++ src/servala/core/models/service.py | 3 +- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/servala/core/migrations/0004_encrypt_api_credentials.py diff --git a/src/servala/core/migrations/0004_encrypt_api_credentials.py b/src/servala/core/migrations/0004_encrypt_api_credentials.py new file mode 100644 index 0000000..9ca4acf --- /dev/null +++ b/src/servala/core/migrations/0004_encrypt_api_credentials.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2b1 on 2025-03-24 06:33 + +import encrypted_fields.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("core", "0003_billing_entity_nullable"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="groups", + field=models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + migrations.AddField( + model_name="user", + name="user_permissions", + field=models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + migrations.AlterField( + model_name="controlplane", + name="api_credentials", + field=encrypted_fields.fields.EncryptedJSONField( + verbose_name="API credentials" + ), + ), + ] diff --git a/src/servala/core/models/service.py b/src/servala/core/models/service.py index acf366b..83cc696 100644 --- a/src/servala/core/models/service.py +++ b/src/servala/core/models/service.py @@ -1,5 +1,6 @@ from django.db import models from django.utils.translation import gettext_lazy as _ +from encrypted_fields.fields import EncryptedJSONField class ServiceCategory(models.Model): @@ -67,7 +68,7 @@ class ControlPlane(models.Model): description = models.TextField(blank=True, verbose_name=_("Description")) k8s_api_endpoint = models.URLField(verbose_name=_("Kubernetes API endpoint")) # TODO: schema - api_credentials = models.JSONField(verbose_name=_("API credentials")) + api_credentials = EncryptedJSONField(verbose_name=_("API credentials")) cloud_provider = models.ForeignKey( to="CloudProvider", -- 2.47.2 From 4e603246f7244caaed735def59a54c2e83911bf0 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 15:58:52 +0100 Subject: [PATCH 04/14] Add and document reencrypt_fields command --- .env.example | 3 +++ README.md | 17 +++++++++++++++ src/servala/core/management/__init__.py | 0 .../core/management/commands/__init__.py | 0 .../management/commands/reencrypt_fields.py | 21 +++++++++++++++++++ 5 files changed, 41 insertions(+) create mode 100644 src/servala/core/management/__init__.py create mode 100644 src/servala/core/management/commands/__init__.py create mode 100644 src/servala/core/management/commands/reencrypt_fields.py diff --git a/.env.example b/.env.example index 2b7ce27..829b56e 100644 --- a/.env.example +++ b/.env.example @@ -5,10 +5,13 @@ SERVALA_ENVIRONMENT='development' # Set SERVALA_PREVIOUS_SECRET_KEY when rotating to a new secret key in order to not expire all sessions and to remain able to read encrypted fields! +# In order to retire the previous key, run the ``reencrypt_fields`` command. Once you drop the previous secret key from +# the rotation, all sessions that still rely on that key will be invalidated (i.e., users will have to log in again). # SERVALA_PREVIOUS_SECRET_KEY='' SERVALA_SECRET_KEY='django-insecure-8sl^1&1f-$3%w7cf)q(rcvi4jo(#s3ug-@be0ooc2ioep*&%7@' # Set SERVALA_PREVIOUS_SALT_KEY when rotating to a new salt in order to remain able to read encrypted fields! +# In order to retire the previous key, run the ``reencrypt_fields`` command. # SERVALA_PREVIOUS_SALT_KEY='' SERVALA_SALT_KEY='eed6UaCi3euZojai5Iequ8ochookun1o' diff --git a/README.md b/README.md index 8959c00..a30699a 100644 --- a/README.md +++ b/README.md @@ -91,3 +91,20 @@ See `.forgejo/workflows/build-deploy-staging.yaml` for the actual workflow. Deployment files are in the `deployment/kustomize` folder and makes use of [Kustomize](https://kustomize.io/) to account for differences between the deployment stages. Stages are configured with overlays in `deployment/kustomize/overlays/$environment`. +## Maintenance and management commands + +You can interface with the Django server and project by running commands like this: + +```bash +uv run --env-file=.env src/manage.py COMMAND +``` + +Useful commands: + +- ``migrate``: Make sure database migrations are applied. +- ``showmigrations``: Show current database migrations status. Good for debugging. +- ``runserver``: Run development server +- ``clearsessions``: Clear away expired user sessions. Recommended to run regularly, e.g. weekly or monthly (doesn’t + need to be frequent, but otherwise, the database is going to bloat eventually) +- ``reencrypt_fields``: Run after you changed your ``SERVALA_SECRET_KEY`` or ``SERVALA_SALT_KEY`` in order to use the + new keys, and be able to retire the previous ones. diff --git a/src/servala/core/management/__init__.py b/src/servala/core/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/servala/core/management/commands/__init__.py b/src/servala/core/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/servala/core/management/commands/reencrypt_fields.py b/src/servala/core/management/commands/reencrypt_fields.py new file mode 100644 index 0000000..787e18b --- /dev/null +++ b/src/servala/core/management/commands/reencrypt_fields.py @@ -0,0 +1,21 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from servala.core.models.service import ControlPlane + + +class Command(BaseCommand): + help = "Re-encrypts all encrypted fields. Use when rotating SECRET_KEY/SALT" + + def handle(self, *args, **options): + self.stdout.write("Starting re-encryption of ControlPlane objects...") + + count = 0 + with transaction.atomic(): + for control_plane in ControlPlane.objects.all(): + control_plane.save() + count += 1 + + self.stdout.write( + self.style.SUCCESS(f"Re-encrypted {count} ControlPlane objects") + ) -- 2.47.2 From 81396297f916f9954c237e9a7ffb7b0cc0705e11 Mon Sep 17 00:00:00 2001 From: Tobias Kunze Date: Fri, 21 Mar 2025 15:58:59 +0100 Subject: [PATCH 05/14] Make sure login redirects (?next=) work --- src/servala/frontend/templates/account/login.html | 3 ++- src/servala/frontend/templates/includes/form.html | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/servala/frontend/templates/account/login.html b/src/servala/frontend/templates/account/login.html index 5a31aea..6124275 100644 --- a/src/servala/frontend/templates/account/login.html +++ b/src/servala/frontend/templates/account/login.html @@ -14,6 +14,7 @@ {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
{% csrf_token %} + {{ redirect_field }}