Documentation
¶
Index ¶
- Constants
- Variables
- func ActiveVersionOf(p RootKeyProvider) int
- func ContextWithDecryptUser(ctx context.Context, userID string) context.Context
- func ContextWithMatchedSigningKey(ctx context.Context, matchedSigningKey []byte) context.Context
- func ContextWithSessionID(ctx context.Context, sessionID string) context.Context
- func DecryptSecret(dek, ciphertext []byte) ([]byte, error)
- func DeriveKEKFromKey(keyMaterial, salt []byte, info string) ([]byte, error)
- func DeriveKEKFromPassword(password, salt []byte) ([]byte, error)
- func DeriveKEKFromPasswordV0(password, salt []byte, info string) ([]byte, error)
- func DeriveSealedKEK(password, salt []byte, info string) ([]byte, error)
- func EncryptSecret(dek, plaintext []byte) ([]byte, error)
- func GenerateDEK() ([]byte, error)
- func GenerateRecoveryKey() ([]byte, error)
- func GenerateSalt() ([]byte, error)
- func SealRootKey(path string, passphrase, rootKey []byte) error
- func UnwrapDEK(kek, wrappedDEK []byte) ([]byte, error)
- func ValidateKind(kind string) error
- func ValidateSlug(slug string) error
- func WrapDEK(kek, dek []byte) ([]byte, error)
- type APIKeyRecord
- type APIKeyStore
- type AdminKeyDeriverdeprecated
- type AsyncAuditLogger
- func (l *AsyncAuditLogger) AddBindings(ctx context.Context, workspaceID string, secretIDs []string) error
- func (l *AsyncAuditLogger) BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error
- func (l *AsyncAuditLogger) CreateSecret(ctx context.Context, secret *UserSecret) error
- func (l *AsyncAuditLogger) DeleteSecret(ctx context.Context, userID, secretID string) error
- func (l *AsyncAuditLogger) GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)
- func (l *AsyncAuditLogger) GetBindingsForSecret(ctx context.Context, secretID string) ([]string, error)
- func (l *AsyncAuditLogger) GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)
- func (l *AsyncAuditLogger) GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)
- func (l *AsyncAuditLogger) GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)
- func (l *AsyncAuditLogger) HasUserProviderCredential(ctx context.Context, userID, slug string) (bool, error)
- func (l *AsyncAuditLogger) ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
- func (l *AsyncAuditLogger) ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
- func (l *AsyncAuditLogger) LogAudit(_ context.Context, entry *AuditEntry) (retErr error)
- func (l *AsyncAuditLogger) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
- func (l *AsyncAuditLogger) ReEncryptUserSecrets(ctx context.Context, userID string, newKeyVersion int, ...) error
- func (l *AsyncAuditLogger) SeedWorkspaceCredentials(ctx context.Context, workspaceID, userID string, orgID *string) error
- func (l *AsyncAuditLogger) SetBindings(ctx context.Context, workspaceID string, secretIDs []string) error
- func (l *AsyncAuditLogger) Stats() AsyncAuditStats
- func (l *AsyncAuditLogger) Stop()
- func (l *AsyncAuditLogger) UpdateSecret(ctx context.Context, secret *UserSecret) error
- func (l *AsyncAuditLogger) UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error
- type AsyncAuditStats
- type AuditEntry
- type AuditQuery
- type AuditWriter
- type AuditedProvider
- type AutoApplyRule
- type BindingsMutationResult
- type BindingsResponse
- type BoundSecret
- type CreateSecretRequest
- type CredentialBinding
- type CredentialBindingInfo
- type CredentialRow
- type CredentialStore
- type DEKCache
- type InjectedSecret
- type JWTSession
- type JWTSessionJanitor
- type JWTSessionStore
- type KEKRotationError
- type KEKRotationResult
- type KeyService
- func (s *KeyService) CacheDEK(ctx context.Context, sessionID string, dek []byte, ttl time.Duration) error
- func (s *KeyService) ChangePassword(ctx context.Context, userID, sessionID string, oldPassword, newPassword []byte) error
- func (s *KeyService) DEKAvailable(ctx context.Context, sessionID string) bool
- func (s *KeyService) DeleteDurableSessionsForUser(ctx context.Context, userID string) error
- func (s *KeyService) EvictDEK(ctx context.Context, sessionID string) error
- func (s *KeyService) GetDEK(ctx context.Context, sessionID string, matchedSigningKey []byte) ([]byte, error)
- func (s *KeyService) GetDEKForUser(ctx context.Context, userID string) (dek []byte, jti string, err error)
- func (s *KeyService) HasKeys(ctx context.Context, userID string) (bool, error)
- func (s *KeyService) InitializeUserKeys(ctx context.Context, userID string, password []byte) (recoveryKeyHex string, err error)
- func (s *KeyService) JWTSessionStoreSet() bool
- func (s *KeyService) ResetWithRecoveryKey(ctx context.Context, userID string, recoveryKeyHex string, newPassword []byte) (newRecoveryKeyHex string, err error)
- func (s *KeyService) RotateKeyWithPassword(ctx context.Context, userID string, password []byte, sessionID string, ...) (RotationResult, error)
- func (s *KeyService) SetAPIKeyStore(store APIKeyStore, provider RootKeyProvider)
- func (s *KeyService) SetJWTSessionStore(store JWTSessionStore)
- func (s *KeyService) SetLogger(l pkginterfaces.LoggerInterface)
- func (s *KeyService) SetSecretStore(store SecretStore)
- func (s *KeyService) SetSigningKeyEnumerator(e SigningKeyEnumerator)
- func (s *KeyService) UnlockDEK(ctx context.Context, userID string, password []byte, sessionID string, ...) error
- func (s *KeyService) UnlockDEKWithSigningKey(ctx context.Context, userID string, password []byte, sessionID string, ...) error
- type KeyStore
- type LLMModelConfig
- type LLMProviderData
- type OrgAutoApplyStore
- type OwnerType
- type PgJWTSessionStore
- func (s *PgJWTSessionStore) DeleteExpiredJWTSessions(ctx context.Context, before time.Time) (int64, error)
- func (s *PgJWTSessionStore) DeleteJWTSession(ctx context.Context, jti uuid.UUID) error
- func (s *PgJWTSessionStore) DeleteJWTSessionsForUser(ctx context.Context, userID string) (int64, error)
- func (s *PgJWTSessionStore) GetJWTSession(ctx context.Context, jti uuid.UUID) (*JWTSession, error)
- func (s *PgJWTSessionStore) ListActiveJWTSessionsForUser(ctx context.Context, userID string, limit int) ([]*JWTSession, error)
- func (s *PgJWTSessionStore) WriteJWTSession(ctx context.Context, session *JWTSession) error
- type PgKeyStore
- func (s *PgKeyStore) CreateUserKey(ctx context.Context, record *UserKeyRecord) error
- func (s *PgKeyStore) GetUserKey(ctx context.Context, userID string) (*UserKeyRecord, error)
- func (s *PgKeyStore) UpdateWrappedDEK(ctx context.Context, userID string, wrappedDEK []byte, salt []byte, ...) error
- func (s *PgKeyStore) UpdateWrappedDEKRecovery(ctx context.Context, userID string, wrappedDEKRecovery []byte, ...) error
- type PgSecretStore
- func (s *PgSecretStore) AddBindings(ctx context.Context, workspaceID string, secretIDs []string) error
- func (s *PgSecretStore) BackfillFreeTierBindings(ctx context.Context) (int64, error)
- func (s *PgSecretStore) BindAllOrgCredentialsToOrgWorkspaces(ctx context.Context, orgID string) error
- func (s *PgSecretStore) BindCredentialToAllOrgWorkspaces(ctx context.Context, credentialID, orgID string) error
- func (s *PgSecretStore) BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error
- func (s *PgSecretStore) BindCredentialToWorkspace(ctx context.Context, credentialID, workspaceID string) error
- func (s *PgSecretStore) CreateAutoApply(ctx context.Context, credentialID, targetType string, targetID *string, ...) error
- func (s *PgSecretStore) CreateCredential(ctx context.Context, ownerType, ownerID string, row *CredentialRow) error
- func (s *PgSecretStore) CreateOrgAutoApply(ctx context.Context, credentialID, orgID string, withinPriority int) error
- func (s *PgSecretStore) CreateSecret(ctx context.Context, secret *UserSecret) error
- func (s *PgSecretStore) DeleteAutoApply(ctx context.Context, credentialID, targetType string, targetID *string) error
- func (s *PgSecretStore) DeleteCredential(ctx context.Context, ownerType, ownerID, credID string) error
- func (s *PgSecretStore) DeleteOrgAutoApply(ctx context.Context, credentialID, orgID string) error
- func (s *PgSecretStore) DeleteSecret(ctx context.Context, userID, secretID string) error
- func (s *PgSecretStore) GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)
- func (s *PgSecretStore) GetBindingsForSecret(ctx context.Context, secretID string) ([]string, error)
- func (s *PgSecretStore) GetCredential(ctx context.Context, ownerType, ownerID, credID string) (*CredentialRow, error)
- func (s *PgSecretStore) GetCredentialBindings(ctx context.Context, credentialID, ownerID string) ([]string, error)
- func (s *PgSecretStore) GetCredentialBindingsWithSource(ctx context.Context, credentialID, ownerID string) ([]CredentialBindingInfo, error)
- func (s *PgSecretStore) GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)
- func (s *PgSecretStore) GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)
- func (s *PgSecretStore) GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)
- func (s *PgSecretStore) HasUserProviderCredential(ctx context.Context, userID, slug string) (bool, error)
- func (s *PgSecretStore) ListAutoApply(ctx context.Context, credentialID string) ([]AutoApplyRule, error)
- func (s *PgSecretStore) ListCredentials(ctx context.Context, ownerType, ownerID string) ([]*CredentialRow, error)
- func (s *PgSecretStore) ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
- func (s *PgSecretStore) ListOrgAutoApply(ctx context.Context, orgID string) ([]*AutoApplyRule, error)
- func (s *PgSecretStore) ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
- func (s *PgSecretStore) LogAudit(ctx context.Context, entry *AuditEntry) error
- func (s *PgSecretStore) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
- func (s *PgSecretStore) ReEncryptOrgCredentials(ctx context.Context, tx pgx.Tx, orgID string, oldDEK, newDEK []byte) (int, error)
- func (s *PgSecretStore) ReEncryptUserSecrets(ctx context.Context, userID string, newKeyVersion int, ...) error
- func (s *PgSecretStore) SeedWorkspaceCredentials(ctx context.Context, workspaceID, userID string, orgID *string) error
- func (s *PgSecretStore) SetBindings(ctx context.Context, workspaceID string, secretIDs []string) error
- func (s *PgSecretStore) UnbindCredentialFromWorkspace(ctx context.Context, credentialID, workspaceID string) error
- func (s *PgSecretStore) UpdateCredential(ctx context.Context, ownerType, ownerID, credID string, row *CredentialRow) error
- func (s *PgSecretStore) UpdateSecret(ctx context.Context, secret *UserSecret) error
- func (s *PgSecretStore) UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error
- type PodBootstrapSecretInjector
- type PostgresSecretProvider
- func (p *PostgresSecretProvider) DEKAvailable(ctx context.Context, owner SecretOwner) bool
- func (p *PostgresSecretProvider) Decrypt(ctx context.Context, owner SecretOwner, ciphertext []byte, keyVersion int) ([]byte, error)
- func (p *PostgresSecretProvider) Encrypt(ctx context.Context, owner SecretOwner, plaintext []byte) ([]byte, int, error)
- func (p *PostgresSecretProvider) RotateKey(ctx context.Context, owner SecretOwner) (int, error)
- type RedisDEKCache
- type RootKeyProvider
- type RotationCoordinator
- type RotationResult
- type RotationRow
- type RotationStore
- type SealedKeyProvider
- type SecretBinding
- type SecretInjector
- type SecretOwner
- type SecretProvider
- type SecretResponse
- type SecretService
- func (s *SecretService) AddBindings(ctx context.Context, userID, workspaceID string, secretIDs []string) (BindingsMutationResult, error)
- func (s *SecretService) CreateSecret(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, ...) (*SecretResponse, error)
- func (s *SecretService) DecryptSecretValue(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, ...) ([]byte, error)
- func (s *SecretService) DeleteSecret(ctx context.Context, userID, secretID string) error
- func (s *SecretService) GetBindings(ctx context.Context, userID, workspaceID string) (*BindingsResponse, error)
- func (s *SecretService) GetBindingsForSecret(ctx context.Context, userID, secretID string) ([]string, error)
- func (s *SecretService) GetSecret(ctx context.Context, userID, secretID string) (*SecretResponse, error)
- func (s *SecretService) GetSecretByName(ctx context.Context, userID, name string) (*SecretResponse, error)
- func (s *SecretService) InjectSecrets(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, ...) ([]byte, error)
- func (s *SecretService) InjectSecretsForPodBootstrap(ctx context.Context, userID, workspaceID string) ([]byte, error)
- func (s *SecretService) InjectSessionlessSecrets(ctx context.Context, userID, workspaceID string) ([]byte, error)
- func (s *SecretService) ListSecrets(ctx context.Context, userID string) ([]*SecretResponse, error)
- func (s *SecretService) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
- func (s *SecretService) SeedGlobalDefaultSecrets(ctx context.Context, workspaceID, userID string) error
- func (s *SecretService) SetAdminProvider(p RootKeyProvider)
- func (s *SecretService) SetBindings(ctx context.Context, userID, workspaceID string, secretIDs []string) (BindingsMutationResult, error)
- func (s *SecretService) SetOrgProvider(p RootKeyProvider)
- func (s *SecretService) UpdateSecret(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, ...) error
- type SecretStore
- type SecretType
- type SessionlessSecretInjector
- type SetBindingsRequest
- type SigningKeyEnumerator
- type StaticKeyProvider
- type UpdateSecretRequest
- type UserKeyRecord
- type UserSecret
- type VersionedProvider
Constants ¶
const ( KDFVersionHKDF = 0 KDFVersionArgon2id = 1 KDFCurrentVersion = KDFVersionArgon2id )
const DefaultJWTSessionJanitorInterval = 60 * time.Second
DefaultJWTSessionJanitorInterval is the period between expiry-pruning passes on the jwt_sessions table. 60s is small relative to the JWT lifetimes the table holds (24h default, 30d remember-me), so a row pruned a minute late costs nothing observable. The DELETE WHERE expires_at < NOW() query is O(log N) thanks to idx_jwt_sessions_expires_at.
const JWTSessionKEKInfo = "llmsafespaces-jwt-session-dek-kek"
JWTSessionKEKInfo is the HKDF `info` constant used to derive the KEK that wraps the durable per-JWT DEK. Pinned here so the rehydrate path and the login durable-write path produce byte-identical KEKs.
const SlugRegex = `^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$`
SlugRegex is the canonical slug-validation regex. Must be byte-identical to the DB CHECK regex in 000001_initial_schema.up.sql:
CHECK (slug ~ '^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$')
Semantics: 1-64 chars, lowercase alphanumeric + hyphens, must start AND end with alphanumeric (no leading/trailing hyphen).
Variables ¶
var ( ErrDecryptionFailed = errors.New("decryption failed: ciphertext tampered or wrong key") ErrInvalidCiphertext = errors.New("ciphertext too short") ErrInvalidSaltLength = errors.New("salt must be 32 bytes") )
var ( // ErrSecretNotFound is returned when a secret does not exist or // is not owned by the requesting user. Both cases are conflated // to avoid leaking workspace existence cross-user. ErrSecretNotFound = &pkgerrors.StatusError{ Status: http.StatusNotFound, Code: "secret_not_found", Message: "secret not found", } // ErrDuplicateSecret is returned when CreateSecret would violate // the (user_id, name) uniqueness constraint. ErrDuplicateSecret = &pkgerrors.StatusError{ Status: http.StatusConflict, Code: "duplicate_secret", Message: "secret with this name already exists", } // in the cache (typically because the JWT's jti has expired or // the user has not logged in since the cache was flushed). ErrDEKUnavailable = &pkgerrors.StatusError{ Status: http.StatusForbidden, Code: "dek_unavailable", Message: "encryption key not available; re-authenticate", } // ErrInvalidSecretType is returned when a CreateSecret request // names a type outside ValidSecretTypes. ErrInvalidSecretType = &pkgerrors.StatusError{ Status: http.StatusBadRequest, Code: "invalid_secret_type", Message: "invalid secret type", } // ErrInvalidMetadata is returned when the metadata blob is // missing a required field for the secret type, fails JSON // validation, or contains an adversarial mount_path. ErrInvalidMetadata = &pkgerrors.StatusError{ Status: http.StatusBadRequest, Code: "invalid_metadata", Message: "invalid secret metadata", } // ErrInvalidPassword is returned by RevealSecret when the // password reconfirmation step fails. The handler maps this to // a uniform 403 — the same status used for missing DEK — so // the response shape does not differentiate between "wrong // password" and "session expired", reducing what an attacker // who has stolen a JWT can learn. ErrInvalidPassword = &pkgerrors.StatusError{ Status: http.StatusForbidden, Code: "invalid_password", Message: "access denied", } // ErrUserKeysMissing is returned when the user_keys row for the // caller does not exist (e.g. legacy account that pre-dates // Epic 10 key initialisation, or a half-failed Register). ErrUserKeysMissing = &pkgerrors.StatusError{ Status: http.StatusPreconditionFailed, Code: "user_keys_missing", Message: "user key material not initialized; please re-login", } // ErrInvalidLLMProvider is returned when LLMProviderData validation // fails (missing provider, missing API key, etc.). ErrInvalidLLMProvider = &pkgerrors.StatusError{ Status: http.StatusBadRequest, Code: "invalid_llm_provider", Message: "invalid LLM provider data", } // ErrWorkspaceNotOwned is returned by binding operations when // the caller does not own the target workspace. Both // "workspace doesn't exist" and "workspace owned by someone // else" map to this single sentinel so the response shape does // not leak workspace existence cross-user. Handlers map to 404. // // The message says only "workspace not found" — NOT "or not // owned by caller" — because a future caller that logs // err.Error() would otherwise leak the same distinction the // type system was designed to hide. Callers that need to // classify the failure mode use errors.Is. ErrWorkspaceNotOwned = &pkgerrors.StatusError{ Status: http.StatusNotFound, Code: "workspace_not_found", Message: "workspace not found", } // ErrCiphertextDecryptFailed is returned when the DEK was // successfully obtained but the stored ciphertext cannot be // decrypted with it (AEAD authentication failure). This is // distinct from ErrDEKUnavailable: the key material is present, // but it does not match the ciphertext. // // Most common cause: the user's DEK was rotated or the user_keys // row was rewritten without re-encrypting the secrets that were // encrypted under the old DEK. Less common: ciphertext corruption, // schema version skew (key_version mismatch), or storage tampering. // // The DEK itself is fine — re-authenticating will not help. ErrCiphertextDecryptFailed = &pkgerrors.StatusError{ Status: http.StatusConflict, Code: "ciphertext_decrypt_failed", Message: "this secret cannot be decrypted with your current encryption key — " + "the ciphertext was likely encrypted with a previous key. " + "Re-create the secret to recover; if you have not changed your password, " + "contact an administrator and reference your audit log.", } )
Sentinel errors returned by the secrets package. Each carries its HTTP status code and user-facing message via StatusError, so the generic error handler (respondWithError in router.go) maps them automatically — no handler-level switch needed.
errors.Is still works for sentinel checks (pointer identity via chain traversal). errors.As can extract the *StatusError for generic typed handling.
Wrapping (`fmt.Errorf("...: %w", ErrSecretNotFound)`) is supported and recommended so the classification survives upstream formatting.
var ErrAutoBindingProtected = &pkgerrors.StatusError{ Status: http.StatusConflict, Code: "auto_binding_protected", Message: "auto-binding cannot be removed via unbind; delete the credential or workspace to remove it", }
ErrAutoBindingProtected is returned when a caller attempts to Unbind a credential that is bound via an auto-apply rule (source_type='auto'). Auto-bindings are managed by SeedWorkspaceCredentials and can only be removed by deleting the underlying credential or the workspace.
This sentinel lives in pkg/ (not api/internal/errors) because it is shared between the API server and the agentd daemon. It uses StatusError so the generic error handler maps it to HTTP 409 automatically.
var MetadataRequirementsBySecretType = map[SecretType][]string{ SecretTypeAPIKey: {}, SecretTypeLLMProvider: {}, SecretTypeSSHKey: {"key_type"}, SecretTypeGitCredential: {}, SecretTypeSecretFile: {"mount_path"}, SecretTypeEnvSecret: {"var_name"}, }
MetadataRequirementsBySecretType is a self-documenting map of which metadata keys each secret type requires. Surfaced in error responses (Bug 7 in worklog 0085) so callers don't have to reverse-engineer the schema from 400s.
var ValidKinds = []string{
"openai",
"anthropic",
"google",
"opencode",
"bedrock",
"azure_openai",
"vertex",
"cohere",
"mistral",
"perplexity",
"groq",
"xai",
"openrouter",
"together",
"openai_compatible",
}
ValidKinds is the canonical SDK-class enum. Adding a new kind requires a coordinated migration that extends the DB CHECK constraint.
Order matches the CHECK constraint declaration in the migration so the two are visually aligned during review.
var ValidSecretTypes = map[SecretType]bool{ SecretTypeAPIKey: true, SecretTypeLLMProvider: true, SecretTypeSSHKey: true, SecretTypeGitCredential: true, SecretTypeSecretFile: true, SecretTypeEnvSecret: true, }
ValidSecretTypes is the set of allowed secret types.
Functions ¶
func ActiveVersionOf ¶
func ActiveVersionOf(p RootKeyProvider) int
ActiveVersionOf returns the active key version of a provider, or 1 if the provider does not implement VersionedProvider (e.g. nil or a future external provider). This is the safe default — version 1 is the initial migration default for all tables.
func ContextWithDecryptUser ¶
ContextWithDecryptUser returns a context carrying the user ID for decrypt audit attribution. Callers should pass the resulting context to the provider's Decrypt method.
func ContextWithMatchedSigningKey ¶
ContextWithMatchedSigningKey carries the JWT signing key that validated the caller's token, so PostgresSecretProvider.Encrypt/Decrypt can pass it through to KeyService.GetDEK for durable-DEK rehydrate (Epic 56). Pass nil for API-key / sessionless paths — KeyService.GetDEK will surface ErrDEKUnavailable when rehydrate is needed.
Note: this is the SecretProvider's own typed-context key, NOT the gin.Context value set by AuthMiddleware. Handlers extract from gin and stash here before invoking provider methods.
Current state (PR #421): no production caller sets this value because PostgresSecretProvider itself has no production caller — *SecretService (not *PostgresSecretProvider) is the live path for user-secret CRUD. The matched-key plumbing is preserved on the SecretProvider interface for symmetry with the design doc and so a future revival of the SecretProvider path inherits Epic 56 rehydrate by setting this key; without that revival it is harmless dead code, not a regression.
func ContextWithSessionID ¶
ContextWithSessionID adds a session ID to the context for the SecretProvider.
func DecryptSecret ¶
func DeriveKEKFromKey ¶
func DeriveKEKFromPassword ¶
func DeriveKEKFromPasswordV0 ¶
func DeriveSealedKEK ¶
DeriveSealedKEK derives the KEK used to wrap the sealed root-key file's root key. It domain-separates via HKDF: Argon2id has no native info/context parameter, so HKDF derives a 32-byte sub-salt from the stored salt bound to info, and that sub-salt feeds Argon2id's salt input. Different info values therefore produce cryptographically independent KEKs for an identical passphrase + salt, while retaining Argon2id's memory-hardness against the (typically low-entropy) passphrase. See US-50.11.
func EncryptSecret ¶
func GenerateDEK ¶
func GenerateRecoveryKey ¶
func GenerateSalt ¶
func SealRootKey ¶
func ValidateKind ¶
ValidateKind returns nil if kind is one of the recognized SDK-class enum values, otherwise an error describing the rejection. Empty kind is rejected with a distinct message so the handler can map both cases to HTTP 400 with a clear field-specific error.
func ValidateSlug ¶
ValidateSlug returns nil if slug matches the canonical slug regex, otherwise an error describing the rejection.
The error message names the constraint (length, charset, anchors) rather than echoing the regex verbatim, which is more useful to API consumers than "regex did not match".
Types ¶
type APIKeyRecord ¶
type APIKeyRecord struct {
ID string
WrappedDEK []byte
KekSalt []byte
KeyCiphertext []byte
DecryptAccess bool
}
APIKeyRecord is the subset of API key data needed for DEK re-wrap.
type APIKeyStore ¶
type APIKeyStore interface {
ListAPIKeysWithDecrypt(ctx context.Context, userID string) ([]*APIKeyRecord, error)
UpdateAPIKeyDEK(ctx context.Context, keyID string, wrappedDEK, kekSalt []byte, synced bool) error
}
APIKeyStore abstracts database operations for API key DEK re-wrap.
type AdminKeyDeriver
deprecated
AdminKeyDeriver derives a server-side encryption key for admin credentials. The label parameter scopes the derived key (e.g. "provider-credentials"). Returns nil when LLMSAFESPACES_MASTER_SECRET is not set.
Deprecated: US-50.2 unifies admin/org credential crypto under RootKeyProvider. New code must not use this type; it is retained for one release so callers can fall back to the legacy path if a production issue surfaces. Removed in a follow-up release. See design/stories/epic-50-master-kek-hardening/README.md.
type AsyncAuditLogger ¶
type AsyncAuditLogger struct {
// contains filtered or unexported fields
}
AsyncAuditLogger wraps a SecretStore and logs audit entries asynchronously. The hot path (Log) never blocks: a full channel drops the entry and increments DroppedCount, which operators can scrape via Stats() and surface as an alert. Drains-on-Stop are idempotent (Stop may be called multiple times without panicking).
AsyncAuditLogger is itself a SecretStore — every CRUD method delegates to the wrapped store, while LogAudit is the only method that becomes asynchronous. This means callers can compose:
auditedStore := NewAsyncAuditLogger(pgStore, 4096) svc := NewSecretService(keys, auditedStore)
and every audit write becomes non-blocking without further wiring.
func NewAsyncAuditLogger ¶
func NewAsyncAuditLogger(store SecretStore, bufSize int, logger pkginterfaces.LoggerInterface) *AsyncAuditLogger
NewAsyncAuditLogger creates an async audit logger with a buffered channel. logger is optional — when set, drop+failure events surface at Warn so operators can detect audit-pipeline degradation.
func (*AsyncAuditLogger) AddBindings ¶
func (*AsyncAuditLogger) BindCredentialToAllUserWorkspaces ¶
func (l *AsyncAuditLogger) BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error
func (*AsyncAuditLogger) CreateSecret ¶
func (l *AsyncAuditLogger) CreateSecret(ctx context.Context, secret *UserSecret) error
func (*AsyncAuditLogger) DeleteSecret ¶
func (l *AsyncAuditLogger) DeleteSecret(ctx context.Context, userID, secretID string) error
func (*AsyncAuditLogger) GetBindings ¶
func (l *AsyncAuditLogger) GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)
func (*AsyncAuditLogger) GetBindingsForSecret ¶
func (*AsyncAuditLogger) GetSecret ¶
func (l *AsyncAuditLogger) GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)
func (*AsyncAuditLogger) GetSecretByName ¶
func (l *AsyncAuditLogger) GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)
func (*AsyncAuditLogger) GetWorkspaceCredentials ¶
func (l *AsyncAuditLogger) GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)
func (*AsyncAuditLogger) HasUserProviderCredential ¶
func (*AsyncAuditLogger) ListGlobalDefaultSecrets ¶
func (l *AsyncAuditLogger) ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
func (*AsyncAuditLogger) ListSecrets ¶
func (l *AsyncAuditLogger) ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
func (*AsyncAuditLogger) LogAudit ¶
func (l *AsyncAuditLogger) LogAudit(_ context.Context, entry *AuditEntry) (retErr error)
LogAudit on AsyncAuditLogger never blocks and never panics, even after Stop(). Entries are sent to the background goroutine via a buffered channel; a full channel drops the entry and increments the drop counter.
There is a small race window between the closed-flag check and the channel send where a concurrent Stop could close the channel out from under a sender. The deferred recover catches the resulting "send on closed channel" panic, increments the drop counter, and returns normally. Without the recover, a request emitting an audit entry concurrently with shutdown would crash the process.
The returned error is always nil — failures are observable via Stats() and the logger.
func (*AsyncAuditLogger) QueryAudit ¶
func (l *AsyncAuditLogger) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
func (*AsyncAuditLogger) ReEncryptUserSecrets ¶
func (*AsyncAuditLogger) SeedWorkspaceCredentials ¶
func (*AsyncAuditLogger) SetBindings ¶
func (*AsyncAuditLogger) Stats ¶
func (l *AsyncAuditLogger) Stats() AsyncAuditStats
Stats returns a snapshot of the dropped / written / failed counters. Safe to call concurrently with logging.
func (*AsyncAuditLogger) Stop ¶
func (l *AsyncAuditLogger) Stop()
Stop drains the channel and waits for completion. Idempotent: a second call returns immediately without panicking on the already- closed channel. Sets the closed flag BEFORE closing the channel so any concurrent LogAudit invocations see closed=true and take the drop path rather than panicking on a send-to-closed-channel.
stopCtx is canceled AFTER the worker drains so a stuck-on-DB LogAudit eventually returns. There is still a small window between the closed-flag check and the channel send where a concurrent Stop could close the channel out from under a sender; the deferred recover in LogAudit catches that panic.
func (*AsyncAuditLogger) UpdateSecret ¶
func (l *AsyncAuditLogger) UpdateSecret(ctx context.Context, secret *UserSecret) error
func (*AsyncAuditLogger) UpsertFreeTierCredential ¶
func (l *AsyncAuditLogger) UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error
type AsyncAuditStats ¶
type AsyncAuditStats struct {
Dropped uint64 // entries dropped because the channel was full
Written uint64 // entries successfully persisted
Failed uint64 // entries that reached the worker but the store rejected
}
AsyncAuditStats is the snapshot returned by AsyncAuditLogger.Stats.
type AuditEntry ¶
type AuditEntry struct {
ID int64 `json:"id"`
UserID string `json:"userId"`
Action string `json:"action"`
SecretID *string `json:"secretId,omitempty"`
WorkspaceID *string `json:"workspaceId,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
AuditEntry represents a secret audit log entry.
type AuditQuery ¶
type AuditQuery struct {
Action string
SecretID string
WorkspaceID string
Since *time.Time
Until *time.Time
Limit int
Offset int
}
AuditQuery defines filters for querying the audit log.
type AuditWriter ¶
type AuditWriter interface {
LogAudit(ctx context.Context, entry *AuditEntry) error
}
AuditWriter is the narrow interface for writing audit entries. Implemented by SecretStore, AsyncAuditLogger, and test doubles. US-50.12.
type AuditedProvider ¶
type AuditedProvider struct {
// contains filtered or unexported fields
}
AuditedProvider wraps a RootKeyProvider and logs every Decrypt call to the audit writer (US-50.12). Encrypt is NOT logged — it is not a sensitive read. The audit write is fire-and-forget (goroutine per call) so Decrypt never blocks on the audit pipeline. In production the AuditWriter is AsyncAuditLogger (already buffered), so the goroutine's LogAudit is a near-instant channel send.
What is logged: caller label, user ID (from context or "_system"), key version, timestamp, success/failure. Metadata is a JSON object.
What is NOT logged: plaintext, ciphertext, key material — never.
func NewAuditedProvider ¶
func NewAuditedProvider(inner RootKeyProvider, audit AuditWriter, label string) *AuditedProvider
NewAuditedProvider wraps inner with an audit-decorating provider. label identifies the purpose string for log attribution.
func (*AuditedProvider) ActiveVersion ¶
func (p *AuditedProvider) ActiveVersion() int
ActiveVersion delegates to the inner provider so the wrapper satisfies VersionedProvider. This is load-bearing: production callers invoke ActiveVersionOf(provider) at encrypt time to stamp the key_version column (auth.go, admin_provider_credentials.go, org_credentials.go). Without this delegation, wrapping with NewAuditedProvider would silently downgrade every key_version to the default 1, corrupting rotation tracking.
type AutoApplyRule ¶
AutoApplyRule is a row from credential_auto_apply (exported for handler use).
type BindingsMutationResult ¶
type BindingsMutationResult struct {
// LLMProviderAffected is true if any llm-provider secret was added or removed,
// OR if the diff could not be computed (conservative fallback).
LLMProviderAffected bool
AddedTypes []string
RemovedTypes []string
}
BindingsMutationResult describes what changed in a SetBindings or AddBindings call. Returned as a value (not pointer) so callers never need a nil check.
type BindingsResponse ¶
type BindingsResponse struct {
Bindings []BoundSecret `json:"bindings"`
}
BindingsResponse is the API response for workspace bindings.
type BoundSecret ¶
type BoundSecret struct {
SecretID string `json:"secretId"`
Name string `json:"name"`
Type SecretType `json:"type"`
}
BoundSecret is a secret reference in a binding response.
type CreateSecretRequest ¶
type CreateSecretRequest struct {
Name string `json:"name" binding:"required,min=1,max=255"`
Type SecretType `json:"type" binding:"required"`
Value string `json:"value" binding:"required"` // plaintext, encrypted before storage
Metadata json.RawMessage `json:"metadata"`
GlobalDefault bool `json:"globalDefault,omitempty"`
}
CreateSecretRequest is the API request for creating a secret.
type CredentialBinding ¶
type CredentialBinding struct {
ID string
OwnerType string
OwnerID string
Kind string // SDK-class enum (openai, anthropic, openai_compatible, ...)
Slug string // slug-safe per-owner identity; the agent-config.json provider-map key
Ciphertext []byte
KeyVersion int
ModelAllowlist []string
ModelContextLimits map[string]int // model_id → context window size in tokens
ModelOutputLimits map[string]int // model_id → max output tokens
SourceType string // "explicit" or "auto"
WithinPriority int
}
CredentialBinding is a joined row from workspace_credential_bindings + provider_credentials.
Identity model (Epic 55):
- Kind: SDK-class discriminator. Enum constrained by the DB CHECK. Selects the adapter that opencode loads (openai, anthropic, bedrock, openai_compatible, ...). Multiple credentials of the same Kind can coexist (e.g. two OpenAI-compatible LiteLLM endpoints) — Kind does NOT have to be unique per owner.
- Slug: stable per-owner identity. UNIQUE(owner_type, owner_id, slug) in the DB. This is also the literal key used in agent-config.json's provider map, so opencode sessions persist this value as providerID. A user with two `openai_compatible` credentials picks distinct slugs (e.g. "litellm-prod-us-west" and "litellm-prod-eu-central") to disambiguate them on the wire.
type CredentialBindingInfo ¶
type CredentialBindingInfo struct {
WorkspaceID string `json:"workspaceId"`
SourceType string `json:"sourceType"` // "explicit" or "auto"
}
CredentialBindingInfo is a minimal binding row used for the ListBindings API. It carries the workspace ID and the source type so the UI can distinguish auto-seeded bindings (which cannot be manually unbound) from explicit ones.
type CredentialRow ¶
type CredentialRow struct {
ID string
OwnerType string
OwnerID string
Name string // display label, free-form
Kind string // SDK-class enum
Slug string // per-owner unique identity; reaches opencode as providerID
Ciphertext []byte
KeyVersion int
ModelAllowlist []string
ModelContextLimits map[string]int // model_id → context window size in tokens
ModelOutputLimits map[string]int // model_id → max response tokens
CreatedAt time.Time
UpdatedAt time.Time
}
CredentialRow is the DB row shape for all provider credential types. Maps to the provider_credentials table; owner_type discriminates the owner scope ("admin", "user", "org") and owner_id the concrete owner ("_platform", a user id, or an org id). Defined here to avoid an import cycle (handlers → secrets → handlers).
Epic 55 identity model:
- Kind is the SDK-class enum (openai, anthropic, openai_compatible, ...). Multiple credentials of the same Kind can exist per owner.
- Slug is the per-owner unique identity AND the literal provider-map key in agent-config.json. Slug-safe regex enforced by the DB CHECK.
- Name is the free-form display label shown in the UI.
type CredentialStore ¶
type CredentialStore interface {
// GetWorkspaceCredentials returns all credential bindings for a workspace,
// ordered by: (source_type='explicit') DESC, within_priority DESC, created_at ASC.
GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)
// UpsertFreeTierCredential atomically upserts the platform free-tier
// credential row and its auto-apply rule in a single transaction.
UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error
// SeedWorkspaceCredentials inserts credential bindings for a new workspace:
// admin auto-apply rules (all, user target types), user-owned credentials,
// and org auto-apply rules + org credentials when orgID is non-nil.
SeedWorkspaceCredentials(ctx context.Context, workspaceID, userID string, orgID *string) error
// BindCredentialToAllUserWorkspaces binds a credential to every workspace
// owned by userID. Called on credential create to maintain the invariant
// that all credentials are bound to all of a user's workspaces.
BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error
// HasUserProviderCredential returns true if the user owns a credential
// with the given slug. The lookup is per-slug because slug is the
// per-owner unique identity (Epic 55); kind alone is not unique per
// owner.
HasUserProviderCredential(ctx context.Context, userID, slug string) (bool, error)
}
CredentialStore abstracts database operations for provider credentials.
type DEKCache ¶
type DEKCache interface {
CacheDEK(ctx context.Context, sessionID string, dek []byte, ttl time.Duration) error
GetDEK(ctx context.Context, sessionID string) ([]byte, error)
EvictDEK(ctx context.Context, sessionID string) error
}
DEKCache abstracts session-based DEK caching (Redis).
type InjectedSecret ¶
type InjectedSecret struct {
Type SecretType `json:"type"`
Name string `json:"name"`
Metadata json.RawMessage `json:"metadata"`
Plaintext string `json:"plaintext"`
}
InjectedSecret is a single secret entry in the secrets.json file that the init container reads to materialize secrets.
type JWTSession ¶
type JWTSession struct {
JTI uuid.UUID
UserID string
WrappedDEK []byte
KEKSalt []byte
CreatedAt time.Time
ExpiresAt time.Time
}
JWTSession is the in-memory shape of a jwt_sessions row.
Layout matches migration 000045:
jwt_sessions(jti UUID PK, user_id TEXT FK, wrapped_dek BYTEA,
kek_salt BYTEA, created_at TIMESTAMPTZ, expires_at TIMESTAMPTZ)
WrappedDEK is the user's DEK encrypted under a KEK derived from (matched_jwt_signing_key || jti) via HKDF-SHA256 with the llmsafespaces-jwt-session-dek-kek info string. See Epic 56 design.
JTI is the canonical UUID form that auth.go generates via uuid.New().String() and embeds in the JWT's "jti" claim.
type JWTSessionJanitor ¶
type JWTSessionJanitor struct {
// contains filtered or unexported fields
}
JWTSessionJanitor periodically prunes expired rows from jwt_sessions so the table stays bounded as login traffic accrues. Mirrors the pattern in handlers.PendingOrgCleaner.
Failure handling: a single tick failure is logged and the next tick retries. We do NOT bail on persistent errors — recovering PG that was briefly unavailable is the same recovery path as a transient network blip, and surfacing the error any other way (panic, channel emit) adds complexity without benefit.
func NewJWTSessionJanitor ¶
func NewJWTSessionJanitor(store JWTSessionStore, interval time.Duration, logger pkginterfaces.LoggerInterface) *JWTSessionJanitor
NewJWTSessionJanitor builds a janitor with the given store and interval. Pass interval=0 to use DefaultJWTSessionJanitorInterval.
func (*JWTSessionJanitor) Run ¶
func (j *JWTSessionJanitor) Run(ctx context.Context)
Run blocks until ctx is canceled. Safe to call exactly once per janitor; mirror PendingOrgCleaner.Run.
type JWTSessionStore ¶
type JWTSessionStore interface {
// GetJWTSession fetches the durable row for jti. Returns (nil, nil)
// when no row exists (the rehydrate path uses this signal — pre-feature
// JWTs, expired-and-pruned rows, soft-unlock backfill cases).
GetJWTSession(ctx context.Context, jti uuid.UUID) (*JWTSession, error)
// WriteJWTSession upserts the row. Used by login (initial write) and
// soft-unlock (backfill / US-50.4 rewrite). ON CONFLICT (jti) DO UPDATE
// because a soft-unlock re-issues a fresh kek_salt + wrapped_dek for an
// existing jti.
WriteJWTSession(ctx context.Context, session *JWTSession) error
// DeleteJWTSession removes the row for a specific jti. Used by EvictDEK
// (logout, cache miss handling, etc.) so the durable row does not
// outlive its Redis counterpart.
DeleteJWTSession(ctx context.Context, jti uuid.UUID) error
// DeleteJWTSessionsForUser removes all rows for a user. Used by
// RevokeAllUserSessions (password reset / explicit logout-everywhere).
// Returns the number of rows deleted.
DeleteJWTSessionsForUser(ctx context.Context, userID string) (int64, error)
// DeleteExpiredJWTSessions prunes rows with expires_at < before. The
// janitor goroutine calls this on a ticker. Returns the number of rows
// deleted. Bounded by the idx_jwt_sessions_expires_at index for O(log N)
// scan even at 1M rows.
DeleteExpiredJWTSessions(ctx context.Context, before time.Time) (int64, error)
// ListActiveJWTSessionsForUser returns non-expired rows for userID,
// ordered created_at DESC (most-recent first) and bounded by limit
// (limit <= 0 means unlimited-per-caller-convention, though the
// caller MUST supply a sensible bound in production).
//
// Used by KeyService.GetDEKForUser to retrieve a durable DEK-wrapping
// row when the caller doesn't have a specific sessionID + matched
// signing key (background paths: workspace watcher, auto-push
// triggered by phase change or pod recreation). Every row for a
// given user wraps the SAME DEK (user_keys has one row per user);
// the caller only needs one row to unwrap.
//
// "Active" means expires_at is strictly AFTER the store's clock
// (SQL: expires_at > NOW()). A row at the exact expires_at is
// expired — matches DeleteExpiredJWTSessions's < semantics.
//
// Bounded by idx_jwt_sessions_user_id + a filter on expires_at
// (partial index on expires_at could speed the AND but is not
// required at current data scale).
//
// Returns nil (or []) with nil error when no matching rows exist —
// callers use empty to signal "no live session; fall back to
// SessionlessInject or ErrDEKUnavailable."
ListActiveJWTSessionsForUser(ctx context.Context, userID string, limit int) ([]*JWTSession, error)
}
JWTSessionStore abstracts the durable jwt_sessions table for tests.
All methods are best-effort writes from the caller's perspective: login's hot path tolerates write failure (Redis cache still works), the revocation paths tolerate delete failure (Redis revocation is authoritative), and the janitor tolerates failure (it retries on the next tick). The DAL itself returns the underlying error verbatim so callers can log it without changing the surface.
type KEKRotationError ¶
KEKRotationError records a per-row failure.
type KEKRotationResult ¶
type KEKRotationResult struct {
Processed int
Skipped int // rows already at target version
Failed int
Errors []KEKRotationError
}
KEKRotationResult summarizes a rotation run.
type KeyService ¶
type KeyService struct {
// contains filtered or unexported fields
}
KeyService manages user key lifecycle.
func NewKeyService ¶
func NewKeyService(store KeyStore, cache DEKCache) *KeyService
NewKeyService creates a new KeyService.
func (*KeyService) CacheDEK ¶
func (s *KeyService) CacheDEK(ctx context.Context, sessionID string, dek []byte, ttl time.Duration) error
CacheDEK stores a DEK in the session cache. Used by API key auth to cache an unwrapped DEK under a deterministic sessionID.
func (*KeyService) ChangePassword ¶
func (s *KeyService) ChangePassword(ctx context.Context, userID, sessionID string, oldPassword, newPassword []byte) error
ChangePassword re-wraps the DEK with a new password-derived KEK. Requires the old password to unwrap first. After the wrap is updated, the cached DEK for sessionID (the caller's current session) is evicted so the next request must re-Unlock with the new password — without this eviction a thief who has the JWT continues to read secrets via the cached DEK even after the user "rotates the password to be safe" (validator pass-3 finding P-1).
LIMITATION: this only evicts the caller's session. A user with multiple active sessions on different devices retains those cached DEKs until they expire naturally. We document the limitation in the API rather than rebuild the cache for cross- session enumeration.
sessionID may be empty (e.g. tests, internal callers without a session); eviction is then a no-op.
func (*KeyService) DEKAvailable ¶
func (s *KeyService) DEKAvailable(ctx context.Context, sessionID string) bool
DEKAvailable checks if a DEK is cached for the given session.
func (*KeyService) DeleteDurableSessionsForUser ¶
func (s *KeyService) DeleteDurableSessionsForUser(ctx context.Context, userID string) error
DeleteDurableSessionsForUser removes every jwt_sessions row for a user. Called by auth.Service.RevokeAllUserSessions (password reset, admin force-logout) so a stolen JWT cannot rehydrate the DEK from the durable store after the user has explicitly invalidated every outstanding session. Best-effort: failure is logged but does not propagate — the Redis revocation markers are already in place and the JWT itself is functionally dead.
Returns nil even on failure — callers do not need to handle the error path; the contract is "drive jwt_sessions toward consistency with the auth-layer revocation, log if we can't".
func (*KeyService) EvictDEK ¶
func (s *KeyService) EvictDEK(ctx context.Context, sessionID string) error
EvictDEK removes the cached DEK for a session AND the durable jwt_sessions row (Epic 56). Called on logout / explicit revocation. Non-JTI sessionIDs (API-key sessions like "apikey:hash") only evict the Redis cache — the api_keys table is the durable home for those.
func (*KeyService) GetDEK ¶
func (s *KeyService) GetDEK(ctx context.Context, sessionID string, matchedSigningKey []byte) ([]byte, error)
GetDEK retrieves the DEK for a session.
Resolution order (Epic 56):
- Redis cache hit → return cached DEK (fast path; no DB).
- Redis cache miss + matchedSigningKey supplied + sessionID is a UUID → attempt durable rehydrate from jwt_sessions: a. Row missing → ErrDEKUnavailable (soft-unlock will backfill). b. Row expired → ErrDEKUnavailable (janitor will prune; client should re-login since the JWT is itself near/past expiry). c. Unwrap failure → ErrDEKUnavailable (post-rotation, US-50.4 DEK rotation, or row corruption — soft-unlock recovers). d. Success → re-cache to Redis, return DEK.
- Anything else → ErrDEKUnavailable.
matchedSigningKey is the JWT signing key that validated the caller's token. Pass nil for non-JWT auth (API keys, controller-internal callers); those paths cannot rehydrate (no KEK material) and will surface ErrDEKUnavailable — the correct behavior, since the API-key auth has its own DEK persistence (api_keys.WrappedDEK) and controller-internal callers do not need user-DEK content.
Redis errors (other than miss) are logged at Warn but DO NOT block the rehydrate attempt: in a Redis-outage + valid-durable-row scenario, rehydrate is exactly the resilience the epic provides. The previous "fail closed on any cache error" behavior is preserved only for the "no rehydrate available" sub-case.
func (*KeyService) GetDEKForUser ¶
func (s *KeyService) GetDEKForUser(ctx context.Context, userID string) (dek []byte, jti string, err error)
GetDEKForUser retrieves the user's DEK without requiring a specific sessionID or matchedSigningKey from the caller. Designed for background paths (workspace watcher, controller-triggered auto-push after pod recreation, etc.) that need to deliver user-DEK content but do not run in an authenticated user-request context.
Returns (dek, jti, error). The jti is the jwt_sessions row's primary key — callers use it as a sessionID when building an agentpush.WithAuth context so that InjectSecrets' subsequent GetDEK(sessionID, matchedSigningKey) call hits the Redis cache this method just populated. Without returning jti, the caller would have no way to reference the DEK just cached, and would re-execute the unwrap on every downstream call.
Resolution order (worklog 0590):
- jwtSessions.ListActiveJWTSessionsForUser(userID, LIMIT) → candidate rows. If empty → ErrDEKUnavailable (no live session for the user; caller falls back to SessionlessInject or logs).
- For each row (most-recent first), check the Redis cache under the row's jti. On hit → return the cached DEK (fast path; avoids KDF + AEAD-decrypt).
- On cache miss for that jti, iterate signingKeys.EachSigningKey. For each candidate key, derive KEK = HKDF(key || jti, kekSalt, JWTSessionKEKInfo) and attempt DecryptSecret. First success → write-back to Redis under this jti so subsequent GetDEK(jti, matchedKey) calls hit the fast path, and return the DEK.
- If NO signing key can unwrap the most-recent row: continue to next row (older sessions may have been wrapped under an even older signing key that this API instance still knows). If all rows exhausted → ErrDEKUnavailable.
Cache-hit short-circuit (step 2) is what makes this safe to call repeatedly for the same user: after the first successful call, all subsequent calls hit Redis in O(1). Only cold-Redis or genuine cache-miss paths do PG+KDF work.
Rows are bounded (LIMIT jwtSessionUserLookupLimit) to prevent pathological unwrap-loops if a user has thousands of sessions.
Errors: ErrDEKUnavailable is used for every legitimate "no user context available" case (no active session, no signing key unwraps, no jwtSessions or signingKeys wired). Genuine infrastructure errors (PG connection failure, cache client fault) are returned verbatim so operators can distinguish debug-worthy outages from expected "user logged out" cases.
func (*KeyService) InitializeUserKeys ¶
func (s *KeyService) InitializeUserKeys(ctx context.Context, userID string, password []byte) (recoveryKeyHex string, err error)
InitializeUserKeys generates a DEK and wraps it with the user's password-derived KEK. Called during account creation or first secret creation for existing users. Returns the recovery key (hex-encoded) that must be displayed to the user once.
func (*KeyService) JWTSessionStoreSet ¶
func (s *KeyService) JWTSessionStoreSet() bool
JWTSessionStoreSet reports whether a JWT-session store has been wired. Exposed so app.go wiring + tests can assert post-init invariants without reaching into private state.
func (*KeyService) ResetWithRecoveryKey ¶
func (s *KeyService) ResetWithRecoveryKey(ctx context.Context, userID string, recoveryKeyHex string, newPassword []byte) (newRecoveryKeyHex string, err error)
ResetWithRecoveryKey unwraps the DEK using the recovery key and re-wraps with a new password. Returns a new recovery key (hex-encoded).
func (*KeyService) RotateKeyWithPassword ¶
func (s *KeyService) RotateKeyWithPassword(ctx context.Context, userID string, password []byte, sessionID string, ttl time.Duration) (RotationResult, error)
RotateKeyWithPassword rotates the user's DEK and eagerly re-encrypts every secret row under the new DEK in a single transaction.
The flow is:
- Verify the password by unwrapping the current DEK with the derived KEK.
- Generate a new random DEK.
- Generate a new recovery key + salt; the old recovery key wraps the old (about-to-be-discarded) DEK and would be useless after rotation. Without this, ResetWithRecoveryKey post-rotate would unwrap a DEK that no longer matches user_secrets.
- Walk all user_secrets rows; decrypt each with the old DEK and re-encrypt with the new DEK. The store implementation runs this under a single atomic operation so partial failures cannot leave orphaned rows.
- Wrap the new DEK with the same KEK and bump key_version, INSIDE the same tx (commit closure). Wrap newDEK with the new recoveryKEK and update user_keys.wrapped_dek_recovery in the same tx.
- Refresh the session DEK cache.
If any step in 4 or 5 fails, the entire tx rolls back: secrets stay at the old key_version, user_keys keeps the old wrapped DEK, the old recovery key still works. The rotation is a no-op from the client's perspective (modulo the function's error return).
SetSecretStore must be called before RotateKeyWithPassword; otherwise the function refuses to run.
func (*KeyService) SetAPIKeyStore ¶
func (s *KeyService) SetAPIKeyStore(store APIKeyStore, provider RootKeyProvider)
SetAPIKeyStore wires the API key store for DEK re-wrap on rotation.
func (*KeyService) SetJWTSessionStore ¶
func (s *KeyService) SetJWTSessionStore(store JWTSessionStore)
SetJWTSessionStore wires the durable jwt_sessions table backing the GetDEK rehydrate path. Optional — tests and pre-Epic-56 callers may leave it nil; GetDEK then behaves Redis-only (cache miss ⇒ error).
Like SetSecretStore, silent rebinding to a different store is refused: the durable rehydrate would otherwise read from a store that holds no rows for the active session set, surfacing as a wave of ErrDEKUnavailable across all live JWTs. Idempotent same-store calls are allowed.
func (*KeyService) SetLogger ¶
func (s *KeyService) SetLogger(l pkginterfaces.LoggerInterface)
SetLogger installs the logger used to surface non-fatal failures (e.g. cache-evict errors during password change). Optional; if nil, those events are silent. Validator pass-5 finding N-3.
Note: ChangePassword's evict-failure log includes the sessionID (JWT jti). The jti is sensitive — an attacker with log read access can correlate user activity across requests, though it does NOT enable token replay (the JWT signature is never logged). Volume is bounded to Redis-outage events. If the log retention crosses a tenant boundary, hash sessionID before logging.
func (*KeyService) SetSecretStore ¶
func (s *KeyService) SetSecretStore(store SecretStore)
SetSecretStore wires the SecretStore used by RotateKeyWithPassword to re-encrypt every user_secrets row under the new DEK. Without this, the rotate endpoint refuses to run rather than orphan secret rows under a discarded DEK (Bug 9 in worklog 0085).
Once set, the store cannot be silently reassigned: a silent reassignment would mean RotateKeyWithPassword ignores secrets owned by an abandoned store — exactly the Bug 9 hazard. Calling SetSecretStore twice with different stores panics; calling with the same store (idempotent re-init) is allowed.
func (*KeyService) SetSigningKeyEnumerator ¶
func (s *KeyService) SetSigningKeyEnumerator(e SigningKeyEnumerator)
SetSigningKeyEnumerator installs the signing-key enumerator. Optional setter (not New arg) because auth.Service is constructed later in app.New; setter-DI is the existing pattern for these late-arrival deps.
Unlike SetJWTSessionStore / SetSecretStore, this setter intentionally has NO double-set panic guard. Rebinding the enumerator cannot cause silent data inconsistency the way rebinding a store can: the worst case is that a subsequent GetDEKForUser call fails to unwrap a row (because the new enumerator returned different keys than were used to wrap that row), which surfaces as ErrDEKUnavailable — the same sentinel the "no session" path uses. The caller falls back cleanly. A double-set panic here would forbid legitimate hot-swap scenarios (test harnesses, key-rotation live-reload) without a corresponding safety benefit.
func (*KeyService) UnlockDEK ¶
func (s *KeyService) UnlockDEK(ctx context.Context, userID string, password []byte, sessionID string, ttl time.Duration) error
UnlockDEK derives the KEK from the password, unwraps the DEK, and caches it. Called during login. sessionID is the JWT's jti claim.
This is the pre-Epic-56 entry point — Redis cache only. Use UnlockDEKWithSigningKey from the login site to additionally write the durable jwt_sessions row (Epic 56). Internal callers (auth.Login) always go through the With-SigningKey variant; tests and Register (which has no JWT yet at the point of call) use this one.
func (*KeyService) UnlockDEKWithSigningKey ¶
func (s *KeyService) UnlockDEKWithSigningKey(ctx context.Context, userID string, password []byte, sessionID string, ttl time.Duration, activeSigningKey []byte) error
UnlockDEKWithSigningKey is UnlockDEK + durable jwt_sessions write (Epic 56). The durable row is wrapped under a KEK derived from activeSigningKey || jti via HKDF-SHA256; the rehydrate path (rehydrateDEKFromJWTSession) re-derives the same KEK from the MATCHED signing key recovered from a presented JWT.
Behavior matrix:
activeSigningKey == nil → Redis cache only; no durable write. This is the path tests and Register take. The legacy UnlockDEK delegates here with nil.
sessionID is not a UUID → Redis cache only. API-key sessions ("apikey:hash") and legacy non-UUID sessionIDs don't belong in jwt_sessions; the api_keys.WrappedDEK design covers API-key DEK durability separately.
jwtSessions store not wired → Redis cache only. Pre-Epic-56 deploys and tests without SetJWTSessionStore.
durable write fails → NOT returned as an error. The Redis cache succeeded, so the JWT is functional for its remaining lifetime; only the durable rehydrate-on-Valkey-restart property is degraded. Log Warn so operators see the loss of resilience. Login MUST NOT fail on a transient PG hiccup.
"activeSigningKey" name is precise: at login the JWT we just issued is signed with s.jwtSecret (active), so we derive against the active key. The rehydrate path may match a previous key if rotation happens between issue and use — that's expected; what matters is the KEY at JWT-validation time, surfaced via parseTokenAcceptingRotatedKeys.
type KeyStore ¶
type KeyStore interface {
GetUserKey(ctx context.Context, userID string) (*UserKeyRecord, error)
CreateUserKey(ctx context.Context, record *UserKeyRecord) error
UpdateWrappedDEK(ctx context.Context, userID string, wrappedDEK []byte, salt []byte, keyVersion int) error
UpdateWrappedDEKRecovery(ctx context.Context, userID string, wrappedDEKRecovery []byte, recoverySalt []byte) error
}
KeyStore abstracts database operations for user keys.
type LLMModelConfig ¶
type LLMModelConfig struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
ContextLimit int `json:"contextLimit,omitempty"`
OutputLimit int `json:"outputLimit,omitempty"`
}
LLMModelConfig declares an allowlisted model and its display/limit metadata. At minimum, ID is required. Label is shown in pickers when set.
ContextLimit and OutputLimit drive the opencode `limit` block in agent-config.json. opencode's published JSON Schema (https://opencode.ai/config.json) declares the model `limit` object with `"required": ["context", "output"]` and `"additionalProperties": false`. Therefore FormatOpenCodeConfig emits a `limit` block ONLY when BOTH are non-zero — emitting a partial block (only context, or only output) makes opencode 1.15.12 reject the entire config with SchemaError: Missing key, which causes every endpoint that calls Config.state() (including POST /session) to return 500.
ContextLimit is the total context window size in tokens. When set together with OutputLimit it is written into agent-config.json as limit.context, which makes opencode's /config/providers return ctx=N, which feeds ModelContextLimit() in agentd → context.total_tokens in /v1/statusz → CRD status.contextTotal → the frontend's "used / total" context bar.
OutputLimit is the maximum response tokens for the model. opencode uses this for compaction sizing. Like ContextLimit it cannot be auto-discovered from a provider's /v1/models endpoint and must be configured explicitly by the workspace/credential owner.
type LLMProviderData ¶
type LLMProviderData struct {
Kind string `json:"kind"`
Slug string `json:"slug"`
APIKey string `json:"apiKey"`
BaseURL string `json:"baseURL,omitempty"`
Models []LLMModelConfig `json:"models,omitempty"`
Default string `json:"default,omitempty"`
SmallModel string `json:"smallModel,omitempty"`
}
LLMProviderData holds structured credentials for one LLM provider. The Plaintext value of an "llm-provider" secret is the JSON encoding of this struct.
Epic 55 identity model:
- Kind is the SDK-class enum (openai, anthropic, openai_compatible, ...). Required. Determines which adapter opencode loads.
- Slug is the per-owner unique identity AND the literal key used in agent-config.json's provider map. opencode persists this as `providerID` on sessions.
APIKey is required. BaseURL is optional; when empty the provider's default endpoint is used. Models is an optional allowlist. When empty or nil all models from the provider are visible (no filtering). When non-empty only the listed models are shown. Default is the model ID to use when no per-session model is specified. SmallModel is the model ID used for lightweight/cheap operations (e.g. summarization).
func (LLMProviderData) Validate ¶
func (d LLMProviderData) Validate() error
Validate checks that required fields are set in LLMProviderData.
type OrgAutoApplyStore ¶
type OrgAutoApplyStore interface {
BindCredentialToAllOrgWorkspaces(ctx context.Context, credentialID, orgID string) error
CreateOrgAutoApply(ctx context.Context, credentialID, orgID string, withinPriority int) error
ListOrgAutoApply(ctx context.Context, orgID string) ([]*AutoApplyRule, error)
DeleteOrgAutoApply(ctx context.Context, credentialID, orgID string) error
}
OrgAutoApplyStore is the DB interface for org-scoped auto-apply and workspace-binding operations (the credential CRUD itself is served by the owner-parameterized CredentialStore methods on PgSecretStore).
type PgJWTSessionStore ¶
type PgJWTSessionStore struct {
// contains filtered or unexported fields
}
PgJWTSessionStore is the production JWTSessionStore backed by Postgres.
func NewPgJWTSessionStore ¶
func NewPgJWTSessionStore(pool *pgxpool.Pool) *PgJWTSessionStore
NewPgJWTSessionStore creates a new PostgreSQL-backed JWTSessionStore.
func (*PgJWTSessionStore) DeleteExpiredJWTSessions ¶
func (s *PgJWTSessionStore) DeleteExpiredJWTSessions(ctx context.Context, before time.Time) (int64, error)
DeleteExpiredJWTSessions prunes rows whose expires_at is strictly before the provided cutoff. The janitor passes time.Now(); tests pass a fixed clock for determinism. Returns the count for logging and monitoring (a sudden spike in deletes flags a clock skew or a large rotation event).
func (*PgJWTSessionStore) DeleteJWTSession ¶
DeleteJWTSession removes the row for jti. Idempotent: deleting a non-existent row is not an error (the DELETE returns rowsAffected=0).
func (*PgJWTSessionStore) DeleteJWTSessionsForUser ¶
func (s *PgJWTSessionStore) DeleteJWTSessionsForUser(ctx context.Context, userID string) (int64, error)
DeleteJWTSessionsForUser removes every row for userID. Used by RevokeAllUserSessions so a password-reset cascade leaves no durable DEK row behind. Returns the number of rows deleted so the caller can audit-log the magnitude.
func (*PgJWTSessionStore) GetJWTSession ¶
func (s *PgJWTSessionStore) GetJWTSession(ctx context.Context, jti uuid.UUID) (*JWTSession, error)
GetJWTSession returns the durable row for jti, or (nil, nil) when none exists.
func (*PgJWTSessionStore) ListActiveJWTSessionsForUser ¶
func (s *PgJWTSessionStore) ListActiveJWTSessionsForUser(ctx context.Context, userID string, limit int) ([]*JWTSession, error)
ListActiveJWTSessionsForUser returns non-expired rows for userID. See interface godoc for full contract. Uses idx_jwt_sessions_user_id (schema 000001) plus a filter on expires_at > NOW(). At current data scale (thousands of sessions per active user max) the trailing filter on expires_at is a scan of the per-user rows only; no compound index needed. If per-user row counts grow past ~10k a partial index (WHERE expires_at > NOW()) can be added later without changing this query.
The NOW() comparison is inline in the SQL so the database's clock is authoritative — same source of truth the janitor uses when it prunes. Fetching a "just barely expired" row and having the caller discover it a millisecond later would waste a signing-key iteration and produce a misleading warn log.
When limit <= 0, no LIMIT clause is added. Callers should always pass a sensible bound (KeyService.GetDEKForUser passes 5, per jwtSessionUserLookupLimit — covers multi-row fallback while preventing pathological unwrap-loops on users with many sessions).
func (*PgJWTSessionStore) WriteJWTSession ¶
func (s *PgJWTSessionStore) WriteJWTSession(ctx context.Context, session *JWTSession) error
WriteJWTSession upserts the row. The primary key is jti, so duplicate writes for the same jti (soft-unlock backfill, US-50.4 rewrite, or two near-simultaneous logins racing on the same uuid — astronomically unlikely but defended against) overwrite the previous wrapped_dek and kek_salt. user_id and created_at are preserved on conflict because they describe identity, not state; only the KEK material rotates.
type PgKeyStore ¶
type PgKeyStore struct {
// contains filtered or unexported fields
}
PgKeyStore implements KeyStore using PostgreSQL.
func NewPgKeyStore ¶
func NewPgKeyStore(pool *pgxpool.Pool) *PgKeyStore
NewPgKeyStore creates a new PostgreSQL-backed key store.
func (*PgKeyStore) CreateUserKey ¶
func (s *PgKeyStore) CreateUserKey(ctx context.Context, record *UserKeyRecord) error
CreateUserKey stores a user's key material. On conflict (the user already has a row — e.g. password reset reinitializing a fresh DEK for a user who already has key material), the row is overwritten. This is required because user_keys.user_id is the PRIMARY KEY and a plain INSERT would fail with unique_violation for any user who has ever created a secret, which is the only case where reinit matters. Overwriting with a freshly-generated DEK (see InitializeUserKeys) is exactly the desired reset behavior: the prior wraps and anything encrypted under the prior DEK become permanently undecryptable.
func (*PgKeyStore) GetUserKey ¶
func (s *PgKeyStore) GetUserKey(ctx context.Context, userID string) (*UserKeyRecord, error)
func (*PgKeyStore) UpdateWrappedDEK ¶
func (s *PgKeyStore) UpdateWrappedDEK(ctx context.Context, userID string, wrappedDEK []byte, salt []byte, keyVersion int) error
UpdateWrappedDEK updates the wrapped DEK for a user. When the context carries an active *pgx.Tx (threaded through by SecretStore.ReEncryptUserSecrets via withTx), the UPDATE runs inside that transaction so the user_keys row and the user_secrets re-encrypt commit or roll back atomically. Otherwise the UPDATE runs on the pool directly. See Bug 9 in worklog 0094.
func (*PgKeyStore) UpdateWrappedDEKRecovery ¶
func (s *PgKeyStore) UpdateWrappedDEKRecovery(ctx context.Context, userID string, wrappedDEKRecovery []byte, recoverySalt []byte) error
UpdateWrappedDEKRecovery updates the recovery-key wrap. Like UpdateWrappedDEK, the implementation honors an active *pgx.Tx threaded through the context (via withTx) so future callers that want to bundle a recovery-key rotation into the same atomic unit as the password-key rotation can do so. No current caller does, but the parity with UpdateWrappedDEK closes a latent footgun.
type PgSecretStore ¶
type PgSecretStore struct {
// contains filtered or unexported fields
}
PgSecretStore implements SecretStore using PostgreSQL.
func NewPgSecretStore ¶
func NewPgSecretStore(pool *pgxpool.Pool) *PgSecretStore
NewPgSecretStore creates a new PostgreSQL-backed secret store.
func (*PgSecretStore) AddBindings ¶
func (s *PgSecretStore) AddBindings(ctx context.Context, workspaceID string, secretIDs []string) error
AddBindings atomically adds secretIDs to a workspace's binding set without touching existing bindings. Takes the same advisory lock as SetBindings so the two cannot interleave dangerously.
The INSERT uses ON CONFLICT DO NOTHING so re-binding an already- bound secret is idempotent rather than a constraint violation.
func (*PgSecretStore) BackfillFreeTierBindings ¶
func (s *PgSecretStore) BackfillFreeTierBindings(ctx context.Context) (int64, error)
BackfillFreeTierBindings inserts workspace_credential_bindings for all existing workspaces that lack the free-tier opencode credential binding. Idempotent — uses ON CONFLICT DO NOTHING. Returns the number of rows inserted.
func (*PgSecretStore) BindAllOrgCredentialsToOrgWorkspaces ¶
func (s *PgSecretStore) BindAllOrgCredentialsToOrgWorkspaces(ctx context.Context, orgID string) error
BindAllOrgCredentialsToOrgWorkspaces binds every org credential to every non-deleted org workspace. Used after invitation acceptance (F7) to ensure newly-migrated workspaces receive the org's shared credentials immediately. Existing bindings are skipped (ON CONFLICT DO NOTHING). Best-effort: callers should log errors but not fail the user-facing operation.
func (*PgSecretStore) BindCredentialToAllOrgWorkspaces ¶
func (s *PgSecretStore) BindCredentialToAllOrgWorkspaces(ctx context.Context, credentialID, orgID string) error
func (*PgSecretStore) BindCredentialToAllUserWorkspaces ¶
func (s *PgSecretStore) BindCredentialToAllUserWorkspaces(ctx context.Context, credentialID, userID string) error
BindCredentialToAllUserWorkspaces binds a user credential to every workspace owned by userID. Called when a user creates a new personal credential so that the invariant "all credentials bound to all workspaces" is maintained. Idempotent.
func (*PgSecretStore) BindCredentialToWorkspace ¶
func (s *PgSecretStore) BindCredentialToWorkspace(ctx context.Context, credentialID, workspaceID string) error
BindCredentialToWorkspace explicitly binds a credential to a workspace.
func (*PgSecretStore) CreateAutoApply ¶
func (s *PgSecretStore) CreateAutoApply(ctx context.Context, credentialID, targetType string, targetID *string, priority int) error
CreateAutoApply inserts an auto-apply rule.
func (*PgSecretStore) CreateCredential ¶
func (s *PgSecretStore) CreateCredential(ctx context.Context, ownerType, ownerID string, row *CredentialRow) error
CreateCredential inserts a provider credential scoped by (ownerType, ownerID). The caller supplies a pre-generated ID (uuid.New().String()), matching the admin/user pattern; the DB DEFAULT gen_random_uuid() is only a fallback.
func (*PgSecretStore) CreateOrgAutoApply ¶
func (*PgSecretStore) CreateSecret ¶
func (s *PgSecretStore) CreateSecret(ctx context.Context, secret *UserSecret) error
func (*PgSecretStore) DeleteAutoApply ¶
func (s *PgSecretStore) DeleteAutoApply(ctx context.Context, credentialID, targetType string, targetID *string) error
DeleteAutoApply removes an auto-apply rule.
func (*PgSecretStore) DeleteCredential ¶
func (s *PgSecretStore) DeleteCredential(ctx context.Context, ownerType, ownerID, credID string) error
DeleteCredential deletes a credential by ID scoped to (ownerType, ownerID). FK cascades handle bindings. Returns pgx.ErrNoRows if no row was deleted so callers can distinguish 404 (L-1 fix).
func (*PgSecretStore) DeleteOrgAutoApply ¶
func (s *PgSecretStore) DeleteOrgAutoApply(ctx context.Context, credentialID, orgID string) error
func (*PgSecretStore) DeleteSecret ¶
func (s *PgSecretStore) DeleteSecret(ctx context.Context, userID, secretID string) error
func (*PgSecretStore) GetBindings ¶
func (s *PgSecretStore) GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)
func (*PgSecretStore) GetBindingsForSecret ¶
func (*PgSecretStore) GetCredential ¶
func (s *PgSecretStore) GetCredential(ctx context.Context, ownerType, ownerID, credID string) (*CredentialRow, error)
GetCredential returns a single credential by ID scoped to (ownerType, ownerID), or nil if not found. Filtering on both owner_type AND owner_id preserves the L-4 defensive multi-admin safety of the former admin path.
func (*PgSecretStore) GetCredentialBindings ¶
func (s *PgSecretStore) GetCredentialBindings(ctx context.Context, credentialID, ownerID string) ([]string, error)
GetCredentialBindings returns workspace IDs the credential is bound to, scoped to workspaces owned by ownerID.
func (*PgSecretStore) GetCredentialBindingsWithSource ¶
func (s *PgSecretStore) GetCredentialBindingsWithSource(ctx context.Context, credentialID, ownerID string) ([]CredentialBindingInfo, error)
GetCredentialBindingsWithSource returns workspace IDs and source type for bindings, scoped to workspaces owned by ownerID (M-1 fix: allows UI to distinguish auto vs explicit).
func (*PgSecretStore) GetSecret ¶
func (s *PgSecretStore) GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)
func (*PgSecretStore) GetSecretByName ¶
func (s *PgSecretStore) GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)
func (*PgSecretStore) GetWorkspaceCredentials ¶
func (s *PgSecretStore) GetWorkspaceCredentials(ctx context.Context, workspaceID string) ([]CredentialBinding, error)
GetWorkspaceCredentials returns all credential bindings for a workspace, ordered by: (source_type='explicit') DESC, within_priority DESC, created_at ASC.
func (*PgSecretStore) HasUserProviderCredential ¶
func (s *PgSecretStore) HasUserProviderCredential(ctx context.Context, userID, slug string) (bool, error)
HasUserProviderCredential returns true if the user owns a credential with the given slug.
func (*PgSecretStore) ListAutoApply ¶
func (s *PgSecretStore) ListAutoApply(ctx context.Context, credentialID string) ([]AutoApplyRule, error)
ListAutoApply returns all auto-apply rules for a credential.
func (*PgSecretStore) ListCredentials ¶
func (s *PgSecretStore) ListCredentials(ctx context.Context, ownerType, ownerID string) ([]*CredentialRow, error)
ListCredentials returns all credentials owned by (ownerType, ownerID), ordered by created_at ASC.
func (*PgSecretStore) ListGlobalDefaultSecrets ¶
func (s *PgSecretStore) ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
ListGlobalDefaultSecrets returns all secrets owned by userID that have global_default=true. Used when seeding bindings on workspace creation.
func (*PgSecretStore) ListOrgAutoApply ¶
func (s *PgSecretStore) ListOrgAutoApply(ctx context.Context, orgID string) ([]*AutoApplyRule, error)
func (*PgSecretStore) ListSecrets ¶
func (s *PgSecretStore) ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
func (*PgSecretStore) LogAudit ¶
func (s *PgSecretStore) LogAudit(ctx context.Context, entry *AuditEntry) error
func (*PgSecretStore) QueryAudit ¶
func (s *PgSecretStore) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
func (*PgSecretStore) ReEncryptOrgCredentials ¶
func (s *PgSecretStore) ReEncryptOrgCredentials(ctx context.Context, tx pgx.Tx, orgID string, oldDEK, newDEK []byte) (int, error)
ReEncryptOrgCredentials re-encrypts all provider_credentials rows where owner_type='org' AND owner_id=orgID atomically within tx.
func (*PgSecretStore) ReEncryptUserSecrets ¶
func (s *PgSecretStore) ReEncryptUserSecrets( ctx context.Context, userID string, newKeyVersion int, transform func([]byte) ([]byte, error), commit func(ctx context.Context) error, ) error
ReEncryptUserSecrets walks every user_secrets row owned by userID inside a single SERIALIZABLE transaction, retrying on serialization failure. After the walk completes the commit closure runs in the same transaction so callers (KeyService.RotateKeyWithPassword) can update related rows (e.g. user_keys.wrapped_dek) atomically. If commit returns non-nil the entire transaction rolls back: secrets stay encrypted with the old DEK and user_keys stays unchanged.
A row-count cap of maxRotateRows is enforced to prevent a malicious or pathological account from holding the rotation transaction open indefinitely.
See Bug 9 in worklog 0085 / 0094.
func (*PgSecretStore) SeedWorkspaceCredentials ¶
func (s *PgSecretStore) SeedWorkspaceCredentials(ctx context.Context, workspaceID, userID string, orgID *string) error
SeedWorkspaceCredentials inserts credential bindings for a new workspace. Idempotent — uses ON CONFLICT DO NOTHING throughout.
- orgID nil: personal workspace — org auto-apply rules are not applied.
- orgID non-nil: org workspace — org auto-apply rules and all org credentials are bound.
func (*PgSecretStore) SetBindings ¶
func (s *PgSecretStore) SetBindings(ctx context.Context, workspaceID string, secretIDs []string) error
SetBindings replaces the binding set for workspaceID atomically.
Concurrency: takes a transaction-scoped advisory lock keyed on the workspace ID's hash so two concurrent SetBindings calls for the same workspace serialize. We use pg_try_advisory_xact_lock with a short retry loop rather than pg_advisory_xact_lock (which would block holding a pool connection indefinitely): under a thundering herd of concurrent SetBindings on the same workspace, blocking would exhaust pool connections and stall the entire API. The try-lock fails fast, sleeps a short jittered interval, and retries up to setBindingsLockMaxAttempts times.
The lock auto-releases on commit/rollback so a panicking writer cannot deadlock future writers. Different workspaces hash to different lock numbers and proceed in parallel.
func (*PgSecretStore) UnbindCredentialFromWorkspace ¶
func (s *PgSecretStore) UnbindCredentialFromWorkspace(ctx context.Context, credentialID, workspaceID string) error
UnbindCredentialFromWorkspace removes an EXPLICIT credential binding. Returns ErrAutoBindingProtected if the binding is auto-managed (H-1 fix).
func (*PgSecretStore) UpdateCredential ¶
func (s *PgSecretStore) UpdateCredential(ctx context.Context, ownerType, ownerID, credID string, row *CredentialRow) error
UpdateCredential updates an existing credential scoped to (ownerType, ownerID).
It uses COALESCE for model_allowlist, model_context_limits, and model_output_limits so a nil value means "don't change this column" — the org handler relies on this: a nil modelContextLimits/modelOutputLimits must reach the DB as SQL NULL so COALESCE preserves the existing column value. An empty slice/map is a valid "clear the column" value and is written as-is. Do NOT normalize nil → {} here (it would convert a "don't change" into a "clear all" via COALESCE).
For the admin handler (which allows provider changes and re-encrypts up-front), the caller passes the fully-resolved row with non-nil fields; COALESCE is a no-op there because the caller always supplies concrete values.
updated_at is read back via RETURNING (M-8 fix); the DB trigger sets it to now().
func (*PgSecretStore) UpdateSecret ¶
func (s *PgSecretStore) UpdateSecret(ctx context.Context, secret *UserSecret) error
func (*PgSecretStore) UpsertFreeTierCredential ¶
func (s *PgSecretStore) UpsertFreeTierCredential(ctx context.Context, ciphertext []byte) error
UpsertFreeTierCredential atomically upserts the platform free-tier opencode credential and its auto-apply rule in a single transaction.
type PodBootstrapSecretInjector ¶ added in v0.2.1
type PodBootstrapSecretInjector interface {
InjectSecretsForPodBootstrap(ctx context.Context, userID, workspaceID string) ([]byte, error)
}
PodBootstrapSecretInjector is the pod-bootstrap-path variant that attempts a best-effort user-DEK unwrap via KeyService.GetDEKForUser (which walks jwt_sessions rows and the enumerator's retained signing keys) and, on success, decrypts user-DEK bindings alongside server-KEK bindings — as if a JWT-authenticated request had been made.
On DEK-unavailable (no active jwt_sessions, or none unwrappable), implementations MUST degrade to SessionlessSecretInjector semantics: user-DEK bindings audited and skipped, server-KEK-only payload returned. The pod boots with the reduced set and the auto-push flow will still deliver user-DEK when the user next logs in.
Trust model: callers (pod-bootstrap handler) authenticate the workspace SA token via TokenReview and verify the request is on behalf of workspace X. The workspace CRD lists X's owner as the principal whose DEK is fetched. This is not privilege escalation — the pod would receive these same secrets via reload-secrets anyway. See design/0045_2026-07-06_boot-time-user-dek-delivery.md § Threat model.
Implementations: *SecretService.
type PostgresSecretProvider ¶
type PostgresSecretProvider struct {
// contains filtered or unexported fields
}
PostgresSecretProvider implements SecretProvider using the KeyService and SecretStore.
func NewPostgresSecretProvider ¶
func NewPostgresSecretProvider(keys *KeyService, store SecretStore) *PostgresSecretProvider
NewPostgresSecretProvider creates a new PostgresSecretProvider.
func (*PostgresSecretProvider) DEKAvailable ¶
func (p *PostgresSecretProvider) DEKAvailable(ctx context.Context, owner SecretOwner) bool
func (*PostgresSecretProvider) Decrypt ¶
func (p *PostgresSecretProvider) Decrypt(ctx context.Context, owner SecretOwner, ciphertext []byte, keyVersion int) ([]byte, error)
func (*PostgresSecretProvider) Encrypt ¶
func (p *PostgresSecretProvider) Encrypt(ctx context.Context, owner SecretOwner, plaintext []byte) ([]byte, int, error)
func (*PostgresSecretProvider) RotateKey ¶
func (p *PostgresSecretProvider) RotateKey(ctx context.Context, owner SecretOwner) (int, error)
type RedisDEKCache ¶
type RedisDEKCache struct {
// contains filtered or unexported fields
}
RedisDEKCache implements DEKCache using Redis. If a master key is provided, DEKs are encrypted before storage in Redis.
func NewRedisDEKCache ¶
func NewRedisDEKCache(client *redis.Client, masterKey ...[]byte) *RedisDEKCache
NewRedisDEKCache creates a new Redis-backed DEK cache. masterKey is optional — if nil or empty, DEKs are stored as plain hex.
type RootKeyProvider ¶
type RotationCoordinator ¶
type RotationCoordinator struct {
// contains filtered or unexported fields
}
RotationCoordinator re-wraps KEK-protected rows from old providers to new providers. It is table-agnostic: the caller provides the store + provider sets.
func NewRotationCoordinator ¶
func NewRotationCoordinator(store RotationStore, oldProviders, newProviders map[string]RootKeyProvider) *RotationCoordinator
NewRotationCoordinator constructs a coordinator with the old (source) and new (target) provider sets. Each map is keyed by purpose string.
func (*RotationCoordinator) RotateAll ¶
func (c *RotationCoordinator) RotateAll(ctx context.Context, targetVersion int, dryRun bool) (map[string]KEKRotationResult, error)
RotateAll rotates all three tables sequentially. The Redis DEK cache is flushed after all tables complete successfully.
func (*RotationCoordinator) RotateTable ¶
func (c *RotationCoordinator) RotateTable(ctx context.Context, table, resumeFromID string, targetVersion int, dryRun bool) (KEKRotationResult, error)
RotateTable rotates all rows in the given table that are below targetVersion. If dryRun is true, no writes occur — only counts are reported. resumeFromID allows resuming from a specific row ID after an interrupted run.
type RotationResult ¶
RotationResult is what RotateKeyWithPassword returns. NewKeyVersion is the bumped key_version; NewRecoveryKeyHex is a freshly-issued recovery key (the previous one wraps the now-discarded old DEK and is invalid after rotation). Callers MUST surface NewRecoveryKeyHex to the user once — the API does not store it anywhere recoverable.
type RotationRow ¶
type RotationRow struct {
ID string
Table string // "provider_credentials", "api_keys", "org_sso_configs"
OwnerType string // for provider_credentials: "admin" or "org"; "" for other tables
Ciphertext []byte
KeyVersion int
}
RotationRow is a generic row from any KEK-protected table. The coordinator re-wraps Ciphertext from oldKeyVersion to newKeyVersion.
type RotationStore ¶
type RotationStore interface {
// ListRotationRows returns rows from the given table whose key_version is
// below targetVersion, ordered by ID ASC, starting after resumeFromID
// (empty = from the beginning). limit caps the batch size (0 = unlimited).
ListRotationRows(ctx context.Context, table, resumeFromID string, targetVersion, limit int) ([]RotationRow, error)
// UpdateRotationRow re-wraps a single row: writes newCiphertext and
// newKeyVersion atomically. Each call is its own transaction.
UpdateRotationRow(ctx context.Context, table, rowID string, newCiphertext []byte, newKeyVersion int) error
// FlushDEKCache flushes the Redis DEK cache so stale DEKs (wrapped under
// the old KEK) are evicted.
FlushDEKCache(ctx context.Context) error
}
RotationStore abstracts the three KEK-protected tables for the rotation CLI. Each method returns rows in a consistent order (by ID ASC) so resume-from- cursor works deterministically.
type SealedKeyProvider ¶
type SealedKeyProvider struct {
// contains filtered or unexported fields
}
SealedKeyProvider holds the unsealed root key in process memory. It defends against attackers who can read the sealed file or the node disk but NOT the passphrase: the on-disk file is Argon2id-wrapped and is useless without the passphrase. It does NOT defend against process-level compromise of the API pod — once the key is unsealed at boot it lives in memory, and an attacker who can run code in the pod can call Decrypt exactly as the application does. See pkg/secrets/README.md for the full threat model.
US-50.4 multi-key support (NewStaticKeyProviderMultiVersion) is NOT mirrored here yet. The sealed provider is constructed once at boot from a single sealed file; multi-file rotation-window support for the sealed path will be added alongside US-50.5 (rotate-kek CLI) when the rotation workflow is exercised end-to-end. The StaticKeyProvider covers the default Helm path.
func NewSealedKeyProvider ¶
func NewSealedKeyProvider(sealedKeyPath, passphrasePath string) (*SealedKeyProvider, error)
type SecretBinding ¶
type SecretBinding struct {
SecretID string `json:"secretId"`
WorkspaceID string `json:"workspaceId"`
CreatedAt time.Time `json:"createdAt"`
}
SecretBinding represents a secret-to-workspace binding.
type SecretInjector ¶
type SecretInjector interface {
InjectSecrets(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, workspaceID string) ([]byte, error)
}
SecretInjector decrypts and serializes the secrets bound to a workspace, using the calling user's session DEK to decrypt user-owned credentials and user_secrets entries (ssh-key, env-secret, secret-file, etc.).
Used by handlers authenticated via JWT — the bind-time live-push and the explicit POST /api/v1/workspaces/:id/reload-secrets endpoint. The sessionID is mandatory for these handlers; without it user-DEK content cannot be decrypted.
Implementations: *SecretService.
type SecretOwner ¶
SecretOwner identifies the owner of a secret (user or org).
type SecretProvider ¶
type SecretProvider interface {
// Encrypt encrypts plaintext with the owner's current DEK.
Encrypt(ctx context.Context, owner SecretOwner, plaintext []byte) (ciphertext []byte, keyVersion int, err error)
// Decrypt decrypts ciphertext using the appropriate DEK version.
Decrypt(ctx context.Context, owner SecretOwner, ciphertext []byte, keyVersion int) (plaintext []byte, err error)
// RotateKey generates a new DEK for the owner. Old DEK retained for lazy migration.
RotateKey(ctx context.Context, owner SecretOwner) (newKeyVersion int, err error)
// DEKAvailable returns true if the owner's DEK is currently cached (active session).
DEKAvailable(ctx context.Context, owner SecretOwner) bool
}
SecretProvider defines the encryption/decryption interface for user secrets. V1: PostgresSecretProvider (HKDF + AES-GCM + session cache) Future: VaultSecretProvider, HSMSecretProvider
type SecretResponse ¶
type SecretResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type SecretType `json:"type"`
Metadata json.RawMessage `json:"metadata"`
GlobalDefault bool `json:"globalDefault"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
SecretResponse is the API response for a secret (never includes value).
type SecretService ¶
type SecretService struct {
// contains filtered or unexported fields
}
SecretService provides encrypted secret CRUD operations.
func NewSecretService ¶
func NewSecretService(keys *KeyService, store SecretStore) *SecretService
NewSecretService creates a new SecretService.
As a side-effect we register the SecretStore on the KeyService so RotateKeyWithPassword can re-encrypt secrets in-place (Bug 9 in worklog 0085). The two services share a store anyway; this just makes the linkage explicit at construction time.
func (*SecretService) AddBindings ¶
func (s *SecretService) AddBindings(ctx context.Context, userID, workspaceID string, secretIDs []string) (BindingsMutationResult, error)
AddBindings adds secretIDs to a workspace's binding set without removing any existing bindings. The store-level implementation takes a workspace-scoped advisory lock so concurrent SetBindings / AddBindings calls cannot lose updates (worklog 0094 pass-2 finding O1). Each secret's ownership is verified before the binding is recorded; an unowned secret produces ErrSecretNotFound.
Used by SetWorkspaceEnv to merge newly-created env-secrets into the workspace bindings without the Get-then-Set window the previous implementation suffered from. Workspace ownership is enforced by WorkspaceAccessMiddleware on PUT /:id/env (design 0041 D5).
func (*SecretService) CreateSecret ¶
func (s *SecretService) CreateSecret(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, req CreateSecretRequest) (*SecretResponse, error)
CreateSecret encrypts and stores a new secret.
func (*SecretService) DecryptSecretValue ¶
func (s *SecretService) DecryptSecretValue(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, secretID string) ([]byte, error)
DecryptSecretValue decrypts a secret's value (used for pod injection).
func (*SecretService) DeleteSecret ¶
func (s *SecretService) DeleteSecret(ctx context.Context, userID, secretID string) error
DeleteSecret removes a secret and its bindings.
func (*SecretService) GetBindings ¶
func (s *SecretService) GetBindings(ctx context.Context, userID, workspaceID string) (*BindingsResponse, error)
GetBindings returns secrets bound to a workspace. Workspace ownership is enforced by WorkspaceAccessMiddleware on GET /:id/bindings (design 0041 D5); the service trusts that decision and does not re-fetch the workspace.
func (*SecretService) GetBindingsForSecret ¶
func (s *SecretService) GetBindingsForSecret(ctx context.Context, userID, secretID string) ([]string, error)
GetBindingsForSecret returns workspace IDs that a secret is bound to.
Ownership-failure modes (secret not found, secret owned by someone else) are conflated to a uniform empty result so the response shape does not leak existence cross-tenant. Genuine system errors (DB outage on the lookup) propagate so the handler can return 5xx instead of a misleading empty 200.
func (*SecretService) GetSecret ¶
func (s *SecretService) GetSecret(ctx context.Context, userID, secretID string) (*SecretResponse, error)
GetSecret returns secret metadata (never the value).
func (*SecretService) GetSecretByName ¶
func (s *SecretService) GetSecretByName(ctx context.Context, userID, name string) (*SecretResponse, error)
GetSecretByName returns secret metadata by name (never the value).
func (*SecretService) InjectSecrets ¶
func (s *SecretService) InjectSecrets(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, workspaceID string) ([]byte, error)
InjectSecrets implements SecretInjector. See interface godoc.
Workspace ownership is enforced by WorkspaceAccessMiddleware on POST /:id/reload-secrets (design 0041 D5) and is inherently true for the bind-time push path inside the SecretsHandler (the caller is the workspace owner). This method does not re-check ownership — the HTTP layer must.
ARCHITECTURAL NOTE — credential-class delivery semantics:
Admin (owner_type='admin') and org (owner_type='org') credentials use server-side KEKs derived in pkg/secrets/root_key.go. They can be decrypted regardless of session, and are always included.
User credentials (owner_type='user') and user_secrets entries (ssh-key, env-secret, etc.) are encrypted with the user's DEK, which requires an active authenticated session. When sessionID identifies a session without a cached DEK (expired, evicted, or never unlocked), the affected entries are skipped with an audit event and the workspace falls back to lower-priority server-KEK entries.
Callers without any session at all (init container, API-key auth) MUST use SessionlessSecretInjector instead of passing an empty sessionID here — that path was previously the source of bug class "buildNonLLMSecrets propagates GetDEK error" (this worklog, 2026-06-24 production incident).
func (*SecretService) InjectSecretsForPodBootstrap ¶ added in v0.2.1
func (s *SecretService) InjectSecretsForPodBootstrap(ctx context.Context, userID, workspaceID string) ([]byte, error)
InjectSecretsForPodBootstrap implements PodBootstrapSecretInjector. See interface godoc and design/0045_2026-07-06_boot-time-user-dek-delivery.md.
Attempts a best-effort user-DEK unwrap via KeyService.GetDEKForUser. On success, delegates to InjectSecrets so user-DEK bindings decrypt through the normal (dek, jti) → decryptBinding path. GetDEKForUser writes the unwrapped DEK back to Redis under the returned jti, so the downstream GetDEK(jti) call in decryptBinding hits the cache — one unwrap per request, not per binding.
On DEK-unavailable (no active jwt_sessions row for this user, none unwrappable with retained signing keys, or KeyService not wired at all), falls back to InjectSessionlessSecrets. The pod boots with server-KEK-only secrets; the auto-push flow (secretautopush) will deliver user-DEK secrets when the user next logs in.
Errors from GetDEKForUser other than ErrDEKUnavailable are treated as "unavailable" and degrade the same way: a transient DB failure at pod boot must not fail the boot; auto-push will retry once the API recovers. The specific error is not logged here because GetDEKForUser's callers (secretautopush.run, this method) both treat it uniformly — the KeyService's own logs already record the failure.
func (*SecretService) InjectSessionlessSecrets ¶
func (s *SecretService) InjectSessionlessSecrets(ctx context.Context, userID, workspaceID string) ([]byte, error)
InjectSessionlessSecrets implements SessionlessSecretInjector. See interface godoc.
Returns server-KEK-decryptable credentials only. User-DEK bindings are not just skipped — they are audited via "secret_skipped_no_session" events so operators have signal that a workspace's user-owned content is awaiting a JWT-authenticated delivery via reload-secrets. Without auditing, an operator inspecting "why is the agent not seeing my SSH key after a pod restart" has no breadcrumb at all.
func (*SecretService) ListSecrets ¶
func (s *SecretService) ListSecrets(ctx context.Context, userID string) ([]*SecretResponse, error)
ListSecrets returns all secret metadata for a user (never values).
func (*SecretService) QueryAudit ¶
func (s *SecretService) QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
QueryAudit returns audit log entries for the current user.
func (*SecretService) SeedGlobalDefaultSecrets ¶
func (s *SecretService) SeedGlobalDefaultSecrets(ctx context.Context, workspaceID, userID string) error
SeedGlobalDefaultSecrets binds all secrets with global_default=true owned by userID to the given workspace. Called by the workspace service on workspace creation as a best-effort operation (failure is logged but does not roll back the workspace). Uses the service-level AddBindings so each auto-bind is recorded in the audit log (matching every user-initiated bind path) and is idempotent if called more than once for the same workspace (ON CONFLICT DO NOTHING at the store layer).
func (*SecretService) SetAdminProvider ¶
func (s *SecretService) SetAdminProvider(p RootKeyProvider)
SetAdminProvider installs the RootKeyProvider for admin (owner_type='admin') provider credentials. When non-nil, the injector methods (InjectSecrets and InjectSessionlessSecrets) decrypt admin bindings through it; when nil, admin bindings are skipped with an audit event.
func (*SecretService) SetBindings ¶
func (s *SecretService) SetBindings(ctx context.Context, userID, workspaceID string, secretIDs []string) (BindingsMutationResult, error)
SetBindings sets which secrets are bound to a workspace. The caller must own every secret being bound; an unowned secret produces ErrSecretNotFound (mapped to 404 by the handler). Workspace ownership itself is enforced by WorkspaceAccessMiddleware on PUT /:id/bindings (design 0041 D5) — the service trusts that decision so it can also be called from background paths where the caller is implicitly authorized (e.g. workspace.Service.refreshEphemeralSecrets).
func (*SecretService) SetOrgProvider ¶
func (s *SecretService) SetOrgProvider(p RootKeyProvider)
SetOrgProvider installs the RootKeyProvider for org (owner_type='org') provider credentials. When non-nil, the injector methods (InjectSecrets and InjectSessionlessSecrets) decrypt org bindings through it; when nil, org bindings are skipped with an audit event.
func (*SecretService) UpdateSecret ¶
func (s *SecretService) UpdateSecret(ctx context.Context, userID, sessionID string, matchedSigningKey []byte, secretID string, req UpdateSecretRequest) error
UpdateSecret re-encrypts and updates a secret's value.
type SecretStore ¶
type SecretStore interface {
CreateSecret(ctx context.Context, secret *UserSecret) error
GetSecret(ctx context.Context, userID, secretID string) (*UserSecret, error)
GetSecretByName(ctx context.Context, userID, name string) (*UserSecret, error)
ListSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
// ListGlobalDefaultSecrets returns all secrets owned by userID that have
// global_default=true. Used when seeding bindings on workspace creation.
ListGlobalDefaultSecrets(ctx context.Context, userID string) ([]*UserSecret, error)
UpdateSecret(ctx context.Context, secret *UserSecret) error
DeleteSecret(ctx context.Context, userID, secretID string) error
// ReEncryptUserSecrets re-encrypts every row owned by userID in a
// single atomic operation. The transform closure receives the old
// ciphertext for a row and must return the new ciphertext (decrypted
// with the old DEK and re-encrypted with the new one). After all
// rows are re-encrypted but before the transaction commits, the
// commit closure is invoked with the same tx so the caller can
// piggyback related updates (e.g. user_keys.wrapped_dek) into the
// same atomic unit; if commit returns non-nil the entire transaction
// rolls back. Implementations MUST run all of this in a single
// SERIALIZABLE transaction with retry on serialization failure.
//
// A partial state would leave rows decryptable only by a key the
// system has discarded — the failure mode of Bug 9 in worklog 0085.
ReEncryptUserSecrets(
ctx context.Context,
userID string,
newKeyVersion int,
transform func(oldCiphertext []byte) (newCiphertext []byte, err error),
commit func(ctx context.Context) error,
) error
// Bindings
SetBindings(ctx context.Context, workspaceID string, secretIDs []string) error
// AddBindings atomically adds secretIDs to a workspace's binding
// set without removing any existing bindings. Implementations
// MUST take the same workspace-scoped advisory lock as
// SetBindings so concurrent Add+Set callers serialize. Existing
// bindings to the same secret are silently ignored
// (INSERT ... ON CONFLICT DO NOTHING semantics).
//
// Used by SetWorkspaceEnv to add new env-secrets without racing
// on a Get-then-Set window — see worklog 0094 pass-2 finding O1.
AddBindings(ctx context.Context, workspaceID string, secretIDs []string) error
GetBindings(ctx context.Context, workspaceID string) ([]*UserSecret, error)
GetBindingsForSecret(ctx context.Context, secretID string) ([]string, error)
// Audit
LogAudit(ctx context.Context, entry *AuditEntry) error
QueryAudit(ctx context.Context, userID string, query AuditQuery) ([]*AuditEntry, error)
}
SecretStore abstracts database operations for user secrets.
type SecretType ¶
type SecretType string
SecretType defines the type of secret.
const ( // APIKeySunsetDate is the fixed date on which new api-key secrets // become uncreatable (US-44.9). Six months after the Epic 44 ship // date. Existing api-key secrets remain functional after this date; // only creation is blocked by the CreateSecret gate. APIKeySunsetDate = "2026-12-19" // SecretTypeAPIKey is for generic API-key secrets (legacy). // New code should use SecretTypeLLMProvider for structured provider // credentials. Kept for backward compatibility with existing secrets. SecretTypeAPIKey SecretType = "api-key" // SecretTypeLLMProvider is for structured LLM provider credentials // (Anthropic, OpenAI, etc.). Each secret holds one provider with // its API key, optional base URL, model visibility allowlist, and // default model selection. Multiple llm-provider secrets bound to // the same workspace are merged by the agent's FormatProviderConfig. SecretTypeLLMProvider SecretType = "llm-provider" SecretTypeSSHKey SecretType = "ssh-key" SecretTypeGitCredential SecretType = "git-credential" SecretTypeSecretFile SecretType = "secret-file" SecretTypeEnvSecret SecretType = "env-secret" )
func ValidSecretTypesList ¶
func ValidSecretTypesList() []SecretType
ValidSecretTypesList returns the canonical list of valid secret types, in stable order. Used to format the error message returned when a caller submits an invalid type, so the response is self-documenting.
type SessionlessSecretInjector ¶
type SessionlessSecretInjector interface {
InjectSessionlessSecrets(ctx context.Context, userID, workspaceID string) ([]byte, error)
}
SessionlessSecretInjector returns the subset of workspace secrets that can be decrypted without a user session — admin and org provider credentials, encrypted with server-KEK material (US-50.2 RootKeyProvider).
Used by callers without a user session:
Pod bootstrap (Epic 35 US-35.3): the init container POSTs the API with a projected SA token; there is no JWT and no DEK in flight. NOTE: pod-bootstrap now prefers PodBootstrapSecretInjector when available (design/0045). SessionlessSecretInjector remains the fallback contract for callers that cannot request user-DEK on the caller's behalf.
API-key authenticated handlers (e.g. SDK calls without a JWT): the handler cannot decrypt user-DEK content, so the SessionlessSecretInjector gives it the server-KEK subset only. The user-DEK content is delivered later by a JWT-authenticated reload (the existing two-phase pattern documented in commit 4b48a4e7).
User-owned credentials and user_secrets entries are intentionally omitted; they are emitted as audit events ("secret_skipped_no_session") so operators have observability into what was deferred. Implementations MUST audit every skipped binding to preserve the existing observability contract documented in pkg/secrets/secret_service.go (M-5 fix).
Implementations: *SecretService.
type SetBindingsRequest ¶
type SetBindingsRequest struct {
SecretIDs []string `json:"secretIds" binding:"required"`
}
SetBindingsRequest is the API request for setting workspace bindings.
type SigningKeyEnumerator ¶
SigningKeyEnumerator exposes the API's active JWT signing keys to callers that need to unwrap a durable DEK on behalf of a user in a background context (workspace watcher, controller-triggered auto- push, etc.). Implemented by auth.Service via a wrapper that iterates s.jwtSecret followed by s.jwtPreviousSecrets.
The callback contract: `fn` returns TRUE to continue iteration or FALSE to stop (typical: stop after first successful unwrap). Bytes passed to `fn` MUST NOT be retained by the callback — implementations may reuse a single backing buffer, or copy from internal state and zero on return. Callers that need to retain a key past the callback call must copy.
type StaticKeyProvider ¶
type StaticKeyProvider struct {
// contains filtered or unexported fields
}
StaticKeyProvider holds one or more versioned keys. Encrypt always uses the highest-version (active) key; Decrypt tries each key newest-to-oldest and returns the first success. This enables zero-downtime KEK rotation (US-50.4, design D4): during the transition window the provider holds both old and new keys so ciphertexts encrypted under either version decrypt correctly.
func NewStaticKeyProvider ¶
func NewStaticKeyProvider(key []byte) (*StaticKeyProvider, error)
NewStaticKeyProvider constructs a single-key provider at version 1. This is the backward-compatible constructor used everywhere except the rotation window.
func NewStaticKeyProviderMultiVersion ¶
func NewStaticKeyProviderMultiVersion(activeVersion int, keyByVersion map[int][]byte) (*StaticKeyProvider, error)
NewStaticKeyProviderMultiVersion constructs a multi-key provider for the rotation transition window (US-50.4). activeVersion is the highest version (the one Encrypt uses); keyByVersion maps every version to its key material. At least one entry at activeVersion must exist. Entries are stored sorted by version descending so Decrypt tries the newest first.
func (*StaticKeyProvider) ActiveVersion ¶
func (p *StaticKeyProvider) ActiveVersion() int
ActiveVersion returns the highest version the provider can encrypt with (US-50.3 uses this to populate key_version columns on encrypt).
type UpdateSecretRequest ¶
type UpdateSecretRequest struct {
Value string `json:"value" binding:"required"`
Metadata json.RawMessage `json:"metadata,omitempty"`
// GlobalDefault, when non-nil, updates whether this secret is automatically
// bound to newly-created workspaces. Nil means "leave unchanged".
GlobalDefault *bool `json:"globalDefault,omitempty"`
}
UpdateSecretRequest is the API request for updating a secret value.
type UserKeyRecord ¶
type UserKeyRecord struct {
UserID string
KeyVersion int
WrappedDEK []byte
WrappedDEKRecovery []byte // nil if user opted out
Salt []byte
RecoverySalt []byte // nil if user opted out
CreatedAt time.Time
RotatedAt *time.Time
}
UserKeyRecord represents a row in the user_keys table.
type UserSecret ¶
type UserSecret struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
Type SecretType `json:"type"`
Ciphertext []byte `json:"-"` // never exposed via API
KeyVersion int `json:"keyVersion"`
Metadata json.RawMessage `json:"metadata"`
GlobalDefault bool `json:"globalDefault"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
UserSecret represents an encrypted secret record.
type VersionedProvider ¶
type VersionedProvider interface {
ActiveVersion() int
}
VersionedProvider is implemented by providers that expose an active key version (US-50.3/50.4). Callers that need the version for key_version column writes assert this interface on the concrete provider — it is intentionally NOT on RootKeyProvider so a future external provider (Vault Transit, which handles versioning server-side) doesn't need to implement it.
Source Files
¶
- audited_provider.go
- bindings_diff.go
- credential_identity.go
- credential_store.go
- crypto.go
- errors.go
- injection.go
- jwt_session_janitor.go
- jwt_session_store.go
- key_service.go
- org_credential_store.go
- pg_credential_store.go
- pg_key_store.go
- pg_secret_store.go
- postgres_provider.go
- provider.go
- redis_cache.go
- root_key.go
- rotation.go
- secret_service.go
- store.go
- types.go