Skip to main content

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​

SecretWhere it restsEncrypted byWho can read it
OAuth app client_secret (Google, HubSpot, Salesforce)HashiCorp KV integrations/<tool>/oauth_app, or VaultSecret.valueVault at rest, or FernetDjango; a superuser via the Tools page form
Per-user refresh_tokenHashiCorp KV integrations/<tool>/user/<id>, or VaultSecret key <prefix>user:<id>Vault at rest, or FernetDjango only — never the sandbox, never the model
Per-user access_token (~30-60 min cache)access_token column on the credential rowFernet, in both modesDjango; nono receives a copy in its supervisor env
SAP password / client credentialsHashiCorp KV integrations/sap/user/<id>, or VaultSecretVault at rest, or FernetDjango only
LLM provider keysVaultSecretAlways Fernet, never HashiCorpDjango; nono's supervisor env
hub_id, hub_domain, emails, expiry timestampscredential 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.

FernetHashiCorp
Secret exists in the app databaseyes, as ciphertextno
Key heldin the backend's environmentin the Vault cluster
A DB dump alone is enoughnono
Dump plus the environment is enoughyesno
Access is audited (who read what, when)noyes
Key rotationcomma-separated VAULT_ENCRYPTION_KEYVault-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​

FileRole
chat/oauth_grants.pynew. 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.pynew. 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.pyPer-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.pyrefresh_token removed from both credential models; access_token is an EncryptedTextField.
chat/google_oauth_api.py, chat/hubspot_oauth_api.pyRead 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.pyDrop 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.pyDrops 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.