Skip to main content

User Secret Management: OpenBao

This page covers how user credentials (AWS keys, database passwords, API tokens) are stored, accessed, and isolated within the platform. There are two access paths: a user-facing REST API through the BFF, and a direct path for notebooks and jobs via a service account.

For how Keycloak token exchange works across the platform, see Token Exchange and Data Access.


Overview

Each workspace runs a dedicated OpenBao KV v2 instance inside the tenant's own cloud account. Credentials are never held by Cogrion infrastructure — they live entirely within the tenant's OpenBao instance.

Isolation is two-tiered:

  • Workspace level — each tenant's OpenBao is a separate, dedicated instance. There is no shared secret store across workspaces.
  • User level — within a workspace's OpenBao instance, every secret is stored under users/<keycloak-sub>/. The BFF enforces this scoping server-side — a user can only read or write their own secrets, regardless of what they send in the request body.

Storage Layout

Secrets are stored in the KV v2 secrets engine under a path derived from the user's Keycloak sub claim:

secrets/data/users/<keycloak-sub>/<credential-uuid>
secrets/metadata/users/<keycloak-sub>/<credential-uuid>

KV v2 separates data and metadata at distinct URL paths. The metadata path holds non-sensitive fields (type, name, createdAt, updatedAt) so that listing credentials never touches secret material — a list operation reads only metadata, never data.


User Flow (via BFF)

When a user interacts with credentials through the Cogrion UI, every request flows through the BFF API at /secrets/**. The BFF owns the entire auth and scoping chain — the caller never specifies a path or owner.

End-to-End Sequence

Step 1: JWT Validation

The BFF's validateJWT middleware decodes and validates the incoming Keycloak Bearer token. It attaches req.user to the request — this includes the user's sub (Keycloak subject UUID) and their resource_access role map.

The sub is used throughout to scope every storage operation. It is extracted from the validated token, never from the request body. A caller cannot impersonate another user by supplying a different subject.

See src/middlewares/auth.js.

Step 2: Token Exchange

Before enforcing roles, the BFF exchanges the user's Keycloak JWT for a new token scoped to the <workspace-id>-openbao Keycloak client. This exchanged token carries the user's OpenBao client roles in its resource_access claim.

The exchange happens automatically via exchangeTokenForBackendMiddleware("openbao"). The user's original session token is never forwarded to OpenBao.

Step 3: Role Check

The requireRole middleware reads resource_access[<workspace-id>-openbao].roles from the exchanged token. If the required role is absent, the BFF returns 403 immediately — OpenBao is never contacted.

secret_writer implies read access: any user with the writer role can also perform reader operations.

See src/routes/secretRoute.js.

Step 4: Credential Operation

The credential service enforces the users/<sub>/ prefix on every KV path. The caller cannot influence the subject — it comes exclusively from req.user.sub on the validated token.

OperationEndpointRequired roleWhat OpenBao receives
CreatePOST /secretssecret_writerWrite to users/<sub>/<new-uuid> — data + metadata in two calls
ListGET /secretssecret_readerList keys under users/<sub>/, then read metadata per entry — no data paths touched
GetGET /secrets/:idsecret_readerRead data + metadata at users/<sub>/<id>
UpdatePATCH /secrets/:idsecret_writerRead current, write full replacement to users/<sub>/<id>
DeleteDELETE /secrets/:idsecret_writerPermanently destroy all versions and metadata at users/<sub>/<id>

Update replaces fields wholesale rather than merging. This is intentional: partial overwrites would leave stale secret material behind in OpenBao's version history.

See src/services/openBao.js and src/clients/openBaoClient.js.


Service Account Flow (Notebooks & Jobs)

Notebooks and Airflow jobs access secrets directly using the secret_utils.py OpenBao backend — they do not go through the BFF. They authenticate using Keycloak's client credentials flow (no user session required) and then exchange the resulting Keycloak JWT for an OpenBao token at the vault's JWT auth endpoint.

End-to-End Sequence

Step 1: Keycloak Client Credentials

The notebook or job is provisioned with a Keycloak client ID and client secret (typically injected as environment variables KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET). It calls Keycloak's token endpoint with grant_type=client_credentials.

The returned JWT has azp set to the client ID (e.g. w-awssbox01-openbao) and aud set to account. This is distinct from user tokens, which carry aud=<workspace>-openbao.

Step 2: OpenBao JWT Login

The service account presents its Keycloak JWT to OpenBao's JWT auth endpoint (POST /v1/auth/jwt/login) and requests either secret-sa-reader or secret-sa-writer. OpenBao validates the JWT against Keycloak's OIDC discovery URL and checks the azp claim — the service account roles are specifically bound to azp=<workspace>-openbao rather than aud, which is why they use dedicated role names.

On success, OpenBao returns a vault token bounded by the corresponding KV policy.

Step 3: KV Access

The vault token is used directly as X-Vault-Token on KV operations. Paths are scoped to users/<sa-sub>/ where <sa-sub> is the service account's Keycloak subject — derived from the validated JWT. OpenBao's own policy enforces this at the storage level, not just at the application level.


Notebook Flow (JupyterHub Single-User Pods)

Notebooks access secrets via the same secret_utils.py backend, but use a pre-authenticated path — the JupyterHub hub obtains a vault token at pod spawn time and injects it directly into the pod environment. No Keycloak interaction is needed at runtime.

See JupyterHub Spawner — Secret Auth for the full flow.


Role and Policy Mapping

Realm roles → OpenBao client roles

Keycloak realm roles map to OpenBao client roles via the roles_mapping configuration in platform-stacks. This mapping is not defined in the BFF.

Keycloak realm roleOpenBao client role
platform_adminsecret_writer
data_engineersecret_writer
ml_engineersecret_writer
tenant_adminsecret_reader

OpenBao roles and JWT validation rules

OpenBao has four JWT roles. User token roles and service account roles use different validation paths because their tokens carry different audience claims.

OpenBao roleUsed byJWT validationKV policy
secret-readerUsers via BFFaud contains <workspace>-openbaoRead + list users/*
secret-writerUsers via BFFaud contains <workspace>-openbaoCreate, update, read users/*
secret-sa-readerService accountsazp == <workspace>-openbao, aud == accountRead + list users/*
secret-sa-writerService accountsazp == <workspace>-openbao, aud == accountCreate, update, read users/*
info

The SA roles exist because service account tokens carry aud=account, not the OpenBao client ID. Binding them on azp (the authorized party) achieves the same identity constraint via a different JWT claim.


Audit Logging

Field values are never written to audit logs. The BFF redacts any field value under routes matching AUDIT_REDACTED_BODY_PREFIXES (default: /secrets,/credentials). Only field keys are logged.

{
"body": {
"type": "aws",
"name": "Prod S3 Key",
"fields": {
"access_key_id": "[REDACTED]",
"secret_access_key": "[REDACTED]",
"region": "[REDACTED]"
}
}
}

This applies at the HTTP audit layer. The credential service's internal logger also redacts fields and a set of known sensitive keys (password, token, api_key, private_key, session_token).


Go Deeper