Skip to content

Commit 24ec0f7

Browse files
committed
feat(secrets): Introduce new package for dynamic secret management
Signed-off-by: Henrique Spanoudis Matulis <[email protected]>
1 parent 95acce1 commit 24ec0f7

File tree

10 files changed

+1385
-0
lines changed

10 files changed

+1385
-0
lines changed

secrets/field.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package secrets
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
7+
"gopkg.in/yaml.v2"
8+
)
9+
10+
// SecretField is a field containing a secret.
11+
type SecretField struct {
12+
provider Provider
13+
manager *Manager
14+
// TODO: Add global secret options here
15+
}
16+
17+
func (s SecretField) String() string {
18+
return fmt.Sprintf("SecretField{Provider: %s}", s.provider.Name())
19+
}
20+
21+
// MarshalYAML implements the yaml.Marshaler interface for SecretField.
22+
func (s SecretField) MarshalYAML() (interface{}, error) {
23+
if s.provider.Name() == "inline" && s.manager != nil && (*s.manager).MarshalInlineSecrets {
24+
return s.Get(), nil
25+
}
26+
out := make(map[string]interface{})
27+
out[s.provider.Name()] = s.provider
28+
return out, nil
29+
}
30+
31+
// MarshalJSON implements the json.Marshaler interface for SecretField.
32+
func (s SecretField) MarshalJSON() ([]byte, error) {
33+
data, err := s.MarshalYAML()
34+
if err != nil {
35+
return nil, err
36+
}
37+
return json.Marshal(data)
38+
}
39+
40+
// providerBase is used to extract the type of the provider.
41+
type providerBase = map[string]interface{}
42+
43+
func (s *SecretField) UnmarshalYAML(unmarshal func(interface{}) error) error {
44+
var plainSecret string
45+
if err := unmarshal(&plainSecret); err == nil {
46+
s.provider = &InlineProvider{
47+
secret: plainSecret,
48+
}
49+
return nil
50+
}
51+
52+
var base providerBase
53+
if err := unmarshal(&base); err != nil {
54+
return err
55+
}
56+
57+
if len(base) != 1 {
58+
return fmt.Errorf("secret must contain exactly one provider type, but found %d.", len(base))
59+
}
60+
61+
var name string
62+
var providerConfig interface{}
63+
for providerType, data := range base {
64+
name = providerType
65+
providerConfig = data
66+
break
67+
}
68+
69+
concreteProvider, err := Providers.Get(name)
70+
if err != nil {
71+
return err
72+
}
73+
configBytes, err := yaml.Marshal(providerConfig)
74+
if err != nil {
75+
return fmt.Errorf("failed to re-marshal config for %s provider: %w", name, err)
76+
}
77+
78+
if err := yaml.Unmarshal(configBytes, concreteProvider); err != nil {
79+
return fmt.Errorf("failed to unmarshal into %s provider: %w", name, err)
80+
}
81+
82+
s.provider = concreteProvider
83+
return nil
84+
}
85+
86+
// SetSecretValidation registers an optional validation function for the secret.
87+
//
88+
// When the secret manager fetches a new version of the secret, it will not
89+
// be used immediately if there is a validator. Instead, the manager will
90+
// hold the new secret in a pending state and call the provided Validate
91+
// with it until it returns true, there is an explicit refresh request,
92+
// there is a time out, or the old secret was never valid.
93+
func (s *SecretField) SetSecretValidation(validator SecretValidator) {
94+
if s.manager == nil {
95+
panic("secret field has not been discovered by a manager; was NewManager(&cfg) called?")
96+
}
97+
(*s.manager).setSecretValidation(s, validator)
98+
}
99+
100+
func (s *SecretField) Get() string {
101+
if s.manager == nil {
102+
panic("secret field has not been discovered by a manager; was NewManager(&cfg) called?")
103+
}
104+
return s.manager.get(s)
105+
}
106+
107+
func (s *SecretField) TriggerRefresh() {
108+
if s.manager == nil {
109+
panic("secret field has not been discovered by a manager; was NewManager(&cfg) called?")
110+
}
111+
s.manager.triggerRefresh(s)
112+
}

