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
| Model | Field | Why |
|---|---|---|
User | phone_number | PII |
SAMLConfiguration | x509_cert | IdP secret |
OAuthConfiguration | client_secret | IdP secret |
LinkedInAccount | access_token, refresh_token | Third-party tokens |
BackupDestination | secret_access_key | Cloud secret |
OrgPaymentProcessor | (provider-specific creds) | PCI |
PlatformEmailConfig | SMTP password, SendGrid API key, AWS keys, Google creds | Mail relay |
AIConfig | chat_api_key, embedding_api_key | LLM 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-fullin production;preferin 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