Vault
Status: implemented (POC). Architecture and the Django/Vault/JIT control plane.
One-page picture of the same loop: Authentication flow (allowed → Vault returns credentials → our code connects → vendor returns data).
Control plane: user logs in → asks for tool data in chat → Django checks if that tool is allowed → if yes and no grant yet, a trusted UI asks for credentials → Django writes HashiCorp Vault → the warm sandbox is recycled → nono proxies the vendor call.
Google, HubSpot, Salesforce, SAP, and anything next share this loop. They are not special cases. Nono never talks to Vault. Credentials are never typed into the chat.
Today’s “vault” in this repo (chat/vault.py) is Fernet-encrypted Postgres, not HashiCorp.
Vendor HTTP tools still follow OAuth via the nono proxy for the
data plane (phantom key + path allowlist). This page is the control plane that decides
when Vault and Connect run.
1. Validation of the requested flow
Requested: user logs in → prompts for SAP data → nono checks if doable → HC Vault → ask for credentials → connection.
The sequence is right. The actors are not.
| Step as stated | Keep / change | Why |
|---|---|---|
| User logs in | Keep | Existing Django session. Not Vault login, not nono. |
| Prompt for SAP (or any tool) data | Keep | Chat is the trigger. Do not require Tools-page Connect first. |
| “Nono checks if doable” | Change actor | Nono is an enforcer (Landlock + path allowlist), not a policy oracle and not a Vault client. Django entitlements decide “is this user allowed to use this tool?” before anyone is asked for a password. If no, refuse — never prompt for credentials. |
| “HC Vault goes in” | Keep, after policy | Vault stores/leases secrets. Django is the only client. |
| “Ask for credentials” | Change channel | Trusted browser UI only: OAuth redirect (Google, HubSpot, Salesforce) or a secret modal (SAP user/password, client credentials). Never the LLM, never a tool argument, never sandbox stdin. Prompt injection would otherwise steal SAP passwords. |
| “Connection goes into the game” | Keep, with recycle | nono 0.75 reads env:// once at proxy start. Mid-chat connect cannot hot-patch a warm session. After Vault write: recycle this conversation’s sandbox, then retry the prompt. Idle-only recycle (refresh_nono_for_user today) is not enough. |
Rejected:
- Nono → Vault (
vault://,cmd://vault, Agent-mountedclient_secret). - Collecting credentials inside the chat transcript.
- Vault as the RBAC system (Django entitlements / data roles stay in charge of “doable”).
2. Executive summary
Each new vendor today copies Fernet key names into Postgres and expects the user to Connect on Tools before chatting. That does not match “I logged in and asked for SAP customers,” and it does not scale to Salesforce, SAP, or the next ten tools.
This proposal:
- A vendor-agnostic registry and one KV tree on the existing org Vault cluster.
- A just-in-time connect loop: policy → collect credentials in the SPA → Vault → recycle sandbox → nono proxy.
- Two layers in Vault: app/org secrets and per-user grants. Nono only ever sees a minted short-lived access token.
<mount>/data/harnesi/<env>/integrations/<tool>/<kind>
<mount>/data/harnesi/<env>/integrations/<tool>/user/<id>
<tool> is google, hubspot, salesforce, sap, … <kind> is oauth_app,
client_credentials, api_key, or certificate. Adding a vendor is a registry entry +
credential UI + proxy route, not a new secrets backend.
Tools-page Connect cards stay as a manual path. Chat needs_connection is the primary
path.
3. Goals and non-goals
Goals
- Just-in-time connect from a chat prompt, for any registered tool.
- Policy-first: never ask for credentials if the user is not allowed to use the tool.
- Secrets only in a trusted UI; Django writes HashiCorp Vault.
- After save, recycle the current warm session so nono can inject the new token.
- One AppRole over
integrations/*so Salesforce/SAP do not need a new Vault policy. - Sandbox never sees Vault,
client_secret, SAP passwords, or Salesforce consumer secret.
Non-goals
- Nono calling Vault, or Vault Agent in the pi sandbox for this entry point.
- Live re-read of
env://inside a running nono 0.75 (we recycle instead). - Replacing
DJANGO_SECRET_KEY,MCP_*in this entry point. - Implementing full Salesforce/SAP product tools — the loop they will use, proven with Google/HubSpot plus a SAP-shaped fixture.
- Using the LLM as a credential form.
4. Actors and use cases
| Use case | Who | Vault? | Nono? |
|---|---|---|---|
| Save org OAuth app / SAP destination | Superuser | Write app path | No |
| Chat “get SAP customers” | End user + agent | No until gate passes | Not yet |
| Is it doable? | Django entitlements | No | No — policy is Django |
| Collect user grant | Trusted SPA | Write user path | No |
| Call vendor API | nono proxy | No | Yes — env:// token only |
5. UML — just-in-time sequence (primary)
needs_connection is a structured app event to the SPA (like other chat UI events), not a
sentence the model is asked to display as a password form.
For Google/HubSpot/Salesforce the modal is the existing OAuth authorize redirect. For SAP it is a form whose POST body goes to Django, then Vault — the model never sees the fields.
6. Current vs target data plane
The data plane after a successful JIT connect is unchanged: Django mints, nono injects.
Hard rule: Vault talks to Django. Nono talks to vendor APIs with a minted runtime token. App secrets and user passwords never cross the sandbox boundary.
7. Can Vault connect to nono?
Not for this control plane. Same verdict as before, now with JIT as the reason people will ask again:
| Approach | Verdict |
|---|---|
nono vault:// | No — not in nono 0.75 |
Django Vault client + nono env:// after recycle | Yes — this platform |
Vault Agent + file:// | Later, runtime tokens only, supervisor-only mount |
cmd://vault in sandbox | No |
| Asking the agent to “check Vault” | No — policy is Django; secrets are Django→Vault |
8. Credential kinds
Google and HubSpot are oauth_app. Salesforce is the same shape. SAP is often
client_credentials or a password — the platform must not assume authorization-code fields.
| name | kind | JIT UI | KV app path | KV user path |
|---|---|---|---|---|
google | oauth_app | OAuth redirect | .../google/oauth_app | .../google/user/<id> |
hubspot | oauth_app | OAuth redirect | .../hubspot/oauth_app | .../hubspot/user/<id> |
salesforce | oauth_app | OAuth redirect | .../salesforce/oauth_app | .../salesforce/user/<id> |
sap | client_credentials or password | Secret modal | .../sap/client_credentials | .../sap/user/<id> |
Existing /api/vault/google-oauth and /hubspot-oauth stay as facades.
User grants moving into Vault is in this entry point because JIT has nowhere else honest to put a SAP password. Leaving those in plaintext Postgres would contradict “Vault goes in when we ask for credentials.”
Done. The Google/HubSpot refresh tokens are out of Postgres and the Fernet backend grew the per-user tree it was missing, so grants work on either backend — Where OAuth secrets and tokens live maps the result.
9. KV tree and AppRole (ops contract)
<mount>/data/harnesi/<env>/integrations/<tool>/<kind>
<mount>/data/harnesi/<env>/integrations/<tool>/user/<id>
VAULT_INTEGRATIONS_PREFIX defaults to harnesi/dev/integrations. Prod uses a separate
prefix and AppRole.
Policy: create/update/read/delete on harnesi/<env>/integrations/* and matching
metadata/. Default-deny elsewhere. Adding a tool does not need a new Vault ticket.
AppRole: secret_id_num_uses = 0 (or per-replica wrapped ids); token TTL 15–60m; renew at ⅔;
login mutex. Never root. DJANGO_DEBUG=false refuses skip-verify and refuses boot if hashicorp
mode lacks addr/AppRole/CA.
Network: Django may egress to VAULT_ADDR. Sandbox must not.
Django AppRole reads user paths only for the authenticated user id it is minting for — the
adapter must interpolate user/<id> from the session, never from the sandbox or from a tool
argument (IDOR).
10. UML — class / package
Mode latch: VAULT_OAUTH_BACKEND=hashicorp|fernet. Empty VAULT_ADDR from Helm is not
Fernet. Hashicorp mode never decrypts leftover VaultSecret rows for registered tools.
11. Other sequences
11.1 Superuser provisions the org app secret (still required for OAuth tools)
JIT user connect fails closed with unconfigured (not a credential prompt) if this org
secret is missing. Asking an end user for the Google client_secret would be wrong.
11.2 Session start + nono (after recycle)
11.3 AppRole login (process-local)
11.4 Fail-closed vs policy-deny vs unconfigured
Three different outcomes. Do not collapse them.
12. UML — states
13. UML — ER
Postgres GoogleCredential / HubspotCredential are migration sources, then tombstoned for
hashicorp mode — same split-brain rule as Fernet app secrets.
14. UML — trust zones
Denylist: sandbox env / session.json must never contain VAULT_*, AppRole ids,
client_secret, passwords, cert PEMs. Do not reuse k8s_runtime._env() (already copies
POSTGRES_PASSWORD).
15. UML — adding a new tool
16. Fail-closed call sites
| Surface | Vault down | Policy deny | Org app missing | User grant missing |
|---|---|---|---|---|
| Chat turn needing the tool | 503 | refuse, no prompt | unconfigured | needs_connection |
| Tools GET/PUT | 503 | 403 if not superuser | empty + flag | n/a |
/authorize | 503 | 403 | unconfigured | start OAuth |
| Token mint / session start | fail mint explicitly | omit route | fail mint | omit route + JIT on next turn |
Cache: last-known-good only within 30–60s TTL, then 503. Write path never reads cache.
17. Config plumbing
VAULT_OAUTH_BACKEND, VAULT_ADDR, VAULT_NAMESPACE, VAULT_ROLE_ID, VAULT_SECRET_ID /
_FILE, VAULT_CACERT, VAULT_KV_MOUNT, VAULT_INTEGRATIONS_PREFIX — backend only.
Redirect URIs in Vault must be on BACKEND_BASE_URL for every oauth_app tool.
18. Security controls
- Django is the only Vault identity. Policy check happens before any Vault read of user grants when the trigger is a chat tool request (do not leak “this secret exists” to a user who is not allowed the tool).
- User KV path is bound to
request.user.idon the server. Never from the agent. - Secrets never in chat, logs, or model context.
- Recycle must drop the old supervisor env (old tokens). Do not leave two sandboxes for one conversation.
- Cluster audit = AppRole; app
action_logon JIT save, Tools GET/PUT, policy deny, Vault auth failure.
19. Tests (mocked hvac, no live cluster)
- Policy deny: no Vault call, no
needs_connection. - Allowed + no grant:
needs_connection, no vendor HTTP. - After mock Vault write: recycle invoked for that conversation, next
tokens_for_sessionincludes the route. - User A cannot mint user B’s
.../user/<id>. - SAP-shaped password never appears in
session.jsonor chat events. - Third spec (
salesforceorsap) needs no adapter change. VAULT_*denylist on sandbox env.
20. Success criteria
- Login → “get HubSpot contacts” (or SAP fixture) with no prior Connect → policy allow → credential UI → Vault write → this conversation recycles → retry succeeds via nono.
- Same prompt from a user without entitlement → refuse, no credential UI, no Vault write.
- Org app secret missing →
unconfigured, not a user password form. - Google/HubSpot Tools cards still work as a manual path.
session.jsonhasenv://only.- Dev prefix cannot write prod.
- Unit tests without a live cluster cover the dispatcher and the JIT gate.
21. Phased delivery
P2 is what makes the requested story true. P1 alone is only a better Tools-page vault.
22. What this still does not fix
- Access tokens remain in supervisor env (
docker inspect/kubectl get pod). - Pre-existing
POSTGRES_PASSWORDin the sandbox — do not copy that pattern. - Mid-session refresh of an already-injected token still needs a later nono re-read or another recycle when the vendor TTL expires.