Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions csil/v1/components/openbao-injector.csil
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
; OpenBao Agent Injector component configuration
;
; Configuration for the OpenBao Agent Injector which installs a
; MutatingWebhookConfiguration to inject secrets into pods.

options {
go_module: "github.com/catalystcommunity/foundry/v1",
go_package: "github.com/catalystcommunity/foundry/v1/internal/component/openbaoinjector"
}

; K8sAuthRole defines a Kubernetes auth role for a specific app
K8sAuthRole = {
role_name: text, ; Role name in OpenBao (e.g., "redditwatch")
service_account_name: text, ; Service account name to bind (e.g., "redditwatch")
service_account_namespace: text, ; Namespace where the SA exists (e.g., "default")
policies: text, ; Comma-separated policies to grant (e.g., "foundry-core-read")
ttl: text, ; Token TTL (e.g., "1h"), default: "1h"
}

; OpenBao Agent Injector component configuration
; Default values will be set in Go code DefaultConfig() function
Config = {
version: text, ; Default: "0.26.2"
namespace: text, ; Default: "openbao"
external_vault_addr: text, ; Required: OpenBao address (e.g. http://100.81.89.62:8200)
configure_k8s_auth: boolean, ; Enable Kubernetes auth in OpenBao (default: false)
k8s_auth_roles: [K8sAuthRole], ; List of K8s auth roles to create
}
207 changes: 207 additions & 0 deletions docs/pod-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Injecting OpenBao Secrets into Pods

This guide explains how to inject secrets from OpenBao into Kubernetes pods using the OpenBao agent injector.

## Prerequisites

The `openbao-injector` component must be installed:

```bash
foundry component install openbao-injector
```

This deploys the OpenBao agent injector and registers a `MutatingWebhookConfiguration` that intercepts pod creation. Pods annotated with `vault.hashicorp.com/agent-inject: "true"` automatically receive a sidecar that mounts secrets from OpenBao before the main container starts.

## How It Works

1. You annotate a pod/deployment/cronjob with the secrets it needs
2. The injector sidecar fetches those secrets from OpenBao at pod startup
3. Secrets are written as files to `/vault/secrets/`
4. Your container sources those files to load them as environment variables

## OpenBao Setup

### Enable Kubernetes Auth

The injector authenticates pods against OpenBao using the Kubernetes service account JWT. You have two options:

#### Option 1: Automatic (Recommended)

Add `configure_k8s_auth: true` to your stack config when installing the injector:

```yaml
components:
openbao-injector:
configure_k8s_auth: true
k8s_auth_roles:
- role_name: "my-app"
service_account_name: "default"
service_account_namespace: "my-namespace"
policies: "my-app"
ttl: "1h"
```

This automatically:
- Creates a `vault-reviewer` ServiceAccount in `kube-system`
- Binds it to `system:auth-delegator` ClusterRole
- Enables Kubernetes auth in OpenBao
- Configures it with `disable_iss_validation=true` (required for in-cluster JWT issuer)
- Creates the roles you specify

#### Option 2: Manual

```bash
vault auth enable kubernetes

vault write auth/kubernetes/config \
kubernetes_host="https://$(kubectl get svc kubernetes -o jsonpath='{.spec.clusterIP}'):443" \
disable_iss_validation=true
```

> **Note**: `kubernetes_host` must use the ClusterIP (e.g., `https://10.43.0.1:443`), not a Tailscale hostname. The injector runs on Kubernetes nodes where the ClusterIP is reachable, but Tailscale addresses may not be routable from the host node.

### Create a Policy

Define what secrets a pod is allowed to read:

```bash
vault policy write my-app - <<EOF
path "secret/data/apps/my-app" {
capabilities = ["read"]
}
EOF
```

### Create a Role

Bind the policy to a Kubernetes service account:

```bash
vault write auth/kubernetes/role/my-app \
bound_service_account_names=default \
bound_service_account_namespaces=my-namespace \
policies=my-app \
ttl=1h
```

## Annotating Pods

Add annotations to your pod spec to request secret injection. The injector creates one file per secret at `/vault/secrets/<name>`.

### Basic Example

```yaml
spec:
template:
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "my-app"

# Inject secret/data/apps/my-app as /vault/secrets/my-app
vault.hashicorp.com/agent-inject-secret-my-app: "secret/data/apps/my-app"

# Template the file as shell exports so the container can source it
vault.hashicorp.com/agent-inject-template-my-app: |
{{- with secret "secret/data/apps/my-app" -}}
export DB_PASSWORD="{{ .Data.data.password }}"
export API_KEY="{{ .Data.data.api_key }}"
{{- end }}
```

### Container Entrypoint

Because the secrets are shell files (not environment variables), the container must source them before starting:

```yaml
containers:
- name: my-app
command: ["/bin/sh", "-c"]
args: [". /vault/secrets/my-app && exec my-binary"]
```

> **Note**: Use `.` (dot) not `source` — slim/alpine images use `dash` as `/bin/sh` which does not support `source`.

## Helm Chart Pattern

For a Helm chart with multiple secrets (e.g. reddit-watcher):

```yaml
# In your cronjob/deployment template
metadata:
annotations:
vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "{{ .Values.vault.role }}"

vault.hashicorp.com/agent-inject-secret-db: "{{ .Values.vault.dbPath }}"
vault.hashicorp.com/agent-inject-template-db: |
{{`{{- with secret "`}}{{ .Values.vault.dbPath }}{{`" -}}`}}
{{`export POSTGRES_URL="{{ .Data.data.postgres_url }}"`}}
{{`{{- end }}`}}

vault.hashicorp.com/agent-inject-secret-api: "{{ .Values.vault.apiPath }}"
vault.hashicorp.com/agent-inject-template-api: |
{{`{{- with secret "`}}{{ .Values.vault.apiPath }}{{`" -}}`}}
{{`export API_KEY="{{ .Data.data.key }}"`}}
{{`{{- end }}`}}

spec:
containers:
- name: my-app
command: ["/bin/sh", "-c"]
args: [". /vault/secrets/db && . /vault/secrets/api && exec python -m main"]
```

## Storing Secrets in OpenBao

```bash
# Store secrets (run from your local machine with VAULT_ADDR set)
vault kv put secret/apps/my-app \
password="my-db-password" \
api_key="my-api-key"

# Add a key to an existing secret without overwriting others
vault kv patch secret/apps/my-app new_key="new-value"

# Using 1Password to inject the value at write time
op run -- vault kv patch secret/apps/my-app \
postgres_url="op://pedro/POSTGRES_URL/credential"
```

## Troubleshooting

### `/vault/secrets/<name>: No such file`

The injector sidecar didn't run. Check:

1. Is `openbao-injector` installed?
```bash
foundry component status openbao-injector
kubectl get mutatingwebhookconfigurations | grep openbao
```

2. Does the pod have `vault.hashicorp.com/agent-inject: "true"` annotation?

3. Is the pod in a namespace the webhook targets? By default it targets all namespaces.

### `permission denied` fetching secrets

The Kubernetes auth role doesn't bind to this pod's service account or namespace:

```bash
# Verify the role bindings
vault read auth/kubernetes/role/my-app
```

Ensure `bound_service_account_namespaces` includes the pod's namespace.

### `source: not found`

Using `source` instead of `.` in a `dash`-based image. Replace:
```sh
# Wrong (bash only)
source /vault/secrets/my-app

# Correct (POSIX sh / dash compatible)
. /vault/secrets/my-app
```
3 changes: 1 addition & 2 deletions v1/cmd/foundry/commands/cluster/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,10 @@ func InitializeCluster(ctx context.Context, cfg *config.Config) error {
}

// Get OpenBAO client with authenticated token
openbaoIP, err := cfg.GetPrimaryOpenBAOAddress()
openbaoAddr, err := cfg.GetPrimaryOpenBAOURL()
if err != nil {
return fmt.Errorf("failed to get OpenBAO address: %w", err)
}
openbaoAddr := fmt.Sprintf("http://%s:8200", openbaoIP)
openbaoClient := openbao.NewClient(openbaoAddr, keyMaterial.RootToken)
fmt.Println("✓ OpenBAO credentials loaded")

Expand Down
3 changes: 1 addition & 2 deletions v1/cmd/foundry/commands/cluster/node_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,10 @@ func printNodeAddPlan(hostname string, role *k3s.DeterminedRole, cfg *config.Con
func addNodeToCluster(ctx context.Context, hostname string, nodeRole *k3s.DeterminedRole, cfg *config.Config) error {
// Step 1: Get OpenBAO client
fmt.Println("Connecting to OpenBAO...")
openbaoIP, err := cfg.GetPrimaryOpenBAOAddress()
openbaoAddr, err := cfg.GetPrimaryOpenBAOURL()
if err != nil {
return fmt.Errorf("failed to get OpenBAO address: %w", err)
}
openbaoAddr := fmt.Sprintf("http://%s:8200", openbaoIP)

// Load OpenBAO token from keys.json file
configDir, err := config.GetConfigDir()
Expand Down
3 changes: 1 addition & 2 deletions v1/cmd/foundry/commands/cluster/node_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,10 @@ func printNodeRemovePlan(hostname string, cfg *config.Config) {
func removeNodeFromCluster(ctx context.Context, hostname string, cfg *config.Config) error {
// Step 1: Get OpenBAO client and load kubeconfig
fmt.Println("Loading kubeconfig from OpenBAO...")
openbaoIP, err := cfg.GetPrimaryOpenBAOAddress()
openbaoAddr, err := cfg.GetPrimaryOpenBAOURL()
if err != nil {
return fmt.Errorf("failed to get OpenBAO address: %w", err)
}
openbaoAddr := fmt.Sprintf("http://%s:8200", openbaoIP)
// TODO: Get token from auth management
openbaoClient := openbao.NewClient(openbaoAddr, "")

Expand Down
Loading