Skip to content

Commit fa80966

Browse files
authored
Merge pull request #9 from mdemauroy/feat/secret-mapping-mode
feat: add mapping mode for multi-key Vault secrets
2 parents de18e99 + d51145f commit fa80966

9 files changed

Lines changed: 391 additions & 18 deletions

File tree

config.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ type ProjectConfig struct {
1717
}
1818

1919
type EnvConfig struct {
20-
Provider string `yaml:"provider"`
21-
Source string `yaml:"source,omitempty"` // deprecated, use Provider
22-
PathPrefix string `yaml:"path_prefix"`
23-
Prefix string `yaml:"prefix"`
20+
Provider string `yaml:"provider"`
21+
Source string `yaml:"source,omitempty"` // deprecated, use Provider
22+
PathPrefix string `yaml:"path_prefix"`
23+
Prefix string `yaml:"prefix"`
24+
Mapping map[string]provider.SecretMapping `yaml:"mapping,omitempty"`
2425
}
2526

2627
func (e EnvConfig) GetProvider() string {
@@ -35,6 +36,7 @@ func (e EnvConfig) ToProviderConfig() provider.EnvConfig {
3536
Provider: e.GetProvider(),
3637
PathPrefix: e.PathPrefix,
3738
Prefix: e.Prefix,
39+
Mapping: e.Mapping,
3840
}
3941
}
4042

@@ -102,6 +104,11 @@ func (c ProjectConfig) Validate() error {
102104
if _, ok := c.Envs[c.DefaultEnv]; !ok {
103105
return fmt.Errorf("default_env %q not found in envs", c.DefaultEnv)
104106
}
107+
for envName, envCfg := range c.Envs {
108+
if len(envCfg.Mapping) > 0 && envCfg.PathPrefix != "" {
109+
return fmt.Errorf("env %q: mapping and path_prefix are mutually exclusive", envName)
110+
}
111+
}
105112
return nil
106113
}
107114

config_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,87 @@ envs:
128128
}
129129
}
130130