secrets/field_test.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package secrets
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"gopkg.in/yaml.v2"
10+
)
11+
12+
func TestSecretField_UnmarshalYAML(t *testing.T) {
13+
tests := []struct {
14+
name string
15+
yaml string
16+
expectProvider Provider
17+
expectErr string
18+
}{
19+
{
20+
name: "Unmarshal plain string into InlineProvider",
21+
yaml: `my_secret_value`,
22+
expectProvider: &InlineProvider{
23+
secret: "my_secret_value",
24+
},
25+
},
26+
{
27+
name: "Unmarshal file provider",
28+
yaml: `
29+
file:
30+
path: /path/to/secret
31+
`,
32+
expectProvider: &FileProvider{
33+
Path: "/path/to/secret",
34+
},
35+
},
36+
{
37+
name: "Error on multiple providers",
38+
yaml: `
39+
file:
40+
path: /path/to/secret
41+
inline: another_secret
42+
`,
43+
expectErr: "secret must contain exactly one provider type, but found 2",
44+
},
45+
{
46+
name: "Error on unknown provider",
47+
yaml: `
48+
gcp_secret_manager:
49+
project: my-project
50+
`,
51+
expectErr: `unknown provider type: "gcp_secret_manager"`,
52+
},
53+
{
54+
name: "Error on invalid provider config",
55+
yaml: `
56+
file:
57+
path: [ "this", "should", "be", "a", "string" ]
58+
`,
59+
expectErr: "failed to unmarshal into file provider",
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
var sf SecretField
66+
err := yaml.Unmarshal([]byte(tt.yaml), &sf)
67+
68+
if tt.expectErr != "" {
69+
require.Error(t, err)
70+
assert.Contains(t, err.Error(), tt.expectErr)
71+
} else {
72+
require.NoError(t, err)
73+
assert.Equal(t, tt.expectProvider.Name(), sf.provider.Name())
74+
assert.Equal(t, tt.expectProvider, sf.provider)
75+
}
76+
})
77+
}
78+
}
79+
80+
func TestSecretField_MarshalYAML(t *testing.T) {
81+
t.Run("Marshal non-inline provider", func(t *testing.T) {
82+
sf := SecretField{
83+
provider: &FileProvider{Path: "/path/to/token"},
84+
}
85+
b, err := yaml.Marshal(sf)
86+
require.NoError(t, err)
87+
expected := "file:\n path: /path/to/token\n"
88+
assert.Equal(t, expected, string(b))
89+
})
90+
91+
t.Run("Marshal inline provider without manager", func(t *testing.T) {
92+
sf := SecretField{
93+
provider: &InlineProvider{secret: "my-password"},
94+
}
95+
b, err := yaml.Marshal(sf)
96+
require.NoError(t, err)
97+
expected := "inline: <secret>\n"
98+
assert.Equal(t, expected, string(b))
99+
})
100+
101+
t.Run("Marshal inline provider with manager and MarshalInlineSecrets=false", func(t *testing.T) {
102+
m := &Manager{MarshalInlineSecrets: false}
103+
sf := SecretField{
104+
manager: m,
105+
provider: &InlineProvider{secret: "my-password"},
106+
}
107+
b, err := yaml.Marshal(sf)
108+
require.NoError(t, err)
109+
expected := "inline: <secret>\n"
110+
assert.Equal(t, expected, string(b))
111+
})
112+
113+
t.Run("Marshal inline provider with manager and MarshalInlineSecrets=true", func(t *testing.T) {
114+
m := &Manager{MarshalInlineSecrets: true}
115+
sf := SecretField{
116+
manager: m,
117+
provider: &InlineProvider{secret: "my-password"},
118+
}
119+
b, err := yaml.Marshal(sf)
120+
require.NoError(t, err)
121+
expected := "my-password\n" // Marshals as a plain string
122+
assert.Equal(t, expected, string(b))
123+
})
124+
}
125+
126+
func TestSecretField_MarshalJSON(t *testing.T) {
127+
// JSON marshaling is just a wrapper around YAML marshaling, so a simple test is sufficient.
128+
sf := SecretField{
129+
provider: &FileProvider{Path: "/path/to/token"},
130+
}
131+
b, err := json.Marshal(sf)
132+
require.NoError(t, err)
133+
expected := `{"file":{"path":"/path/to/token"}}`
134+
assert.JSONEq(t, expected, string(b))
135+
}
136+
137+
func TestSecretField_ManagerPanics(t *testing.T) {
138+
sf := SecretField{} // No manager attached
139+
140+
assert.PanicsWithValue(t, "secret field has not been discovered by a manager; was NewManager(&cfg) called?", func() { sf.Get() }, "Get should panic without a manager")
141+
assert.PanicsWithValue(t, "secret field has not been discovered by a manager; was NewManager(&cfg) called?", func() { sf.SetSecretValidation(nil) }, "SetSecretValidation should panic without a manager")
142+
assert.PanicsWithValue(t, "secret field has not been discovered by a manager; was NewManager(&cfg) called?", func() { sf.TriggerRefresh() }, "TriggerRefresh should panic without a manager")
143+
}

secrets/internal_providers.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package secrets
2+
3+
import (
4+
"context"
5+
"os"
6+
)
7+
8+
// FileProvider fetches secrets from a file.
9+
type FileProvider struct {
10+
Path string `yaml:"path" json:"path"`
11+
}
12+
13+
func (fp *FileProvider) FetchSecret(ctx context.Context) (string, error) {
14+
content, err := os.ReadFile(fp.Path)
15+
if err != nil {
16+
return "", err
17+
}
18+
return string(content), nil
19+
}
20+
21+
func (fp *FileProvider) Name() string {
22+
return "file"
23+
}
24+
25+
func (fp *FileProvider) Key() string {
26+
return fp.Path
27+
}
28+
29+
func (fp *FileProvider) MarshalYAML() (interface{}, error) {
30+
return map[string]interface{}{
31+
"path": fp.Path,
32+
}, nil
33+
}
34+
35+
// InlineProvider reads an config secret.
36+
type InlineProvider struct {
37+
secret string
38+
}
39+
40+
func (ip *InlineProvider) FetchSecret(ctx context.Context) (string, error) {
41+
return ip.secret, nil
42+
}
43+
44+
func (ip *InlineProvider) Name() string {
45+
return "inline"
46+
}
47+
48+
func (ip *InlineProvider) Key() string {
49+
return ip.secret
50+
}
51+
52+
func (ip *InlineProvider) MarshalYAML() (interface{}, error) {
53+
return "<secret>", nil
54+
}
55+
56+
func init() {
57+
Providers.Register(func() Provider { return &InlineProvider{} })
58+
Providers.Register(func() Provider { return &FileProvider{} })
59+
}

0 commit comments

Comments
 (0)