Skip to content

Commit 3e04f6a

Browse files
authoredMar 24, 2025··
feat: secrets manager interface (#75)
1 parent 74acabd commit 3e04f6a

File tree

11 files changed

+2524
-45
lines changed

11 files changed

+2524
-45
lines changed
 

‎agent/agent.go

+17-4
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/netboxlabs/orb-agent/agent/config"
1616
"github.com/netboxlabs/orb-agent/agent/configmgr"
1717
"github.com/netboxlabs/orb-agent/agent/policymgr"
18+
"github.com/netboxlabs/orb-agent/agent/secretsmgr"
1819
"github.com/netboxlabs/orb-agent/agent/version"
1920
)
2021

@@ -49,8 +50,9 @@ type orbAgent struct {
4950
// AgentGroup channels sent from core
5051
groupsInfos map[string]groupInfo
5152

52-
policyManager policymgr.PolicyManager
53-
configManager configmgr.Manager
53+
policyManager policymgr.PolicyManager
54+
configManager configmgr.Manager
55+
secretsManager secretsmgr.Manager
5456
}
5557

5658
type groupInfo struct {
@@ -71,9 +73,15 @@ func New(logger *zap.Logger, c config.Config) (Agent, error) {
7173
logger.Error("policy manager failed to get repository", zap.Error(err))
7274
return nil, err
7375
}
74-
cm := configmgr.New(logger, pm, c.OrbAgent.ConfigManager)
7576

76-
return &orbAgent{logger: logger, config: c, policyManager: pm, configManager: cm, groupsInfos: make(map[string]groupInfo)}, nil
77+
sm := secretsmgr.New(logger, c.OrbAgent.SecretsManger)
78+
79+
cm := configmgr.New(logger, pm, sm, c.OrbAgent.ConfigManager)
80+
81+
return &orbAgent{
82+
logger: logger, config: c, policyManager: pm, configManager: cm,
83+
secretsManager: sm, groupsInfos: make(map[string]groupInfo),
84+
}, nil
7785
}
7886

