The system assumes that any single layer can fail: a template can forget to escape, a view can forget a permission, a query can forget a filter. Each layer below is independent and covers the one above it.
ENVmust bedev,testorprod;DEBUGcan only be true indev.SECURE(default true in prod) turns on HTTPS redirect, HSTS (1 year, subdomains, preload),__Host-session cookie,X-Forwarded-Prototrust and whitenoise manifest storage.- Sessions in the database, 8-hour lifetime, renewed every request, expire on browser close,
HttpOnly,SameSite=Lax. CSRF tokens live in the session (CSRF_USE_SESSIONS). X-Frame-Options: DENY,Referrer-Policy: same-origin,COOP: same-origin,X-Content-Type-Options: nosniff.- Uploads capped at 5 MB in memory only (
MemoryFileUploadHandler), 2000 form fields. core/checks.pyadds deploy checks: DEBUG in prod, SECURE off in prod, SECRET_KEY under 50 characters.manage.py check --deploy --fail-level WARNINGruns in CI and at every web container start.
| Role | Used by | Rights |
|---|---|---|
tt_owner |
migrations only | owns the schema, BYPASSRLS |
tt_app (login web) |
gunicorn | SELECT, INSERT, UPDATE, DELETE on tables, no DELETE on timetables, versions, resources, subjects; no UPDATE/DELETE on audit; 5 s statement and 2 s lock timeouts |
tt_worker (login worker) |
procrastinate worker | as tt_app, 120 s statement timeout |
tt_ops (login ops) |
operators | SELECT on everything across tenants, 600 s timeout |
Passwords are substituted into roles.sql at first database start by deploy/init-db.sh
from OWNER_DB_PASSWORD, WEB_DB_PASSWORD, WORKER_DB_PASSWORD, OPS_DB_PASSWORD.
Every tenant table has ENABLE and FORCE ROW LEVEL SECURITY with policy
tenant_id = app_tenant_id() for reads and writes and a read-only ops_read policy for
tt_ops. app_tenant_id() reads a transaction-local GUC that TenantMiddleware sets inside
tenant_scope. Outside a scope the GUC is empty, the function returns null and no row
matches. otp_totp_totpdevice is covered by a policy through core_user. Composite foreign
keys prevent cross-tenant references even for a role that bypasses RLS.
- Passwords hashed with Argon2 (
argon2-cffi), minimum 12 characters plus Django's similarity, common-password and numeric validators. django-axes: 5 failures per (username, IP) lock the pair for 15 minutes; reset on success. Behind the proxy, one trusted forwarder is configured.core.auth.TenantBackendonly authenticates users of the request's tenant.auth_versionon the user is part of the session hash, so bumping it (password reset, role change, offboarding) invalidates every session at once.- MFA: TOTP via
django-otp. Any user whose role holds a privileged permission (timetable.publish,user.manage,role.manage,tenant.settings) must enrol and verify beforePermissionedViewlets a request through (core.perms.mfa_redirect).
New users are created without a usable password. An invite token is generated with
secrets.token_urlsafe, only its SHA-256 (invite_token_hash) is stored, and the lookup is a
database-side equality on the hash, which is constant time with respect to the token. Invites
expire after INVITE_TTL (72 h) and are single use. The invite email contains the link; the
log and audit trail contain only the hash prefix.
Permissions are string codes (core.perms.Perm) held by roles; a role's permission array is
check-constrained to known codes. Every page and panel view subclasses PermissionedView with
an explicit permission; a view without one raises ImproperlyConfigured at request time, so
"forgot to protect" fails closed. Object-level access uses can(user, perm, obj, write):
department-scoped users may write only their department's objects and read tenant-wide ones.
Querysets expose visible_to(user) and owned_by(user), and objects outside the user's scope
return 404 rather than 403, so their existence is not disclosed. Templates gate buttons with
if 'perm.code' in perms, which is a convenience; the view check is the control.
core.audit.audit(actor, action, obj, before, after) writes an AuditEvent with the request
id. Fields named email, name, password and invite_token_hash are replaced by a 16-hex
SHA-256 prefix, so the audit log never contains personal data in clear. The table is
append-only (revoked UPDATE/DELETE, forbid_change trigger).
django-csp sends default-src 'self'; script-src 'self' 'nonce-...'; style-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'. htmx is vendored and loaded from static/, configured with selfRequestsOnly,
allowEval: false, allowScriptTags: false, no indicator styles and no history cache.
Templates may not contain <script, <style, style=, hx-on, js:, !=, !{ or
|safe (scripts/check_templates.py), so there is no path to inline code or raw HTML.
Imports accept .xlsx parsed by openpyxl with defusedxml active, read fully in memory and
capped at 5 MB by Django and 6 MB by Caddy. Rows are validated against the tenant's resources
before any write; a failed import writes nothing. Exports are generated server side and served
with Content-Disposition: attachment; cell values that start with =, +, - or @ are
prefixed to prevent formula injection.
Solves run in the worker as a subprocess with RLIMIT_AS = SOLVER_MEMORY_BYTES (2 GiB), a
wall-clock limit (SOLVER_MAX_SECONDS, 600 s) and SOLVER_MAX_MODEL_SIZE literals, with a
fixed worker count. A tenant may start SOLVES_PER_TENANT_PER_HOUR (10) solves. Versions
stuck in solving beyond STALE_SOLVE_GRACE are reset to failed. The worker container has a
3 GiB memory limit, a read-only root filesystem and no capabilities.
core/logging.py emits one JSON object per line with time, level, logger, message,
request_id and tenant_id, never request bodies, emails or names. Caddy access logs are
JSON with the query string removed from the URI and Cookie, Authorization and Set-Cookie
headers deleted. gunicorn access logs go to stdout. Nothing is written to the container
filesystem.
Caddy obtains wildcard certificates through the DNS-01 challenge (no port 80 validation,
no per-tenant certificate issuance), redirects HTTP to HTTPS, removes the Server header,
limits bodies to 6 MB and forwards X-Request-ID. Only Caddy publishes ports; PostgreSQL is
on an internal network with no external route.
The application image runs as uid 10001, has no shell for that user, is read-only at runtime
with a 64 MB /tmp tmpfs, drops all capabilities, sets no-new-privileges, and has memory
and pid limits. Static files are collected at build time.
- Every GitHub Action is pinned to a commit SHA (
actions/checkoutv6.1.0,actions/setup-pythonv6.1.0,gitleaks/gitleaks-actionv2.3.9 atff98106e4c7b2bc287b24eaf42907196329070c7). - CI runs
pip-audit -r requirements.txtand gitleaks over the full history. - Workflow permissions are
contents: read. - Dependencies are pinned to compatible ranges in
requirements.txt.
| Item | Why it matters | Plan |
|---|---|---|
| Hashed lock file | Ranges allow a compromised release to install. | uv pip compile --generate-hashes to requirements.lock; pip install --require-hashes -r requirements.lock in Dockerfile and CI. |
| SSO (SAML/OIDC) | Universities want institutional login and offboarding. | Add an OIDC backend behind TenantBackend, per-tenant issuer in Tenant.settings. |
| Backups with restore drills | A backup that was never restored is a hope. | Nightly pg_dump to object storage with 30-day retention; quarterly restore into a staging stack, documented in OPERATIONS.md. |
| Two-approver MFA reset | A single admin resetting another admin's MFA is the social-engineering path. | Reset requests recorded as audit events and require a second user.manage holder to confirm within 24 h. |
| procrastinate table privileges | The worker role currently holds full DML on every table through default privileges. | Restrict tt_worker to timetable_assignment, timetable_version and the procrastinate_* tables; revoke core_user writes. |
tt_ops usage |
Operators reading across tenants is powerful. | Route ops logins through a bastion with session recording; rotate the password per incident; log pg_stat_activity for the role. |
| Content-Security-Policy reporting | Silent CSP failures hide attempted injection. | Add report-to with a small endpoint and alerting. |
| Dependabot or Renovate | Range pins still need a human to bump. | Enable weekly PRs for requirements*.txt and the Actions SHAs. |
Security issues go to the operators' address configured in DEFAULT_FROM_EMAIL's domain
(security@<BASE_DOMAIN>). Do not open public issues for vulnerabilities.