Skip to main content

Audit & encryption

Two adjacent concerns that protect customer data: audit logging for accountability, and encryption at rest for sensitive fields.

Audit logging

What gets logged

AuditMiddleware (backend/apps/common/middleware.py:245-380) intercepts requests matching sensitive URL patterns and writes an AuditLog row.

Logged on every method (GET/POST/PUT/PATCH/DELETE):

  • /api/finances/*
  • /api/organizations/*/admins/
  • /api/organizations/*/storage-config/
  • /api/ai-services/config/
  • /api/members/*/profile/
  • /api/auth/sso/*
  • /api/platform/*

Logged on writes only (POST/PUT/PATCH/DELETE):

  • /api/organizations/*/members/
  • /api/chapters/*/members/
  • /api/memberships/
  • /api/elections/*
  • /api/compliance/*
  • /api/organizations/*/settings/
  • /api/organizations/*/dues/

Schema

backend/apps/common/models.py:17-51 (AuditLog)
class AuditLog(models.Model):
user = models.ForeignKey(User, ...)
action = models.CharField(choices=["CREATE", "READ", "UPDATE", "DELETE"])
resource_type = models.CharField() # "member", "finance", "election", ...
resource_id = models.UUIDField()
org_id = models.UUIDField() # for tenant scoping
ip_address = models.GenericIPAddressField()
metadata = models.JSONField() # method, path, status_code, query_params, request_body
timestamp = models.DateTimeField(auto_now_add=True)

class Meta:
indexes = [
models.Index(fields=["org_id", "timestamp"]),
models.Index(fields=["user", "timestamp"]),
models.Index(fields=["resource_type", "resource_id"]),
]

Append-only

AuditLog rows are never updated or deleted via the application. The database role used by the app does not have UPDATE/DELETE on this table (enforced via Postgres GRANT in the production setup).

To investigate or modify, use a separate read-only role or admin tooling.

S3 archival

backend/apps/common/tasks.py:48-165 (archive_audit_logs)

Daily Celery task at 03:30 UTC:

Per-org retention

OrgAuditRetentionConfig (backend/apps/common/models.py:54-84):

class OrgAuditRetentionConfig(models.Model):
organization = models.OneToOneField(Organization, ...)
retention_days = models.IntegerField(default=180, validators=[Min(1), Max(180)])

Default 180 days. Customers on regulated industries (e.g., FERPA-adjacent) can request the cap increase via the platform admin.

After archival to S3:

  • Logs are kept in S3 for the lifecycle policy of that bucket (default 7 years for compliance / discovery)
  • DB stays lean (queries against the live DB stay fast)
  • Restore-from-S3 is a manual ops task

Disabling audit logs

settings.AUDIT_LOGGING_ENABLED defaults to True. Disabled in tests to avoid 900 INSERT queries per E2E run. Never disable in production.

Encryption at rest

Library

cryptography 46 — Fernet for symmetric encryption, MultiFernet for key rotation.

EncryptedTextField

backend/apps/authentication/fields.py:6-19
class EncryptedTextField(models.TextField):
"""Auto-encrypts on save, auto-decrypts on retrieval."""

def from_db_value(self, value, expression, connection):
if value is None:
return None
return decrypt(value)

def to_python(self, value):
return value # already decrypted

def get_prep_value(self, value):
if value is None:
return None
return encrypt(value)

What's encrypted

ModelFieldWhy
Userphone_numberPII
SAMLConfigurationx509_certIdP secret
OAuthConfigurationclient_secretIdP secret
LinkedInAccountaccess_token, refresh_tokenThird-party tokens
BackupDestinationsecret_access_keyCloud secret
OrgPaymentProcessor(provider-specific creds)PCI
PlatformEmailConfigSMTP password, SendGrid API key, AWS keys, Google credsMail relay
AIConfigchat_api_key, embedding_api_keyLLM provider keys

Key management

backend/apps/authentication/encryption.py
  • Active key: settings.ENCRYPTION_KEY (Base64-encoded Fernet key, required at boot)
  • Key rotation: MultiFernet([new_key, old_key]) decrypts with either, encrypts with the first (new)
  • Helper: rotate_value(encrypted_value, old_key, new_key) re-encrypts a single value with the new key

Rotation procedure (manual today; roadmap to automate)

# 1. Generate new key
NEW_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")

# 2. Set both keys in env (new first, old second)
ENCRYPTION_KEY="${NEW_KEY},${OLD_KEY}"

# 3. Roll out: app boots with MultiFernet — decrypts with either, encrypts with new
docker compose restart backend celery

# 4. Run management command to re-encrypt all encrypted columns
python manage.py rotate_encrypted_fields

# 5. After confirming all values use the new key, drop the old key from env
ENCRYPTION_KEY="${NEW_KEY}"

The rotate_encrypted_fields management command iterates known encrypted columns, reads + re-saves each row.

What's NOT encrypted at the column level

  • Forum post bodies, message bodies, document text — large-volume content where column-level encryption is impractical
  • Profile photo URLs (URLs themselves, not the photos)
  • Most user-visible fields

These rely on:

  • TLS in transit
  • Disk-level encryption at the storage provider (AWS / Azure / GCP / on-prem RAID)
  • Tenant isolation via RLS

For customers requiring column-level encryption of additional fields, that's a custom engagement.

Encryption in transit

  • All HTTP: TLS 1.2+ via cert-manager + Let's Encrypt (production); self-signed in dev
  • Database connections: Postgres sslmode=verify-full in production; prefer in dev
  • Redis: TLS in production (typically via in-cluster networking, but TLS available)
  • AI provider calls: HTTPS to provider endpoints

Key environment variables

ENCRYPTION_KEY # Required — Base64 Fernet key (or comma-separated for rotation)
DJANGO_SECRET_KEY # Required — used by Django for cookie signing, CSRF tokens

Both must be set at boot or the backend refuses to start.

Compliance posture

  • PCI DSS: Payment data flows directly to processors (Stripe, etc.) — GreekManage stores tokens, not card numbers. SAQ-A applies.
  • GDPR / CCPA: Data export + account deletion + consent management built-in (apps/accounts/)
  • SOC 2: Controls in place; formal attestation roadmap
  • FERPA-adjacent: Custom field permissions + audit log retention configurable

Privacy & GDPR