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 integrations table as Integration(type='secret_value') rows, not the legacy user_secrets table. The AAD format f"{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:

  1. Navigate to Settings → Pack Secrets
  2. Select the pack (e.g., @acme/financial-sync)
  3. Enter values for required and optional secrets
  4. 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:

  1. User Secret: Check if the user has provided a secret with the standard name (e.g., OPENAI_API_KEY).
  2. 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):

  1. User Action: The user sets OPENAI_API_KEY in the Pack Secrets settings for that pack.
  2. 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:

  1. Go to Settings → Pack Secrets → [Pack]
  2. Update the secret value
  3. 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

  1. 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" ```

  1. 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"

  1. Use optional secrets for premium features

python if ctx.secrets.exists("PREMIUM_API_KEY"): # Enable premium features pass else: # Graceful fallback pass

  1. 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

  1. 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 ```

  1. Rotate secrets regularly

  2. Use separate keys for dev/staging/production

For End Users

  1. Never share secrets - Each user should have their own API keys

  2. Use least-privilege keys - Create API keys with minimal required permissions

  3. Rotate compromised keys immediately

  4. 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}")