7987
func (a *orbAgent) startBackends(agentCtx context.Context) error {
@@ -143,6 +151,11 @@ func (a *orbAgent) Start(ctx context.Context, cancelFunc context.CancelFunc) err
143151
a.cancelFunction = cancelFunc
144152
a.logger.Info("agent started", zap.String("version", version.GetBuildVersion()), zap.Any("routine", agentCtx.Value(routineKey)))
145153

154+
if err := a.secretsManager.Start(ctx); err != nil {
155+
a.logger.Error("error during start secrets manager", zap.Error(err))
156+
return err
157+
}
158+
146159
if err := a.startBackends(ctx); err != nil {
147160
return err
148161
}

‎agent/config/types.go

+25-3
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,37 @@ type GitManager struct {
3232
PrivateKey string `mapstructure:"private_key"`
3333
}
3434

35-
// ManagerSources represents the configuration for manager sources, including cloud, local and git.
36-
type ManagerSources struct {
35+
// Sources represents the configuration for manager sources, including cloud, local and git.
36+
type Sources struct {
3737
Local LocalManager `mapstructure:"local"`
3838
Git GitManager `mapstructure:"git"`
3939
}
4040

4141
// ManagerConfig represents the configuration for the Config Manager
4242
type ManagerConfig struct {
43+
Active string `mapstructure:"active"`
44+
Sources Sources `mapstructure:"sources"`
45+
}
46+
47+
// VaultManager represents the configuration for the Vault manager
48+
type VaultManager struct {
49+
Auth string `yaml:"auth"`
50+
AuthArgs map[string]any `mapstructure:"auth_args"`
51+
Address string `yaml:"address"`
52+
Namespace string `yaml:"namespace"`
53+
Timeout *int `yaml:"timeout,omitempty"`
54+
Schedule *string `yaml:"schedule,omitempty"`
55+
}
56+
57+
// SecretsSources represents the configuration for manager sources, including vault.
58+
type SecretsSources struct {
59+
Vault VaultManager `mapstructure:"vault"`
60+
}
61+
62+
// ManagerSecrets represents the configuration for the Secrets Manager
63+
type ManagerSecrets struct {
4364
Active string `mapstructure:"active"`
44-
Sources ManagerSources `mapstructure:"sources"`
65+
Sources SecretsSources `mapstructure:"sources"`
4566
}
4667

4768
// BackendCommons represents common configuration for backends
@@ -64,6 +85,7 @@ type OrbAgent struct {
6485
Policies map[string]map[string]any `mapstructure:"policies"`
6586
Labels map[string]string `mapstructure:"labels"`
6687
ConfigManager ManagerConfig `mapstructure:"config_manager"`
88+
SecretsManger ManagerSecrets `mapstructure:"secrets_manager"`
6789
Debug struct {
6890
Enable bool `mapstructure:"enable"`
6991
} `mapstructure:"debug"`

‎agent/configmgr/git.go

+7
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ import (
2424
"github.com/netboxlabs/orb-agent/agent/backend"
2525
"github.com/netboxlabs/orb-agent/agent/config"
2626
"github.com/netboxlabs/orb-agent/agent/policymgr"
27+
"github.com/netboxlabs/orb-agent/agent/secretsmgr"
2728
)
2829

2930
var _ Manager = (*gitConfigManager)(nil)
3031

3132
type gitConfigManager struct {
3233
logger *zap.Logger
3334
pMgr policymgr.PolicyManager
35+
sMgr secretsmgr.Manager
3436
config config.GitManager
3537
scheduler gocron.Scheduler
3638
repo *gitv5.Repository
@@ -157,6 +159,11 @@ func (gc *gitConfigManager) applyPolicies(policies policyData, backends map[stri
157159
Version: gc.version,
158160
Data: data,
159161
}
162+
var err error
163+
payload, err = gc.sMgr.SolveSecrets(payload)
164+
if err != nil {
165+
return err
166+
}
160167
gc.pMgr.ManagePolicy(payload)
161168
}
162169
}

‎agent/configmgr/local.go

+7
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ import (
1010
"github.com/netboxlabs/orb-agent/agent/backend"
1111
"github.com/netboxlabs/orb-agent/agent/config"
1212
"github.com/netboxlabs/orb-agent/agent/policymgr"
13+
"github.com/netboxlabs/orb-agent/agent/secretsmgr"
1314
)
1415

1516
var _ Manager = (*localConfigManager)(nil)
1617

1718
type localConfigManager struct {
1819
logger *zap.Logger
1920
pMgr policymgr.PolicyManager
21+
sMgr secretsmgr.Manager
2022
config config.LocalManager
2123
}
2224

@@ -37,6 +39,11 @@ func (lc *localConfigManager) Start(cfg config.Config, backends map[string]backe
3739
ID: policyID, Action: "manage",
3840
Name: pName, DatasetID: id, Backend: beName, Version: 1, Data: data,
3941
}
42+
var err error
43+
payload, err = lc.sMgr.SolveSecrets(payload)
44+
if err != nil {
45+
return err
46+
}
4047
lc.pMgr.ManagePolicy(payload)
4148
}
4249

‎agent/configmgr/manager.go

+5-4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/netboxlabs/orb-agent/agent/backend"
99
"github.com/netboxlabs/orb-agent/agent/config"
1010
"github.com/netboxlabs/orb-agent/agent/policymgr"
11+
"github.com/netboxlabs/orb-agent/agent/secretsmgr"
1112
)
1213

1314
// Manager is the interface for configuration manager
@@ -17,13 +18,13 @@ type Manager interface {
1718
}
1819

1920
// New creates a new instance of ConfigManager based on the configuration
20-
func New(logger *zap.Logger, mgr policymgr.PolicyManager, c config.ManagerConfig) Manager {
21+
func New(logger *zap.Logger, pMgr policymgr.PolicyManager, sMgr secretsmgr.Manager, c config.ManagerConfig) Manager {
2122
switch c.Active {
2223
case "local":
23-
return &localConfigManager{logger: logger, pMgr: mgr, config: c.Sources.Local}
24+
return &localConfigManager{logger: logger, pMgr: pMgr, sMgr: sMgr, config: c.Sources.Local}
2425
case "git":
25-
return &gitConfigManager{logger: logger, pMgr: mgr, config: c.Sources.Git}
26+
return &gitConfigManager{logger: logger, pMgr: pMgr, sMgr: sMgr, config: c.Sources.Git}
2627
default:
27-
return &localConfigManager{logger: logger, pMgr: mgr, config: c.Sources.Local}
28+
return &localConfigManager{logger: logger, pMgr: pMgr, sMgr: sMgr, config: c.Sources.Local}
2829
}
2930
}

‎agent/secretsmgr/manager.go

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package secretsmgr
2+
3+
import (
4+
"context"
5+
6+
"go.uber.org/zap"
7+
8+
"github.com/netboxlabs/orb-agent/agent/config"
9+
)
10+
11+
// Manager is an interface for managing secrets
12+
type Manager interface {
13+
Start(ctx context.Context) error
14+
RegisterUpdateCallback(callback func([]string))
15+
SolveSecrets(payload config.PolicyPayload) (config.PolicyPayload, error)
16+
}
17+
18+
// New creates a new instance of ConfigManager based on the configuration
19+
func New(logger *zap.Logger, c config.ManagerSecrets) Manager {
20+
switch c.Active {
21+
case "vault":
22+
return &vaultManager{logger: logger, config: c.Sources.Vault}
23+
default:
24+
return &dummyManager{}
25+
}
26+
}
27+
28+
var _ Manager = (*dummyManager)(nil)
29+
30+
type dummyManager struct{}
31+
32+
func (v *dummyManager) Start(_ context.Context) error {
33+
return nil
34+
}
35+
36+
func (v *dummyManager) RegisterUpdateCallback(_ func([]string)) {
37+
}
38+
39+
func (v *dummyManager) SolveSecrets(payload config.PolicyPayload) (config.PolicyPayload, error) {
40+
return payload, nil
41+
}

‎agent/secretsmgr/vault.go

+192
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package secretsmgr
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"regexp"
7+
"strings"
8+
"time"
9+
10+
vault "github.com/hashicorp/vault/api"
11+
"go.uber.org/zap"
12+
13+
"github.com/netboxlabs/orb-agent/agent/config"
14+
)
15+
16+
var _ Manager = (*vaultManager)(nil)
17+
18+
type vaultManager struct {
19+
logger *zap.Logger
20+
config config.VaultManager
21+
ctx context.Context
22+
client *vault.Client
23+
usedVars map[string]cachedSecret
24+
callback func([]string)
25+
auth authMethod
26+
token *vault.Secret
27+
}
28+
29+
type cachedSecret struct {
30+
Value string // The actual secret value
31+
policyIDs map[string]any // The IDs of policies that have used this secret
32+
}
33+
34+
func (v *vaultManager) Start(ctx context.Context) error {
35+
v.ctx = ctx
36+
v.usedVars = make(map[string]cachedSecret)
37+
38+
config := vault.DefaultConfig()
39+
40+
config.Address = v.config.Address
41+
if v.config.Timeout == nil || *v.config.Timeout == 0 {
42+
config.Timeout = 60 * time.Second
43+
} else {
44+
config.Timeout = time.Duration(*v.config.Timeout) * time.Second
45+
}
46+
47+
if v.config.Auth == "" {
48+
return fmt.Errorf("no auth method specified")
49+
}
50+
51+
var err error
52+
v.client, err = vault.NewClient(config)
53+
if err != nil {
54+
return err
55+
}
56+
57+
if v.config.Namespace != "" {
58+
v.client.SetNamespace(v.config.Namespace)
59+
}
60+
61+
v.auth, err = newAuthentication(v.config.Auth, v.config.AuthArgs)
62+
if err != nil {
63+
return err
64+
}
65+
66+
v.token, err = v.auth.vaultAuthenticate(ctx, v.client)
67+
if err != nil {
68+
return err
69+
}
70+
71+
return nil
72+
}
73+
74+
// RegisterUpdateCallback registers a callback function to be called when secrets are updated
75+
func (v *vaultManager) RegisterUpdateCallback(callback func([]string)) {
76+
v.callback = callback
77+
}
78+
79+
// SolveSecrets processes a policy payload and replaces vault references with environment variables
80+
func (v *vaultManager) SolveSecrets(payload config.PolicyPayload) (config.PolicyPayload, error) {
81+
// Create a copy of the payload
82+
newPayload := payload
83+
84+
// Process the Data field
85+
processedData, err := v.processValue(payload.Data, payload.ID)
86+
if err != nil {
87+
return payload, err
88+
}
89+
90+
newPayload.Data = processedData
91+
return newPayload, nil
92+
}
93+
94+
func (v *vaultManager) processValue(value any, id string) (any, error) {
95+
switch val := value.(type) {
96+
case string:
97+
return v.processString(val, id)
98+
case map[string]any:
99+
return v.processMap(val, id)
100+
case []any:
101+
return v.processSlice(val, id)
102+
default:
103+
return val, nil
104+
}
105+
}
106+
107+
// processString processes a string and replaces vault references
108+
func (v *vaultManager) processString(s string, id string) (string, error) {
109+
re := regexp.MustCompile(`\${vault://([^}]+)}`)
110+
if !re.MatchString(s) {
111+
return s, nil
112+
}
113+
114+
match := re.FindStringSubmatchIndex(s)
115+
if match == nil || len(match) < 4 {
116+
return "", fmt.Errorf("failed to find vault reference in string: %s", s)
117+
}
118+
119+
vaultPath := s[match[2]:match[3]]
120+
121+
if secrets, exists := v.usedVars[vaultPath]; exists {
122+
secrets.policyIDs[id] = true
123+
v.usedVars[vaultPath] = secrets
124+
return secrets.Value, nil
125+
}
126+
127+
secret, err := v.getSecret(vaultPath)
128+
if err != nil {
129+
return "", err
130+
}
131+
132+
v.usedVars[vaultPath] = cachedSecret{
133+
Value: secret,
134+
policyIDs: map[string]any{id: true},
135+
}
136+
137+
return secret, nil
138+
}
139+
140+
// processMap processes a map recursively and replaces vault references in its values
141+
func (v *vaultManager) processMap(m map[string]any, id string) (map[string]any, error) {
142+
result := make(map[string]any)
143+
for key, val := range m {
144+
processedVal, err := v.processValue(val, id)
145+
if err != nil {
146+
return nil, fmt.Errorf("failed to process value for key %s: %w", key, err)
147+
}
148+
result[key] = processedVal
149+
}
150+
return result, nil
151+
}
152+
153+
// processSlice processes a slice recursively and replaces vault references in its elements
154+
func (v *vaultManager) processSlice(s []any, id string) ([]any, error) {
155+
result := make([]any, len(s))
156+
for i, val := range s {
157+
processedVal, err := v.processValue(val, id)
158+
if err != nil {
159+
return nil, fmt.Errorf("failed to process value at index %d: %w", i, err)
160+
}
161+
result[i] = processedVal
162+
}
163+
return result, nil
164+
}
165+
166+
// getSecret retrieves a secret from the vault
167+
func (v *vaultManager) getSecret(path string) (string, error) {
168+
// Split the path by forward slashes
169+
parts := strings.Split(path, "/")
170+
if len(parts) < 3 {
171+
return "", fmt.Errorf("invalid vault path format: %s", path)
172+
}
173+
secret, err := v.client.KVv2(parts[0]).Get(v.ctx, strings.Join(parts[1:len(parts)-1], "/"))
174+
if err != nil {
175+
return "", fmt.Errorf("failed to get secret path %s: %w", path, err)
176+
}
177+
if secret == nil || secret.Data == nil {
178+
return "", fmt.Errorf("secret not found: %s", path)
179+
}
180+
value, ok := secret.Data[parts[len(parts)-1]]
181+
if !ok {
182+
return "", fmt.Errorf("secret not found: %s", path)
183+
}
184+
strValue, ok := value.(string)
185+
if !ok {
186+
return "", fmt.Errorf("secret is not a string: %s", path)
187+
}
188+
if strValue == "" {
189+
return "", fmt.Errorf("secret is empty: %s", path)
190+
}
191+
return strValue, nil
192+
}

‎agent/secretsmgr/vault_auth.go

+256
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
package secretsmgr
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
vault "github.com/hashicorp/vault/api"
8+
"github.com/hashicorp/vault/api/auth/approle"
9+
"github.com/hashicorp/vault/api/auth/kubernetes"
10+
"github.com/hashicorp/vault/api/auth/ldap"
11+
"github.com/hashicorp/vault/api/auth/userpass"
12+
"gopkg.in/yaml.v3"
13+
)
14+
15+
type authMethod interface {
16+
vaultAuthenticate(context.Context, *vault.Client) (*vault.Secret, error)
17+
}
18+
19+
func newAuthentication(auth string, authArgs map[string]any) (authMethod, error) {
20+
var authObj authMethod
21+
22+
switch auth {
23+
case "token":
24+
authObj = &AuthToken{}
25+
case "approle":
26+
authObj = &AuthAppRole{}
27+
case "userpass":
28+
authObj = &AuthUserPass{}
29+
case "kubernetes":
30+
authObj = &AuthKubernetes{}
31+
case "ldap":
32+
authObj = &AuthLDAP{}
33+
case "aws", "azure", "gcp":
34+
return nil, fmt.Errorf("auth method %s is not currently implemented", auth)
35+
default:
36+
return nil, fmt.Errorf("unsupported auth method: %s", auth)
37+
}
38+
39+
// Convert the map to YAML
40+
yamlData, err := yaml.Marshal(authArgs)
41+
if err != nil {
42+
return nil, fmt.Errorf("failed to marshal auth_args: %w", err)
43+
}
44+
45+
// Unmarshal YAML into the auth structure
46+
if err := yaml.Unmarshal(yamlData, authObj); err != nil {
47+
return nil, fmt.Errorf("failed to unmarshal '%s' auth_args: %w", auth, err)
48+
}
49+
50+
return authObj, nil
51+
}
52+
53+
// AuthToken authenticates against Vault with a token.
54+
type AuthToken struct {
55+
Token string `yaml:"token"`
56+
}
57+
58+
// UnmarshalYAML for AuthToken validates required fields after unmarshaling
59+
func (a *AuthToken) UnmarshalYAML(value *yaml.Node) error {
60+
type tempAuthToken AuthToken
61+
temp := tempAuthToken{}
62+
if err := value.Decode(&temp); err != nil {
63+
return err
64+
}
65+
*a = AuthToken(temp)
66+
if a.Token == "" {
67+
return fmt.Errorf("missing required field 'token'")
68+
}
69+
return nil
70+
}
71+
72+
func (a *AuthToken) vaultAuthenticate(_ context.Context, cli *vault.Client) (*vault.Secret, error) {
73+
cli.SetToken(a.Token)
74+
_, err := cli.Auth().Token().LookupSelf()
75+
if err != nil {
76+
return nil, err
77+
}
78+
return nil, nil
79+
}
80+
81+
// AuthAppRole authenticates against Vault with AppRole.
82+
type AuthAppRole struct {
83+
RoleID string `yaml:"role_id"`
84+
SecretID string `yaml:"secret_id"`
85+
WrappingToken bool `yaml:"wrapping_token,ommitempty"`
86+
MountPath *string `yaml:"mount_path,ommitempty"`
87+
}
88+
89+
// UnmarshalYAML for AuthAppRole validates required fields after unmarshaling
90+
func (a *AuthAppRole) UnmarshalYAML(value *yaml.Node) error {
91+
type tempAuthAppRole AuthAppRole
92+
temp := tempAuthAppRole{}
93+
if err := value.Decode(&temp); err != nil {
94+
return err
95+
}
96+
*a = AuthAppRole(temp)
97+
if a.RoleID == "" {
98+
return fmt.Errorf("missing required field 'role_id'")
99+
}
100+
if a.SecretID == "" {
101+
return fmt.Errorf("missing required field 'secret_id'")
102+
}
103+
return nil
104+
}
105+
106+
func (a *AuthAppRole) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
107+
secret := &approle.SecretID{FromString: string(a.SecretID)}
108+
109+
var opts []approle.LoginOption
110+
if a.WrappingToken {
111+
opts = append(opts, approle.WithWrappingToken())
112+
}
113+
if a.MountPath != nil && *a.MountPath != "" {
114+
opts = append(opts, approle.WithMountPath(*a.MountPath))
115+
}
116+
117+
auth, err := approle.NewAppRoleAuth(a.RoleID, secret, opts...)
118+
if err != nil {
119+
return nil, fmt.Errorf("auth.approle: %w", err)
120+
}
121+
s, err := cli.Auth().Login(ctx, auth)
122+
if err != nil {
123+
return nil, fmt.Errorf("auth.approle: %w", err)
124+
}
125+
return s, nil
126+
}
127+
128+
// AuthUserPass authenticates against Vault with Userpass.
129+
type AuthUserPass struct {
130+
Username string `yaml:"username"`
131+
Password string `yaml:"password"`
132+
MountPath string `yaml:"mount_path"`
133+
}
134+
135+
// UnmarshalYAML for AuthUserPass validates required fields after unmarshaling
136+
func (a *AuthUserPass) UnmarshalYAML(value *yaml.Node) error {
137+
type tempAuthUserPass AuthUserPass
138+
temp := tempAuthUserPass{}
139+
if err := value.Decode(&temp); err != nil {
140+
return err
141+
}
142+
*a = AuthUserPass(temp)
143+
if a.Username == "" {
144+
return fmt.Errorf("missing required field 'username'")
145+
}
146+
if a.Password == "" {
147+
return fmt.Errorf("missing required field 'password'")
148+
}
149+
return nil
150+
}
151+
152+
func (a *AuthUserPass) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
153+
secret := &userpass.Password{FromString: string(a.Password)}
154+
155+
var opts []userpass.LoginOption
156+
157+
if a.MountPath != "" {
158+
opts = append(opts, userpass.WithMountPath(a.MountPath))
159+
}
160+
161+
auth, err := userpass.NewUserpassAuth(a.Username, secret, opts...)
162+
if err != nil {
163+
return nil, fmt.Errorf("auth.userpass: %w", err)
164+
}
165+
s, err := cli.Auth().Login(ctx, auth)
166+
if err != nil {
167+
return nil, fmt.Errorf("auth.userpass: %w", err)
168+
}
169+
return s, nil
170+
}
171+
172+
// AuthKubernetes authenticates against Vault with Kubernetes.
173+
type AuthKubernetes struct {
174+
Role string `yaml:"role"`
175+
ServiceAccountTokenFile string `yaml:"service_account_file"`
176+
MountPath string `yaml:"mount_path"`
177+
}
178+
179+
// UnmarshalYAML for AuthKubernetes validates required fields after unmarshaling
180+
func (a *AuthKubernetes) UnmarshalYAML(value *yaml.Node) error {
181+
type tempAuthKubernetes AuthKubernetes
182+
temp := tempAuthKubernetes{}
183+
if err := value.Decode(&temp); err != nil {
184+
return err
185+
}
186+
*a = AuthKubernetes(temp)
187+
if a.Role == "" {
188+
return fmt.Errorf("missing required field 'role'")
189+
}
190+
return nil
191+
}
192+
193+
func (a *AuthKubernetes) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
194+
var opts []kubernetes.LoginOption
195+
196+
if a.ServiceAccountTokenFile != "" {
197+
opts = append(opts, kubernetes.WithServiceAccountTokenPath(a.ServiceAccountTokenFile))
198+
}
199+
if a.MountPath != "" {
200+
opts = append(opts, kubernetes.WithMountPath(a.MountPath))
201+
}
202+
203+
auth, err := kubernetes.NewKubernetesAuth(a.Role, opts...)
204+
if err != nil {
205+
return nil, fmt.Errorf("auth.kubernetes: %w", err)
206+
}
207+
s, err := cli.Auth().Login(ctx, auth)
208+
if err != nil {
209+
return nil, fmt.Errorf("auth.kubernetes: %w", err)
210+
}
211+
return s, nil
212+
}
213+
214+
// AuthLDAP authenticates against Vault with LDAP.
215+
type AuthLDAP struct {
216+
Username string `yaml:"username"`
217+
Password string `yaml:"password"`
218+
MountPath string `yaml:"mount_path"`
219+
}
220+
221+
// UnmarshalYAML for AuthLDAP validates required fields after unmarshaling
222+
func (a *AuthLDAP) UnmarshalYAML(value *yaml.Node) error {
223+
type tempAuthLDAP AuthLDAP
224+
temp := tempAuthLDAP{}
225+
if err := value.Decode(&temp); err != nil {
226+
return err
227+
}
228+
*a = AuthLDAP(temp)
229+
if a.Username == "" {
230+
return fmt.Errorf("missing required field 'username'")
231+
}
232+
if a.Password == "" {
233+
return fmt.Errorf("missing required field 'password'")
234+
}
235+
return nil
236+
}
237+
238+
func (a *AuthLDAP) vaultAuthenticate(ctx context.Context, cli *vault.Client) (*vault.Secret, error) {
239+
secret := &ldap.Password{FromString: string(a.Password)}
240+
241+
var opts []ldap.LoginOption
242+
243+
if a.MountPath != "" {
244+
opts = append(opts, ldap.WithMountPath(a.MountPath))
245+
}
246+
247+
auth, err := ldap.NewLDAPAuth(a.Username, secret, opts...)
248+
if err != nil {
249+
return nil, fmt.Errorf("auth.ldap: %w", err)
250+
}
251+
s, err := cli.Auth().Login(ctx, auth)
252+
if err != nil {
253+
return nil, fmt.Errorf("auth.ldap: %w", err)
254+
}
255+
return s, nil
256+
}

