Security
Authentication
Section titled “Authentication”TruePPM uses JWT (JSON Web Tokens) via djangorestframework-simplejwt:
- Access token — short-lived (15 minutes by default), held in browser memory
only and sent on every API request as
Authorization: Bearer <token>. The short lifetime bounds the blast radius of a leaked access token. - Refresh token — longer-lived (7 days by default), exchanged for new access
tokens via
POST /api/v1/auth/token/refresh/. It rides in an httpOnly, Secure, SameSite=Strict cookie — never inlocalStorageand never readable by JavaScript, so an XSS bug cannot exfiltrate it. The refresh endpoint reads the token from the cookie. - Rotation and revocation — refresh tokens rotate on every use
(
ROTATE_REFRESH_TOKENS). Thetoken_blacklistapp ships inINSTALLED_APPSby default, so revocation-on-rotation (BLACKLIST_AFTER_ROTATION) is active: once a refresh token is rotated, the previous token is rejected on replay rather than living out its 7-day TTL. Logging out (POST /api/v1/auth/logout/) likewise blacklists the presented refresh token. A lean deployment that removes thetoken_blacklistapp degrades gracefully to TTL-only expiry — the refresh/logout endpoints tolerate its absence.
Token lifetimes and the cookie attributes are configurable — see
Configuration for AUTH_REFRESH_COOKIE_*.
Blacklist tables and cleanup
Section titled “Blacklist tables and cleanup”With the token_blacklist app installed, every issued refresh token is recorded
in an OutstandingToken row, and rotated/revoked tokens add a BlacklistedToken
row. To stop these tables growing unbounded, a Celery Beat job
(access.flush_expired_blacklisted_tokens, nightly at 04:30 UTC) deletes rows
whose tokens have already expired — bounding the tables to roughly the active
refresh-token window. The job requires a running Celery Beat scheduler (the
same one that drives retention and outbox-drain jobs); deployments that run the
API without Beat should schedule the flushexpiredtokens management command
out-of-band instead. See Management commands.
WebSocket connections authenticate via ?token=<jwt> on the connection URL.
Split-origin deploys
Section titled “Split-origin deploys”SameSite=Strict means the browser will not send the refresh cookie on a
cross-origin request. If you serve the web app from a different origin than the
API, relax AUTH_REFRESH_COOKIE_SAMESITE (to Lax or None) and add the API
origin to CSP_CONNECT_SRC so the browser can reach it. See
Split-origin deploys.
Content-Security-Policy
Section titled “Content-Security-Policy”The API sends a strict Content-Security-Policy header on every response,
including frame-ancestors 'none' (the app cannot be framed — clickjacking
protection) and default-src 'self'. The connect-src directive defaults to
'self' wss: so the SPA can open same-origin XHR and the WebSocket
collaboration channel.
Operators serving the SPA from a different origin than the API, or behind a
proxy that rewrites origins, must add that origin (and its wss:// origin) to
CSP_CONNECT_SRC — otherwise the browser blocks the connection. See
Configuration.
TruePPM does not terminate TLS itself. In production, place a reverse proxy in front of the API and web services:
- nginx — configure with
proxy_passto the API container - Caddy — automatic TLS with Let’s Encrypt
- Cloud load balancer — AWS ALB, GCP HTTPS LB, etc.
Ensure WebSocket upgrade headers are forwarded correctly.
Database security
Section titled “Database security”- PostgreSQL should not be exposed to the public internet
- Use network policies or firewall rules to restrict access to the API and Celery containers
- Use a strong, unique password for the
trueppmdatabase user - Enable PostgreSQL SSL in production
Encrypt the database connection. TruePPM does not force TLS on the database
link — the sslmode is whatever your DATABASE_URL specifies. For any
deployment where the API and PostgreSQL are not on the same trusted host, append
?sslmode=require (or stricter — verify-ca / verify-full with a CA bundle)
to DATABASE_URL. When the API boots in a production configuration
(DEBUG=False) with a DATABASE_URL that has no sslmode, it logs an advisory
warning so the gap is visible in your logs rather than silent.
The Helm chart generates a strong random password for the bundled PostgreSQL on first install rather than shipping a default credential — see Helm secure-by-default below.
Outbound requests (SSRF boundary)
Section titled “Outbound requests (SSRF boundary)”Features that fetch a user- or admin-supplied URL — currently the outbound webhook delivery path — run behind an egress guard that rejects requests resolving to private, loopback, link-local, or otherwise non-public address ranges, blocking the common server-side request forgery (SSRF) vectors.
The OSS guard validates the resolved address at request time. It does not pin that address for the life of the connection, so a name that resolves to a public IP at check time and a private IP a moment later (DNS-rebinding) is a residual, admin-gated risk accepted by design (ADR-0049 §6). Mitigate it at the network layer: run the API’s outbound traffic through an egress proxy or NetworkPolicy that denies the internal ranges you care about, rather than relying on the application guard alone. Connection-time IP pinning is an Enterprise hardening.
Cache (Valkey/Redis) security
Section titled “Cache (Valkey/Redis) security”- The cache requires authentication by default in the Helm chart
(
valkey.auth.enabled: true); a generated password is injected viarequirepass. When running your own Valkey/Redis, enablerequirepassor keep it on a private network. - The cache is used as a broker and Channels layer; it does not store persistent data
- If the cache is compromised, an attacker could inject WebSocket events or manipulate the Celery task queue
Secret management
Section titled “Secret management”| Secret | Where it’s used | Impact if leaked |
|---|---|---|
SECRET_KEY | Django session signing, JWT signing | Full account takeover — attacker can forge any session or token |
DATABASE_URL | PostgreSQL connection | Full data access |
REDIS_URL | Celery broker, Channels layer | Task injection, event spoofing |
Helm secure-by-default
Section titled “Helm secure-by-default”The Helm chart installs securely with no extra flags. The full reference lives in the chart README; the operator-facing highlights:
- Generated datastore credentials. Leave
postgresql.auth.passwordandvalkey.auth.passwordempty and the chart mints a strong random password on first install, persisting it in a chart-owned connection Secret (<release>-trueppm-connection) annotatedhelm.sh/resource-policy: keep. Re-renders read the existing password back, sohelm upgradenever churns the credential or orphans the database PVC. The kept Secret also surviveshelm uninstall. Retrieve the DB password with:Terminal window kubectl get secret <release>-trueppm-connection \-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d - No plaintext credentials in any manifest.
DATABASE_URLandREDIS_URLare built server-side and injected viasecretKeyRefagainst the connection Secret — they are never rendered into a Deployment. The bundled datastores read their password from the same Secret, so the server credential and the URL can never drift apart. - Cache auth on by default (
valkey.auth.enabled: true). - Hardened containers. API and worker run with
runAsNonRoot,allowPrivilegeEscalation: false,readOnlyRootFilesystem: true, all capabilities dropped, and theRuntimeDefaultseccomp profile, with writableemptyDirmounts only where required (/tmp,/app/staticfiles,/run/trueppm). Tune viapodSecurityContext/containerSecurityContext. automountServiceAccountToken: falseon the API and worker pods.- Opt-in NetworkPolicy (
networkPolicy.enabled: true) restricting ingress to the bundled PostgreSQL (5432) and Valkey (6379) to only the API and worker pods. Off by default because it requires a CNI that enforces NetworkPolicy — a silently-unenforced policy is worse than an explicit opt-in.
External (managed) datastores
Section titled “External (managed) datastores”When you disable the bundled subcharts (postgresql.enabled: false /
valkey.enabled: false) to use managed services, the chart can no longer build
the connection strings for you, so env.DATABASE_URL and env.REDIS_URL become
required — the render fails with a clear message if either is missing.
Inject them via an external Secret rather than --set so they don’t land in
shell history. See Deployment and the chart
README.
RBAC enforcement
Section titled “RBAC enforcement”All API endpoints enforce role-based access control. See the RBAC documentation for the full permission matrix.
Key security properties:
- No global admin role — permissions are scoped to individual projects
- Role escalation prevention — you can only assign roles below your own
- IDOR prevention — querysets are scoped to the user’s project memberships; non-members see empty results, not 403 errors
- Last-Owner guard — prevents accidental removal of all project owners
Reporting vulnerabilities
Section titled “Reporting vulnerabilities”If you discover a security vulnerability in TruePPM, please report it privately — do not open a public issue. Report through either channel:
- Email — security@trueppm.com (preferred).
- Confidential GitLab issue — open an issue in the GitLab repository and tick “This issue is confidential” so it is visible only to project members.
Our full policy — supported versions, response SLAs (2 business days to
acknowledge), coordinated-disclosure process, and safe-harbor terms for
good-faith research — lives in
SECURITY.md at
the repository root. We publish a GitLab Security Advisory and credit reporters
(unless anonymity is requested) when a fix ships.