Skip to main content

Authentication and Keychain Reference

This document provides comprehensive reference documentation for NoETL's authentication and keychain systems.

Table of Contents​


Overview​

NoETL provides two complementary systems for authentication and credential management:

  1. Auth Attribute - References to stored credentials used in tool executions
  2. Keychain System - Dynamic token caching with automatic renewal and scope-based access control

Key Concepts​

  • Auth Reference: A named reference (e.g., pg_local, gcp_auth) that points to stored credentials
  • Credential: Static authentication data stored in noetl.credential table (username/password, API keys, service accounts)
  • Keychain Entry: Dynamic, time-limited tokens stored in noetl.keychain table with automatic renewal capabilities
  • Scope: Access control level determining which executions can access a keychain entry

Auth Attribute​

The auth attribute is used in playbook steps to reference stored credentials. It is NOT the credential itself, but a reference name/profile.

Naming Convention​

Use names that clearly indicate they are references, not actual credentials:

Good Examples:

  • pg_auth - PostgreSQL auth reference
  • gcp_auth - Google Cloud Platform auth reference
  • aws_auth - AWS auth reference
  • snowflake_auth - Snowflake auth reference

Avoid:

  • oauth_cred - Misleading (sounds like the credential itself)
  • postgres_password - Sounds like the actual password
  • api_key - Sounds like the actual key

Usage in Playbooks​

current DSL format​

workload:
pg_auth: pg_local
gcp_auth: google_oauth

workflow:
- step: query_database
tool:
- query:
kind: postgres
auth: "{{ workload.pg_auth }}"
command: "SELECT * FROM users"

- step: fetch_secret
tool:
- get_secret:
kind: http
auth: "{{ workload.gcp_auth }}"
method: GET
url: "https://secretmanager.googleapis.com/v1/..."

Credential Storage​

Credentials are stored in the noetl.credential table:

CREATE TABLE noetl.credential (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL,
data_encrypted TEXT NOT NULL,
schema JSONB,
meta JSONB,
tags TEXT[],
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Key Fields:

  • name - Unique credential identifier (e.g., pg_prod, google_oauth)
  • type - Credential type (postgres, oauth2, bearer, api_key, etc.)
  • data_encrypted - Encrypted credential data (client_id, client_secret, etc.)
  • schema - Optional JSON schema definition for validation
  • meta - Additional metadata (version, owner, environment)
  • tags - Searchable tags for organization

Credential Types:

  • postgres - Database credentials (host, port, user, password, database)
  • oauth2 - OAuth2 credentials (client_id, client_secret, token_url)
  • bearer - Bearer token authentication
  • basic - HTTP basic auth (username, password)
  • api_key - API key authentication
  • service_account - Service account JSON (GCP, AWS)
  • snowflake - Snowflake credentials (account, user, password, warehouse, database)

Credential Resolution​

When a tool uses auth: "{{ workload.pg_auth }}", the worker:

  1. Extracts the auth reference name (pg_local)
  2. Calls credential API: GET /api/credentials/{identifier}?include_data=true
  3. Receives decrypted credential data
  4. Uses credentials to connect to the target service

Keychain System​

The keychain system provides dynamic token caching with scope-based access control and automatic renewal. It is designed for:

  • OAuth tokens that expire and need renewal
  • Session tokens from authentication APIs
  • Temporary credentials that should be shared across steps
  • Cached API responses that don't change frequently

Keychain Table Schema​

CREATE TABLE noetl.keychain (
cache_key TEXT PRIMARY KEY,
keychain_name TEXT NOT NULL,
catalog_id BIGINT NOT NULL REFERENCES noetl.catalog(catalog_id),
credential_type TEXT NOT NULL,
cache_type TEXT NOT NULL CHECK (cache_type IN ('secret', 'token')),
scope_type TEXT NOT NULL CHECK (scope_type IN ('local', 'global', 'shared')),
execution_id BIGINT,
parent_execution_id BIGINT,
data_encrypted TEXT NOT NULL,
schema JSONB,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
access_count INTEGER DEFAULT 0,
auto_renew BOOLEAN DEFAULT false,
renew_config JSONB
);

-- Indexes
CREATE INDEX idx_keychain_name_catalog ON noetl.keychain(keychain_name, catalog_id);
CREATE INDEX idx_keychain_execution ON noetl.keychain(execution_id);
CREATE INDEX idx_keychain_expires ON noetl.keychain(expires_at) WHERE auto_renew = true;

Key Fields:

  • cache_key - Composite key: {keychain_name}:{catalog_id}:{scope_qualifier}
  • keychain_name - Logical name from playbook (e.g., amadeus_token)
  • catalog_id - Links to playbook catalog entry
  • credential_type - Type of credential used (oauth2, secret_manager, bearer)
  • cache_type - Either secret (static) or token (refreshable)
  • scope_type - Access scope: local, global, or shared
  • data_encrypted - Encrypted token/secret data
  • schema - Optional validation schema for token structure
  • expires_at - Expiration timestamp (UTC)
  • auto_renew - Whether to automatically renew on expiration
  • renew_config - Configuration for automatic token renewal

Keychain Playbook Section​

Define keychain entries in the keychain: section of your playbook:

keychain:
- name: openai_token
kind: secret_manager
provider: gcp
scope: global
auth: "{{ workload.gcp_auth }}"
map:
api_key: '{{ workload.openai_secret_path }}'

- name: amadeus_credentials
kind: secret_manager
provider: gcp
scope: global
auth: "{{ workload.gcp_auth }}"
map:
client_id: '{{ workload.amadeus_key_path }}'
client_secret: '{{ workload.amadeus_secret_path }}'

- name: amadeus_token
kind: oauth2
scope: global
auto_renew: true
endpoint: https://test.api.amadeus.com/v1/security/oauth2/token
method: POST
headers:
Content-Type: application/x-www-form-urlencoded
data:
grant_type: client_credentials
client_id: '{{ keychain.amadeus_credentials.client_id }}'
client_secret: '{{ keychain.amadeus_credentials.client_secret }}'

Keychain Entry Kinds​

1. secret_manager​

Fetch secrets from external secret managers (GCP Secret Manager, AWS Secrets Manager, Azure Key Vault).

- name: api_credentials
kind: secret_manager
provider: gcp
scope: global
auth: "{{ workload.gcp_auth }}"
map:
username: 'projects/123/secrets/api-username/versions/latest'
password: 'projects/123/secrets/api-password/versions/latest'

Providers:

  • gcp - Google Cloud Secret Manager
  • aws - AWS Secrets Manager
  • azure - Azure Key Vault

2. oauth2​

Fetch OAuth2 tokens from authorization servers with automatic renewal.

- name: service_token
kind: oauth2
scope: global
auto_renew: true
endpoint: https://auth.example.com/oauth/token
method: POST
headers:
Content-Type: application/x-www-form-urlencoded
data:
grant_type: client_credentials
client_id: '{{ keychain.credentials.client_id }}'
client_secret: '{{ keychain.credentials.client_secret }}'

Auto-Renewal Configuration:

When auto_renew: true, the system stores the renewal configuration and automatically refreshes expired tokens:

renew_config:
endpoint: https://auth.example.com/oauth/token
method: POST
headers:
Content-Type: application/x-www-form-urlencased
data:
grant_type: client_credentials
client_id: "..."
client_secret: "..."
token_field: access_token # Field containing the token (default: "access_token")
ttl_field: expires_in # Field containing TTL in seconds (default: "expires_in")

Using Keychain Entries in Steps​

Reference keychain entries using Jinja2 templates:

workflow:
- step: call_api
tool:
- call:
kind: http
method: GET
url: "https://api.example.com/data"
headers:
Authorization: "Bearer {{ keychain.service_token.access_token }}"
params:
limit: 100

- step: translate_text
tool:
- translate:
kind: http
method: POST
url: "https://api.openai.com/v1/chat/completions"
headers:
Authorization: "Bearer {{ keychain.openai_token.api_key }}"
body:
model: gpt-4
messages:
- role: user
content: "Translate to French: {{ workload.text }}"

Scope Types​

Keychain entries support three scope types that control access and lifetime:

  • global: Shared across all executions
  • local: Isolated to single execution (execution-scoped)
  • shared: Shared across execution tree (parent + children)

1. Global Scope​

Access: Available to ALL executions of the playbook
Lifetime: Until token expires (respects TTL)
Use Case: Shared service tokens, API keys used across multiple executions

- name: shared_api_token
kind: oauth2
scope: global
auto_renew: true
endpoint: https://api.example.com/oauth/token

Cache Key Format: {keychain_name}:{catalog_id}:global

Example:

  • Keychain name: amadeus_token
  • Catalog ID: 518486534513754563
  • Cache key: amadeus_token:518486534513754563:global

Access Pattern:

  • Any execution of this playbook can access the token
  • Token is shared across all concurrent and future executions
  • One token refresh serves all executions
  • Most efficient for high-concurrency scenarios

2. Local Scope (Execution-Scoped)​

Access: Available ONLY to this specific execution (execution-scoped isolation)
Lifetime: Until execution completes OR token expires
Use Case: Execution-specific tokens, user session tokens, temporary credentials, per-run isolation

- name: user_session
kind: oauth2
scope: local
endpoint: https://auth.example.com/login
data:
username: "{{ workload.username }}"
password: "{{ workload.password }}"

Cache Key Format: {keychain_name}:{catalog_id}:{execution_id}

Example:

  • Keychain name: user_session
  • Catalog ID: 518486534513754563
  • Execution ID: 518508477736551392
  • Cache key: user_session:518486534513754563:518508477736551392

Access Pattern:

  • Only the execution that created it can access
  • Child playbooks: NO access (use shared scope for inheritance)
  • Sibling executions: NO access
  • Concurrent executions: Isolated (each has its own)
  • Automatically cleaned up when execution completes

Note: If you need child playbooks to access the keychain entry, use scope: shared instead.

3. Shared Scope (Execution Tree)​

Access: Available to entire execution tree (parent, children, grandchildren)
Lifetime: Until root execution completes OR token expires
Use Case: Multi-level playbook orchestration, parent-child shared state, context passing

Execution Tree Example:

Playbook A (execution_id: 123, creates shared keychain)
├─ Playbook B (execution_id: 456, can access)
│ └─ Playbook C (execution_id: 789, can access)
└─ Playbook D (execution_id: 101, can access)

All executions in this tree can access the shared keychain entry created by Playbook A.

- name: shared_context
kind: http
scope: shared
endpoint: https://api.example.com/initialize
method: POST
data:
project_id: "{{ workload.project_id }}"

Cache Key Format: {keychain_name}:{catalog_id}:shared:{execution_id}

Access Pattern:

  • Parent execution creates the entry (e.g., Playbook A)
  • All direct children can access (e.g., Playbook B, D)
  • All descendants can access (e.g., Playbook C)
  • Entry tagged with root execution_id for tree-wide access
  • Cleaned up when root execution completes

Use Cases:

  • Passing authentication context through playbook call chain
  • Sharing initialization state across orchestration layers
  • Multi-step workflows where sub-playbooks need parent's credentials

Scope Comparison Table​

ScopeAccess LevelLifetimeCache Key PatternCleanup Trigger
globalAll executionsUntil expires{name}:{catalog}:globalTTL expiration
catalogSame playbookUntil expires{name}:{catalog}:catalogTTL expiration
localExecution + childrenExecution OR expires{name}:{catalog}:{exec_id}Execution completion
sharedExecution treeRoot completion OR expires{name}:{catalog}:shared:{exec_id}Root completion

Schema Validation​

Both the credential and keychain tables support optional schema definitions to validate the structure of stored data.

Schema Format​

Schema definitions use JSON format with the following structure:

{
"fields": ["field1", "field2", "field3"],
"required": ["field1", "field2"],
"types": {
"field1": "string",
"field2": "integer",
"field3": "boolean"
},
"description": "Human-readable description of the credential type"
}

Common Schema Examples​

OAuth2 Client Credentials​

{
"fields": ["client_id", "client_secret", "token_url"],
"required": ["client_id", "client_secret"],
"types": {
"client_id": "string",
"client_secret": "string",
"token_url": "string"
},
"description": "OAuth2 client credentials for token-based authentication"
}

PostgreSQL Database​

{
"fields": ["db_host", "db_port", "db_user", "db_password", "db_name"],
"required": ["db_host", "db_user", "db_password", "db_name"],
"types": {
"db_host": "string",
"db_port": "integer",
"db_user": "string",
"db_password": "string",
"db_name": "string"
},
"description": "PostgreSQL database connection credentials"
}

Bearer Token​

{
"fields": ["access_token", "token_type", "expires_in"],
"required": ["access_token"],
"types": {
"access_token": "string",
"token_type": "string",
"expires_in": "integer"
},
"description": "Bearer token for API authentication"
}

API Key​

{
"fields": ["api_key", "api_secret"],
"required": ["api_key"],
"types": {
"api_key": "string",
"api_secret": "string"
},
"description": "API key and optional secret for service authentication"
}

Schema Validation Benefits​

Data Integrity: Validate credential structure before storage to catch configuration errors early.

Clear Error Messages: Get specific feedback about missing or incorrect fields:

Error: Missing required field 'client_secret' in OAuth2 credentials
Error: Field 'db_port' must be of type integer, got string

Custom Credential Types: Support any authentication system by defining custom schemas:

{
"fields": ["tenant_id", "subscription_key", "region", "endpoint"],
"required": ["tenant_id", "subscription_key"],
"types": {
"tenant_id": "string",
"subscription_key": "string",
"region": "string",
"endpoint": "string"
},
"description": "Azure Cognitive Services credentials"
}

Self-Documentation: Schema definitions serve as documentation for what fields are expected.

Using Schemas in Playbooks​

When registering credentials with schemas:

# Register via API (example)
POST /api/credentials
{
"name": "azure_cognitive",
"type": "custom",
"data": {
"tenant_id": "abc-123",
"subscription_key": "secret123",
"region": "eastus",
"endpoint": "https://eastus.api.cognitive.microsoft.com"
},
"schema": {
"fields": ["tenant_id", "subscription_key", "region", "endpoint"],
"required": ["tenant_id", "subscription_key"],
"types": {
"tenant_id": "string",
"subscription_key": "string",
"region": "string",
"endpoint": "string"
}
}
}

In keychain entries, schemas are automatically extracted from the credential definition or can be specified explicitly.

Validation Behavior​

On Storage (POST/PUT):

  • System validates data against schema before storing
  • Returns 400 error with specific field-level errors if validation fails
  • Prevents storing malformed credentials

On Retrieval (GET):

  • System validates cached data against schema
  • Logs warnings if cached data doesn't match schema
  • Helps detect data corruption or schema evolution issues

Optional Validation:

  • Schema field is optional - credentials without schemas work normally
  • Useful for legacy credentials or dynamic credential types
  • Add schemas incrementally as you refactor existing credentials

Validation Implementation​

Python Validation Function​

from typing import Dict, Any, List, Tuple

def validate_schema(data: Dict[str, Any], schema: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""
Validate data against schema definition.

Args:
data: Credential or token data to validate
schema: Schema definition with fields, required, types

Returns:
(is_valid, error_messages)

Example:
>>> data = {"client_id": "abc123"}
>>> schema = {
... "fields": ["client_id", "client_secret"],
... "required": ["client_id", "client_secret"]
... }
>>> validate_schema(data, schema)
(False, ['Missing required field: client_secret'])
"""
errors = []

# Check required fields
for field in schema.get('required', []):
if field not in data:
errors.append(f"Missing required field: {field}")

# Check field types
types_map = {
'string': str,
'integer': int,
'boolean': bool,
'number': (int, float),
'array': list,
'object': dict
}

for field, expected_type_str in schema.get('types', {}).items():
if field in data:
expected_type = types_map.get(expected_type_str)
if expected_type and not isinstance(data[field], expected_type):
actual_type = type(data[field]).__name__
errors.append(
f"Field '{field}' must be {expected_type_str}, got {actual_type}"
)

# Check for unexpected fields (optional, configurable)
allowed_fields = set(schema.get('fields', []))
if allowed_fields:
unexpected = set(data.keys()) - allowed_fields
if unexpected:
errors.append(f"Unexpected fields: {', '.join(unexpected)}")

return len(errors) == 0, errors

Integration Points​

1. Credential API (noetl/server/api/credential/endpoint.py):

@router.post("/")
async def create_credential(credential: CredentialCreate):
# Validate against schema if provided
if credential.schema:
is_valid, errors = validate_schema(credential.data, credential.schema)
if not is_valid:
raise HTTPException(
status_code=400,
detail={"message": "Credential validation failed", "errors": errors}
)

# Store credential
return await service.create_credential(credential)

2. Keychain Service (noetl/server/api/keychain/service.py):

async def set_keychain_entry(
keychain_name: str,
catalog_id: int,
token_data: Dict[str, Any],
schema: Optional[Dict[str, Any]] = None,
**kwargs
):
# Validate against schema if provided
if schema:
is_valid, errors = validate_schema(token_data, schema)
if not is_valid:
logger.error(f"Token validation failed: {errors}")
raise ValueError(f"Token validation failed: {', '.join(errors)}")

# Store token
await self._store_keychain_entry(...)

3. Worker Keychain Resolver (noetl/worker/keychain_resolver.py):

async def resolve_keychain_entries(keychain_section, catalog_id, ...):
for entry in keychain_entries:
result = await fetch_keychain(entry.name, catalog_id)

if result.get('schema'):
# Validate resolved data against schema
is_valid, errors = validate_schema(
result.get('data', {}),
result['schema']
)
if not is_valid:
logger.warning(
f"KEYCHAIN: Entry '{entry.name}' failed schema validation: {errors}"
)

Error Message Examples​

Missing Required Field:

Error: Missing required field: 'client_secret'
Expected fields: client_id, client_secret, token_url
Provided fields: client_id, token_url

Type Mismatch:

Error: Field 'db_port' must be integer, got string
Value: "5432" (string)
Expected: 5432 (integer)

Unexpected Fields:

Error: Unexpected fields: extra_field, unknown_param
Allowed fields: client_id, client_secret, token_url

Additional Use Cases​

Custom Cloud Provider:

{
"fields": ["tenant_id", "subscription_key", "region", "endpoint"],
"required": ["tenant_id", "subscription_key"],
"types": {
"tenant_id": "string",
"subscription_key": "string",
"region": "string",
"endpoint": "string"
},
"description": "Azure Cognitive Services credentials"
}

Multi-Region Configuration:

{
"fields": ["primary_key", "secondary_key", "primary_region", "secondary_region"],
"required": ["primary_key", "primary_region"],
"types": {
"primary_key": "string",
"secondary_key": "string",
"primary_region": "string",
"secondary_region": "string"
},
"description": "Multi-region API credentials with failover"
}

Expiration and Auto-Renewal​

Expiration Behavior​

Keychain entries have configurable expiration times:

- name: short_lived_token
kind: oauth2
scope: local
ttl_seconds: 300 # 5 minutes
endpoint: https://api.example.com/token

Default TTL:

  • local scope: 3600 seconds (1 hour)
  • global/catalog/shared scope: 86400 seconds (24 hours)

Expiration Handling:

  1. Worker requests keychain entry via API
  2. API checks expires_at timestamp
  3. If expired:
    • Without auto_renew: Returns status: expired, worker fails gracefully
    • With auto_renew: Returns status: expired with renew_config, worker triggers renewal

Auto-Renewal Process​

When auto_renew: true and token expires:

Auto-Renewal Configuration:

The renew_config stored in the keychain entry contains:

{
"endpoint": "https://auth.example.com/oauth/token",
"method": "POST",
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
},
"data": {
"grant_type": "client_credentials",
"client_id": "...",
"client_secret": "..."
},
"token_field": "access_token",
"ttl_field": "expires_in"
}

Worker Renewal Logic:

# In noetl/worker/keychain_resolver.py
async def _renew_token(keychain_name, renew_config, client):
endpoint = renew_config['endpoint']
method = renew_config.get('method', 'POST')
headers = renew_config.get('headers', {})
data = renew_config.get('data')

response = await client.request(method, endpoint, headers=headers, data=data)

if response.status_code == 200:
token_data = response.json()
return token_data # Contains access_token, expires_in, etc.
else:
return None

async def _update_keychain_entry(keychain_name, catalog_id, token_data, renew_config, api_base_url, client):
ttl_seconds = token_data.get('expires_in', 3600)

payload = {
"token_data": token_data,
"credential_type": "oauth2_client_credentials",
"ttl_seconds": ttl_seconds,
"auto_renew": True,
"renew_config": renew_config
}

response = await client.post(f"{api_base_url}/api/keychain/{catalog_id}/{keychain_name}", json=payload)
return response.status_code == 200

Renewal Failure Handling​

If renewal fails:

  1. Worker logs error: KEYCHAIN: Failed to renew '{keychain_name}'
  2. Returns empty dict: resolved[keychain_name] = {}
  3. Template rendering fails with clear error
  4. Step fails with error event

Retry Strategy:

Renewals are NOT automatically retried. If renewal fails:

  • Manual intervention required (check credentials, network, auth server)
  • Or wait for next execution to attempt renewal again
  • Or delete expired keychain entry and re-initialize

API Reference​

Keychain API Endpoints​

Base URL: http://noetl.noetl.svc.cluster.local:8080/api/keychain

1. Get Keychain Entry​

GET /api/keychain/{catalog_id}/{keychain_name}?execution_id={execution_id}&scope_type={scope_type}

Parameters:

  • catalog_id (path, required): Catalog/playbook identifier
  • keychain_name (path, required): Name of keychain entry
  • execution_id (query, optional): Execution ID for local/shared scope
  • scope_type (query, optional): Scope type (default: "global")

Response (Success):

{
"status": "success",
"keychain_name": "amadeus_token",
"catalog_id": 518486534513754563,
"cache_key": "amadeus_token:518486534513754563:global",
"token_data": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 1799
},
"credential_type": "oauth2_client_credentials",
"cache_type": "token",
"scope_type": "global",
"expires_at": "2025-12-16T02:30:00Z",
"ttl_seconds": 1234.5,
"accessed_at": "2025-12-16T02:10:00Z",
"access_count": 15,
"auto_renew": true,
"expired": false
}

Response (Expired with Auto-Renew):

{
"status": "expired",
"keychain_name": "amadeus_token",
"catalog_id": 518486534513754563,
"cache_key": "amadeus_token:518486534513754563:global",
"auto_renew": true,
"renew_config": {
"endpoint": "https://test.api.amadeus.com/v1/security/oauth2/token",
"method": "POST",
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
"data": {
"grant_type": "client_credentials",
"client_id": "...",
"client_secret": "..."
}
},
"expired": true
}

Response (Not Found):

{
"status": "not_found",
"keychain_name": "nonexistent_token",
"catalog_id": 518486534513754563,
"cache_key": "nonexistent_token:518486534513754563:global"
}

2. Set Keychain Entry​

POST /api/keychain/{catalog_id}/{keychain_name}

Request Body:

{
"token_data": {
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 1799
},
"credential_type": "oauth2_client_credentials",
"cache_type": "token",
"scope_type": "global",
"execution_id": null,
"parent_execution_id": null,
"ttl_seconds": 1800,
"expires_at": null,
"auto_renew": true,
"renew_config": {
"endpoint": "https://test.api.amadeus.com/v1/security/oauth2/token",
"method": "POST",
"headers": {"Content-Type": "application/x-www-form-urlencoded"},
"data": {"grant_type": "client_credentials", "client_id": "...", "client_secret": "..."}
}
}

Response:

{
"status": "success",
"message": "Keychain entry cached successfully with 1800s TTL",
"keychain_name": "amadeus_token",
"catalog_id": 518486534513754563,
"cache_key": "amadeus_token:518486534513754563:global",
"expires_at": "2025-12-16T02:30:00Z",
"ttl_seconds": 1800,
"auto_renew": true
}

3. Delete Keychain Entry​

DELETE /api/keychain/{catalog_id}/{keychain_name}?execution_id={execution_id}&scope_type={scope_type}

Response:

{
"status": "success",
"message": "Keychain entry deleted successfully",
"keychain_name": "amadeus_token",
"catalog_id": 518486534513754563
}

4. List Catalog Keychain Entries​

GET /api/keychain/catalog/{catalog_id}

Response:

{
"status": "success",
"catalog_id": 518486534513754563,
"entries": [
{
"keychain_name": "amadeus_token",
"cache_key": "amadeus_token:518486534513754563:global",
"scope_type": "global",
"credential_type": "oauth2_client_credentials",
"expires_at": "2025-12-16T02:30:00Z",
"auto_renew": true,
"access_count": 15
},
{
"keychain_name": "openai_token",
"cache_key": "openai_token:518486534513754563:global",
"scope_type": "global",
"credential_type": "secret_manager",
"expires_at": "2025-12-17T01:00:00Z",
"auto_renew": false,
"access_count": 42
}
],
"count": 2
}

Credential API Endpoints​

Base URL: http://<server>:8082/api

Get Credential (by id or name)​

GET /api/credentials/{identifier}
GET /api/credentials/{identifier}?include_data=true

Response:

{
"id": "123",
"name": "pg_local",
"type": "postgres",
"data": {
"db_host": "localhost",
"db_port": 5432,
"db_user": "demo",
"db_password": "demo",
"db_name": "demo_noetl"
},
"created_at": "2025-12-01T00:00:00Z",
"updated_at": "2025-12-15T12:00:00Z"
}

Playbook Examples​

These examples:

  • use url (HTTP tool) instead of legacy endpoint
  • avoid payload.* (execution-request inputs merge into workload)
  • pass nested-playbook inputs via token args (from next.arcs[].args or a kind: playbook task)

Example 1: API key from keychain (secret manager) → HTTP call​

apiVersion: noetl.io/v2
kind: Playbook
metadata:
name: openai_example
path: examples/openai_example

workload:
prompt: "Translate to French: hello"

keychain:
- name: openai_token
kind: secret_manager
scope: global
auth: google_oauth
map:
api_key: "projects/123/secrets/openai-key/versions/latest"

workflow:
- step: call_openai
tool:
- chat:
kind: http
method: POST
url: "https://api.openai.com/v1/chat/completions"
headers:
Authorization: "Bearer {{ keychain.openai_token.api_key }}"
body:
model: gpt-4
messages:
- role: user
content: "{{ workload.prompt }}"

Example 2: OAuth2 token in keychain → HTTP call​

keychain:
- name: oauth_client
kind: secret_manager
scope: global
auth: google_oauth
map:
client_id: "projects/123/secrets/client-id/versions/latest"
client_secret: "projects/123/secrets/client-secret/versions/latest"

- name: service_token
kind: oauth2
scope: global
auto_renew: true
endpoint: https://auth.example.com/oauth/token
method: POST
headers:
Content-Type: application/x-www-form-urlencoded
data:
grant_type: client_credentials
client_id: "{{ keychain.oauth_client.client_id }}"
client_secret: "{{ keychain.oauth_client.client_secret }}"

workflow:
- step: call_api
tool:
- call:
kind: http
method: GET
url: "https://api.example.com/data"
headers:
Authorization: "Bearer {{ keychain.service_token.access_token }}"

Example 3: Parent → child playbook (args payload)​

# Parent playbook
workflow:
- step: run_child
tool:
- child:
kind: playbook
path: orchestration/child_playbook
args:
dataset_name: dataset_a
project_token: "{{ keychain.project_context.project_token }}"

# Child playbook
workflow:
- step: fetch_data
tool:
- fetch:
kind: http
method: GET
url: "https://api.example.com/v1/datasets/{{ args.dataset_name }}"
headers:
Authorization: "Bearer {{ args.project_token }}"

Best Practices​

1. Use Appropriate Scopes​

  • Global: Service-to-service tokens, organization-wide API keys
  • Catalog: Playbook-specific credentials
  • Local: User sessions, execution-specific temporary credentials
  • Shared: Multi-level orchestration, parent-child shared state

2. Enable Auto-Renewal for Long-Running Workflows​

- name: long_running_token
kind: oauth2
scope: global
auto_renew: true # Critical for workflows >1 hour
endpoint: https://auth.example.com/token

3. Set Reasonable TTLs​

- name: short_lived_session
kind: oauth2
scope: local
ttl_seconds: 900 # 15 minutes for quick tasks

- name: daily_batch_token
kind: oauth2
scope: global
ttl_seconds: 86400 # 24 hours for daily jobs

4. Use Clear Naming Conventions​

workload:
pg_auth: pg_local # Auth reference
gcp_auth: google_oauth # Auth reference
api_key_path: projects/... # Secret path

keychain:
- name: service_credentials # Keychain entry name
kind: secret_manager
auth: "{{ workload.gcp_auth }}" # Uses auth reference

5. Secure Sensitive Data​

  • Store credentials in noetl.credential table (encrypted at rest)
  • Use keychain for temporary tokens (encrypted in database)
  • Never log credential data or tokens
  • Use secret managers (GCP, AWS, Azure) for production secrets

6. Monitor Keychain Access​

-- Check keychain access patterns
SELECT
keychain_name,
scope_type,
access_count,
accessed_at,
expires_at,
auto_renew
FROM noetl.keychain
WHERE catalog_id = <your_catalog_id>
ORDER BY accessed_at DESC;

-- Find expired entries
SELECT keychain_name, expires_at, auto_renew
FROM noetl.keychain
WHERE expires_at < NOW()
ORDER BY expires_at DESC;

7. Clean Up Local Entries​

Local scope entries are automatically cleaned up when executions complete, but you can manually clean up if needed:

-- Delete local entries for completed execution
DELETE FROM noetl.keychain
WHERE scope_type = 'local'
AND execution_id = <completed_execution_id>;

-- Delete expired entries without auto_renew
DELETE FROM noetl.keychain
WHERE expires_at < NOW()
AND auto_renew = FALSE;

Troubleshooting​

Token Not Found​

Error: KEYCHAIN: Entry 'token_name' not found

Causes:

  1. Keychain entry not initialized
  2. Wrong scope type specified
  3. Execution ID mismatch for local scope

Solution:

  • Verify keychain section in playbook
  • Check scope_type matches usage
  • For local scope, ensure execution_id is correct

Token Expired​

Error: KEYCHAIN: Entry 'token_name' expired

Causes:

  1. TTL too short
  2. Auto-renewal not enabled
  3. Renewal credentials invalid

Solution:

  • Increase ttl_seconds
  • Enable auto_renew: true
  • Verify renewal credentials are valid

Renewal Failed​

Error: KEYCHAIN: Failed to renew 'token_name'

Causes:

  1. Invalid client credentials
  2. Auth server unreachable
  3. Incorrect renewal endpoint

Solution:

  • Verify client_id and client_secret
  • Check network connectivity
  • Verify renewal endpoint URL
  • Check auth server logs

Template Resolution Failed​

Error: {{ keychain.token_name.access_token }} not resolved

Causes:

  1. catalog_id not in context
  2. Keychain resolver not called
  3. Worker-side resolution issue

Solution:

  • Verify catalog_id flows from server to worker
  • Check worker logs for keychain resolution
  • Verify HTTP/Postgres executor calls populate_keychain_context()

See Also​