131+
func TestLoadProjectConfigWithMapping(t *testing.T) {
132+
dir := t.TempDir()
133+
cfgPath := filepath.Join(dir, ".envmap.yaml")
134+
135+
content := `
136+
project: testapp
137+
default_env: dev
138+
envs:
139+
dev:
140+
provider: vault
141+
mapping:
142+
CDN_TOKEN:
143+
path: shared/cdn
144+
key: CDN_TOKEN
145+
API_KEY:
146+
path: myapp
147+
key: API_SECRET_KEY
148+
`
149+
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
150+
t.Fatal(err)
151+
}
152+
153+
cfg, err := LoadProjectConfig(cfgPath)
154+
if err != nil {
155+
t.Fatalf("LoadProjectConfig: %v", err)
156+
}
157+
158+
devCfg := cfg.Envs["dev"]
159+
if len(devCfg.Mapping) != 2 {
160+
t.Fatalf("len(Mapping) = %d, want 2", len(devCfg.Mapping))
161+
}
162+
163+
cdnMapping := devCfg.Mapping["CDN_TOKEN"]
164+
if cdnMapping.Path != "shared/cdn" {
165+
t.Errorf("CDN_TOKEN.Path = %q, want %q", cdnMapping.Path, "shared/cdn")
166+
}
167+
if cdnMapping.Key != "CDN_TOKEN" {
168+
t.Errorf("CDN_TOKEN.Key = %q, want %q", cdnMapping.Key, "CDN_TOKEN")
169+
}
170+
171+
apiMapping := devCfg.Mapping["API_KEY"]
172+
if apiMapping.Path != "myapp" {
173+
t.Errorf("API_KEY.Path = %q, want %q", apiMapping.Path, "myapp")
174+
}
175+
if apiMapping.Key != "API_SECRET_KEY" {
176+
t.Errorf("API_KEY.Key = %q, want %q", apiMapping.Key, "API_SECRET_KEY")
177+
}
178+
179+
// Verify it propagates to provider config
180+
providerCfg := devCfg.ToProviderConfig()
181+
if len(providerCfg.Mapping) != 2 {
182+
t.Fatalf("provider EnvConfig.Mapping = %d, want 2", len(providerCfg.Mapping))
183+
}
184+
}
185+
186+
func TestLoadProjectConfigMappingAndPathPrefixConflict(t *testing.T) {
187+
dir := t.TempDir()
188+
cfgPath := filepath.Join(dir, ".envmap.yaml")
189+
190+
content := `
191+
project: testapp
192+
default_env: dev
193+
envs:
194+
dev:
195+
provider: vault
196+
path_prefix: /some/prefix
197+
mapping:
198+
SOME_VAR:
199+
path: some/path
200+
key: SOME_KEY
201+
`
202+
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
203+
t.Fatal(err)
204+
}
205+
206+
_, err := LoadProjectConfig(cfgPath)
207+
if err == nil {
208+
t.Fatal("expected error when both mapping and path_prefix are set")
209+
}
210+
}
211+
131212
func TestLoadProjectConfigValidation(t *testing.T) {
132213
tests := []struct {
133214
name string

env.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ func CollectEnvWithMetadata(ctx context.Context, projectCfg ProjectConfig, globa
6565
if err != nil {
6666
return nil, err
6767
}
68+
69+
if len(envCfg.Mapping) > 0 {
70+
mkp, ok := p.(provider.MultiKeyProvider)
71+
if !ok {
72+
return nil, fmt.Errorf("provider %s does not support multi-key secret reading (required by mapping)", envCfg.GetProvider())
73+
}
74+
return provider.CollectMappedSecrets(ctx, mkp, envCfg.Mapping)
75+
}
76+
6877
return provider.ListOrDescribe(ctx, p, provider.ResolvedPrefix(envCfg.ToProviderConfig()))
6978
}
7079

@@ -77,6 +86,23 @@ func FetchSecret(ctx context.Context, projectCfg ProjectConfig, globalCfg Global
7786
if err != nil {
7887
return "", err
7988
}
89+
90+
if sm, ok := envCfg.Mapping[key]; ok {
91+
mkp, ok := p.(provider.MultiKeyProvider)
92+
if !ok {
93+
return "", fmt.Errorf("provider %s does not support multi-key secret reading (required by mapping)", envCfg.GetProvider())
94+
}
95+
data, err := mkp.ReadSecret(ctx, sm.Path)
96+
if err != nil {
97+
return "", err
98+
}
99+
val, ok := data[sm.Key]
100+
if !ok {
101+
return "", fmt.Errorf("key %q not found in secret at path %q", sm.Key, sm.Path)
102+
}
103+
return val, nil
104+
}
105+
80106
return p.Get(ctx, provider.ApplyPrefix(envCfg.ToProviderConfig(), key))
81107
}
82108

@@ -85,6 +111,9 @@ func WriteSecret(ctx context.Context, projectCfg ProjectConfig, globalCfg Global
85111
if !ok {
86112
return fmt.Errorf("env %q not found in project config", envName)
87113
}
114+
if len(envCfg.Mapping) > 0 {
115+
return fmt.Errorf("env %q uses mapping mode; secrets are read-only and managed externally", envName)
116+
}
88117
p, err := NewProvider(envName, envCfg, globalCfg)
89118
if err != nil {
90119
return err
@@ -97,6 +126,9 @@ func DeleteSecret(ctx context.Context, projectCfg ProjectConfig, globalCfg Globa
97126
if !ok {
98127
return fmt.Errorf("env %q not found in project config", envName)
99128
}
129+
if len(envCfg.Mapping) > 0 {
130+
return fmt.Errorf("env %q uses mapping mode; secrets are read-only and managed externally", envName)
131+
}
100132
p, err := NewProvider(envName, envCfg, globalCfg)
101133
if err != nil {
102134
return err

provider/config.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,18 @@ package provider
22

33
import "strings"
44

5+
// SecretMapping maps an env var to a specific key within a multi-key Vault secret.
6+
type SecretMapping struct {
7+
Path string `yaml:"path"`
8+
Key string `yaml:"key"`
9+
}
10+
511
// EnvConfig represents the environment-specific configuration from the project file.
612
type EnvConfig struct {
7-
Provider string `yaml:"provider"`
8-
PathPrefix string `yaml:"path_prefix"`
9-
Prefix string `yaml:"prefix"`
13+
Provider string `yaml:"provider"`
14+
PathPrefix string `yaml:"path_prefix"`
15+
Prefix string `yaml:"prefix"`
16+
Mapping map[string]SecretMapping `yaml:"mapping,omitempty"`
1017
}
1118

1219
// ProviderConfig represents the provider configuration from the global config file.

provider/mapping.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package provider
2+
3+
import (
4+
"context"
5+
"fmt"
6+
)
7+
8+
// CollectMappedSecrets fetches secrets using the mapping configuration.
9+
// It groups entries by path to minimize API calls, then extracts the specific
10+
// key for each env var from the returned map.
11+
func CollectMappedSecrets(ctx context.Context, p MultiKeyProvider, mapping map[string]SecretMapping) (map[string]SecretRecord, error) {
12+
// Group env vars by Vault path to deduplicate reads.
13+
type entry struct {
14+
envVar string
15+
key string
16+
}
17+
byPath := make(map[string][]entry)
18+
for envVar, sm := range mapping {
19+
byPath[sm.Path] = append(byPath[sm.Path], entry{envVar: envVar, key: sm.Key})
20+
}
21+
22+
out := make(map[string]SecretRecord, len(mapping))
23+
24+
for path, entries := range byPath {
25+
data, err := p.ReadSecret(ctx, path)
26+
if err != nil {
27+
return nil, fmt.Errorf("read secret at %s: %w", path, err)
28+
}
29+
30+
for _, e := range entries {
31+
val, ok := data[e.key]
32+
if !ok {
33+
return nil, fmt.Errorf("key %q not found in secret at path %q (env var %s)", e.key, path, e.envVar)
34+
}
35+
out[e.envVar] = SecretRecord{Value: val}
36+
}
37+
}
38+
39+
return out, nil
40+
}

0 commit comments

Comments
 (0)