Skip to main content

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 statedKeep / changeWhy
User logs inKeepExisting Django session. Not Vault login, not nono.
Prompt for SAP (or any tool) dataKeepChat is the trigger. Do not require Tools-page Connect first.
“Nono checks if doable”Change actorNono 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 policyVault stores/leases secrets. Django is the only client.
“Ask for credentials”Change channelTrusted 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 recyclenono 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-mounted client_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:

  1. A vendor-agnostic registry and one KV tree on the existing org Vault cluster.
  2. A just-in-time connect loop: policy → collect credentials in the SPA → Vault → recycle sandbox → nono proxy.
  3. 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 caseWhoVault?Nono?
Save org OAuth app / SAP destinationSuperuserWrite app pathNo
Chat “get SAP customers”End user + agentNo until gate passesNot yet
Is it doable?Django entitlementsNoNo — policy is Django
Collect user grantTrusted SPAWrite user pathNo
Call vendor APInono proxyNoYes — 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:

ApproachVerdict
nono vault://No — not in nono 0.75
Django Vault client + nono env:// after recycleYes — this platform
Vault Agent + file://Later, runtime tokens only, supervisor-only mount
cmd://vault in sandboxNo
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.

namekindJIT UIKV app pathKV user path
googleoauth_appOAuth redirect.../google/oauth_app.../google/user/<id>
hubspotoauth_appOAuth redirect.../hubspot/oauth_app.../hubspot/user/<id>
salesforceoauth_appOAuth redirect.../salesforce/oauth_app.../salesforce/user/<id>
sapclient_credentials or passwordSecret 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​

SurfaceVault downPolicy denyOrg app missingUser grant missing
Chat turn needing the tool503refuse, no promptunconfiguredneeds_connection
Tools GET/PUT503403 if not superuserempty + flagn/a
/authorize503403unconfiguredstart OAuth
Token mint / session startfail mint explicitlyomit routefail mintomit 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.id on 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_log on 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_session includes the route.
  • User A cannot mint user B’s .../user/<id>.
  • SAP-shaped password never appears in session.json or chat events.
  • Third spec (salesforce or sap) needs no adapter change.
  • VAULT_* denylist on sandbox env.

20. Success criteria​

  1. Login → “get HubSpot contacts” (or SAP fixture) with no prior Connect → policy allow → credential UI → Vault write → this conversation recycles → retry succeeds via nono.
  2. Same prompt from a user without entitlement → refuse, no credential UI, no Vault write.
  3. Org app secret missing → unconfigured, not a user password form.
  4. Google/HubSpot Tools cards still work as a manual path.
  5. session.json has env:// only.
  6. Dev prefix cannot write prod.
  7. 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_PASSWORD in 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.