secrets

package
v1.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 28 Imported by: 0

Documentation

Overview

Package secrets seals and unseals sensitive values (BYOK API keys, OAuth credentials, OIDC client secrets) at rest. The Sealer interface lets us swap the AES-GCM master-key implementation for a KMS-backed one later without touching call sites.

Wire format of an AES-GCM sealed blob (single byte version prefix for forward compatibility):

v1: 0x01 | nonce(12) | ciphertext+tag

Authenticated additional data (AAD) binds the ciphertext to a caller-supplied context string (e.g. "api_key:<id>") so a sealed value cannot be silently moved between records.

Index

Constants

View Source
const ApiKeysCollectionName = "api_keys"
View Source
const BotSecretBindingsCollectionName = "bot_secret_bindings"
View Source
const DefaultAnthropicOAuthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"

Browser OAuth (authorization-code + PKCE) for the Claude Code subscription "forfait". This reproduces exactly what `claude login` does — same public client, same PKCE S256, same endpoints — but driven from the studio so a cloud operator never has to run the CLI in a pod nor paste a credentials.json file.

Why the code-paste (headless) redirect and not a silent callback: the public Claude Code OAuth client only permits two redirect URIs — Anthropic's own code-display page, or a localhost loopback. A remote studio is neither, so it cannot register itself as a redirect target. The headless page shows the user a `code#state` string they paste back; iterion exchanges it server-side. This is the only flow that works from a cloud-hosted studio.

All values are env-overridable because this is an undocumented, reverse-engineered surface: if Anthropic rotates the client/flow, an operator can re-point it without a rebuild. DefaultAnthropicOAuthClientID is the public Claude Code OAuth client id. It is a PUBLIC PKCE client (no client secret), the same id the `claude` CLI embeds — not a confidential value. Shipping it as a default lets the browser connect flow work out of the box; an operator can still override it via ITERION_OAUTH_FORFAIT_ANTHROPIC_CLIENT_ID if Anthropic rotates the client.

View Source
const DefaultOAuthPendingTTL = 10 * time.Minute

DefaultOAuthPendingTTL bounds how long a started browser-OAuth flow can wait for the user to paste the code back.

View Source
const DefaultRunSecretsTTL = 24 * time.Hour

DefaultRunSecretsTTL bounds how long a sealed bundle can live untouched. Resume paths re-publish so the runner can always re- fetch even after a TTL eviction (the publisher will re-resolve).

View Source
const GenericSecretsCollectionName = "generic_secrets"
View Source
const LocalScopeTeam = "local"

LocalScopeTeam is the synthetic team id stamped on every secret held by the local (desktop / headless) file-backed store. Resolution reuses the cloud path verbatim — ResolveGeneric requires a non-empty team id and the Mongo store keys on a tenant pulled from context; the file store has no tenant, so it pins this constant instead, and ResolveGeneric is called as ResolveGeneric(ctx, store, LocalScopeTeam, "", names, sealer).

View Source
const LocalSecretsFileName = "secrets.json"

LocalSecretsFileName is the basename of the sealed store, used for both the global (~/.iterion) and per-project (<repo>/.iterion) files.

View Source
const (

	// MasterKeyFileName is the basename of the keyfile fallback used when
	// the OS keychain is unavailable (headless servers with no dbus/session).
	MasterKeyFileName = "secrets.key"
)
View Source
const OAuthCollectionName = "oauth_credentials"
View Source
const OAuthPendingCollectionName = "oauth_pending"
View Source
const OrgOwnerPrefix = "org:"

OrgOwnerPrefix marks an OAuthRecord whose owner is a team/org rather than an individual user. An org-scoped forfait is stored as an ordinary OAuthRecord whose UserID is OrgOwnerKey(tenantID) — this reuses the whole store/seal/refresh machinery (AAD, Mongo id, ExpiringBefore) without a schema change. The cloud publisher uses these as a FALLBACK when the run's owner has no personal record, covering automated runs (webhook/dispatcher/cron) whose owner is a synthetic identity. See OrgOwnerKey.

View Source
const RunSecretsCollectionName = "run_secrets"
View Source
const SecretFilesMountDir = "/run/iterion/secrets"
View Source
const XAIDefaultBaseURL = "https://api.x.ai"

XAIDefaultBaseURL is the host claw's OpenAI-compatible client targets for xAI Grok. The openai provider appends `/v1/chat/completions`, so this value must NOT include a trailing `/v1` (unlike the public OpenAI-SDK convention of `base_url=https://api.x.ai/v1`). Override with `XAI_BASE_URL` when pointing at a proxy or regional endpoint.

View Source
const ZAIDefaultBaseURL = "https://api.z.ai/api/anthropic"

ZAIDefaultBaseURL is the Anthropic-compatible endpoint z.ai's Coding Plan publishes. Centralised here so the delegate and the model registry agree without re-deriving the URL.

Variables

View Source
var (
	ErrBindingNotFound      = errors.New("secrets: bot secret binding not found")
	ErrBindingTenantMissing = errors.New("secrets: bot secret binding store called without tenant context")
)
View Source
var (
	ErrGenericSecretNotFound      = errors.New("secrets: generic secret not found")
	ErrGenericSecretTenantMissing = errors.New("secrets: generic secret store called without tenant context")
)
View Source
var (
	ErrSealedFormat       = errors.New("secrets: invalid sealed format")
	ErrSealedVersion      = errors.New("secrets: unsupported sealed version")
	ErrSealedAuthenticate = errors.New("secrets: authentication failed")
)

Errors returned by Open when the input is malformed, the master key is wrong, or the ciphertext was tampered with. All three look the same on the wire to avoid oracle attacks; we still tag them internally for logging.

View Source
var (
	ErrApiKeyNotFound = errors.New("secrets: api key not found")
)

Sentinel errors raised by Store implementations.

View Source
var ErrApiKeyTenantMissing = errors.New("secrets: ApiKey store called without tenant context")

ErrApiKeyTenantMissing is returned when an ApiKeyStore call lacks the tenant_id needed to scope its query. Callers must propagate it up — falling back to an unscoped query would expose every tenant's keys.

View Source
var ErrNotRefreshable = errors.New("secrets: credential has no refresh token")

ErrNotRefreshable marks a credential whose sealed payload carries no refresh token: no refresh exchange can ever succeed for it, so callers must skip it (worker) or tell the user to re-connect (HTTP) instead of retrying forever.

View Source
var ErrOAuthForfaitInThirdParty = errors.New("secrets: refusing to use Claude Code OAuth-forfait via third-party SDK (CGU violation)")

ErrOAuthForfaitInThirdParty is the sentinel error guarding the claw backend (and any other in-process LLM SDK consumer) from using a Claude Pro/Max OAuth bearer token. Reusing the forfait outside the official Claude Code CLI surface violates Anthropic's Consumer Terms — see memory feedback_no_anthropic_oauth_in_third_party.

Callers should invoke GuardThirdPartyOAuth right before consuming a credential for an in-process LLM call. The delegate backends (claude_code, codex) which spawn the upstream CLI are exempt: the CLI itself remains the authorised consumer in that path.

View Source
var ErrOAuthNotFound = errors.New("secrets: oauth record not found")

ErrOAuthNotFound is the sentinel for missing records.

View Source
var ErrOAuthPendingNotFound = errors.New("secrets: oauth pending not found")

ErrOAuthPendingNotFound is the sentinel for a missing/expired pending.

View Source
var ErrRunSecretsNotFound = errors.New("secrets: run secrets not found")

ErrRunSecretsNotFound is returned by Get when the ref is unknown (already consumed or never published).

Functions

func AnthropicAuthorizeURL

func AnthropicAuthorizeURL(clientID, redirectURI, challenge, state string) string

AnthropicAuthorizeURL builds the claude.ai authorization URL the studio opens in a new tab. challenge is the PKCE S256 challenge; state is round-tripped and validated on completion.

func AnthropicRedirectURI

func AnthropicRedirectURI() string

AnthropicRedirectURI returns the redirect URI used for the headless code-paste flow (env-overridable).

func ApplyAnthropicRefresh

func ApplyAnthropicRefresh(payload []byte, r RefreshResult) ([]byte, error)

ApplyAnthropicRefresh updates a credentials.json blob with fresh tokens. Returns the new JSON to seal back into the OAuthRecord.

func ApplyCodexRefresh

func ApplyCodexRefresh(payload []byte, r RefreshResult) ([]byte, error)

ApplyCodexRefresh updates an auth.json blob with fresh tokens.

