Secrets Management
Secrets Management¶
Huitzo uses a three-tier secrets management system that separates platform-level, pack-level, and user-level secrets. This enables secure handling of API keys and credentials while maintaining proper access boundaries.
Overview¶
┌─────────────────────────────────────────────────────────────────────┐
│ Three-Tier Secrets Model │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ TIER 1: Platform Secrets │ │
│ │ Owner: Platform Admin │ │
│ │ Scope: All packs │ │
│ │ Examples: OPENAI_API_KEY, SENDGRID_API_KEY │ │
│ │ Access: ctx.env.require("OPENAI_API_KEY") │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ TIER 2: Pack Configuration │ │
│ │ Owner: Pack Developer │ │
│ │ Scope: Single pack │ │
│ │ Examples: Pack-specific settings, feature flags │ │
│ │ Access: ctx.config.get("setting_name") │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ TIER 3: User Secrets │ │
│ │ Owner: End User │ │
│ │ Scope: User + Pack │ │
│ │ Examples: User's external API keys │ │
│ │ Access: ctx.secrets.require("FINANCIAL_API_KEY") │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
Tier Comparison¶
| Aspect | Platform Secrets | Pack Config | User Secrets |
|---|---|---|---|
| Owner | Platform admin | Pack developer | End user |
| Scope | All packs | Single pack | User + Pack |
| Managed By | .env / platform config | Pack configuration | Dashboard UI / CLI |
| Examples | OpenAI, SendGrid, Anthropic | Feature flags, thresholds | User's CRM API key |
| Access Method | ctx.env.get() |
ctx.config.get() |
ctx.secrets.get() |
| Encryption | At rest | At rest | At rest |
| Visibility | Platform only | Pack developer | User only |
Tier 1: Platform Secrets¶
Platform secrets are API keys and credentials managed by the platform administrator. They are available to all packs but not directly accessible to end users.
Configuration¶
# .env or environment variables
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
SENDGRID_API_KEY=SG....
SDK Access¶
from huitzo_sdk import command, Context
@command("analyze", namespace="analytics")
async def analyze(args: Args, ctx: Context) -> dict:
# Access platform secret
openai_key = ctx.env.get("OPENAI_API_KEY")
# Require (raises if not set)
openai_key = ctx.env.require("OPENAI_API_KEY")
# Use with LLM service (uses platform key automatically)
response = await ctx.llm.complete(
"Analyze this data...",
model="gpt-4o" # Uses OPENAI_API_KEY automatically
)
return {"analysis": response}
When to Use Platform Secrets¶
- LLM API keys (OpenAI, Anthropic) for all packs
- Email service keys (SendGrid, Mailgun)
- Platform-wide external service credentials
- Services billed at the platform level
Required platform secrets (paid registration)¶
A handful of platform secrets are not optional — the backend refuses to boot
in non-debug mode without them, or the relevant flow fails at request time.
Set these in the GitHub Environment that owns each deploy
(gh secret set <NAME> --env staging and --env production); both
staging.yml and release.yml inject them into the per-environment .env
file at deploy time.
| Variable | Format | Purpose | What breaks if missing |
|---|---|---|---|
HUITZO_JWT_SECRET_KEY |
random string, ≥32 chars | Signs access/refresh tokens. | All authentication. |
HUITZO_AUDIT_PEPPER |
random string, ≥32 chars | HMAC pepper for redacted admin audit logs. | Backend boot fails (config.py). |
HUITZO_SECRETS_ENCRYPTION_KEY |
64-char hex (32 bytes) | Column-level AES-256-GCM key for encrypted columns (currently users.stripe_customer_id). The @validates hook also derives the lookup hash used by Stripe webhook routing. |
/api/v1/auth/register raises RuntimeError at request time; backend boot rejects in non-debug mode. Rotation cost: existing rows in users.stripe_customer_id would need to be re-encrypted, so pick a value at first paid signup and keep it stable. |
HUITZO_STRIPE_SECRET_KEY |
sk_test_… / sk_live_… |
Stripe API access for /registration/checkout. |
Endpoint returns 503. |
HUITZO_STRIPE_WEBHOOK_SECRET_REGISTRATION |
whsec_… |
Verifies the registration webhook signature. | Webhook events are rejected, no access codes minted. |
HUITZO_SENDGRID_API_KEY |
SendGrid token | Sends transactional email (access codes, dispute notices). | Access-code emails silently dropped. |
HUITZO_SENDGRID_TEMPLATE_ACCESS_CODE |
SendGrid template id | Template used for the access-code email. | Email render fails. |
Generate HUITZO_SECRETS_ENCRYPTION_KEY with:
openssl rand -hex 32
Use the same value in staging and production unless you have a deliberate
reason to differ (for example, isolating staging Stripe customers from
production-decrypt access). Rotating later requires a backfill pass over
users.stripe_customer_id.
Tier 2: Pack Configuration¶
Pack configuration stores pack-specific settings that developers can adjust per deployment.
Configuration¶
# Pack installation configuration
packs:
"@acme/claims":
config:
auto_approve_threshold: 1000
notification_email: "[email protected]"
features:
premium_analytics: true
SDK Access¶
@command("process", namespace="claims")
async def process(args: Args, ctx: Context) -> dict:
# Get pack config
threshold = ctx.config.get("auto_approve_threshold", default=500)
email = ctx.config.get("notification_email")
# Nested config
premium = ctx.config.get("features.premium_analytics", default=False)
if args.amount < threshold:
# Auto-approve
return {"status": "auto_approved"}
return {"status": "pending_review"}
When to Use Pack Configuration¶
- Per-deployment thresholds and limits
- Feature flags
- Integration endpoints
- Non-sensitive settings
Tier 3: User Secrets¶
User secrets are API keys and credentials that end users provide for their own external services. These are scoped to the user + pack combination.
As of v0.3 (Integrations Subsystem v2): Tier-3 secrets are stored in the unified
integrationstable asIntegration(type='secret_value')rows, not the legacyuser_secretstable. The AAD formatf"{tenant_id}:{user_id}:{pack_id}:{key}"is preserved byte-for-byte by migration 025, so existing ciphertexts decrypt unchanged. The pack-facing API (ctx.secrets.require("KEY")) is unchanged. See Integrations Subsystem v2 for details.
Declaring User Secrets in Manifest¶
# huitzo.yaml
pack:
name: "financial-sync"
namespace: "finance"
secrets:
user_required:
- name: "QUICKBOOKS_API_KEY"
description: "Your QuickBooks Online API key"
help_url: "https://developer.intuit.com/app/developer/qbo/docs/get-started"
- name: "PLAID_CLIENT_ID"
description: "Your Plaid client ID"
help_url: "https://dashboard.plaid.com/developers/keys"
- name: "PLAID_SECRET"
description: "Your Plaid secret key"
help_url: "https://dashboard.plaid.com/developers/keys"
user_optional:
- name: "PREMIUM_DATA_KEY"
description: "Optional: Premium financial data provider API key"
help_url: "https://premium-data.example.com/api"
SDK Access¶
The secrets API provides three access methods:
| Method | Behavior | Use When |
|---|---|---|
ctx.secrets.require(name) |
Returns value or raises SecretsError |
Secret is mandatory |
ctx.secrets.get(name) |
Returns value or None |
Secret is optional |
ctx.secrets.exists(name) |
Returns bool |
Checking before conditional logic |
# pseudocode — accessing secrets in a command
# Require a secret (raises SecretsError if user hasn't configured it)
credential = ctx.secrets.require("SECRET_NAME")
# Optional secret (returns None if not set)
optional_key = ctx.secrets.get("OPTIONAL_SECRET_NAME")
# Check existence before branching
if ctx.secrets.exists("OPTIONAL_SECRET_NAME"):
# use premium features
...
else:
# use standard features
...
# Use the credential to call an external service
result = await external_client.call(credential=credential, ...)
User Interface for Managing Secrets¶
Users manage their secrets through the Dashboard:
- Navigate to Settings → Pack Secrets
- Select the pack (e.g.,
@acme/financial-sync) - Enter values for required and optional secrets
- Click Save
Alternatively, via CLI:
# Set a secret
huitzo secrets set @acme/financial-sync PLAID_CLIENT_ID "your_client_id"
# List secrets for a pack
huitzo secrets list @acme/financial-sync
# Remove a secret
huitzo secrets remove @acme/financial-sync PREMIUM_DATA_KEY
Installation Flow¶
When installing a pack with required secrets:
Note: Installing a pack registers it in your tenant's database. Packs are not loaded as persistent processes; commands execute on-demand when invoked. See Resource Lifecycle Management for details.
1. User clicks "Install Pack"
2. Dashboard detects user_required secrets
3. Prompt user to enter each required secret:
┌─────────────────────────────────────────────────┐
│ @acme/financial-sync requires these secrets: │
│ │
│ PLAID_CLIENT_ID * │
│ [________________________] │
│ Your Plaid client ID (Required) │
│ ℹ️ How to get this key │
│ │
│ PLAID_SECRET * │
│ [________________________] │
│ Your Plaid secret key (Required) │
│ ℹ️ How to get this key │
│ │
│ PREMIUM_DATA_KEY (optional) │
│ [________________________] │
│ │
│ [Cancel] [Install Pack] │
└─────────────────────────────────────────────────┘
4. Secrets stored (encrypted) in user's secret store
5. Pack installation completes
Bring Your Own Key (BYOK)¶
Huitzo supports a "Bring Your Own Key" model that allows users to override Platform Secrets with their own credentials. This is primarily used for LLM API keys (e.g., OpenAI, Anthropic) to bypass platform usage limits.
How Precedence Works¶
The Huitzo SDK (ctx.llm and other integrations) follows this resolution order:
- User Secret: Check if the user has provided a secret with the standard name (e.g.,
OPENAI_API_KEY). - Platform Secret: If no user secret exists, fall back to the platform-managed secret (
ctx.env).
Example: Overriding OpenAI Key¶
If a user wants to use their own OpenAI subscription for a specific pack (e.g., to use a fine-tuned model or avoid platform rate limits):
- User Action: The user sets
OPENAI_API_KEYin the Pack Secrets settings for that pack. - SDK Behavior:
# Internal SDK Logic (simplified)
async def get_llm_client(ctx: Context):
# 1. Try to get User Secret first (BYOK)
api_key = ctx.secrets.get("OPENAI_API_KEY")
if api_key:
# User provided a key - use it (Does not consume platform quota)
return OpenAI(api_key=api_key)
# 2. Fall back to Platform Secret
api_key = ctx.env.require("OPENAI_API_KEY")
# Platform key used (Consumes platform quota)
return OpenAI(api_key=api_key)
This mechanism ensures that users can seamlessly upgrade their capacity without changing the code within the packs.
Security Model¶
Encryption¶
All secrets are encrypted at rest:
User provides secret
│
▼
┌──────────────────┐
│ Frontend (HTTPS) │
└────────┬─────────┘
│ TLS encrypted
▼
┌──────────────────┐
│ Huitzo API │
│ Encrypts with │
│ per-user key │
└────────┬─────────┘
│
▼
┌──────────────────┐
│ PostgreSQL │
│ (AES-256-GCM) │
└──────────────────┘
Access Control¶
| Actor | Platform Secrets | Pack Config | User Secrets |
|---|---|---|---|
| Platform Admin | ✅ Read/Write | ✅ Read/Write | ❌ |
| Pack Developer | ❌ | ✅ Read/Write | ❌ |
| End User | ❌ | ❌ | ✅ Own secrets only |
| Pack (Runtime) | ✅ | ✅ | ✅ For current user |
| Other Packs | ✅ Platform only | ❌ | ❌ |
Logging and Auditing¶
- Secrets are NEVER logged - Masked in all logs
- Access is audited - Who accessed which secret, when
- Error messages sanitized - No secret values in errors
# Example log output (secret masked)
INFO: Command sync-transactions accessed secret PLAID_CLIENT_ID
DEBUG: HTTP request to api.plaid.com (credentials: <masked>)
Rotation¶
Users can rotate secrets without reinstalling packs:
- Go to Settings → Pack Secrets → [Pack]
- Update the secret value
- New value takes effect immediately
Error Handling¶
SecretsError¶
Raised when a required secret is missing:
from huitzo_sdk.errors import SecretsError
@command("sync", namespace="finance")
async def sync(args: Args, ctx: Context) -> dict:
try:
api_key = ctx.secrets.require("FINANCIAL_API_KEY")
except SecretsError as e:
# Return helpful error message
return {
"error": "Missing API key",
"help": "Configure your API key in Settings → Pack Secrets",
"secret_name": e.secret_name
}
ExternalAPIError¶
Use when an external API call fails due to invalid credentials:
from huitzo_sdk.errors import ExternalAPIError
@command("fetch-data", namespace="data")
async def fetch_data(args: Args, ctx: Context) -> dict:
api_key = ctx.secrets.require("DATA_API_KEY")
try:
response = await ctx.http.get(
"https://api.external.com/data",
headers={"Authorization": f"Bearer {api_key}"}
)
except HTTPError as e:
if e.status_code == 401:
raise ExternalAPIError(
service="external-data",
message="Invalid API key. Please verify your key in Settings → Pack Secrets."
)
raise
return response.json()
Best Practices¶
For Pack Developers¶
- Minimize required secrets - Only require what's truly necessary
```yaml # ✅ Good: Only essential secrets required secrets: user_required: - name: "API_KEY" description: "Main API key for data access"
# ❌ Avoid: Too many required secrets secrets: user_required: - name: "API_KEY" - name: "API_SECRET" - name: "API_REGION" - name: "API_VERSION" ```
- Provide clear descriptions and help URLs
yaml
secrets:
user_required:
- name: "STRIPE_API_KEY"
description: "Your Stripe secret API key (starts with sk_)"
help_url: "https://dashboard.stripe.com/apikeys"
- Use optional secrets for premium features
python
if ctx.secrets.exists("PREMIUM_API_KEY"):
# Enable premium features
pass
else:
# Graceful fallback
pass
- Document which commands use which secrets
```yaml commands: - name: "basic-lookup" description: "Basic data lookup" # Uses: API_KEY only
- name: "premium-analytics"
description: "Advanced analytics (requires PREMIUM_API_KEY)"
# Uses: API_KEY, PREMIUM_API_KEY
```
For Platform Administrators¶
- Use environment variables or secrets manager
```bash # Development export OPENAI_API_KEY=sk-dev-...
# Production (use secrets manager) aws secretsmanager get-secret-value --secret-id huitzo/openai ```
-
Rotate secrets regularly
-
Use separate keys for dev/staging/production
For End Users¶
-
Never share secrets - Each user should have their own API keys
-
Use least-privilege keys - Create API keys with minimal required permissions
-
Rotate compromised keys immediately
-
Review which packs have access to your secrets
API Reference¶
ctx.secrets Methods¶
class SecretsService:
def get(self, name: str, default: str | None = None) -> str | None:
"""Get a user secret by name.
Args:
name: Secret name (UPPER_SNAKE_CASE)
default: Value to return if secret not set
Returns:
Secret value or default
"""
def require(self, name: str) -> str:
"""Get a required user secret.
Args:
name: Secret name (UPPER_SNAKE_CASE)
Returns:
Secret value
Raises:
SecretsError: If secret is not set
"""
def exists(self, name: str) -> bool:
"""Check if a user secret exists.
Args:
name: Secret name
Returns:
True if secret is set
"""
Error Classes¶
class SecretsError(HuitzoError):
"""Raised when a required secret is missing."""
def __init__(self, secret_name: str):
self.secret_name = secret_name
super().__init__(f"Missing required secret: {secret_name}")
class ExternalAPIError(HuitzoError):
"""Raised when an external API call fails."""
def __init__(self, service: str, message: str):
self.service = service
super().__init__(f"External API error ({service}): {message}")
Related Documentation¶
- Pack Manifest - Secrets - Declaring secrets
- SDK Context - Accessing secrets
- Error Handling - SecretsError, ExternalAPIError
- Configuration Reference - Platform configuration
- Resource Lifecycle - How packs are loaded and executed