Aegis Orchestrator
SEAL Gateway

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=secret

OpenBao 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):

SettingValue
Access typeconfidential
Service Accounts Enabledoff
Standard Flow Enabledoff
Direct Access Grantsoff
Token Exchangeon (Permissions tab → token-exchange permission granted)
Audience mappersone 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_SECRET

If 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 HumanDelegated against target_service.
  • No user token → behave as SystemJit against 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-token

Failure 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:

  1. Extract the sub claim from the calling user's JWT.
  2. Query credential_bindings joined to credential_grants, filtered by owner_user_id = sub, provider = <provider>, status = 'active', and a tenant filter that matches either the calling tenant or a NULL (un-tenanted) binding.
  3. Read the bound secret from OpenBao at the path stored in credential_bindings.secret_path.
  4. 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 the GatewayError variant 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:

FailureRoot cause
Internal: SEAL_GATEWAY_OPENBAO_ADDR is requiredOpenBao not configured; required by SystemJit, StaticRef, UserBound.
Http: OpenBao JIT request returned 403Gateway service token lacks read on <engine_path>/creds/<role>.
Serialization: OpenBao JIT response missing token/password fieldRole defined but does not return a token-shaped credential.
Http: Keycloak token exchange returned 400Token Exchange permission not granted to the seal-gateway client, or the target_service audience mapper is missing.
Unauthorized from HumanDelegatedNo Zaru user token attached to the SEAL session. Use Auto if a system-context fallback is desired.
Validation: StaticRef key cannot be emptycredential_path.key is empty or whitespace.
Forbidden from registry resolutionHumanDelegated 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.

On this page