Skip to content

Commit 698fa6f

Browse files
authored
feat(secrets): Infisical $secret provider (H1) (#150)
Register a self-hosted (or cloud) Infisical as a Tier-2 secrets backend, mirroring the Vault provider (dependency-free net/http, no SDK). Auth is either Universal Auth (machine identity: clientId + client secret exchanged for a short-lived access token) or a static service/access token used directly as a Bearer token. Secrets are read via GET /api/v3/secrets/raw/<name>?workspaceId&environment&secretPath. Marker key grammar "[<secretPath>#]<secretName>" (path defaults to "/"); project and environment are fixed per named provider (register multiple for multiple envs), mirroring how Vault takes a namespace. Config: a `type: infisical` entry under secrets.providers reusing address/tokenSecret* and adding clientId/projectId/environment. Only the resolved value is used transiently (handed to provider.Apply); never persisted — same discipline as every $secret backend. Unit-tested with a fake Infisical server: universal-auth + static-token happy paths, bad credentials, missing secret (404), malformed key, no-auth, and end-to-end through the registry + resolver. Also switches registerConfiguredSecretProviders' loop to index-based iteration (the enlarged config struct tripped gocritic's rangeValCopy).
1 parent dfbb19c commit 698fa6f

5 files changed

Lines changed: 337 additions & 7 deletions

File tree

ROADMAP.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -203,9 +203,20 @@ prerequisite (Longhorn); the rest are convenience + robustness.
203203
dance for blogs, sites, Jellyfin, Open WebUI, Authentik, etc.
204204

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

