This document describes the security architecture, threat model, and deliberate design decisions for potterdoc.com.
PotterDoc uses Google OAuth 2.0 as the sole authentication mechanism. No passwords are stored.
On first sign-in, the backend receives a Google ID token and extracts the OpenID Connect sub claim — a stable, Google-assigned subject identifier. This value is immediately hashed with SHA-256 before any storage or use:
hashed_sub = sha256(google_sub)
The raw sub is never written to disk. Django's username field and UserProfile.openid_subject both store only hashed_sub. This means:
- The database contains no PII that could be linked back to a Google account without already knowing the
subvalue. - Email addresses, names, and profile photos from Google are not requested and not stored.
- A database dump is not sufficient to enumerate or identify users — an attacker also needs the original Google
subvalues.
Access is gated by invite code. New users cannot register without a valid, unused invite.
| Category | What we store | Notes |
|---|---|---|
| User identity | SHA-256 hash of Google sub |
Not reversible without the original sub |
| User content | Pottery piece records, workflow states, images | Owned by the authenticated user; isolated by FK |
| Public library | Shared pieces with user=NULL |
Admin-managed only; not user-writable |
| Credentials | Django SECRET_KEY, POSTGRES_PASSWORD, Cloudinary keys, email relay key, Dropbox tokens |
Source of truth: Infisical Cloud; synced into k8s Secrets via External Secrets Operator |
| Backups | Plain pg_dump output | See Backup section |
No Social Security numbers, payment card numbers, physical addresses, or government IDs are collected or processed.
- All external traffic is served over HTTPS. TLS certificates are issued by Let's Encrypt via cert-manager on the k3s cluster.
- HTTP Strict Transport Security (HSTS) is enabled with a one-year
max-age,includeSubDomains, andpreload. SECURE_SSL_REDIRECT = Truein production — all HTTP requests are redirected to HTTPS (health checks at/api/health/are exempt to allow k8s probes).- Session and CSRF cookies are
Secureand scoped to the parent domain to support the shared auth session across subdomains. - CORS is restricted to the configured
APP_ORIGIN.
The k3s cluster node (DigitalOcean droplet) is enrolled in a Tailscale tailnet. Operator access (SSH, kubectl, Helm deployments) flows exclusively through the tailnet:
- No public SSH port exposure — the droplet's SSH daemon is accessible only via
tailscale ssh. - Tailscale's WireGuard-based mesh provides mutual authentication between operator devices and the server at the network layer.
- The tailnet auth key is rotated on re-enrollment via
tools/ensure_k3s_tailscale.sh.
This boundary means that even if the application's public endpoints were compromised, the control plane (SSH, cluster API) remains unreachable from the internet.
Source of truth: Infisical Cloud (glaze-production project, prod environment).
The secrets lifecycle is:
- Secrets are authored and rotated in Infisical Cloud's web UI or CLI.
- A read-only machine identity (
glaze-eso) grants the External Secrets Operator (ESO) access to Infisical via Universal Auth. - ESO syncs secrets into the
glaze-secretsKubernetes Secret on a 1-hour refresh cycle (infra/k3s/secretstore.yaml,chart/glaze/templates/externalsecret.yaml). - Pods consume the k8s Secret as environment variables at runtime.
The k8s Secret is a derived operational artifact, not the source of truth. Deleting or corrupting it does not lose data — ESO recreates it from Infisical on the next sync.
Secrets are not baked into container images or committed to source control. The only credential that bootstraps this chain is the Infisical client secret stored in the GitHub Actions glaze-droplet environment, used during CD deploys.
The threat model for secret storage:
- Cluster compromise: An attacker with
kubectlaccess can read the k8s Secret, but not the Infisical vault itself. Access to Infisical requires the ESO machine identity credential, which is not exposed inside the cluster. - Infisical Cloud compromise: Infisical is a third-party SaaS. Their security posture is documented in their Cure53 penetration test report (request via
security@infisical.com). The credential scope is read-only for the ESO identity. - Image compromise: Secrets are not in images, so a leaked image does not expose credentials.
- Source control compromise: No secrets in git;
.env.production.exampledocuments required variable names without values.
Database backups are taken via a k8s CronJob that runs pg_dump and uploads the result to Dropbox over TLS.
Backups are not encrypted at rest. This is a deliberate choice:
- The backup destination (Dropbox) provides its own at-rest encryption at the storage layer.
- Avoiding application-level encryption eliminates key management complexity (no key escrow, no risk of losing the decryption key and making backups unrecoverable).
- The threat being defended against is accidental data loss, not targeted exfiltration of backup files. An attacker who compromises the Dropbox account could read the backup — this is accepted risk given the low sensitivity of the data (pseudonymized user content with no PII).
- If the data sensitivity profile changes (e.g., PII is introduced), this decision should be revisited and backups should be encrypted with an escrowed key (e.g., age + Tailscale KMS or a cloud KMS).
Pottery piece images are stored in Cloudinary. All uploads are signed and scoped to the authenticated user's account. The public upload preset (unsigned, separate folder) is restricted to Django admin accounts only — regular users cannot trigger unsigned uploads. Cloudinary provides its own access controls and CDN-level delivery.
| Threat | Mitigated by | Residual risk |
|---|---|---|
| Credential theft from source control | No secrets in git | Low |
| Database dump exposing user identity | SHA-256 pseudonymization of sub |
Attacker needs Google sub values too |
| Unauthorized account creation | Invite-code gate | Invite codes can be shared |
| Cluster takeover via SSH | Tailnet-only SSH access | Tailscale account compromise |
| Backup exfiltration | Dropbox account-level access controls | Dropbox account compromise |
| TLS stripping | HSTS preload, SECURE_SSL_REDIRECT | Preload list propagation delay on new domains |
| Session hijacking | Secure + HttpOnly cookies, CSRF protection | Standard Django session risk |
| SSRF / injection | No user-controlled outbound requests; Django ORM | Standard Django mitigations |
PotterDoc does not currently:
- Process payments
- Store health, financial, or legal records
- Operate under HIPAA, PCI-DSS, or SOC 2 frameworks
- Offer a supported public API — an OpenAPI-compatible API exists and is documented, but it carries no SLA, is tailored for the web UI, and requires CSRF token handling that makes programmatic consumption cumbersome. The API surface is nonetheless held to the same security mitigations described above.
These are not future commitments — they are statements about the current scope that inform the proportional security controls above.