Skip to content

Commit b0203a4

Browse files
committed
add initial version of vault extension
1 parent 9b818de commit b0203a4

File tree

13 files changed

+832
-0
lines changed

13 files changed

+832
-0
lines changed

vault/.gitignore

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Python
2+
.venv/
3+
build/
4+
dist/
5+
.eggs/
6+
*egg-info/
7+
8+
# OS
9+
.DS_Store
10+
11+
# Ignored Directories
12+
sample-app-terraform/

vault/Makefile

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
VENV_BIN = python3 -m venv
2+
VENV_DIR ?= .venv
3+
VENV_ACTIVATE = $(VENV_DIR)/bin/activate
4+
VENV_RUN = . $(VENV_ACTIVATE)
5+
6+
usage: ## Shows usage for this Makefile
7+
@cat Makefile | grep -E '^[a-zA-Z_-]+:.*?## .*$$' | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
8+
9+
venv: $(VENV_ACTIVATE)
10+
11+
$(VENV_ACTIVATE): pyproject.toml
12+
test -d .venv || $(VENV_BIN) .venv
13+
$(VENV_RUN); pip install --upgrade pip setuptools plux
14+
$(VENV_RUN); pip install -e .[dev]
15+
touch $(VENV_DIR)/bin/activate
16+
17+
clean:
18+
rm -rf .venv/
19+
rm -rf build/
20+
rm -rf .eggs/
21+
rm -rf *.egg-info/
22+
23+
install: venv ## Install dependencies
24+
$(VENV_RUN); python -m plux entrypoints
25+
26+
dist: venv ## Create distribution
27+
$(VENV_RUN); python -m build
28+
29+
publish: clean-dist venv dist ## Publish extension to pypi
30+
$(VENV_RUN); pip install --upgrade twine; twine upload dist/*
31+
32+
entrypoints: venv # Generate plugin entrypoints for Python package
33+
$(VENV_RUN); python -m plux entrypoints
34+
35+
format: ## Run ruff to format the whole codebase
36+
$(VENV_RUN); python -m ruff format .; python -m ruff check --output-format=full --fix .
37+
38+
lint: ## Run ruff to lint the codebase
39+
$(VENV_RUN); python -m ruff check --output-format=full .
40+
41+
sample-app: ## Deploy sample app with Lambda + Vault
42+
@echo "Setting up Vault secrets..."
43+
sample-app-extension/bin/setup-vault.sh
44+
@echo "Deploying Lambda function with Vault Lambda Extension..."
45+
sample-app-extension/bin/deploy-lambda.sh
46+
@echo "Testing Lambda invocation..."
47+
sample-app-extension/bin/test-lambda.sh
48+
49+
clean-dist: clean
50+
rm -rf dist/
51+
52+
.PHONY: clean clean-dist dist install publish usage venv format lint sample-app

vault/README.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# HashiCorp Vault on LocalStack
2+
3+
This [LocalStack Extension](https://github.com/localstack/localstack-extensions) runs [HashiCorp Vault](https://www.vaultproject.io/) alongside LocalStack for secrets management testing.
4+
5+
## Prerequisites
6+
7+
- Docker
8+
- LocalStack Pro (free trial available)
9+
- `localstack` CLI
10+
11+
## Install from GitHub repository
12+
13+
```bash
14+
localstack extensions install "git+https://github.com/localstack/localstack-extensions.git#egg=localstack-extension-vault&subdirectory=vault"
15+
```
16+
17+
## Install local development version
18+
19+
```bash
20+
make install
21+
localstack extensions dev enable .
22+
```
23+
24+
Start LocalStack with `EXTENSION_DEV_MODE=1`:
25+
26+
```bash
27+
EXTENSION_DEV_MODE=1 localstack start
28+
```
29+
30+
## Usage
31+
32+
Vault is available at `http://vault.localhost.localstack.cloud:4566`.
33+
34+
### Add Secrets
35+
36+
```bash
37+
export VAULT_ADDR=http://vault.localhost.localstack.cloud:4566
38+
export VAULT_TOKEN=root
39+
40+
# Add a secret
41+
vault kv put secret/my-app/config api_key=secret123 db_password=hunter2
42+
43+
# Verify
44+
vault kv get secret/my-app/config
45+
```
46+
47+
### Use with Lambda
48+
49+
Deploy a Lambda with the [Vault Lambda Extension](https://github.com/hashicorp/vault-lambda-extension) layer and these environment variables:
50+
51+
| Variable | Value |
52+
|----------|-------|
53+
| `VAULT_ADDR` | `http://vault.localhost.localstack.cloud:4566` |
54+
| `VAULT_AUTH_PROVIDER` | `aws` |
55+
| `VAULT_AUTH_ROLE` | `default-lambda-role` |
56+
| `VAULT_SECRET_PATH_*` | Path to your secret (e.g., `secret/data/my-app/config`) |
57+
| `VAULT_SECRET_FILE_*` | Where extension writes secrets (e.g., `/tmp/secrets/myapp`) |
58+
59+
See `sample-app-extension/` for a complete working example.
60+
61+
## Configuration
62+
63+
| Environment Variable | Default | Description |
64+
|---------------------|---------|-------------|
65+
| `VAULT_ROOT_TOKEN` | `root` | Dev mode root token |
66+
| `VAULT_PORT` | `8200` | Vault API port (internal) |
67+
68+
## Pre-configured Resources
69+
70+
### Secrets Engines
71+
72+
| Path | Type | Description |
73+
|------|------|-------------|
74+
| `secret/` | KV v2 | Key-value secrets storage |
75+
| `transit/` | Transit | Encryption as a service |
76+
77+
### Auth Methods
78+
79+
| Path | Type | Description |
80+
|------|------|-------------|
81+
| `aws/` | AWS IAM | Pre-configured for Lambda IAM auth |
82+
83+
### Policies
84+
85+
| Name | Permissions |
86+
|------|-------------|
87+
| `default-lambda-policy` | Full access to `secret/*` and `transit/*` |
88+
89+
## Sample App
90+
91+
See `sample-app-extension/` for a complete working example using the official Vault Lambda Extension layer.
92+
93+
```bash
94+
make sample-app
95+
```
96+
97+
## Limitations
98+
99+
- **Ephemeral**: All secrets are lost when LocalStack restarts
100+
- **Dev mode only**: No production Vault features (seal/unseal, HA, etc.)
101+
102+
## Disclaimer
103+
104+
This extension is not affiliated with HashiCorp. Vault is a trademark of HashiCorp, Inc.

vault/localstack_vault/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# HashiCorp Vault Extension for LocalStack
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import logging
2+
import os
3+
import time
4+
5+
import hvac
6+
import requests
7+
8+
from localstack import config, constants
9+
from localstack.utils.net import get_addressable_container_host
10+
from localstack_extensions.utils.docker import ProxiedDockerContainerExtension
11+
12+
LOG = logging.getLogger(__name__)
13+
14+
# Environment variables
15+
ENV_VAULT_ROOT_TOKEN = "VAULT_ROOT_TOKEN"
16+
ENV_VAULT_PORT = "VAULT_PORT"
17+
18+
# Defaults
19+
DEFAULT_ROOT_TOKEN = "root"
20+
DEFAULT_PORT = 8200
21+
22+
23+
class VaultExtension(ProxiedDockerContainerExtension):
24+
"""
25+
HashiCorp Vault Extension for LocalStack.
26+
27+
Runs Vault in dev mode with:
28+
- KV v2 secrets engine at secret/
29+
- Transit secrets engine at transit/
30+
- IAM auth method pre-configured to accept any Lambda role
31+
"""
32+
33+
name = "localstack-vault"
34+
35+
HOST = "vault.<domain>"
36+
DOCKER_IMAGE = "hashicorp/vault:latest"
37+
38+
def __init__(self):
39+
self.root_token = os.getenv(ENV_VAULT_ROOT_TOKEN, DEFAULT_ROOT_TOKEN)
40+
self.vault_port = int(os.getenv(ENV_VAULT_PORT, DEFAULT_PORT))
41+
42+
env_vars = {
43+
"VAULT_DEV_ROOT_TOKEN_ID": self.root_token,
44+
"VAULT_DEV_LISTEN_ADDRESS": f"0.0.0.0:{self.vault_port}",
45+
"VAULT_LOG_LEVEL": "info",
46+
}
47+
48+
def _health_check():
49+
"""Check if Vault is initialized and unsealed."""
50+
container_host = get_addressable_container_host()
51+
health_url = f"http://{container_host}:{self.vault_port}/v1/sys/health"
52+
LOG.debug("Vault health check: %s", health_url)
53+
response = requests.get(health_url, timeout=5)
54+
# Vault returns 200 when initialized, unsealed, and active
55+
# In dev mode, it should always be ready
56+
assert response.status_code == 200, f"Vault not ready: {response.status_code}"
57+
58+
super().__init__(
59+
image_name=self.DOCKER_IMAGE,
60+
container_ports=[self.vault_port],
61+
host=self.HOST,
62+
env_vars=env_vars,
63+
health_check_fn=_health_check,
64+
health_check_retries=60,
65+
health_check_sleep=1.0,
66+
)
67+
68+
def on_platform_ready(self):
69+
"""Configure Vault after it's running and LocalStack is ready."""
70+
try:
71+
self._configure_vault()
72+
except Exception as e:
73+
LOG.error("Failed to configure Vault: %s", e)
74+
raise
75+
76+
url = f"http://vault.{constants.LOCALHOST_HOSTNAME}:{config.get_edge_port_http()}"
77+
LOG.info("Vault extension ready: %s", url)
78+
LOG.info("Root token: %s", self.root_token)
79+
80+
def _configure_vault(self):
81+
"""Set up Vault with KV v2, Transit, and IAM auth."""
82+
container_host = get_addressable_container_host()
83+
vault_addr = f"http://{container_host}:{self.vault_port}"
84+
85+
# Wait a moment for Vault to be fully ready for API calls
86+
time.sleep(1)
87+
88+
client = hvac.Client(url=vault_addr, token=self.root_token)
89+
90+
if not client.is_authenticated():
91+
raise RuntimeError("Failed to authenticate with Vault")
92+
93+
LOG.info("Configuring Vault secrets engines and auth methods...")
94+
95+
# KV v2 is enabled by default at secret/ in dev mode
96+
# Just verify it's there
97+
try:
98+
secrets_engines = client.sys.list_mounted_secrets_engines()
99+
if "secret/" in secrets_engines:
100+
LOG.debug("KV v2 secrets engine already mounted at secret/")
101+
except Exception as e:
102+
LOG.warning("Could not verify secrets engines: %s", e)
103+
104+
# Enable Transit secrets engine
105+
self._enable_transit_engine(client)
106+
107+
# Configure IAM auth method
108+
self._configure_iam_auth(client)
109+
110+
# Create default Lambda policy
111+
self._create_default_policy(client)
112+
113+
LOG.info("Vault configuration complete")
114+
115+
def _enable_transit_engine(self, client: hvac.Client):
116+
"""Enable the Transit secrets engine for encryption-as-a-service."""
117+
try:
118+
secrets_engines = client.sys.list_mounted_secrets_engines()
119+
if "transit/" not in secrets_engines:
120+
client.sys.enable_secrets_engine(
121+
backend_type="transit",
122+
path="transit",
123+
)
124+
LOG.info("Enabled Transit secrets engine at transit/")
125+
else:
126+
LOG.debug("Transit secrets engine already mounted")
127+
except Exception as e:
128+
LOG.warning("Could not enable Transit engine: %s", e)
129+
130+
def _configure_iam_auth(self, client: hvac.Client):
131+
"""Configure AWS IAM auth method to work with LocalStack."""
132+
try:
133+
# Enable AWS auth method
134+
auth_methods = client.sys.list_auth_methods()
135+
if "aws/" not in auth_methods:
136+
client.sys.enable_auth_method(
137+
method_type="aws",
138+
path="aws",
139+
)
140+
LOG.info("Enabled AWS auth method at aws/")
141+
142+
# Configure the AWS auth to use LocalStack's STS endpoint
143+
localstack_endpoint = f"http://{get_addressable_container_host()}:{config.get_edge_port_http()}"
144+
145+
client.auth.aws.configure(
146+
sts_endpoint=localstack_endpoint,
147+
sts_region=os.getenv("AWS_DEFAULT_REGION", "us-east-1"),
148+
iam_server_id_header_value="",
149+
)
150+
LOG.info("Configured AWS auth to use LocalStack STS: %s", localstack_endpoint)
151+
152+
# Create a wildcard IAM role that accepts any Lambda
153+
# This role maps any IAM principal to the default-lambda-policy
154+
self._create_wildcard_iam_role(client)
155+
156+
except Exception as e:
157+
LOG.warning("Could not configure IAM auth: %s", e)
158+
159+
def _create_wildcard_iam_role(self, client: hvac.Client):
160+
"""Create an IAM role that accepts any AWS principal from LocalStack."""
161+
role_name = "default-lambda-role"
162+
163+
try:
164+
# Create a role that accepts any IAM role from LocalStack's account
165+
# Note: bound_iam_principal_arn="*" doesn't work in Vault - we need a
166+
# specific ARN pattern. LocalStack uses account 000000000000.
167+
# We also MUST set resolve_aws_unique_ids=false since Vault can't
168+
# resolve LocalStack IAM principals via AWS APIs.
169+
client.auth.aws.create_role(
170+
role=role_name,
171+
auth_type="iam",
172+
bound_iam_principal_arn=["arn:aws:iam::000000000000:role/*"],
173+
token_policies=["default-lambda-policy"],
174+
token_ttl="24h",
175+
token_max_ttl="24h",
176+
resolve_aws_unique_ids=False, # Critical for LocalStack
177+
)
178+
LOG.info("Created IAM auth role: %s", role_name)
179+
except hvac.exceptions.InvalidRequest as e:
180+
if "already exists" in str(e).lower():
181+
LOG.debug("IAM role %s already exists", role_name)
182+
else:
183+
LOG.warning("Could not create IAM role %s: %s", role_name, e)
184+
raise
185+
186+
def _create_default_policy(self, client: hvac.Client):
187+
"""Create a default policy for Lambda functions."""
188+
policy_name = "default-lambda-policy"
189+
policy_hcl = """
190+
# Default policy for Lambda functions using Vault
191+
# Allows full access to secret/ and transit/ paths
192+
193+
path "secret/*" {
194+
capabilities = ["create", "read", "update", "delete", "list"]
195+
}
196+
197+
path "secret/data/*" {
198+
capabilities = ["create", "read", "update", "delete", "list"]
199+
}
200+
201+
path "secret/metadata/*" {
202+
capabilities = ["list", "read", "delete"]
203+
}
204+
205+
path "transit/*" {
206+
capabilities = ["create", "read", "update", "delete", "list"]
207+
}
208+
209+
path "transit/encrypt/*" {
210+
capabilities = ["create", "update"]
211+
}
212+
213+
path "transit/decrypt/*" {
214+
capabilities = ["create", "update"]
215+
}
216+
"""
217+
try:
218+
client.sys.create_or_update_policy(
219+
name=policy_name,
220+
policy=policy_hcl,
221+
)
222+
LOG.info("Created policy: %s", policy_name)
223+
except Exception as e:
224+
LOG.warning("Could not create policy %s: %s", policy_name, e)

0 commit comments

Comments
 (0)