cmd/openctl-controller/main.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ func resolveRetainPerResource() int {
5757
// backend must surface loudly, not silently leave a $secret marker unresolved.
5858
func registerConfiguredSecretProviders(reg *secrets.Registry, providers []config.SecretProviderConfig) ([]string, error) {
5959
var names []string
60-
for _, pc := range providers {
60+
for i := range providers {
61+
pc := &providers[i]
6162
if pc.Name == "" {
6263
return nil, fmt.Errorf("a secret provider is missing its name")
6364
}
@@ -72,6 +73,21 @@ func registerConfiguredSecretProviders(reg *secrets.Registry, providers []config
7273
}
7374
reg.Register(secrets.NewVaultProvider(pc.Name, pc.Address, token, pc.Namespace))
7475
names = append(names, pc.Name+" (vault)")
76+
case "infisical":
77+
if pc.Address == "" {
78+
return nil, fmt.Errorf("secret provider %q (infisical) requires an address", pc.Name)
79+
}
80+
if pc.ProjectID == "" || pc.Environment == "" {
81+
return nil, fmt.Errorf("secret provider %q (infisical) requires projectId and environment", pc.Name)
82+
}
83+
// The token is the Universal Auth client secret (with ClientID) or a
84+
// static service/access token (without).
85+
token, err := pc.ResolveToken()
86+
if err != nil {
87+
return nil, fmt.Errorf("secret provider %q token: %w", pc.Name, err)
88+
}
89+
reg.Register(secrets.NewInfisicalProvider(pc.Name, pc.Address, pc.ClientID, token, pc.ProjectID, pc.Environment))
90+
names = append(names, pc.Name+" (infisical)")
7591
default:
7692
return nil, fmt.Errorf("secret provider %q: unknown type %q", pc.Name, pc.Type)
7793
}

internal/config/config.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,26 @@ type Secrets struct {
7171
type SecretProviderConfig struct {
7272
// Name is the identifier a $secret marker's `provider` field references.
7373
Name string `yaml:"name"`
74-
// Type selects the backend implementation. Currently "vault".
74+
// Type selects the backend implementation: "vault" or "infisical".
7575
Type string `yaml:"type"`
76-
// Address is the backend base URL (e.g. https://vault.lan:8200).
76+
// Address is the backend base URL (e.g. https://vault.lan:8200 or
77+
// https://infisical.lan).
7778
Address string `yaml:"address"`
7879
// Token authenticates to the backend. Prefer TokenSecretFile (0600) over
79-
// an inline TokenSecret — never commit a real token.
80+
// an inline TokenSecret — never commit a real token. For Vault this is the
81+
// Vault token; for Infisical it is the Universal Auth client secret (when
82+
// ClientID is set) or a static service/access token (when it isn't).
8083
TokenSecret string `yaml:"tokenSecret,omitempty"`
8184
TokenSecretFile string `yaml:"tokenSecretFile,omitempty"`
8285
// Namespace is an optional Vault Enterprise namespace (X-Vault-Namespace).
8386
Namespace string `yaml:"namespace,omitempty"`
87+
// Infisical-only. ClientID enables Universal Auth (machine identity) — the
88+
// client secret comes from TokenSecret/TokenSecretFile; leave empty to use
89+
// that token directly as a Bearer service/access token. ProjectID and
90+
// Environment scope which secrets this named provider reads.
91+
ClientID string `yaml:"clientId,omitempty"`
92+
ProjectID string `yaml:"projectId,omitempty"`
93+
Environment string `yaml:"environment,omitempty"`
8494
}
8595

8696
// ResolveToken returns the backend token, reading TokenSecretFile when set
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package secrets
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"time"
13+
)
14+
15+
// InfisicalProvider resolves secrets from a self-hosted (or cloud) Infisical
16+
// instance over its HTTP API. Like the Vault provider it is dependency-free
17+
// (net/http, no SDK) — a Tier 2 backend only needs Resolve.
18+
//
19+
// A $secret marker names a secret by (optional) path and name:
20+
//
21+
// {$secret: {provider: "infisical", key: "DB_PASSWORD"}}
22+
// {$secret: {provider: "infisical", key: "/app/prod#DB_PASSWORD"}}
23+
//
24+
// The key is "[<secretPath>#]<secretName>" — the path defaults to "/". The
25+
// project and environment are fixed per configured provider (register multiple
26+
// named providers for multiple environments), mirroring how the Vault provider
27+
// takes a namespace.
28+
//
29+
// Auth is one of two modes, chosen by whether a clientID is configured:
30+
// - Universal Auth (machine identity): clientID + secret (the client secret)
31+
// are exchanged for a short-lived access token via the universal-auth login
32+
// endpoint, then used as a Bearer token.
33+
// - Static token: secret is used directly as a Bearer token (a service token
34+
// or a pre-minted access token).
35+
type InfisicalProvider struct {
36+
name string
37+
host string // base URL, no trailing slash
38+
clientID string // set → Universal Auth; empty → static bearer token
39+
secret string // client secret (universal auth) or bearer token (static)
40+
projectID string
41+
environment string
42+
client *http.Client
43+
}
44+
45+
// NewInfisicalProvider builds an Infisical-backed SecretProvider. When clientID
46+
// is non-empty, secret is the Universal Auth client secret; otherwise secret is
47+
// used directly as a Bearer token (service/access token).
48+
func NewInfisicalProvider(name, host, clientID, secret, projectID, environment string) *InfisicalProvider {
49+
return &InfisicalProvider{
50+
name: name,
51+
host: strings.TrimRight(host, "/"),
52+
clientID: clientID,
53+
secret: secret,
54+
projectID: projectID,
55+
environment: environment,
56+
client: &http.Client{Timeout: 15 * time.Second},
57+
}
58+
}
59+
60+
func (p *InfisicalProvider) Name() string { return p.name }
61+
62+
func (p *InfisicalProvider) Resolve(ctx context.Context, key string) (string, error) {
63+
secretPath, secretName := "/", key
64+
if path, name, ok := strings.Cut(key, "#"); ok {
65+
if name == "" {
66+
return "", fmt.Errorf("infisical key %q: secret name after # is empty", key)
67+
}
68+
secretName = name
69+
if path != "" {
70+
secretPath = path
71+
}
72+
}
73+
if secretName == "" {
74+
return "", fmt.Errorf("infisical key %q: secret name is empty", key)
75+
}
76+
77+
token, err := p.accessToken(ctx)
78+
if err != nil {
79+
return "", err
80+
}
81+
82+
q := url.Values{}
83+
q.Set("workspaceId", p.projectID)
84+
q.Set("environment", p.environment)
85+
q.Set("secretPath", secretPath)
86+
reqURL := fmt.Sprintf("%s/api/v3/secrets/raw/%s?%s", p.host, url.PathEscape(secretName), q.Encode())
87+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
88+
if err != nil {
89+
return "", err
90+
}
91+
req.Header.Set("Authorization", "Bearer "+token)
92+
resp, err := p.client.Do(req)
93+
if err != nil {
94+
return "", fmt.Errorf("infisical request: %w", err)
95+
}
96+
defer func() { _ = resp.Body.Close() }()
97+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
98+
if resp.StatusCode != http.StatusOK {
99+
return "", fmt.Errorf("infisical %s (env %s): status %d", secretName, p.environment, resp.StatusCode)
100+
}
101+
var parsed struct {
102+
Secret struct {
103+
SecretValue string `json:"secretValue"`
104+
} `json:"secret"`
105+
}
106+
if err := json.Unmarshal(body, &parsed); err != nil {
107+
return "", fmt.Errorf("infisical %s: decode response: %w", secretName, err)
108+
}
109+
if parsed.Secret.SecretValue == "" {
110+
return "", fmt.Errorf("infisical %s: empty or missing secretValue", secretName)
111+
}
112+
return parsed.Secret.SecretValue, nil
113+
}
114+
115+
// accessToken returns the Bearer token to use: the static token as-is when no
116+
// clientID is configured, or a fresh Universal Auth access token otherwise.
117+
// (Not cached — resolution happens a handful of times per apply, and a stale
118+
// cached token would be worse than a re-login.)
119+
func (p *InfisicalProvider) accessToken(ctx context.Context) (string, error) {
120+
if p.clientID == "" {
121+
if p.secret == "" {
122+
return "", fmt.Errorf("infisical %q: no clientId (Universal Auth) or token configured", p.name)
123+
}
124+
return p.secret, nil
125+
}
126+
reqBody, _ := json.Marshal(map[string]string{
127+
"clientId": p.clientID,
128+
"clientSecret": p.secret,
129+
})
130+
reqURL := p.host + "/api/v1/auth/universal-auth/login"
131+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(reqBody))
132+
if err != nil {
133+
return "", err
134+
}
135+
req.Header.Set("Content-Type", "application/json")
136+
resp, err := p.client.Do(req)
137+
if err != nil {
138+
return "", fmt.Errorf("infisical login: %w", err)
139+
}
140+
defer func() { _ = resp.Body.Close() }()
141+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
142+
if resp.StatusCode != http.StatusOK {
143+
return "", fmt.Errorf("infisical login: status %d", resp.StatusCode)
144+
}
145+
var parsed struct {
146+
AccessToken string `json:"accessToken"`
147+
}
148+
if err := json.Unmarshal(body, &parsed); err != nil {
149+
return "", fmt.Errorf("infisical login: decode response: %w", err)
150+
}
151+
if parsed.AccessToken == "" {
152+
return "", fmt.Errorf("infisical login: no accessToken in response")
153+
}
154+
return parsed.AccessToken, nil
155+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package secrets
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
)
11+
12+
// fakeInfisical serves a minimal Universal Auth login + v3 raw secret read. It
13+
// mints a fixed access token from the given client credentials and serves one
14+
// secret (DB_PASSWORD=hunter2 at path "/") for either that access token or a
15+
// static bearer token.
16+
func fakeInfisical(t *testing.T, clientID, clientSecret, staticToken string) *httptest.Server {
17+
t.Helper()
18+
const accessToken = "eyJ-access-token"
19+
mux := http.NewServeMux()
20+
21+
mux.HandleFunc("/api/v1/auth/universal-auth/login", func(w http.ResponseWriter, r *http.Request) {
22+
var body struct{ ClientID, ClientSecret string }
23+
_ = json.NewDecoder(r.Body).Decode(&body)
24+
if body.ClientID != clientID || body.ClientSecret != clientSecret {
25+
w.WriteHeader(http.StatusUnauthorized)
26+
return
27+
}
28+
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": accessToken, "tokenType": "Bearer"})
29+
})
30+
31+
mux.HandleFunc("/api/v3/secrets/raw/", func(w http.ResponseWriter, r *http.Request) {
32+
auth := r.Header.Get("Authorization")
33+
wantUA := "Bearer " + accessToken
34+
wantStatic := "Bearer " + staticToken
35+
if auth != wantUA && (staticToken == "" || auth != wantStatic) {
36+
w.WriteHeader(http.StatusUnauthorized)
37+
return
38+
}
39+
if r.URL.Query().Get("workspaceId") != "proj-1" || r.URL.Query().Get("environment") != "prod" {
40+
w.WriteHeader(http.StatusBadRequest)
41+
return
42+
}
43+
name := strings.TrimPrefix(r.URL.Path, "/api/v3/secrets/raw/")
44+
if name != "DB_PASSWORD" || r.URL.Query().Get("secretPath") != "/" {
45+
w.WriteHeader(http.StatusNotFound)
46+
return
47+
}
48+
_ = json.NewEncoder(w).Encode(map[string]any{
49+
"secret": map[string]any{"secretKey": "DB_PASSWORD", "secretValue": "hunter2"},
50+
})
51+
})
52+
return httptest.NewServer(mux)
53+
}
54+
55+
func TestInfisicalProvider_UniversalAuth(t *testing.T) {
56+
srv := fakeInfisical(t, "cid", "csecret", "")
57+
defer srv.Close()
58+
p := NewInfisicalProvider("infisical", srv.URL, "cid", "csecret", "proj-1", "prod")
59+
60+
got, err := p.Resolve(context.Background(), "DB_PASSWORD")
61+
if err != nil {
62+
t.Fatalf("Resolve: %v", err)
63+
}
64+
if got != "hunter2" {
65+
t.Errorf("got %q, want hunter2", got)
66+
}
67+
}
68+
69+
func TestInfisicalProvider_StaticToken(t *testing.T) {
70+
srv := fakeInfisical(t, "", "", "st.service-token")
71+
defer srv.Close()
72+
// No clientID → the token is used directly as a Bearer token.
73+
p := NewInfisicalProvider("infisical", srv.URL, "", "st.service-token", "proj-1", "prod")
74+
75+
got, err := p.Resolve(context.Background(), "/#DB_PASSWORD")
76+
if err != nil {
77+
t.Fatalf("Resolve: %v", err)
78+
}
79+
if got != "hunter2" {
80+
t.Errorf("got %q, want hunter2", got)
81+
}
82+
}
83+
84+
func TestInfisicalProvider_BadCredentials(t *testing.T) {
85+
srv := fakeInfisical(t, "cid", "csecret", "")
86+
defer srv.Close()
87+
p := NewInfisicalProvider("infisical", srv.URL, "cid", "wrong", "proj-1", "prod")
88+
if _, err := p.Resolve(context.Background(), "DB_PASSWORD"); err == nil {
89+
t.Fatal("expected a login failure with the wrong client secret")
90+
}
91+
}
92+
93+
func TestInfisicalProvider_MissingSecret(t *testing.T) {
94+
srv := fakeInfisical(t, "cid", "csecret", "")
95+
defer srv.Close()
96+
p := NewInfisicalProvider("infisical", srv.URL, "cid", "csecret", "proj-1", "prod")
97+
_, err := p.Resolve(context.Background(), "NONEXISTENT")
98+
if err == nil || !strings.Contains(err.Error(), "status 404") {
99+
t.Fatalf("want a 404 status error, got %v", err)
100+
}
101+
}
102+
103+
func TestInfisicalProvider_MalformedKey(t *testing.T) {
104+
p := NewInfisicalProvider("infisical", "http://x", "cid", "csecret", "proj-1", "prod")
105+
for _, key := range []string{"", "/path#"} {
106+
if _, err := p.Resolve(context.Background(), key); err == nil {
107+
t.Errorf("key %q should be rejected", key)
108+
}
109+
}
110+
}
111+
112+
func TestInfisicalProvider_NoAuthConfigured(t *testing.T) {
113+
// No clientID and no token → cannot authenticate.
114+
p := NewInfisicalProvider("infisical", "http://x", "", "", "proj-1", "prod")
115+
if _, err := p.Resolve(context.Background(), "DB_PASSWORD"); err == nil {
116+
t.Fatal("expected an error when no auth is configured")
117+
}
118+
}
119+
120+
// End-to-end: a $secret marker naming the infisical provider resolves through
121+
// the registry + resolver.
122+
func TestInfisicalProvider_ThroughResolver(t *testing.T) {
123+
srv := fakeInfisical(t, "cid", "csecret", "")
124+
defer srv.Close()
125+
reg := NewRegistry()
126+
reg.Register(NewInfisicalProvider("infisical", srv.URL, "cid", "csecret", "proj-1", "prod"))
127+
128+
in := map[string]any{
129+
"password": map[string]any{SecretMarker: map[string]any{"provider": "infisical", "key": "DB_PASSWORD"}},
130+
}
131+
out, err := New(reg).Resolve(context.Background(), in)
132+
if err != nil {
133+
t.Fatalf("Resolve: %v", err)
134+
}
135+
if out["password"] != "hunter2" {
136+
t.Errorf("resolved %v, want hunter2", out["password"])
137+
}
138+
}

0 commit comments

Comments
 (0)