‎agent/secretsmgr/vault_test.go

+148
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package secretsmgr
2+
3+
import (
4+
"context"
5+
"net"
6+
"testing"
7+
"time"
8+
9+
vault "github.com/hashicorp/vault/api"
10+
vaulthttp "github.com/hashicorp/vault/http"
11+
vaultsrv "github.com/hashicorp/vault/vault"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
"go.uber.org/zap"
15+
16+
"github.com/netboxlabs/orb-agent/agent/config"
17+
)
18+
19+
func TestVaultManager_getSecret(t *testing.T) {
20+
// Create test vault server
21+
ln, client := createTestVault(t)
22+
defer func() {
23+
if err := ln.Close(); err != nil {
24+
assert.NoError(t, err, "Failed to close test vault listener")
25+
}
26+
}()
27+
28+
// Create the vault manager with the test client
29+
logger, _ := zap.NewDevelopment()
30+
ctx, cancel := context.WithCancel(context.Background())
31+
defer cancel()
32+
vm := &vaultManager{
33+
logger: logger,
34+
config: config.VaultManager{},
35+
ctx: ctx,
36+
client: client,
37+
}
38+
39+
tests := []struct {
40+
name string
41+
path string
42+
expectedValue string
43+
expectedError string
44+
}{
45+
{
46+
name: "valid path and secret",
47+
path: "testsecret/app/credentials/password",
48+
expectedValue: "secretvalue",
49+
expectedError: "",
50+
},
51+
{
52+
name: "invalid path format",
53+
path: "testsecret/password",
54+
expectedValue: "",
55+
expectedError: "invalid vault path format: testsecret/password",
56+
},
57+
{
58+
name: "secret not found",
59+
path: "testsecret/nonexistent/path/key",
60+
expectedValue: "",
61+
expectedError: "failed to get secret path testsecret/nonexistent/path/key:",
62+
},
63+
{
64+
name: "key not found in data",
65+
path: "testsecret/app/credentials/nonexistentkey",
66+
expectedValue: "",
67+
expectedError: "secret not found: testsecret/app/credentials/nonexistentkey",
68+
},
69+
{
70+
name: "non-string value",
71+
path: "testsecret/app/credentials/numeric",
72+
expectedValue: "",
73+
expectedError: "secret is not a string: testsecret/app/credentials/numeric",
74+
},
75+
{
76+
name: "empty string value",
77+
path: "testsecret/app/credentials/empty",
78+
expectedValue: "",
79+
expectedError: "secret is empty: testsecret/app/credentials/empty",
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
// Call the method under test
86+
value, err := vm.getSecret(tt.path)
87+
88+
// Assertions
89+
if tt.expectedError != "" {
90+
assert.Error(t, err)
91+
assert.Contains(t, err.Error(), tt.expectedError)
92+
} else {
93+
assert.NoError(t, err)
94+
assert.Equal(t, tt.expectedValue, value)
95+
}
96+
})
97+
}
98+
}
99+
100+
func createTestVault(t *testing.T) (net.Listener, *vault.Client) {
101+
t.Helper()
102+
103+
// Create an in-memory, unsealed core
104+
core, keyShares, rootToken := vaultsrv.TestCoreUnsealed(t)
105+
_ = keyShares
106+
107+
// Start an HTTP server for the core
108+
ln, addr := vaulthttp.TestServer(t, core)
109+
110+
// Create a client that talks to the server
111+
conf := vault.DefaultConfig()
112+
conf.Address = addr
113+
114+
client, err := vault.NewClient(conf)
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
client.SetToken(rootToken)
119+
120+
// Enable KV v2 secret engine
121+
mountInput := &vault.MountInput{
122+
Type: "kv",
123+
Options: map[string]string{"version": "2"},
124+
}
125+
err = client.Sys().Mount("testsecret", mountInput)
126+
if err != nil {
127+
t.Fatal(err)
128+
}
129+
130+
// Wait for KV v2 to become available
131+
time.Sleep(500 * time.Millisecond)
132+
133+
// Setup various test secrets
134+
secrets := map[string]map[string]interface{}{
135+
"app/credentials": {
136+
"password": "secretvalue",
137+
"numeric": 12345,
138+
"empty": "",
139+
},
140+
}
141+
142+
for path, data := range secrets {
143+
_, err = client.KVv2("testsecret").Put(context.Background(), path, data)
144+
require.NoError(t, err, "Failed to set up secret at %s", path)
145+
}
146+
147+
return ln, client
148+
}

‎go.mod

+286-9
Large diffs are not rendered by default.

‎go.sum

+1,540-25
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)
Please sign in to comment.