Credential Resolution
How the SEAL Gateway resolves upstream credentials at invocation time — the five resolution strategies, their configuration, and their failure modes.
Credential Resolution
The gateway never embeds credentials into a registered API spec, workflow, or CLI tool. It stores a resolution path — a typed reference that tells the gateway how to obtain the credential when an invocation runs. Every call resolves fresh; nothing is cached past the request.
There are five resolution strategies. Pick the one that matches who owns the credential and how it is provisioned.
Choosing a strategy
Is the credential per-user
(each Zaru user has their own)?
│
┌────────────────┴────────────────┐
│ │
yes no
│ │
▼ ▼
User identities have Credential is dynamic
per-user grants stored and tenant-scoped
in Postgres? (e.g. AWS STS)?
│ │
┌─────────┴─────────┐ ┌─────────────┴─────────┐
│ │ │ │
yes no yes no
│ │ │ │
▼ ▼ ▼ ▼
UserBound HumanDelegated SystemJit Static and shared
(pg-only) (token exch) (OpenBao engine) across all callers?
│
┌─────────┴─────────┐
│ │
yes no
│ │
▼ ▼
StaticRef Auto
(KV path) (HumanDelegated
if user token
present, else
SystemJit)Use Auto when a tool can be invoked from both interactive (user-context) and automation (system-context) flows and you want the same definition to behave correctly in both. Use the explicit strategies when the call site is unambiguous.
Strategy reference
SystemJit
Dynamic credentials minted by an OpenBao secrets engine at invocation time. The gateway calls the engine's creds/<role> endpoint and surfaces the returned token (or password) field as a Bearer token.
{
"kind": "system_jit",
"openbao_engine_path": "aws/creds",
"role": "read-only-deployer"
}The path is automatically tenant-scoped: an engine_path of aws/creds resolved under tenant acme becomes tenant-acme/aws/creds. Operate engine mounts under the matching tenant-<slug>/ namespace in OpenBao to take advantage of this — system-level calls (no tenant) use the path as-is.
Required environment:
SEAL_GATEWAY_OPENBAO_ADDR=https://openbao.internal:8200
SEAL_GATEWAY_OPENBAO_TOKEN=<gateway service token>
SEAL_GATEWAY_OPENBAO_KV_MOUNT=secretOpenBao role configuration (AWS engine example):
bao write tenant-acme/aws/config/root \
access_key=AKIA... \
secret_key=...
bao write tenant-acme/aws/roles/read-only-deployer \
credential_type=iam_user \
policy_arns=arn:aws:iam::aws:policy/ReadOnlyAccess
bao read tenant-acme/aws/creds/read-only-deployer
# -> { "data": { "access_key": "...", "secret_key": "...", "token": "..." } }Failure mode: a non-2xx response from OpenBao surfaces as a CredentialExchangeFailed audit event. The most common cause is the gateway's service token lacking read on the role path.
HumanDelegated
Performs an OAuth 2.0 token exchange against Keycloak, swapping the calling user's access token for one whose aud is the upstream service. The exchanged token is returned as a Bearer credential.
{
"kind": "human_delegated",
"target_service": "https://api.github.example.com"
}Required environment:
SEAL_GATEWAY_KEYCLOAK_TOKEN_EXCHANGE_URL=https://kc.example.com/realms/aegis/protocol/openid-connect/token
SEAL_GATEWAY_KEYCLOAK_CLIENT_ID=seal-gateway
SEAL_GATEWAY_KEYCLOAK_CLIENT_SECRET=<client secret>Keycloak client configuration (the seal-gateway client):
| Setting | Value |
|---|---|
| Access type | confidential |
| Service Accounts Enabled | off |
| Standard Flow Enabled | off |
| Direct Access Grants | off |
| Token Exchange | on (Permissions tab → token-exchange permission granted) |
| Audience mappers | one per target_service value, mapping the audience claim |
The exchange uses the standard token-exchange grant:
grant_type = urn:ietf:params:oauth:grant-type:token-exchange
subject_token_type = urn:ietf:params:oauth:token-type:access_token
requested_token_type = urn:ietf:params:oauth:token-type:access_token
subject_token = <user access token from the SEAL session>
audience = <target_service from credential_path>
client_id = $SEAL_GATEWAY_KEYCLOAK_CLIENT_ID
client_secret = $SEAL_GATEWAY_KEYCLOAK_CLIENT_SECRETIf the SEAL session has no Zaru user token attached, HumanDelegated returns Unauthorized immediately — there is no system fallback for this strategy. Use Auto if you want the system-context fallback.
Auto
A delegated/system hybrid. Configured with both a target_service (for the delegated branch) and an OpenBao engine_path + role (for the system branch). At resolve time:
- Zaru user token present on the session → behave as
HumanDelegatedagainsttarget_service. - No user token → behave as
SystemJitagainst the engine path and role.
{
"kind": "auto",
"system_jit_openbao_engine_path": "aws/creds",
"system_jit_role": "read-only-deployer",
"target_service": "https://aws.amazonaws.com"
}This is the right shape for tools that legitimately serve both interactive (Zaru chat) and automation (workflow executor) callers.
StaticRef
A fixed pointer into the OpenBao KV mount. No tenant scoping — the same key resolves the same value for every caller. Useful for shared service tokens that are not user- or tenant-specific (a single internal SaaS API key, a shared GitHub bot PAT).
{
"kind": "static_ref",
"key": "shared/saas-api-token"
}The KV path resolved is <openbao_kv_mount>/data/<key>. The gateway expects the secret to expose either a token or a value field; the resolver wraps it as Authorization: Bearer <value> for HTTP injection.
bao kv put secret/shared/saas-api-token token="abc123..."
bao kv get secret/shared/saas-api-tokenFailure modes: missing token/value field surfaces as CredentialExchangeFailed with a deserialization error; a non-2xx KV read surfaces as an HTTP failure. Empty key is rejected as a validation error before any network call.
UserBound (Postgres only)
Per-user credential bindings stored in the gateway's Postgres database, gated by per-user grants. Only available when the gateway is configured with Postgres — SQLite deployments do not have the binding tables and UserBound returns empty (with a HumanDelegated fallback if a user token is present, otherwise Unauthorized).
{
"kind": "user_bound",
"provider": "github"
}Resolution:
- Extract the
subclaim from the calling user's JWT. - Query
credential_bindingsjoined tocredential_grants, filtered byowner_user_id = sub,provider = <provider>,status = 'active', and a tenant filter that matches either the calling tenant or aNULL(un-tenanted) binding. - Read the bound secret from OpenBao at the path stored in
credential_bindings.secret_path. - Surface as
Authorization: Bearer <token|value>.
If no active binding exists for that user and provider, the resolver falls back: HumanDelegated if a user token is present, otherwise Unauthorized. This is the only strategy that has an automatic fallback to a different strategy.
Per-spec, per-workflow, per-CLI configuration
Every registered tool kind carries the same credential_path discriminated-union field. The wire shape is identical across surfaces — only the surrounding object differs.
API spec
POST /v1/specs:
{
"name": "github-api",
"base_url": "https://api.github.com",
"source_url": "https://api.github.com/openapi.json",
"inline_json": null,
"source_fetch_url": "https://api.github.com/openapi.json",
"credential_path": {
"kind": "user_bound",
"provider": "github"
}
}Workflow
POST /v1/workflows — workflows inherit the credential path from their bound API spec; per-step overrides are not modeled at the workflow registration layer.
CLI tool
POST /v1/cli-tools — separate registry_credential_path field used to pull the tool's container image (rather than to authenticate the tool's outbound calls).
{
"name": "tf",
"description": "Terraform CLI in a sandboxed image.",
"docker_image": "ghcr.io/example/terraform:1.7.5",
"allowed_subcommands": ["init", "plan", "apply"],
"require_semantic_judge": false,
"default_timeout_seconds": 600,
"registry_credential_path": {
"kind": "static_ref",
"key": "shared/ghcr-pull-token"
}
}Registry credentials are surfaced as RegistryCredentials { registry, username, password } rather than a Bearer header, so the resolver pulls the username / user / access_key and password / secret_key / token / value fields out of the KV / dynamic secret. If the secret only exposes a token, the resolver synthesizes oauth2accesstoken as the username (the GCR / Artifact Registry convention).
Credential injection mechanics
For HTTP requests (API spec calls, workflow steps), the resolved credential injects into the outbound request as a header pair. The resolver returns a list of (header_name, sensitive_value) tuples, and by default that list contains one entry: ("Authorization", "Bearer <token>"). Specs can declare alternative auth headers, but the default path is Bearer.
For CLI tools, the credential injects into the spawned container's environment rather than a header. The resolver returns the same (name, value) tuple shape, but the call site reads it as an env binding instead of an HTTP header.
The gateway's SensitiveString wrapper prevents the resolved credential from ever appearing in Debug output, structured logs, or the OpenAPI response examples. The only places a credential value is observable are:
- The outbound request to the upstream service (where it must appear by construction).
- The OpenBao audit log (where you opted in by configuring it).
The gateway emits metadata — credential strategy, KV path, role name, target service, exit status — to its own audit stream. Credential values are never emitted there.
Failure modes and audit events
Every resolution attempt produces exactly one of two events:
CredentialExchangeCompleted— strategy succeeded; metadata only.CredentialExchangeFailed— strategy failed; carries theGatewayErrorvariant and a redacted error string.
Query the audit stream by event name. With the OTLP / Loki sink configured:
{job="aegis-seal-gateway"} | json | gateway_event = "CredentialExchangeFailed"Or via the gateway's /v1/audit-events endpoint:
curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \
"https://gateway.example.com/v1/audit-events?event=CredentialExchangeFailed&since=2026-04-27T00:00:00Z"Common failures and their root causes:
| Failure | Root cause |
|---|---|
Internal: SEAL_GATEWAY_OPENBAO_ADDR is required | OpenBao not configured; required by SystemJit, StaticRef, UserBound. |
Http: OpenBao JIT request returned 403 | Gateway service token lacks read on <engine_path>/creds/<role>. |
Serialization: OpenBao JIT response missing token/password field | Role defined but does not return a token-shaped credential. |
Http: Keycloak token exchange returned 400 | Token Exchange permission not granted to the seal-gateway client, or the target_service audience mapper is missing. |
Unauthorized from HumanDelegated | No Zaru user token attached to the SEAL session. Use Auto if a system-context fallback is desired. |
Validation: StaticRef key cannot be empty | credential_path.key is empty or whitespace. |
Forbidden from registry resolution | HumanDelegated registry credentials require the security context to permit human-delegated credential resolution. |
When debugging a failed resolution, start at the audit event, identify the strategy and the underlying error string, and confirm the corresponding environment variable / secret store / IdP setting matches what that strategy needs.
Security Contexts
Define named, server-side capability policies that gate every SEAL tool call by tool name, path, command, subcommand, domain, and output size.
Registering API Specs
Register OpenAPI 3.0 documents with the SEAL Gateway so workflows and the API Explorer can call HTTP operations on your behalf.