diff --git a/ROADMAP.md b/ROADMAP.md index 53da4e5..4267d5d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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/?workspaceId&environment&secretPath`. Marker + key grammar `[#]` (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). diff --git a/cmd/openctl-controller/main.go b/cmd/openctl-controller/main.go index 42fff1e..218edcd 100644 --- a/cmd/openctl-controller/main.go +++ b/cmd/openctl-controller/main.go @@ -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") } @@ -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) } diff --git a/internal/config/config.go b/internal/config/config.go index b2486fa..5f51c6a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 diff --git a/internal/controller/secrets/infisical.go b/internal/controller/secrets/infisical.go new file mode 100644 index 0000000..0322b81 --- /dev/null +++ b/internal/controller/secrets/infisical.go @@ -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 "[#]" — 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 +} diff --git a/internal/controller/secrets/infisical_test.go b/internal/controller/secrets/infisical_test.go new file mode 100644 index 0000000..cd4a79c --- /dev/null +++ b/internal/controller/secrets/infisical_test.go @@ -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"]) + } +}