Skip to main content

Django support for PostgreSQL security labels

Project description

Django Security Label

Django support for PostgreSQL security labels.

Overview

This package provides tools for applying and managing security labels in PostgreSQL databases on your Django models. This can allow you to dynamically mask specific columns to limit a user's ability to find sensitive data.

This was created with Jay Miller for the "Elephant in the Room" series. The inspiration for the project was from his blog post, "Using PostgreSQL Anonymizer to safely share data with LLMs"

Prerequisites

This package requires the PostgreSQL Anonymizer extension to be installed on your database.

Please follow the installation instructions for your environment. This will require changes to the server that runs your database, but several managed database services such as Aiven support it out of the box.

Installation

pip install django-security-label
  1. Add the app to your INSTALLED_APPS:
INSTALLED_APPS = [
    # ...
    "django_security_label",
]
  1. Enable the middleware for relevant environments:
MIDDLEWARE = [
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    # MaskedReadsMiddleware must be after AuthenticationMiddleware because
    # AuthenticationMiddleware writes to the database.
    "django_security_label.middleware.MaskedReadsMiddleware",
]

Note: The PostgreSQL role that is used for masked reads will not have the ability to insert, update or delete data on any table that has a masking security label applied to it.

  1. Run the migrations. This will install the anon provider and configure dynamic masking. It will also create the dsl_masked_reader role with the same permissions as your database user.
python manage.py migrate django_security_label

You can view the SQL with: python manage.py sqlmigrate django_security_label 0001

  1. Define your security labels on your models:
