The application uses a custom User model with a UUID primary key (id UUID). UUID PKs prevent enumeration attacks — an attacker who obtains a single user ID cannot guess adjacent IDs or infer the total user count.
Email address is the login identifier. Usernames are not used.
All passwords are hashed with Argon2id via Django's django-argon2 backend:
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher", # fallback for legacy imports
]Argon2id is the winner of the Password Hashing Competition and is resistant to GPU and side-channel attacks. The second entry in PASSWORD_HASHERS is kept only to allow migration of any legacy hashes and is never used for new passwords.
django-axes is configured to lock an account after 10 failed login attempts within a rolling window:
AXES_FAILURE_LIMIT = 10
AXES_COOLOFF_TIME = timedelta(hours=1)
AXES_LOCKOUT_PARAMETERS = ["ip_address", "username"]Lockout is recorded in axes_accessattempt and also written to the audit log. Administrators can unlock accounts via Django admin.
Sessions use Django's database-backed session engine. The session cookie is configured to maximise security:
SESSION_COOKIE_HTTPONLY = True # Not accessible to JavaScript
SESSION_COOKIE_SAMESITE = "Strict" # Never sent on cross-site requests
SESSION_COOKIE_SECURE = True # HTTPS only (enforced in production)
SESSION_COOKIE_AGE = 8 * 60 * 60 # 8-hour maximum session lifetime
SESSION_EXPIRE_AT_BROWSER_CLOSE = TrueSessions are stored in PostgreSQL. Invalidating a session (logout, admin kick, password change) is a single DELETE on the session row — it takes effect immediately for all tabs and devices.
The User model includes a password_changed_at timestamp. A future middleware will compare this timestamp against the session creation time — if password_changed_at > session created_at, the session is invalidated and the user is redirected to login.
Three roles are implemented as Django Groups. Users are assigned to groups via Django admin or data migration.
| Role | Description |
|---|---|
| Admin | Full access. Can manage users, view audit log, manage reminder schedule. |
| Manager | Can create and manage their own contracts. Can view all contracts. Cannot delete or manage users. |
| Viewer | Read-only access to their own contracts only. |
| Permission | Admin | Manager | Viewer |
|---|---|---|---|
| View all contracts | YES | YES | NO |
| View own contracts | YES | YES | YES |
| Create contract | YES | YES | NO |
| Edit own contract | YES | YES | NO |
| Edit any contract | YES | NO | NO |
| Delete / terminate contract | YES | NO | NO |
| Manage users | YES | NO | NO |
| View audit log | YES | NO | NO |
| Manage reminder schedule | YES | NO | NO |
| Update own profile | YES | YES | YES |
"Own contract" means a contract where contract.owner == request.user.
RBAC is enforced at two layers (defense in depth):
- View layer:
UserPassesTestMixin.test_func()checks permission before the view body executes (returns 403 if unauthorized). - Service layer:
assert_can_view(),assert_can_edit(),assert_can_delete()incontracts/services.pyraisePermissionDeniedregardless of how the view was reached.
Permission checks in templates are for UI rendering only (showing/hiding buttons) and must not be relied upon for security.
Django's CsrfViewMiddleware is enabled and cannot be bypassed. All state-changing operations (POST, PUT, DELETE) require a valid CSRF token. The token is delivered via a cookie and must be echoed in a form field or X-CSRFToken header.
Two layers of XSS protection:
- Django template auto-escaping: All template variables are HTML-escaped by default. Developers must explicitly use
{{ value|safe }}to render raw HTML, which is disallowed in code review unless accompanied by a sanitisation step. - bleach sanitisation: Any user-supplied content that may contain markup (e.g. contract notes) is sanitised with
bleach.clean()in the service layer before persistence, using an explicit allowlist of safe tags.
The Django ORM is used for all database access. Raw SQL (cursor.execute, Model.objects.raw) is prohibited except where absolutely necessary and only with parameterised queries. This is enforced via code review and linting.
All secrets (database credentials, email credentials, secret key, Redis URL) are loaded from environment variables using django-environ:
env = environ.Env()
SECRET_KEY = env("DJANGO_SECRET_KEY")
DATABASES = {"default": env.db("DATABASE_URL")}No secrets are committed to the repository. .env files are listed in .gitignore. In production, secrets are injected via the container environment or a secrets manager.
The following headers are set on all responses:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Strict-Transport-Security |
max-age=31536000; includeSubDomains (production) |
Referrer-Policy |
same-origin |
Content-Security-Policy |
default-src 'self' (tightened in Phase 4) |
django-csp manages the Content Security Policy header. The policy is intentionally restrictive: no inline scripts, no external CDN resources.
All user input is validated at two levels:
- Django form validation: Field types, max lengths, required fields, and custom validators run before the service layer is called.
- Model
clean()methods: Invariants such asend_date > start_dateare enforced at the model level so they cannot be bypassed by direct model saves in tests or management commands.
| Threat | Mitigation |
|---|---|
| Brute-force login | django-axes lockout after 10 failures |
| Session hijacking | HttpOnly, SameSite=Strict, Secure cookies; short 8hr TTL |
| CSRF | CsrfViewMiddleware on all POST endpoints |
| XSS via stored content | Template auto-escape + bleach sanitisation |
| SQL injection | ORM-only access policy |
| Privilege escalation | RBAC enforced in service layer, not just templates |
| Secrets in source code | django-environ, .env excluded from git |
| Stale sessions after password change | password_changed_at field on User model (middleware TBD) |
| Enumeration via sequential IDs | UUID primary keys on all tables |
| Unrevocable tokens | Session auth (no JWTs) — instant revocation via DB delete |