Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,20 @@ prerequisite (Longhorn); the rest are convenience + robustness.
dance for blogs, sites, Jellyfin, Open WebUI, Authentik, etc.

### H. Integrations (optional — deploying the app works without these)
- [ ] **H1 — Infisical `$secret` provider.** Register a self-hosted Infisical
as a Tier-2 secrets backend (like Vault) so openctl *reads* secrets from
it. Deploying Infisical itself is a plain HelmRelease.
- [x] **H1 — Infisical `$secret` provider.** `secrets.InfisicalProvider`
registers a self-hosted (or cloud) Infisical as a Tier-2 backend, mirroring
the Vault provider (dependency-free net/http). Auth is either **Universal
Auth** (machine identity: `clientId` + a client secret → short-lived access
token) or a **static** service/access token; secrets are read via
`GET /api/v3/secrets/raw/<name>?workspaceId&environment&secretPath`. Marker
key grammar `[<secretPath>#]<secretName>` (path defaults to `/`); project +
environment are fixed per named provider. Config: a `type: infisical` entry
under `secrets.providers` (reuses `address`/`tokenSecret*`, adds
`clientId`/`projectId`/`environment`). Only the resolved value is used
transiently — never persisted. Unit-tested with a fake Infisical server
(universal-auth + static-token happy paths, bad creds, 404, malformed key,
through-resolver). Deploying Infisical itself is a plain HelmRelease (see
the J1 examples pattern).
- [x] **H2 — Authentik as openctl SSO.** Already works, no new code: Authentik
is an OIDC IdP; point `auth.oidc` at it (OIDC backend shipped #110).

Expand Down
18 changes: 17 additions & 1 deletion cmd/openctl-controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ func resolveRetainPerResource() int {
// backend must surface loudly, not silently leave a $secret marker unresolved.
func registerConfiguredSecretProviders(reg *secrets.Registry, providers []config.SecretProviderConfig) ([]string, error) {
var names []string
for _, pc := range providers {
for i := range providers {
pc := &providers[i]
if pc.Name == "" {
return nil, fmt.Errorf("a secret provider is missing its name")
}
Expand All @@ -72,6 +73,21 @@ func registerConfiguredSecretProviders(reg *secrets.Registry, providers []config
}
reg.Register(secrets.NewVaultProvider(pc.Name, pc.Address, token, pc.Namespace))
names = append(names, pc.Name+" (vault)")
case "infisical":
if pc.Address == "" {
return nil, fmt.Errorf("secret provider %q (infisical) requires an address", pc.Name)
}
if pc.ProjectID == "" || pc.Environment == "" {
return nil, fmt.Errorf("secret provider %q (infisical) requires projectId and environment", pc.Name)
}
// The token is the Universal Auth client secret (with ClientID) or a
// static service/access token (without).
token, err := pc.ResolveToken()
if err != nil {
return nil, fmt.Errorf("secret provider %q token: %w", pc.Name, err)
}
reg.Register(secrets.NewInfisicalProvider(pc.Name, pc.Address, pc.ClientID, token, pc.ProjectID, pc.Environment))
names = append(names, pc.Name+" (infisical)")
default:
return nil, fmt.Errorf("secret provider %q: unknown type %q", pc.Name, pc.Type)
}
Expand Down
16 changes: 13 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,26 @@ type Secrets struct {
type SecretProviderConfig struct {
// Name is the identifier a $secret marker's `provider` field references.
Name string `yaml:"name"`
// Type selects the backend implementation. Currently "vault".
// Type selects the backend implementation: "vault" or "infisical".
Type string `yaml:"type"`
// Address is the backend base URL (e.g. https://vault.lan:8200).
// Address is the backend base URL (e.g. https://vault.lan:8200 or
// https://infisical.lan).
Address string `yaml:"address"`
// Token authenticates to the backend. Prefer TokenSecretFile (0600) over
// an inline TokenSecret — never commit a real token.
// an inline TokenSecret — never commit a real token. For Vault this is the
// Vault token; for Infisical it is the Universal Auth client secret (when
// ClientID is set) or a static service/access token (when it isn't).
TokenSecret string `yaml:"tokenSecret,omitempty"`
TokenSecretFile string `yaml:"tokenSecretFile,omitempty"`
// Namespace is an optional Vault Enterprise namespace (X-Vault-Namespace).
Namespace string `yaml:"namespace,omitempty"`
// Infisical-only. ClientID enables Universal Auth (machine identity) — the
// client secret comes from TokenSecret/TokenSecretFile; leave empty to use
// that token directly as a Bearer service/access token. ProjectID and
// Environment scope which secrets this named provider reads.
ClientID string `yaml:"clientId,omitempty"`
ProjectID string `yaml:"projectId,omitempty"`
Environment string `yaml:"environment,omitempty"`
}

// ResolveToken returns the backend token, reading TokenSecretFile when set
Expand Down
155 changes: 155 additions & 0 deletions internal/controller/secrets/infisical.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package secrets

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

// InfisicalProvider resolves secrets from a self-hosted (or cloud) Infisical
// instance over its HTTP API. Like the Vault provider it is dependency-free
// (net/http, no SDK) — a Tier 2 backend only needs Resolve.
//
// A $secret marker names a secret by (optional) path and name:
//
// {$secret: {provider: "infisical", key: "DB_PASSWORD"}}
// {$secret: {provider: "infisical", key: "/app/prod#DB_PASSWORD"}}
//
// The key is "[<secretPath>#]<secretName>" — the path defaults to "/". The
// project and environment are fixed per configured provider (register multiple
// named providers for multiple environments), mirroring how the Vault provider
// takes a namespace.
//
// Auth is one of two modes, chosen by whether a clientID is configured:
// - Universal Auth (machine identity): clientID + secret (the client secret)
// are exchanged for a short-lived access token via the universal-auth login
// endpoint, then used as a Bearer token.
// - Static token: secret is used directly as a Bearer token (a service token
// or a pre-minted access token).
type InfisicalProvider struct {
name string
host string // base URL, no trailing slash
clientID string // set → Universal Auth; empty → static bearer token
secret string // client secret (universal auth) or bearer token (static)
projectID string
environment string
client *http.Client
}

// NewInfisicalProvider builds an Infisical-backed SecretProvider. When clientID
// is non-empty, secret is the Universal Auth client secret; otherwise secret is
// used directly as a Bearer token (service/access token).
func NewInfisicalProvider(name, host, clientID, secret, projectID, environment string) *InfisicalProvider {
return &InfisicalProvider{
name: name,
host: strings.TrimRight(host, "/"),
clientID: clientID,
secret: secret,
projectID: projectID,
environment: environment,
client: &http.Client{Timeout: 15 * time.Second},
}
}

func (p *InfisicalProvider) Name() string { return p.name }

func (p *InfisicalProvider) Resolve(ctx context.Context, key string) (string, error) {
secretPath, secretName := "/", key
if path, name, ok := strings.Cut(key, "#"); ok {
if name == "" {
return "", fmt.Errorf("infisical key %q: secret name after # is empty", key)
}
secretName = name
if path != "" {
secretPath = path
}
}
if secretName == "" {
return "", fmt.Errorf("infisical key %q: secret name is empty", key)
}

token, err := p.accessToken(ctx)
if err != nil {
return "", err
}

q := url.Values{}
q.Set("workspaceId", p.projectID)
q.Set("environment", p.environment)
q.Set("secretPath", secretPath)
reqURL := fmt.Sprintf("%s/api/v3/secrets/raw/%s?%s", p.host, url.PathEscape(secretName), q.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := p.client.Do(req)
if err != nil {
return "", fmt.Errorf("infisical request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("infisical %s (env %s): status %d", secretName, p.environment, resp.StatusCode)
}
var parsed struct {
Secret struct {
SecretValue string `json:"secretValue"`
} `json:"secret"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("infisical %s: decode response: %w", secretName, err)
}
if parsed.Secret.SecretValue == "" {
return "", fmt.Errorf("infisical %s: empty or missing secretValue", secretName)
}
return parsed.Secret.SecretValue, nil
}

// accessToken returns the Bearer token to use: the static token as-is when no
// clientID is configured, or a fresh Universal Auth access token otherwise.
// (Not cached — resolution happens a handful of times per apply, and a stale
// cached token would be worse than a re-login.)
func (p *InfisicalProvider) accessToken(ctx context.Context) (string, error) {
if p.clientID == "" {
if p.secret == "" {
return "", fmt.Errorf("infisical %q: no clientId (Universal Auth) or token configured", p.name)
}
return p.secret, nil
}
reqBody, _ := json.Marshal(map[string]string{
"clientId": p.clientID,
"clientSecret": p.secret,
})
reqURL := p.host + "/api/v1/auth/universal-auth/login"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(reqBody))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.client.Do(req)
if err != nil {
return "", fmt.Errorf("infisical login: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("infisical login: status %d", resp.StatusCode)
}
var parsed struct {
AccessToken string `json:"accessToken"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("infisical login: decode response: %w", err)
}
if parsed.AccessToken == "" {
return "", fmt.Errorf("infisical login: no accessToken in response")
}
return parsed.AccessToken, nil
}
138 changes: 138 additions & 0 deletions internal/controller/secrets/infisical_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package secrets

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

// fakeInfisical serves a minimal Universal Auth login + v3 raw secret read. It
// mints a fixed access token from the given client credentials and serves one
// secret (DB_PASSWORD=hunter2 at path "/") for either that access token or a
// static bearer token.
func fakeInfisical(t *testing.T, clientID, clientSecret, staticToken string) *httptest.Server {
t.Helper()
const accessToken = "eyJ-access-token"
mux := http.NewServeMux()

mux.HandleFunc("/api/v1/auth/universal-auth/login", func(w http.ResponseWriter, r *http.Request) {
var body struct{ ClientID, ClientSecret string }
_ = json.NewDecoder(r.Body).Decode(&body)
if body.ClientID != clientID || body.ClientSecret != clientSecret {
w.WriteHeader(http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": accessToken, "tokenType": "Bearer"})
})

mux.HandleFunc("/api/v3/secrets/raw/", func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
wantUA := "Bearer " + accessToken
wantStatic := "Bearer " + staticToken
if auth != wantUA && (staticToken == "" || auth != wantStatic) {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("workspaceId") != "proj-1" || r.URL.Query().Get("environment") != "prod" {
w.WriteHeader(http.StatusBadRequest)
return
}
name := strings.TrimPrefix(r.URL.Path, "/api/v3/secrets/raw/")
if name != "DB_PASSWORD" || r.URL.Query().Get("secretPath") != "/" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"secret": map[string]any{"secretKey": "DB_PASSWORD", "secretValue": "hunter2"},
})
})
return httptest.NewServer(mux)
}

func TestInfisicalProvider_UniversalAuth(t *testing.T) {
srv := fakeInfisical(t, "cid", "csecret", "")
defer srv.Close()
p := NewInfisicalProvider("infisical", srv.URL, "cid", "csecret", "proj-1", "prod")

got, err := p.Resolve(context.Background(), "DB_PASSWORD")
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if got != "hunter2" {
t.Errorf("got %q, want hunter2", got)
}
}

func TestInfisicalProvider_StaticToken(t *testing.T) {
srv := fakeInfisical(t, "", "", "st.service-token")
defer srv.Close()
// No clientID → the token is used directly as a Bearer token.
p := NewInfisicalProvider("infisical", srv.URL, "", "st.service-token", "proj-1", "prod")

got, err := p.Resolve(context.Background(), "/#DB_PASSWORD")
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if got != "hunter2" {
t.Errorf("got %q, want hunter2", got)
}
}

func TestInfisicalProvider_BadCredentials(t *testing.T) {
srv := fakeInfisical(t, "cid", "csecret", "")
defer srv.Close()
p := NewInfisicalProvider("infisical", srv.URL, "cid", "wrong", "proj-1", "prod")
if _, err := p.Resolve(context.Background(), "DB_PASSWORD"); err == nil {
t.Fatal("expected a login failure with the wrong client secret")
}
}

func TestInfisicalProvider_MissingSecret(t *testing.T) {
srv := fakeInfisical(t, "cid", "csecret", "")
defer srv.Close()
p := NewInfisicalProvider("infisical", srv.URL, "cid", "csecret", "proj-1", "prod")
_, err := p.Resolve(context.Background(), "NONEXISTENT")
if err == nil || !strings.Contains(err.Error(), "status 404") {
t.Fatalf("want a 404 status error, got %v", err)
}
}

func TestInfisicalProvider_MalformedKey(t *testing.T) {
p := NewInfisicalProvider("infisical", "http://x", "cid", "csecret", "proj-1", "prod")
for _, key := range []string{"", "/path#"} {
if _, err := p.Resolve(context.Background(), key); err == nil {
t.Errorf("key %q should be rejected", key)
}
}
}

func TestInfisicalProvider_NoAuthConfigured(t *testing.T) {
// No clientID and no token → cannot authenticate.
p := NewInfisicalProvider("infisical", "http://x", "", "", "proj-1", "prod")
if _, err := p.Resolve(context.Background(), "DB_PASSWORD"); err == nil {
t.Fatal("expected an error when no auth is configured")
}
}

// End-to-end: a $secret marker naming the infisical provider resolves through
// the registry + resolver.
func TestInfisicalProvider_ThroughResolver(t *testing.T) {
srv := fakeInfisical(t, "cid", "csecret", "")
defer srv.Close()
reg := NewRegistry()
reg.Register(NewInfisicalProvider("infisical", srv.URL, "cid", "csecret", "proj-1", "prod"))

in := map[string]any{
"password": map[string]any{SecretMarker: map[string]any{"provider": "infisical", "key": "DB_PASSWORD"}},
}
out, err := New(reg).Resolve(context.Background(), in)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if out["password"] != "hunter2" {
t.Errorf("resolved %v, want hunter2", out["password"])
}
}
Loading