class MyModel(models.Model):
    text = models.TextField()
    confidential = models.TextField()
    random_int = models.IntegerField()

    class Meta:
        # Defining any security labels will prevent any changes to the table
        # when masking is enabled.
        indexes = [
            labels.MaskColumn(
                fields=["text"], mask_function=labels.MaskFunction.dummy_catchphrase
            ),
            labels.AnonymizeColumn(
                fields=["confidential"],
                string_literal="MASKED WITH VALUE $$CONFIDENTIAL$$",
            ),
            labels.AnonymizeColumn(
                fields=["random_int"],
                string_literal="MASKED WITH FUNCTION anon.random_int_between(0,50)",
            ),
        ]
  1. Create migrations and add a dependency on ("django_security_label", "0001)
python manage.py makemigrations

The dependency on ("django_security_label", "0001_initial") will ensure that your security labels will be applied after the anon provider is installed.

Example migration file:

from django.db import migrations, models
import django_security_label.labels


class Migration(migrations.Migration):
    initial = True

    dependencies = [
        ("django_security_label", "0001_initial"),
    ]

    operations = [
        migrations.CreateModel(
            name="MyModel",
            fields=[
                (
                    "id",
                    models.BigAutoField(
                        auto_created=True,
                        primary_key=True,
                        serialize=False,
                        verbose_name="ID",
                    ),
                ),
                ("text", models.TextField()),
                ("confidential", models.TextField()),
                ("random_int", models.IntegerField()),
            ],
            options={
                "indexes": [
                    django_security_label.labels.MaskColumn(
                        fields=["text"],
                        mask_function=django_security_label.labels.MaskFunction[
                            "dummy_catchphrase"
                        ],
                        name="mymodel_text_6adba0_idx",
                        provider="anon",
                        string_literal="MASKED WITH FUNCTION anon.dummy_catchphrase()",
                    ),
                    django_security_label.labels.AnonymizeColumn(
                        fields=["confidential"],
                        name="mymodel_confide_030817_idx",
                        provider="anon",
                        string_literal="MASKED WITH VALUE $$CONFIDENTIAL$$",
                    ),
                    django_security_label.labels.AnonymizeColumn(
                        fields=["random_int"],
                        name="mymodel_random__45b12e_idx",
                        provider="anon",
                        string_literal="MASKED WITH FUNCTION anon.random_int_between(0,50)",
                    ),
                ],
            },
        ),
    ]

Documentation

Examples

Below are examples of the Django admin using the masked reading with a superuser and a typical staff user.

Superuser / unmasked Read

Unmasked Read

Staff user / masked Read

Masked Read

Using the example app

  1. Run migrations:
uv run python -m example.manage migrate
  1. Create the PostgreSQL roles and Django groups defined in SECURITY_LABEL_GROUPS_TO_POLICIES:
uv run python example/manage.py setup_policies
  1. Set up staff users and sample data:
uv run python example/manage.py setup_data

This will create a staff user for each group with permissions to manage all core models. You will be prompted to set a password for each user. It also ensures there are at least 3 MaskedColumn rows and prints a table of the raw data.

  1. Run the development server:
uv run python example/manage.py runserver
  1. Log in as each staff user (e.g. analysts, developers) and view the MaskedColumn list. Compare the values shown in the admin to the raw data printed by setup_data to see how each role's masking rules affect the data.

Controlling masked reads

The default configuration of this package uses dynamic masking. This is because static masking will irrevocably destroy your database's data. While this is a valuable tool for some staging environments, it's not the goal at the moment.

The challenge is to use a different database role when we need to have masked reads. This is why we create the dsl_masked_reader role. We can switch to this role with SET SESSION ROLE dsl_masked_reader; and ROLE RESET; to enable and disable masked reads respectively.

The middleware, MaskedReadsMiddleware controls when the role is switched. It does so crudely at this point by only allowing user.is_superuser users to use the default database role. All other queries will use the dsl_masked_reader role and be subject to the security labels that were defined on the columns.

Customizing when masking is used

Please copy and update the code in MaskedReadsMiddleware to suit your needs. The majority of the complexity will be in your definition of use_masked_reads.

For example, if you only wanted to force anonymous users to have masked reads:

from __future__ import annotations

from django.db import connection, InternalError
from django.http import HttpRequest
from django_security_label.middleware import enable_masked_reads, disable_masked_reads


def use_masked_reads(request: HttpRequest) -> bool:
    user = getattr(request, "user", None)
    return user is None or not user.is_authenticated


class AnonymousOnlyMaskedReadsMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        if enable_masking := use_masked_reads(request):
            enable_masked_reads()

        try:
            response = self.get_response(request)
        except InternalError:
            disable_masked_reads()
            raise

        if enable_masking:
            disable_masked_reads()

        return response

Masking functions

The PostgreSQL Anonymizer provider includes dozens of functions.

You can use a predefined function such as fake_email the following:

from django_security_label import labels

labels.MaskColumn(fields=["email"], mask_function=labels.MaskFunction.fake_email)

You can also define the string literal portion of the SECURITY LABEL with the following:

labels.AnonymizeColumn(
    fields=["confidential"],
    provider="anon",
    string_literal="MASKED WITH VALUE $$CONFIDENTIAL$$",
),

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_security_label-0.2.0.tar.gz (22.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_security_label-0.2.0-py3-none-any.whl (16.7 kB view details)

Uploaded Python 3

File details

Details for the file django_security_label-0.2.0.tar.gz.

File metadata

  • Download URL: django_security_label-0.2.0.tar.gz
  • Upload date:
  • Size: 22.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for django_security_label-0.2.0.tar.gz
Algorithm Hash digest
SHA256 009f22c3d2ebb0edf38a4ff0f36ba1f555e4198d82dfe4230d8fe05eb33756f6
MD5 2b43a5a68626dbf035d2da679176f4c5
BLAKE2b-256 34705c971aea5ac319206d43552737d3b603fa09b45a32c85b74af244f66aa99

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_security_label-0.2.0.tar.gz:

Publisher: release.yml on tim-schilling/django-security-label

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_security_label-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_security_label-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 de8f3c8de2e0e16b9179cc95684324e7fa51e347bbe36a586fae91d70e839cba
MD5 0b881d4558eb17ef74567105d704f35b
BLAKE2b-256 c519d615997bb8f4a0dbb0155806e267ffd8e8aabe8e75ff044b8e0aae0c4262

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_security_label-0.2.0-py3-none-any.whl:

Publisher: release.yml on tim-schilling/django-security-label

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page