func BuildAnthropicCredentials

func BuildAnthropicCredentials(r RefreshResult) ([]byte, error)

BuildAnthropicCredentials renders a fresh credentials.json blob (the `{claudeAiOauth:{…}}` shape the Claude Code CLI reads) from an exchange/refresh result, so the browser flow produces the exact same sealed payload the file-paste path would. Reuses ApplyAnthropicRefresh over an empty object — no duplicate serialisation.

func CodexAuthJSONPath

func CodexAuthJSONPath() string

CodexAuthJSONPath returns the on-disk location of Codex CLI's auth.json, honouring the `CODEX_HOME` env var (Codex's documented override) and falling back to `~/.codex/auth.json`. Returns an empty string when no home directory is resolvable, leaving callers to treat it as "no auth".

func DecodeBase64Lenient

func DecodeBase64Lenient(b64 string) ([]byte, error)

DecodeBase64Lenient accepts std/URL/raw variants of base64. Exposed for callers that need to decode an operator-supplied key (notably pkg/auth's JWT secret loader).

func DefaultFileMountPath

func DefaultFileMountPath(name string) string

DefaultFileMountPath returns the stable in-sandbox file path for a workflow secret mounted as a file. The path is deterministic so prompts can reference it before the sandbox container is started.

func FingerprintSHA256

func FingerprintSHA256(secret string) string

FingerprintSHA256 returns a stable 16-char hex fingerprint of a secret value. Useful for logs that need to correlate two records using the same key without ever revealing the secret.

func GuardThirdPartyOAuth

func GuardThirdPartyOAuth(ctx context.Context, provider Provider, kind OAuthKind) error

GuardThirdPartyOAuth returns ErrOAuthForfaitInThirdParty when the given ctx has an OAuth-forfait connection for kind but no matching API key for provider — i.e. the only available credential is the forfait, which is forbidden in this code path.

Returns nil when the ctx has no credentials, when an API key IS available, or when no OAuth credential of that kind is present.

func IntersectHosts

func IntersectHosts(a, b []string) []string

IntersectHosts returns the stricter of two egress host policies. An empty list means "no restriction"; the result never broadens either input:

  • both empty -> empty (unrestricted)
  • one empty, one set -> the set (the restriction wins)
  • both set -> their intersection

func Last4

func Last4(secret string) string

Last4 returns the last four characters of a secret for display. If the input is shorter than 8, it returns "****" — never reveal more than half of a short value.

func LoadOrCreateMasterKey

func LoadOrCreateMasterKey(dataDir string, logf func(string, ...any)) ([]byte, error)

LoadOrCreateMasterKey resolves the 32-byte AES-GCM master key that seals the local secret store, in this precedence order:

  1. ITERION_SECRETS_KEY (base64) — explicit operator override (parity with cloud, scriptable/CI). Must decode to exactly 32 bytes.
  2. An existing keyfile at <dataDir>/secrets.key — a prior run already chose the keyfile; reuse it so a store sealed on a headless host stays openable even if a keychain later becomes available (no orphaning).
  3. The OS keychain entry, when present.
  4. Keychain empty but functional → generate a fresh key and store it in the keychain.
  5. Keychain unavailable → generate a fresh key and write the keyfile (0600), logging the fallback explicitly (no silent recovery).

logf is an optional Warn-level sink (pass logger.Warn; nil is a no-op).

func NewApiKeyID

func NewApiKeyID() string

NewApiKeyID returns a fresh UUID-string id for an ApiKey record. Centralised so the routes layer doesn't reach for uuid directly.

func NewGenericSecretID

func NewGenericSecretID() string

func NewOAuthState

func NewOAuthState() (string, error)

NewOAuthState returns a random URL-safe state token for CSRF binding.

func NewPKCE

func NewPKCE() (verifier, challenge string, err error)

NewPKCE returns a fresh (verifier, challenge) pair. verifier is a 43-char base64url-encoded 32-byte random value; challenge is base64url(SHA256(verifier)), per RFC 7636 S256.

func NewSecretsRef

func NewSecretsRef() string

NewSecretsRef returns a fresh opaque ref for a RunSecretsRecord. Random UUID rather than the run id so an attacker who can guess run ids cannot enumerate sealed bundles.

func OpenGenericSecret

func OpenGenericSecret(sealer Sealer, secretID string, sealed []byte) ([]byte, error)

func OpenOAuthPayload

func OpenOAuthPayload(sealer Sealer, userID string, kind OAuthKind, sealed []byte) ([]byte, error)

OpenOAuthPayload is the inverse: returns the raw JSON blob.

func OpenOAuthVerifier

func OpenOAuthVerifier(sealer Sealer, ownerKey string, kind OAuthKind, sealed []byte) (string, error)

OpenOAuthVerifier is the inverse of SealOAuthVerifier.

func OrgOwnerKey

func OrgOwnerKey(tenantID string) string

OrgOwnerKey returns the synthetic owner key under which a team/org's shared forfait credential is stored.

func RefreshRecord

func RefreshRecord(ctx context.Context, sealer Sealer, hc *http.Client, anthropicClientID, codexClientID string, rec *OAuthRecord) error

RefreshRecord drives the refresh exchange for one OAuthRecord and rewrites its sealed payload in place with the new tokens. It is the single refresh primitive shared by the HTTP handler (manual refresh) and the background OAuthRefreshWorker — keep them on this function so the two paths can never drift.

The (userID, kind) AAD is derived from rec, so an org-scoped record (rec.UserID == OrgOwnerKey(tenantID)) refreshes identically to a personal one. Returns an error when the provider rejects the refresh or no client_id is configured for the record's kind.

func RelativeToSecretFilesMountDir

func RelativeToSecretFilesMountDir(mountPath string) (string, bool)

RelativeToSecretFilesMountDir returns mountPath relative to the default file-secret directory when mountPath lives directly under it. The caller is expected to validate mountPath as a clean absolute file path first.

func RequiredSecretsError added in v0.43.0

func RequiredSecretsError(missing []string, scope string) error

RequiredSecretsError formats the launch-blocking error naming every unresolved required secret. `scope` describes the resolution context so the operator knows where the lookup came up empty (e.g. "this team/bot" for a cloud launch, "this workspace" for a local run). Returns nil when nothing is missing so callers can `if err := RequiredSecretsError(...); err != nil`.

func Resolve

func Resolve(
	ctx context.Context,
	store ApiKeyStore,
	teamID, userID string,
	providers []Provider,
	keyOverrides map[Provider]string,
	sealer Sealer,
) (map[Provider]Resolution, error)

Resolve returns at most one ApiKey for each requested provider, applying the priority chain documented in the cloud admin plan:

  1. KeyOverrides[provider] — caller-pinned key id (validated to belong to the team and to be visible to userID).
  2. (team, userID, provider, default=true)
  3. (team, userID, provider) — first match
  4. (team, "", provider, default=true)
  5. (team, "", provider) — first match

Providers without a hit are simply omitted. Callers consult the returned map and either inject what's there or fall back to env.

When sealer is non-nil, every Resolution.Plaintext is decrypted; on decrypt failure the resolution is skipped and an error is logged to logErr. Pass nil sealer to get sealed blobs only.

func ResolveFileMountPath

func ResolveFileMountPath(name, override string) string

func ResolveGeneric

func ResolveGeneric(
	ctx context.Context,
	secretStore GenericSecretStore,
	teamID, userID string,
	names []string,
	sealer Sealer,
	logger *iterlog.Logger,
) (map[string]GenericResolution, error)

func ResolveGenericWithBindings

func ResolveGenericWithBindings(
	ctx context.Context,
	secretStore GenericSecretStore,
	bindingStore BotSecretBindingStore,
	teamID, userID, botID string,
	names []string,
	secretOverrides map[string]string,
	sealer Sealer,
	logger *iterlog.Logger,
) (map[string]GenericResolution, error)

ResolveGenericWithBindings resolves each requested workflow-secret name with the priority user-scoped > bot-binding > team-scoped:

  • a developer's personal secret of that name still wins (interactive opt-in);
  • else a bot binding maps the name to a stored team-scoped secret — the canonical route for unattended (webhook) runs whose synthetic actor owns no user secrets;
  • else a team-scoped secret of that name (the existing fallback).

Binding-sourced resolutions carry the binding's AllowedHosts so the caller can intersect the egress policy.

func SanitizeFileName added in v0.50.0

func SanitizeFileName(name string) string

SanitizeFileName reduces a secret name to a safe basename for a secret file (letters, digits, `_`, `.`, `-`). It is the shared rule behind DefaultFileMountPath (sandbox mount) and the host-side materialisation used by non-sandbox runs, so the same secret lands under the same filename on either path.

func SealAPIKey

func SealAPIKey(sealer Sealer, keyID string, plaintext []byte) ([]byte, error)

SealAPIKey produces the sealed blob for storage. Pass the caller's shared Sealer (e.g. ITERION_SECRETS_KEY-driven AESGCMSealer) and the freshly-generated key ID; the AAD ties the ciphertext to that record so it cannot be moved.

func SealGenericSecret

func SealGenericSecret(sealer Sealer, secretID string, plaintext []byte) ([]byte, error)

func SealInto

func SealInto(sealer Sealer, rec *GenericSecret, value string) error

SealInto seals value for rec.ID and stamps the three derived fields (SealedSecret + Last4 + Fingerprint) onto rec. rec.ID must already be set (the AAD binds the ciphertext to it). This owns the seal-and-stamp triple so the CLI and every REST surface share one definition instead of hand-copying it (a missed field would silently ship a stale last4/fingerprint).

func SealOAuthPayload

func SealOAuthPayload(sealer Sealer, userID string, kind OAuthKind, payload []byte) ([]byte, error)

SealOAuthPayload encrypts the raw credentials JSON. AAD binds the ciphertext to (userID, kind) so a sealed payload moved between users or kinds cannot be opened.

func SealOAuthVerifier

func SealOAuthVerifier(sealer Sealer, ownerKey string, kind OAuthKind, verifier string) ([]byte, error)

SealOAuthVerifier seals a PKCE verifier bound to (ownerKey, kind) so a persisted pending record never stores a usable verifier at rest.

func SealRunBundle

func SealRunBundle(sealer Sealer, runID string, b RunBundle) ([]byte, error)

SealRunBundle marshals + seals a RunBundle for a given run. Returns the sealed blob; the caller stores it as RunSecretsRecord.SealedBundle.

func SplitAnthropicCode

func SplitAnthropicCode(pasted string) (code, state string)

SplitAnthropicCode splits the `code#state` string Anthropic's headless page shows the user. When no `#` is present the whole input is the code and state is empty (the caller then relies on its server-side pending record for CSRF binding).

func UnresolvedRequired added in v0.43.0

func UnresolvedRequired(required []string, resolved map[string]bool) []string

UnresolvedRequired returns, sorted, the names in `required` that are absent from `resolved` (the set of workflow-secret names that resolved to a non-empty value for this run).

It is the launch-time gate behind the required-secret contract: a workflow declares a secret non-`optional` precisely because the run cannot do correct work without it (push with no auth, call an API unauthenticated). If such a secret resolves to nothing — no store match, no binding, no override — the launch must fail loudly here rather than the runner silently skipping the empty value (`optional/unresolved → skip`) and the bot proceeding blind. `optional: true` secrets are never passed in `required`, so they keep the skip behaviour.

func ValidGenericSecretName

func ValidGenericSecretName(name string) bool

ValidGenericSecretName reports whether name is a legal secret name: a non-empty identifier of [A-Za-z_][A-Za-z0-9_]* up to 128 chars (the same rule the cloud secret routes enforce). A valid name is also a valid POSIX env-var name, which the file-mount / env-indirection paths rely on.

func WithCredentials

func WithCredentials(parent context.Context, c Credentials) context.Context

WithCredentials returns a child ctx carrying the resolved credentials. Empty / zero-value Credentials are still stored so callers can detect "we are inside a per-run scope with no keys" vs "no credentials ctx at all" (env fallback).

Types

type AESGCMSealer

type AESGCMSealer struct {
	// contains filtered or unexported fields
}

AESGCMSealer is an AES-256-GCM implementation of Sealer driven by a single master key. The master key MUST be 32 bytes; supply it via NewAESGCMSealer or NewAESGCMSealerFromBase64.

func NewAESGCMSealer

func NewAESGCMSealer(masterKey []byte) (*AESGCMSealer, error)

NewAESGCMSealer constructs a sealer from a 32-byte master key. The key is consumed immediately by aes.NewCipher; callers should avoid retaining the slice elsewhere.

func NewAESGCMSealerFromBase64

func NewAESGCMSealerFromBase64(b64 string) (*AESGCMSealer, error)

NewAESGCMSealerFromBase64 decodes a standard or URL-safe base64 master key (with or without padding) and constructs a sealer. Suitable for reading directly from ITERION_SECRETS_KEY.

func NewLocalSealer

func NewLocalSealer(dataDir string, logf func(string, ...any)) (*AESGCMSealer, error)

NewLocalSealer resolves the master key (LoadOrCreateMasterKey) and builds the AES-GCM sealer for the local secret store. Convenience for the studio/ CLI wiring so callers don't repeat the key→sealer dance.

func (*AESGCMSealer) Open

func (s *AESGCMSealer) Open(sealed, aad []byte) ([]byte, error)

Open verifies and decrypts a sealed blob.

func (*AESGCMSealer) Seal

func (s *AESGCMSealer) Seal(plaintext, aad []byte) ([]byte, error)

Seal returns version|nonce|ciphertext (with appended GCM tag).

type AnthropicCredentialsView

type AnthropicCredentialsView struct {
	ClaudeAIOauth struct {
		AccessToken  string   `json:"accessToken"`
		RefreshToken string   `json:"refreshToken"`
		ExpiresAt    int64    `json:"expiresAt"` // ms epoch
		Scopes       []string `json:"scopes,omitempty"`
	} `json:"claudeAiOauth"`
}

AnthropicCredentialsView is the minimal shape we extract from a Claude Code credentials.json blob to drive expiry tracking + refresh. We do NOT replace the user-supplied JSON with this struct on store — extra fields the CLI cares about round-trip via the sealed payload.

func ParseAnthropicView

func ParseAnthropicView(payload []byte) (AnthropicCredentialsView, error)

ParseAnthropicView extracts the lightweight metadata view from a raw credentials.json blob. Returns the parsed view; errors when the JSON is malformed but never inspects scopes / expiry validity.

type ApiKey

type ApiKey struct {
	ID           string     `bson:"_id" json:"id"`
	TenantID     string     `bson:"tenant_id" json:"tenant_id"`
	ScopeTeamID  string     `bson:"scope_team" json:"scope_team_id"`
	ScopeUserID  string     `bson:"scope_user,omitempty" json:"scope_user_id,omitempty"`
	Provider     Provider   `bson:"provider" json:"provider"`
	Name         string     `bson:"name" json:"name"`
	Last4        string     `bson:"last4,omitempty" json:"last4,omitempty"`
	SealedSecret []byte     `bson:"sealed_secret" json:"-"`
	IsDefault    bool       `bson:"is_default,omitempty" json:"is_default,omitempty"`
	CreatedBy    string     `bson:"created_by" json:"created_by"`
	CreatedAt    time.Time  `bson:"created_at" json:"created_at"`
	LastUsedAt   *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
	ExpiresAt    *time.Time `bson:"expires_at,omitempty" json:"expires_at,omitempty"`
	Fingerprint  string     `bson:"fingerprint,omitempty" json:"fingerprint,omitempty"`
}

ApiKey is a BYOK record: a single API key (or AWS-style credential blob, JSON-encoded inside SealedSecret) attached to a team and optionally scoped to a single user. The plaintext secret is never persisted — only the AES-GCM-sealed blob.

Scope semantics:

  • ScopeUserID == "": team-wide. Any member of the team picks it up when their per-user keys do not provide the requested provider.
  • ScopeUserID != "": user-only. Visible only to that user even when listed by other team members (the API list endpoint hides them).

Default flag: per (team, user, provider) tuple at most ONE entry is flagged is_default. Resolution prefers it over non-default keys.

type ApiKeyStore

type ApiKeyStore interface {
	Create(ctx context.Context, k ApiKey) error
	Get(ctx context.Context, id string) (ApiKey, error)
	Update(ctx context.Context, k ApiKey) error
	Delete(ctx context.Context, id string) error
	// ListByTeam returns every key visible from teamID — i.e. team-
	// scoped keys plus the requesting user's user-scoped keys. The
	// requestingUserID filter MUST be applied; passing "" returns
	// only team-wide keys (admin path).
	ListByTeam(ctx context.Context, teamID, requestingUserID string) ([]ApiKey, error)
	// ListByUser returns the requesting user's user-scoped keys
	// inside a given team.
	ListByUser(ctx context.Context, teamID, userID string) ([]ApiKey, error)
	// MarkUsed updates last_used_at without altering anything else.
	MarkUsed(ctx context.Context, id string, at time.Time) error
	// ClearDefault removes the is_default flag from any other key in
	// the same (team, user, provider) tuple. Used when a new key is
	// created with is_default=true or an existing one is promoted.
	ClearDefault(ctx context.Context, teamID, userID string, provider Provider, exceptID string) error
}

ApiKeyStore is the persistence interface for BYOK records.

type BotSecretBinding

type BotSecretBinding struct {
	ID                    string `bson:"_id" json:"id"`
	TenantID              string `bson:"tenant_id" json:"tenant_id"`
	BotID                 string `bson:"bot_id" json:"bot_id"`
	SecretID              string `bson:"secret_id" json:"secret_id"` // -> GenericSecret._id
	SecretNameForWorkflow string `bson:"secret_name_for_workflow" json:"secret_name_for_workflow"`

	// AllowedHosts, when non-empty, intersects (never broadens) the
	// workflow secret's declared egress hosts. This is an ENFORCED egress
	// control: the resolver carries it on GenericResolution.AllowedHosts,
	// the publisher threads it onto RunBundle.GenericSecretHosts, the
	// runner puts it on Credentials.GenericHosts, and the secret guard
	// intersects it with the workflow's `secrets.<name>.hosts`
	// (model.effectiveSecretHosts). Empty = no binding-level restriction.
	AllowedHosts []string `bson:"allowed_hosts,omitempty" json:"allowed_hosts,omitempty"`

	CreatedBy string    `bson:"created_by" json:"created_by"`
	CreatedAt time.Time `bson:"created_at" json:"created_at"`
	UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}

BotSecretBinding is a policy wrapper over an existing generic secret: it makes a stored org/user secret resolvable for a specific bot under the name the bot's workflow declares in its `secrets:` block. It does NOT store secret material — only a reference (SecretID) plus optional tightening (AllowedHosts narrows the egress policy).

type BotSecretBindingStore

type BotSecretBindingStore interface {
	Create(ctx context.Context, b BotSecretBinding) error
	Get(ctx context.Context, id string) (BotSecretBinding, error)
	Update(ctx context.Context, b BotSecretBinding) error
	Delete(ctx context.Context, id string) error
	ListByTenantBot(ctx context.Context, tenantID, botID string) ([]BotSecretBinding, error)
	ListByTenant(ctx context.Context, tenantID string) ([]BotSecretBinding, error)
}

BotSecretBindingStore persists bot-secret bindings.

type CodexCredentialsView

type CodexCredentialsView struct {
	AuthMode string `json:"auth_mode,omitempty"`
	Tokens   struct {
		AccessToken  string `json:"access_token"`
		RefreshToken string `json:"refresh_token"`
		IDToken      string `json:"id_token,omitempty"`
		ExpiresIn    int64  `json:"expires_in,omitempty"`
		AccountID    string `json:"account_id,omitempty"`
	} `json:"tokens"`
	LastRefresh string `json:"last_refresh,omitempty"`
}

CodexCredentialsView is the analogous shape for the Codex CLI's auth.json. Field names mirror the Codex SDK.

AuthMode is "apikey" or "chatgpt" — Codex CLI sets it based on how the user signed in. Tokens.AccountID is only populated in "chatgpt" mode and is required by the ChatGPT-Codex backend (sent verbatim in the `ChatGPT-Account-ID` request header).

func LoadCodexCredentialsFrom added in v0.50.0

func LoadCodexCredentialsFrom(dir string) (CodexCredentialsView, error)

LoadCodexCredentialsFrom reads and parses Codex CLI's auth.json from an EXPLICIT CODEX_HOME-shaped directory (`<dir>/auth.json`), rather than the process's default location. This is the cloud path: the runner materialises a tenant's resolved codex OAuth-forfait into a per-run temp dir (Credentials.OAuthDir("codex")), and the in-process claw model factory reads it from there instead of the pod's (empty) ~/.codex. Empty dir → error.

func LoadCodexCredentialsFromDisk

func LoadCodexCredentialsFromDisk() (CodexCredentialsView, error)

LoadCodexCredentialsFromDisk reads and parses Codex CLI's auth.json from its standard location. Returns the parsed view on success; on missing or malformed file it returns the zero view plus a non-nil error. Callers gating on availability should use `errors.Is(err, fs.ErrNotExist)` to distinguish "no auth installed" from "auth file is corrupted".

The reader does not validate token expiry — refresh is delegated to Codex CLI's background process; iterion just reads whatever access_token is currently materialised on disk.

func ParseCodexView

func ParseCodexView(payload []byte) (CodexCredentialsView, error)

ParseCodexView extracts the analogous view from auth.json.

func (CodexCredentialsView) IsChatGPTMode

func (v CodexCredentialsView) IsChatGPTMode() bool

IsChatGPTMode reports whether the auth blob authorises ChatGPT-Codex backend access (forfait), with the access token + account id required to actually issue requests.

type Credentials

type Credentials struct {
	APIKeys map[Provider]string
	// Generic maps workflow/user secret names to plaintext values. It is
	// populated by the cloud runner from sealed per-run bundles and used
	// by declared workflow secrets whose value is intentionally empty
	// (meaning "resolve by name from the user's/team's stored secrets").
	Generic map[string]string
	// GenericHosts maps a generic secret name to the egress host
	// allowlist a bot-secret binding imposes on it (empty/absent = no
	// binding restriction). The secret guard intersects it with the
	// workflow's declared `secrets.<name>.hosts` so a binding can only
	// narrow, never broaden, where the credential may egress.
	GenericHosts map[string][]string
	// GenericRefs maps a generic secret name to its generic-secret store
	// record ID (IDs only, never values). It lets the runner re-read the
	// server-refreshed record mid-run and rewrite the secret's
	// materialised file before a short-TTL credential (e.g. a 1h GitHub
	// App installation token) expires under a long run.
	GenericRefs map[string]string
	// OAuthCredentialFiles maps "claude_code" / "codex" → the
	// absolute path of a temp directory holding the materialised
	// credentials.json or auth.json. The delegate backends pass
	// this directory via CLAUDE_CONFIG_DIR / CODEX_HOME to the
	// CLI subprocess. Empty when no OAuth-forfait is in play.
	OAuthCredentialFiles map[string]string
	// ForgeAppBotLogin, when set, is the GitHub-App bot login whose
	// installation token pushes this run's commits (see RunBundle). The
	// runner uses it to seed the App-bot git committer identity, which a
	// bare installation token can't self-resolve. Empty for PAT/OAuth runs.
	ForgeAppBotLogin string
}

Credentials carries the resolved per-run BYOK plaintext keyed by provider. Stamped into context by the runner right before the engine starts, consumed by pkg/backend/model/registry.go and the claude_code/codex delegate backends.

Plaintexts here are sensitive: never log them, never include them in events. The runner zeroes the slice after the run completes (best effort — Go does not give us secure-erase guarantees, but the bundle's TTL bounds exposure on the wire and at rest).

func CredentialsFromContext

func CredentialsFromContext(ctx context.Context) (Credentials, bool)

CredentialsFromContext returns the resolved credentials and a flag indicating whether a per-run scope was active at all.

func ResolveLocalCredentials

func ResolveLocalCredentials(ctx context.Context, store GenericSecretStore, sealer Sealer, names []string, logger *iterlog.Logger) (Credentials, error)

ResolveLocalCredentials resolves the named secrets from a local (file-backed) GenericSecretStore into a Credentials value ready to stamp into a run ctx via WithCredentials. It is the in-process, single-hop equivalent of the cloud runner's injectCredentials (which decrypts a sealed per-run bundle shipped over NATS): here the store IS local, so resolution reads + opens the sealed values directly.

names is typically the workflow's declared `secrets:` keys — the only names a `{{secrets.X}}` reference can legally use (compile-checked). Returns an empty (non-nil-map) Credentials when store/sealer is nil or names is empty, so callers can WithCredentials unconditionally.

func (Credentials) APIKey

func (c Credentials) APIKey(p Provider) string

APIKey returns the plaintext API key for the requested provider (or "" when none is configured for the run).

func (Credentials) GenericSecret

func (c Credentials) GenericSecret(name string) string

func (Credentials) OAuthDir

func (c Credentials) OAuthDir(kind string) string

OAuthDir returns the temp dir holding sealed credentials for kind (claude_code / codex), or "" when no OAuth bundle was injected.

type FileGenericSecretStore

type FileGenericSecretStore struct {
	// contains filtered or unexported fields
}

FileGenericSecretStore is a filesystem-backed GenericSecretStore for the local (desktop / CLI / non-cloud studio) path. Values are AES-GCM sealed by the caller (SealGenericSecret) before Create/Update — the file never holds plaintext. The store keeps an in-memory index guarded by a mutex and persists the whole file atomically (0600) on every mutation, mirroring the plugin registry's load/save idiom.

func NewFileGenericSecretStore

func NewFileGenericSecretStore(path string) (*FileGenericSecretStore, error)

NewFileGenericSecretStore opens (or lazily creates) a sealed secrets file at path. A missing file is treated as an empty store; a malformed file is a hard error (we never silently discard a store the operator may be able to recover).

func (*FileGenericSecretStore) Create

func (*FileGenericSecretStore) Delete

func (*FileGenericSecretStore) Get

func (*FileGenericSecretStore) GetByName

func (s *FileGenericSecretStore) GetByName(name string) (GenericSecret, bool)

GetByName returns the secret with the given name, or ErrGenericSecretNotFound. Not part of GenericSecretStore — used by the CLI/handlers to implement upsert-by-name (a friendlier `secret set` than create-only).

func (*FileGenericSecretStore) ListByTeam

func (s *FileGenericSecretStore) ListByTeam(_ context.Context, teamID, _ string) ([]GenericSecret, error)

ListByTeam returns every secret in the file. userID is accepted for interface parity but the local store has no user scoping — a local secret is always "team"-wide (ScopeUserID empty). teamID must be LocalScopeTeam.

func (*FileGenericSecretStore) ListByUser

func (s *FileGenericSecretStore) ListByUser(ctx context.Context, teamID, userID string) ([]GenericSecret, error)

ListByUser mirrors ListByTeam for the local store (no per-user scoping).

func (*FileGenericSecretStore) MarkUsed

func (s *FileGenericSecretStore) MarkUsed(_ context.Context, id string, at time.Time) error

func (*FileGenericSecretStore) Update

func (*FileGenericSecretStore) UpsertByName

func (s *FileGenericSecretStore) UpsertByName(sealer Sealer, name, value string, hosts []string, applyHosts bool) (GenericSecret, bool, error)

UpsertByName atomically creates or rotates (by name) a secret, sealing value with sealer, under the cross-process lock — so a concurrent create of the same name from another goroutine/process cannot produce a duplicate record. On rotate, the egress host lock is overwritten only when applyHosts is true (callers preserve it otherwise, so a value rotation never silently broadens egress). Returns the resulting record and whether it was newly created.

type GenericResolution

type GenericResolution struct {
	Name        string
	SecretID    string
	Plaintext   []byte
	SealedBlob  []byte
	SourceScope string // "user" | "binding" | "team"
	// AllowedHosts is the egress host allowlist a bot-secret binding
	// imposes on this secret (empty = no extra restriction). The publisher
	// threads it onto RunBundle.GenericSecretHosts; the runner's secret
	// guard intersects it with the workflow's declared hosts
	// (model.effectiveSecretHosts) so a binding can only narrow, never
	// broaden, the policy.
	AllowedHosts []string
}

type GenericSecret

type GenericSecret struct {
	ID           string     `bson:"_id" json:"id"`
	TenantID     string     `bson:"tenant_id" json:"tenant_id"`
	ScopeTeamID  string     `bson:"scope_team" json:"scope_team_id"`
	ScopeUserID  string     `bson:"scope_user,omitempty" json:"scope_user_id,omitempty"`
	Name         string     `bson:"name" json:"name"`
	Last4        string     `bson:"last4,omitempty" json:"last4,omitempty"`
	SealedSecret []byte     `bson:"sealed_secret" json:"-"`
	CreatedBy    string     `bson:"created_by" json:"created_by"`
	CreatedAt    time.Time  `bson:"created_at" json:"created_at"`
	LastUsedAt   *time.Time `bson:"last_used_at,omitempty" json:"last_used_at,omitempty"`
	Fingerprint  string     `bson:"fingerprint,omitempty" json:"fingerprint,omitempty"`
	// AllowedHosts, when non-empty, pins the egress host allowlist this
	// secret may ever be materialised toward (parent-domain match, e.g.
	// "github.com" also permits "api.github.com"). A managed forge token is
	// created with its forge host here so a prompt-injected bot cannot
	// exfiltrate it off-forge. It is the secret's OWN egress lock: it travels
	// through every resolution tier (buildGenericResolution) and is
	// intersected — never broadened — with any binding/workflow host policy
	// downstream (IntersectHosts / model.effectiveSecretHosts).
	AllowedHosts []string `bson:"allowed_hosts,omitempty" json:"allowed_hosts,omitempty"`
}

type GenericSecretStore

type GenericSecretStore interface {
	Create(ctx context.Context, s GenericSecret) error
	Get(ctx context.Context, id string) (GenericSecret, error)
	Update(ctx context.Context, s GenericSecret) error
	Delete(ctx context.Context, id string) error
	ListByTeam(ctx context.Context, teamID, requestingUserID string) ([]GenericSecret, error)
	ListByUser(ctx context.Context, teamID, userID string) ([]GenericSecret, error)
	MarkUsed(ctx context.Context, id string, at time.Time) error
}

type LayeredGenericSecretStore

type LayeredGenericSecretStore struct {
	// contains filtered or unexported fields
}

LayeredGenericSecretStore composes an optional per-project store over a global store. Resolution (ListByTeam) merges both with the project layer winning by secret Name, giving the "project overrides global" precedence. Mutations by ID search project first then global; Create targets an explicit layer chosen by the caller (Global()/Project()).

func LocalStoreForProject

func LocalStoreForProject(projectStoreDir string) (*LayeredGenericSecretStore, error)

LocalStoreForProject builds the layered local store for a project store dir, sourcing the machine-global dir from GlobalIterionDataDir(). It is the single place that couples "the global layer lives under GlobalIterionDataDir" so the CLI, the studio wiring, the project-switch rebuild, and the dispatcher all agree without repeating it.

func NewLayeredGenericSecretStore

func NewLayeredGenericSecretStore(global, project *FileGenericSecretStore) *LayeredGenericSecretStore

NewLayeredGenericSecretStore wraps a required global store and an optional project store (pass nil for none).

func NewLocalLayeredStore

func NewLocalLayeredStore(globalDir, projectStoreDir string) (*LayeredGenericSecretStore, error)

NewLocalLayeredStore builds the machine-global store at <globalDir>/secrets.json plus an optional per-project store at <projectStoreDir>/secrets.json — but only when that dir is distinct from the global one (else it would layer the same file on itself). The project layer overrides the global by name. Used by the CLI, the local studio wiring, and the studio project-switch rebuild.

func (*LayeredGenericSecretStore) Create

func (*LayeredGenericSecretStore) Delete

func (*LayeredGenericSecretStore) ForScope

ForScope returns the concrete store for a scope selector ("project" → the project store when active, else global; anything else → global).

func (*LayeredGenericSecretStore) Get

func (*LayeredGenericSecretStore) Global

Global returns the machine-wide store (the default write target).

func (*LayeredGenericSecretStore) ListByTeam

func (l *LayeredGenericSecretStore) ListByTeam(ctx context.Context, teamID, userID string) ([]GenericSecret, error)

ListByTeam merges global + project, project winning by Name, sorted by Name. Delegates to ListScoped (the single source of the merge/precedence rule) and drops the scope tag.

func (*LayeredGenericSecretStore) ListByUser

func (l *LayeredGenericSecretStore) ListByUser(ctx context.Context, teamID, userID string) ([]GenericSecret, error)

func (*LayeredGenericSecretStore) ListScoped

func (l *LayeredGenericSecretStore) ListScoped(ctx context.Context, teamID, userID string) ([]ScopedSecret, error)

ListScoped returns every secret tagged with its owning layer, project overriding global by name (a name present in both appears once, scope "project"). Sorted by Name.

func (*LayeredGenericSecretStore) MarkUsed

func (l *LayeredGenericSecretStore) MarkUsed(ctx context.Context, id string, at time.Time) error

func (*LayeredGenericSecretStore) Project

Project returns the per-project store and whether one is active.

func (*LayeredGenericSecretStore) Update

type MemoryApiKeyStore

type MemoryApiKeyStore struct {
	// contains filtered or unexported fields
}

MemoryApiKeyStore is the in-process store used by tests of the resolution chain.

func NewMemoryApiKeyStore

func NewMemoryApiKeyStore() *MemoryApiKeyStore

func (*MemoryApiKeyStore) ClearDefault

func (m *MemoryApiKeyStore) ClearDefault(_ context.Context, teamID, userID string, provider Provider, exceptID string) error

func (*MemoryApiKeyStore) Create

func (m *MemoryApiKeyStore) Create(_ context.Context, k ApiKey) error

func (*MemoryApiKeyStore) Delete

func (m *MemoryApiKeyStore) Delete(_ context.Context, id string) error

func (*MemoryApiKeyStore) Get

func (*MemoryApiKeyStore) ListByTeam

func (m *MemoryApiKeyStore) ListByTeam(_ context.Context, teamID, userID string) ([]ApiKey, error)

func (*MemoryApiKeyStore) ListByUser

func (m *MemoryApiKeyStore) ListByUser(_ context.Context, teamID, userID string) ([]ApiKey, error)

func (*MemoryApiKeyStore) MarkUsed

func (m *MemoryApiKeyStore) MarkUsed(_ context.Context, id string, at time.Time) error

func (*MemoryApiKeyStore) Update

func (m *MemoryApiKeyStore) Update(_ context.Context, k ApiKey) error

type MemoryBotSecretBindingStore

type MemoryBotSecretBindingStore struct {
	// contains filtered or unexported fields
}

func NewMemoryBotSecretBindingStore

func NewMemoryBotSecretBindingStore() *MemoryBotSecretBindingStore

func (*MemoryBotSecretBindingStore) Create

func (*MemoryBotSecretBindingStore) Delete

func (*MemoryBotSecretBindingStore) Get

func (*MemoryBotSecretBindingStore) ListByTenant

func (m *MemoryBotSecretBindingStore) ListByTenant(_ context.Context, tenantID string) ([]BotSecretBinding, error)

func (*MemoryBotSecretBindingStore) ListByTenantBot

func (m *MemoryBotSecretBindingStore) ListByTenantBot(_ context.Context, tenantID, botID string) ([]BotSecretBinding, error)

func (*MemoryBotSecretBindingStore) Update

type MemoryGenericSecretStore

type MemoryGenericSecretStore struct {
	// contains filtered or unexported fields
}

func NewMemoryGenericSecretStore

func NewMemoryGenericSecretStore() *MemoryGenericSecretStore

func (*MemoryGenericSecretStore) Create

func (*MemoryGenericSecretStore) Delete

func (*MemoryGenericSecretStore) Get

func (*MemoryGenericSecretStore) ListByTeam

func (m *MemoryGenericSecretStore) ListByTeam(_ context.Context, teamID, userID string) ([]GenericSecret, error)

func (*MemoryGenericSecretStore) ListByUser

func (m *MemoryGenericSecretStore) ListByUser(_ context.Context, teamID, userID string) ([]GenericSecret, error)

func (*MemoryGenericSecretStore) MarkUsed

func (m *MemoryGenericSecretStore) MarkUsed(_ context.Context, id string, at time.Time) error

func (*MemoryGenericSecretStore) Update

type MemoryOAuthPendingStore

type MemoryOAuthPendingStore struct {
	// contains filtered or unexported fields
}

MemoryOAuthPendingStore — for tests / local mode.

func NewMemoryOAuthPendingStore

func NewMemoryOAuthPendingStore() *MemoryOAuthPendingStore

func (*MemoryOAuthPendingStore) Put

func (*MemoryOAuthPendingStore) Take

type MemoryOAuthStore

type MemoryOAuthStore struct {
	// contains filtered or unexported fields
}

MemoryOAuthStore — for tests.

func NewMemoryOAuthStore

func NewMemoryOAuthStore() *MemoryOAuthStore

func (*MemoryOAuthStore) Delete

func (s *MemoryOAuthStore) Delete(_ context.Context, userID string, kind OAuthKind) error

func (*MemoryOAuthStore) ExpiringBefore

func (s *MemoryOAuthStore) ExpiringBefore(_ context.Context, t time.Time) ([]OAuthRecord, error)

func (*MemoryOAuthStore) Get

func (s *MemoryOAuthStore) Get(_ context.Context, userID string, kind OAuthKind) (OAuthRecord, error)

func (*MemoryOAuthStore) ListByUser

func (s *MemoryOAuthStore) ListByUser(_ context.Context, userID string) ([]OAuthRecord, error)

func (*MemoryOAuthStore) Upsert

func (s *MemoryOAuthStore) Upsert(_ context.Context, rec OAuthRecord) error

type MemoryRunSecretsStore

type MemoryRunSecretsStore struct {
	// contains filtered or unexported fields
}

MemoryRunSecretsStore is the test variant.

func NewMemoryRunSecretsStore

func NewMemoryRunSecretsStore() *MemoryRunSecretsStore

func (*MemoryRunSecretsStore) Delete

func (s *MemoryRunSecretsStore) Delete(ctx context.Context, id string) error

func (*MemoryRunSecretsStore) Get

func (*MemoryRunSecretsStore) Put

type MongoApiKeyStore

type MongoApiKeyStore struct {
	// contains filtered or unexported fields
}

MongoApiKeyStore implements ApiKeyStore on Mongo.

func NewMongoApiKeyStore

func NewMongoApiKeyStore(db *mongo.Database) *MongoApiKeyStore

func (*MongoApiKeyStore) ClearDefault

func (s *MongoApiKeyStore) ClearDefault(ctx context.Context, teamID, userID string, provider Provider, exceptID string) error

func (*MongoApiKeyStore) Create

func (s *MongoApiKeyStore) Create(ctx context.Context, k ApiKey) error

func (*MongoApiKeyStore) Delete

func (s *MongoApiKeyStore) Delete(ctx context.Context, id string) error

func (*MongoApiKeyStore) EnsureSchema

func (s *MongoApiKeyStore) EnsureSchema(ctx context.Context) error

EnsureSchema creates the indexes used by the store.

func (*MongoApiKeyStore) Get

func (s *MongoApiKeyStore) Get(ctx context.Context, id string) (ApiKey, error)

func (*MongoApiKeyStore) ListByTeam

func (s *MongoApiKeyStore) ListByTeam(ctx context.Context, teamID, userID string) ([]ApiKey, error)

func (*MongoApiKeyStore) ListByUser

func (s *MongoApiKeyStore) ListByUser(ctx context.Context, teamID, userID string) ([]ApiKey, error)

func (*MongoApiKeyStore) MarkUsed

func (s *MongoApiKeyStore) MarkUsed(ctx context.Context, id string, at time.Time) error

func (*MongoApiKeyStore) Update

func (s *MongoApiKeyStore) Update(ctx context.Context, k ApiKey) error

type MongoBotSecretBindingStore

type MongoBotSecretBindingStore struct {
	// contains filtered or unexported fields
}

func NewMongoBotSecretBindingStore

func NewMongoBotSecretBindingStore(db *mongo.Database) *MongoBotSecretBindingStore

func (*MongoBotSecretBindingStore) Create

func (*MongoBotSecretBindingStore) Delete

func (*MongoBotSecretBindingStore) EnsureSchema

func (s *MongoBotSecretBindingStore) EnsureSchema(ctx context.Context) error

func (*MongoBotSecretBindingStore) Get

func (*MongoBotSecretBindingStore) ListByTenant

func (s *MongoBotSecretBindingStore) ListByTenant(ctx context.Context, tenantID string) ([]BotSecretBinding, error)

func (*MongoBotSecretBindingStore) ListByTenantBot

func (s *MongoBotSecretBindingStore) ListByTenantBot(ctx context.Context, tenantID, botID string) ([]BotSecretBinding, error)

func (*MongoBotSecretBindingStore) Update

type MongoGenericSecretStore

type MongoGenericSecretStore struct {
	// contains filtered or unexported fields
}

func NewMongoGenericSecretStore

func NewMongoGenericSecretStore(db *mongo.Database) *MongoGenericSecretStore

func (*MongoGenericSecretStore) Create

func (*MongoGenericSecretStore) Delete

func (s *MongoGenericSecretStore) Delete(ctx context.Context, id string) error

func (*MongoGenericSecretStore) EnsureSchema

func (s *MongoGenericSecretStore) EnsureSchema(ctx context.Context) error

func (*MongoGenericSecretStore) Get

func (*MongoGenericSecretStore) ListByTeam

func (s *MongoGenericSecretStore) ListByTeam(ctx context.Context, teamID, userID string) ([]GenericSecret, error)

func (*MongoGenericSecretStore) ListByUser

func (s *MongoGenericSecretStore) ListByUser(ctx context.Context, teamID, userID string) ([]GenericSecret, error)

func (*MongoGenericSecretStore) MarkUsed

func (s *MongoGenericSecretStore) MarkUsed(ctx context.Context, id string, at time.Time) error

func (*MongoGenericSecretStore) Update

type MongoOAuthPendingStore

type MongoOAuthPendingStore struct {
	// contains filtered or unexported fields
}

MongoOAuthPendingStore — production impl with a TTL guard.

func NewMongoOAuthPendingStore

func NewMongoOAuthPendingStore(db *mongo.Database) *MongoOAuthPendingStore

func (*MongoOAuthPendingStore) EnsureSchema

func (s *MongoOAuthPendingStore) EnsureSchema(ctx context.Context) error

func (*MongoOAuthPendingStore) Put

func (*MongoOAuthPendingStore) Take

func (s *MongoOAuthPendingStore) Take(ctx context.Context, ownerKey string, kind OAuthKind) (OAuthPending, error)

type MongoOAuthStore

type MongoOAuthStore struct {
	// contains filtered or unexported fields
}

MongoOAuthStore — production impl.

func NewMongoOAuthStore

func NewMongoOAuthStore(db *mongo.Database) *MongoOAuthStore

func (*MongoOAuthStore) Delete

func (s *MongoOAuthStore) Delete(ctx context.Context, userID string, kind OAuthKind) error

func (*MongoOAuthStore) EnsureSchema

func (s *MongoOAuthStore) EnsureSchema(ctx context.Context) error

func (*MongoOAuthStore) ExpiringBefore

func (s *MongoOAuthStore) ExpiringBefore(ctx context.Context, t time.Time) ([]OAuthRecord, error)

func (*MongoOAuthStore) Get

func (s *MongoOAuthStore) Get(ctx context.Context, userID string, kind OAuthKind) (OAuthRecord, error)

func (*MongoOAuthStore) ListByUser

func (s *MongoOAuthStore) ListByUser(ctx context.Context, userID string) ([]OAuthRecord, error)

func (*MongoOAuthStore) Upsert

func (s *MongoOAuthStore) Upsert(ctx context.Context, rec OAuthRecord) error

type MongoRunSecretsStore

type MongoRunSecretsStore struct {
	// contains filtered or unexported fields
}

MongoRunSecretsStore implements RunSecretsStore on Mongo with a 24h TTL guard.

func NewMongoRunSecretsStore

func NewMongoRunSecretsStore(db *mongo.Database) *MongoRunSecretsStore

func (*MongoRunSecretsStore) Delete

func (s *MongoRunSecretsStore) Delete(ctx context.Context, id string) error

func (*MongoRunSecretsStore) EnsureSchema

func (s *MongoRunSecretsStore) EnsureSchema(ctx context.Context) error

func (*MongoRunSecretsStore) Get

func (*MongoRunSecretsStore) Put

type OAuthKind

type OAuthKind string

OAuthKind enumerates the third-party CLIs whose OAuth subscription (forfait) iterion can drive on behalf of an authenticated user. The names match the delegate.Backend slug so cloudpublisher and the runner can resolve them mechanically.

const (
	OAuthKindClaudeCode OAuthKind = "claude_code"
	OAuthKindCodex      OAuthKind = "codex"
)

func (OAuthKind) Valid

func (k OAuthKind) Valid() bool

type OAuthPending

type OAuthPending struct {
	ID             string    `bson:"_id" json:"id"`
	OwnerKey       string    `bson:"owner_key" json:"owner_key"`
	Kind           OAuthKind `bson:"kind" json:"kind"`
	SealedVerifier []byte    `bson:"sealed_verifier" json:"-"`
	State          string    `bson:"state" json:"state"`
	RedirectURI    string    `bson:"redirect_uri" json:"redirect_uri"`
	CreatedAt      time.Time `bson:"created_at" json:"created_at"`
	ExpiresAt      time.Time `bson:"expires_at" json:"expires_at"`
}

OAuthPending is the short-lived server-side state held between the browser OAuth /authorize/start and /authorize/complete calls. It keeps the PKCE verifier (sealed) and the expected state so the completion can finish the exchange and validate CSRF — without trusting anything the client echoes back except the code.

Keyed by (OwnerKey, Kind): one in-flight connect per owner+kind. A second start simply overwrites the first. Persisted (Mongo) rather than in-process so the start and complete may land on different replicas (HA-safe). Mirrors the TTL pattern of run_secrets.

type OAuthPendingStore

type OAuthPendingStore interface {
	// Put upserts the pending record (overwriting any existing one for
	// the same owner+kind).
	Put(ctx context.Context, rec OAuthPending) error
	// Take reads and deletes the pending record (one-shot), returning
	// ErrOAuthPendingNotFound when absent or expired.
	Take(ctx context.Context, ownerKey string, kind OAuthKind) (OAuthPending, error)
}

OAuthPendingStore persists in-flight browser-OAuth connects.

type OAuthRecord

type OAuthRecord struct {
	ID                   string     `bson:"_id" json:"id"`
	UserID               string     `bson:"user_id" json:"user_id"`
	Kind                 OAuthKind  `bson:"kind" json:"kind"`
	SealedPayload        []byte     `bson:"sealed_payload" json:"-"`
	Scopes               []string   `bson:"scopes,omitempty" json:"scopes,omitempty"`
	AccessTokenExpiresAt *time.Time `bson:"access_token_expires_at,omitempty" json:"access_token_expires_at,omitempty"`
	LastRefreshedAt      *time.Time `bson:"last_refreshed_at,omitempty" json:"last_refreshed_at,omitempty"`
	// NotRefreshable marks a payload that carries no refresh token: the
	// refresh worker and manual refresh must skip it — only a re-connect
	// can renew it. Inverted polarity so legacy records (field absent =
	// false) keep being attempted; the first ErrNotRefreshable outcome
	// self-heals them by setting this flag.
	NotRefreshable bool      `bson:"not_refreshable,omitempty" json:"not_refreshable,omitempty"`
	CreatedAt      time.Time `bson:"created_at" json:"created_at"`
	UpdatedAt      time.Time `bson:"updated_at" json:"updated_at"`
}

OAuthRecord is the per-(user, kind) sealed credential bundle.

SealedPayload is opaque to iterion — it holds the verbatim credentials.json (Anthropic) or auth.json (OpenAI Codex) blob the user uploaded, sealed with the master key bound to the record id. We never decrypt for display; the only consumer is the runner, which materialises the file in a tmpdir and points the CLI at it.

AccessTokenExpiresAt is captured separately from the sealed blob so the refresh worker can identify expiring records without decrypting. Best-effort: providers without an access-token expiry (or when the user pasted only the refresh token) leave it zero and the worker skips them.

type OAuthRefreshWorker

type OAuthRefreshWorker struct {
	Store             OAuthStore
	Sealer            Sealer
	HTTP              *http.Client
	AnthropicClientID string
	CodexClientID     string
	// Lead is how far ahead of expiry a record is refreshed (a record
	// expiring within Lead is rotated now). Defaults to 30m.
	Lead time.Duration
}

OAuthRefreshWorker proactively rotates OAuth-forfait access tokens before they expire, so neither an interactive run nor an automated (webhook/dispatcher/cron) run ever reads a stale credential. This is the analogue of forge.RefreshWorker for the forfait store.

It covers BOTH personal and org-scoped records uniformly: org records are ordinary OAuthRecords keyed under OrgOwnerKey(tenant), so they surface from ExpiringBefore like any other — and the org credential is exactly the one that powers 24/7 automation, so keeping it fresh is the whole point.

func (*OAuthRefreshWorker) RunOnce

func (w *OAuthRefreshWorker) RunOnce(ctx context.Context) (int, error)

RunOnce refreshes every record expiring within Lead. It is best-effort: a single record's failure (provider rejection, missing client id) is logged via the returned error aggregate but does not abort the sweep. Returns the number of records successfully refreshed.

type OAuthStore

type OAuthStore interface {
	Upsert(ctx context.Context, rec OAuthRecord) error
	Get(ctx context.Context, userID string, kind OAuthKind) (OAuthRecord, error)
	ListByUser(ctx context.Context, userID string) ([]OAuthRecord, error)
	Delete(ctx context.Context, userID string, kind OAuthKind) error
	// ExpiringBefore returns records whose access token is set and
	// expires before t — used by the background refresh worker.
	ExpiringBefore(ctx context.Context, t time.Time) ([]OAuthRecord, error)
}

OAuthStore is the persistence interface for sealed OAuth records.

type Provider

type Provider string

Provider enumerates the supported LLM credential providers. The string values are stable wire identifiers; do not rename without a migration. Naming mirrors what the model registry consumes.

const (
	ProviderAnthropic  Provider = "anthropic"
	ProviderOpenAI     Provider = "openai"
	ProviderBedrock    Provider = "bedrock"
	ProviderVertex     Provider = "vertex"
	ProviderAzure      Provider = "azure"
	ProviderOpenRouter Provider = "openrouter"
	ProviderXAI        Provider = "xai"
	// ProviderZAI is z.ai's Coding-Plan token. The provider exposes an
	// Anthropic-compatible HTTP surface, so credentials flow through the
	// existing Anthropic codepath with ANTHROPIC_BASE_URL pointed at
	// z.ai's endpoint and the ZAI token used as ANTHROPIC_AUTH_TOKEN.
	// Kept as a distinct Provider so per-tenant BYOK can pick "z.ai"
	// vs "anthropic" explicitly.
	ProviderZAI Provider = "zai"
)

func ParseProvider

func ParseProvider(s string) (Provider, error)

ParseProvider returns Provider when s matches one of the known names (case-insensitive) or an error otherwise.

func (Provider) Valid

func (p Provider) Valid() bool

Valid reports whether p is one of the known providers.

type RefreshResult

type RefreshResult struct {
	AccessToken  string
	RefreshToken string
	ExpiresAt    time.Time
	Scopes       []string
	IDToken      string
}

RefreshResult carries the bits a successful refresh produces. Pass them through ApplyAnthropicRefresh / ApplyCodexRefresh to rebuild the credentials JSON the CLI expects.

func ExchangeAnthropicCode

func ExchangeAnthropicCode(ctx context.Context, hc *http.Client, clientID, code, verifier, redirectURI, state string) (RefreshResult, error)

ExchangeAnthropicCode trades an authorization code for tokens against the Anthropic OAuth token endpoint (grant_type=authorization_code). It mirrors RefreshAnthropic's request/response handling and reuses the same retry + validation primitives. redirectURI must match the one used to obtain the code; state is sent when non-empty.

func RefreshAnthropic

func RefreshAnthropic(ctx context.Context, hc *http.Client, clientID, refreshToken string) (RefreshResult, error)

RefreshAnthropic exchanges a refresh_token for a new access_token against the Anthropic OAuth endpoint. clientID is provided per deployment (the publicly-known Claude Code OAuth client).

func RefreshCodex

func RefreshCodex(ctx context.Context, hc *http.Client, clientID, refreshToken string) (RefreshResult, error)

RefreshCodex mirrors RefreshAnthropic for the OpenAI Codex CLI. clientID is the Codex CLI's published OAuth client; deployments using a custom Codex fork override it.

type Resolution

type Resolution struct {
	Provider Provider
	KeyID    string
	// Plaintext is filled by the resolver only if the caller passes
	// a Sealer; without one the resolver returns the sealed blob
	// untouched (handlers that do not need plaintext yet).
	Plaintext   []byte
	SealedBlob  []byte
	SourceScope string // "user" or "team" — for audit logging
}

Resolution describes a single resolved key the publisher needs to inject for one provider on a given run.

type RunBundle

type RunBundle struct {
	APIKeys map[Provider]string `json:"api_keys,omitempty"`
	// GenericSecrets maps workflow secret names to plaintext payloads
	// resolved from the tenant/user secret store at publish time.
	GenericSecrets map[string]string `json:"generic_secrets,omitempty"`
	// GenericSecretHosts maps a workflow secret name to the egress host
	// allowlist a bot-secret binding imposes on it (empty/absent = no
	// binding-level restriction). The runner intersects this with the
	// workflow's own declared `secrets.<name>.hosts` so a binding can
	// only NARROW egress, never broaden it. This is what makes a
	// binding's AllowedHosts an enforced control rather than metadata.
	GenericSecretHosts map[string][]string `json:"generic_secret_hosts,omitempty"`
	// GenericSecretRefs maps a workflow secret name to the ID of the
	// generic-secret store record it was resolved from (IDs only, never
	// values). A short-lived credential (a GitHub App installation token
	// lives 1h) can expire while the run executes; the server-side
	// refresh worker keeps the STORE record fresh, so these refs let the
	// runner re-read the current value mid-run and rewrite the secret's
	// materialised file — the bundle snapshot alone would go stale.
	GenericSecretRefs map[string]string `json:"generic_secret_refs,omitempty"`
	// OAuthCredentials maps "claude_code" / "codex" → opaque blob
	// that the runner materialises as a credentials.json /
	// auth.json before spawning the CLI subprocess.
	OAuthCredentials map[string][]byte `json:"oauth_credentials,omitempty"`
	// ForgeAppBotLogin is the GitHub-App bot login (e.g.
	// "iterion-forge-1234[bot]") when the run's forge_token was resolved
	// from a github_app connection. An installation token can't `GET /user`
	// (403), so the runner can't self-resolve the committer identity from
	// the token alone — this login lets it look up the bot's numeric id via
	// `GET /users/<login>` (which an installation token CAN read) and seed
	// the canonical `<id>+<login>@users.noreply.github.com` committer, so a
	// bot's commits are attributed to the App bot, not the neutral fallback.
	// Empty for PAT/OAuth connections (the token's own /user resolves them).
	ForgeAppBotLogin string `json:"forge_app_bot_login,omitempty"`
}

RunBundle is the per-run sealed payload the runner needs in order to execute. It carries every API-key + OAuth credential the publisher pre-resolved, keyed by provider/kind.

The structure is JSON-marshalled and then sealed once with the run-scoped AAD ("run_secrets:<run_id>"). Runners decrypt with the shared master key.

func OpenRunBundle

func OpenRunBundle(sealer Sealer, runID string, sealed []byte) (RunBundle, error)

OpenRunBundle is the inverse: decrypt + unmarshal.

type RunSecretsRecord

type RunSecretsRecord struct {
	ID           string    `bson:"_id" json:"id"`
	TenantID     string    `bson:"tenant_id" json:"tenant_id"`
	RunID        string    `bson:"run_id" json:"run_id"`
	SealedBundle []byte    `bson:"sealed_bundle" json:"-"`
	CreatedAt    time.Time `bson:"created_at" json:"created_at"`
	// ExpiresAt drives the Mongo TTL — the runner deletes the
	// record on success, but a TTL guard ensures abandoned bundles
	// never linger past 24h.
	ExpiresAt time.Time `bson:"expires_at" json:"expires_at"`
}

RunSecretsRecord is the persisted form of a sealed bundle. _id is the SecretsRef the publisher writes into the queue.RunMessage; the runner uses that ref to fetch + decrypt right before executing the run.

type RunSecretsStore

type RunSecretsStore interface {
	Put(ctx context.Context, rec RunSecretsRecord) error
	Get(ctx context.Context, id string) (RunSecretsRecord, error)
	Delete(ctx context.Context, id string) error
}

RunSecretsStore persists sealed RunBundle records keyed by an opaque ref carried in the NATS message.

type ScopedSecret

type ScopedSecret struct {
	Secret GenericSecret
	Scope  string
}

ScopedSecret pairs a secret with the layer it resolved from ("global" | "project"), so a UI/CLI can show which layer owns each entry.

type Sealer

type Sealer interface {
	// Seal returns a sealed blob. AAD is optional context binding;
	// pass the same value to Open.
	Seal(plaintext, aad []byte) ([]byte, error)

	// Open returns the plaintext or an error. AAD must match the
	// value supplied at Seal.
	Open(sealed, aad []byte) ([]byte, error)
}

Sealer seals and opens secret payloads. Implementations MUST provide authenticated encryption: tampering with sealed bytes must surface as an Open error, never as silently-corrupted plaintext.

func NewLazyLocalSealer

func NewLazyLocalSealer(dataDir string, logf func(string, ...any)) Sealer

NewLazyLocalSealer returns a Sealer that resolves the real AES-GCM sealer on first use. Errors from master-key resolution surface at the first Seal/Open, not at construction.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL