Secrets and tokens
Status: implemented. The map of every credential in the integration stack — what holds it, what encrypts it, and who can read it.
Companion pages: HashiCorp Vault as the integration-secrets platform (the control plane that decides when a secret is asked for) and OAuth via the nono proxy (the data plane that spends the token). This page is narrower: where the bytes sit at rest.
1. What this changed
Until now two of these credentials were plaintext columns in Postgres:
GoogleCredential.refresh_token and HubspotCredential.refresh_token, plus both
access_token caches. A database dump, a backup, or pgweb access handed over every user's
Google and HubSpot grant — enough to read their CRM and send mail as them, without touching the
application at all.
The HashiCorp write that existed next to those columns was a mirror: put_user_grant ran
only under VAULT_OAUTH_BACKEND=hashicorp, was wrapped in a try/except that merely logged, and
nothing ever read it back. Every refresh used the Postgres column. A mirror nobody reads buys no
confidentiality — the weakest copy sets the security level.
Now there is one copy of each durable secret, behind one accessor, and both backend modes are real stores rather than one store and one mirror.
2. The map
The two store boxes are alternatives, not layers. Fernet does not encrypt anything inside
HashiCorp, and HashiCorp does not hold the Fernet key. chat/vault.py picks one per key and the
callers never know which.
3. How a secret gets in
Two properties worth naming:
- Grant first, row second. The row is only a "this user is connected" marker. Writing it before the secret is safely stored would let the Tools page show a working connection that cannot mint a token.
- The write is no longer best-effort. A failing store fails the connect. When it was a mirror, swallowing the error was harmless; now it would lose the only copy.
4. How a token gets out
An access token that will not decrypt (key rotated out, a row that predates the field) is a cache miss, not an error: the next line refreshes from the grant. A retired encryption key therefore costs one extra round trip to the vendor, never an outage.
5. The inventory
| Secret | Where it rests | Encrypted by | Who can read it |
|---|---|---|---|
OAuth app client_secret (Google, HubSpot, Salesforce) | HashiCorp KV integrations/<tool>/oauth_app, or VaultSecret.value | Vault at rest, or Fernet | Django; a superuser via the Tools page form |
Per-user refresh_token | HashiCorp KV integrations/<tool>/user/<id>, or VaultSecret key <prefix>user:<id> | Vault at rest, or Fernet | Django only — never the sandbox, never the model |
Per-user access_token (~30-60 min cache) | access_token column on the credential row | Fernet, in both modes | Django; nono receives a copy in its supervisor env |
| SAP password / client credentials | HashiCorp KV integrations/sap/user/<id>, or VaultSecret | Vault at rest, or Fernet | Django only |
| LLM provider keys | VaultSecret | Always Fernet, never HashiCorp | Django; nono's supervisor env |
hub_id, hub_domain, emails, expiry timestamps | credential row, plaintext | — | not secrets; they drive the status line |
The access token is deliberately not in Vault. It rotates every half hour, so pushing it through KV v2 would version a value that is only ever a cache. Fernet in its own column costs one key and no round trip.
6. Where the keys themselves are
This is the part the diagrams cannot show, and it is what actually bounds the protection.
| Fernet | HashiCorp | |
|---|---|---|
| Secret exists in the app database | yes, as ciphertext | no |
| Key held | in the backend's environment | in the Vault cluster |
| A DB dump alone is enough | no | no |
| Dump plus the environment is enough | yes | no |
| Access is audited (who read what, when) | no | yes |
| Key rotation | comma-separated VAULT_ENCRYPTION_KEY | Vault-native |
So Fernet's honest claim is "a database dump by itself reveals nothing", and HashiCorp's is "the secret is not in the database and every read is logged". Both are strictly better than a plaintext column, and they are not equivalent to each other.
In DEBUG=true with no key set, the Fernet key is derived from SECRET_KEY
(sha256(b'harnesi-vault|' + SECRET_KEY)) so ./dev.sh and CI need no setup. With
DJANGO_DEBUG=false and no key, writes raise VaultUnavailable rather than falling back — a
production deployment cannot accidentally store a secret in the clear.
Rotation. VAULT_ENCRYPTION_KEY now accepts several keys separated by commas, newest first.
MultiFernet encrypts with the first and decrypts with any of them, so retiring a compromised
key is: prepend a new one, restart, let values be re-saved, then drop the old one. Previously a
single key meant a compromised key could only be replaced by re-entering every secret by hand —
which is to say, never.
7. What a database dump reveals
8. Code shape
| File | Role |
|---|---|
chat/oauth_grants.py | new. The only accessor for a per-user refresh token: write_grant / read_refresh_token / delete_grant. Salesforce should call these rather than copy the flow a third time. |
chat/fields.py | new. EncryptedTextField — Fernet ciphertext in the column, transparent to the app, '' on an undecryptable read, and empty under dumpdata so a fixture never carries a live token. |
chat/vault.py | Per-user grants now work on both backends (Fernet mode previously had no user-grant tree at all, which is why the plaintext columns existed). Adds encrypt_value / decrypt_value and MultiFernet rotation. |
chat/models.py | refresh_token removed from both credential models; access_token is an EncryptedTextField. |
chat/google_oauth_api.py, chat/hubspot_oauth_api.py | Read and write the grant through oauth_grants; a failed store aborts the connect instead of logging a warning. |
chat/integration_access.py, chat/vendor_connect.py, chat/vault_api.py | Drop their per-backend branching — one call covers both. The JIT grant endpoint no longer refuses on the Fernet backend. |
chat/migrations/0052_oauth_grants_out_of_postgres.py | Drops the columns, after deleting the rows that hold them. |
Cutover
The migration deletes existing credential rows instead of migrating the tokens. Writing them
into HashiCorp from inside a migration is not safe — Vault need not be reachable during
manage.py migrate, and a half-finished run would drop the column with the secret nowhere.
Everyone reconnects once from the Tools page; the OAuth apps themselves are untouched, so that is
a single consent click per user and no admin work.
If a future deployment has too many users for that, the safe shape is a management command that
reads each plaintext value, writes it through oauth_grants, verifies the read back, and only
then clears the column — with the RemoveField in a later migration.