Add prefix support to password policies

This adds a 'prefix' field to password policy configuration, allowing
generated passwords to include a configurable prefix. This enables
secret scanning tools to detect Vault-generated passwords.

Fixes #31889
This commit is contained in:
Wahaj Ahmed 2026-05-13 00:25:48 +02:00
parent 4d1cc6e442
commit f9fadfc3fa
4 changed files with 123 additions and 1 deletions

View file

@ -326,7 +326,14 @@ func (d dynamicSystemView) GeneratePasswordFromPolicy(ctx context.Context, polic
if err != nil {
return "", fmt.Errorf("stored password policy is invalid: %w", err)
}
return passPolicy.Generate(ctx, rng)
password, err := passPolicy.Generate(ctx, rng)
if err != nil {
return "", fmt.Errorf("failed to generate password: %w", err)
}
if policyCfg.Prefix != "" {
password = policyCfg.Prefix + password
}
return password, nil
}
func (d dynamicSystemView) ClusterID(ctx context.Context) (string, error) {

View file

@ -4200,6 +4200,7 @@ func (b *SystemBackend) handlePoliciesDelete(policyType PolicyType) framework.Op
type passwordPolicyConfig struct {
HCLPolicy string `json:"policy"`
EntropySource string `json:"entropy_source,omitempty"`
Prefix string `json:"prefix,omitempty"`
}
func getPasswordPolicyKey(policyName string) string {
@ -4304,6 +4305,7 @@ func (*SystemBackend) handlePoliciesPasswordSet(ctx context.Context, req *logica
cfg := passwordPolicyConfig{
HCLPolicy: rawPolicy,
EntropySource: entropySource,
Prefix: data.Get("prefix").(string),
}
entry, err := logical.StorageEntryJSON(getPasswordPolicyKey(policyName), cfg)
if err != nil {
@ -4343,6 +4345,9 @@ func (*SystemBackend) handlePoliciesPasswordGet(ctx context.Context, req *logica
if cfg.EntropySource != "" {
resp.Data["entropy_source"] = cfg.EntropySource
}
if cfg.Prefix != "" {
resp.Data["prefix"] = cfg.Prefix
}
return resp, nil
}
@ -4413,6 +4418,10 @@ func (b *SystemBackend) handlePoliciesPasswordGenerate(ctx context.Context, req
fmt.Sprintf("failed to generate password from policy: %s", err))
}
if cfg.Prefix != "" {
password = cfg.Prefix + password
}
resp := &logical.Response{
Data: map[string]interface{}{
"password": password,

View file

@ -37,6 +37,10 @@ var passwordPolicySchema = map[string]*framework.FieldSchema{
Type: framework.TypeString,
Description: "The entropy source for generation",
},
"prefix": {
Type: framework.TypeString,
Description: "The prefix to prepend to generated passwords",
},
}
func (b *SystemBackend) configPaths() []*framework.Path {

View file

@ -5877,6 +5877,97 @@ func TestHandlePoliciesPasswordGenerate(t *testing.T) {
}
}
})
t.Run("success with prefix", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
policyEntry := storageEntryWithPrefix(t, "testprefix",
"length = 20\n"+
"rule \"charset\" {\n"+
"\tcharset=\"abcdefghij\"\n"+
"}", "", "vault.")
storage := makeStorage(t, policyEntry)
inputData := passwordPoliciesFieldData(map[string]interface{}{
"name": "testprefix",
})
for i := 0; i < 100; i++ {
req := &logical.Request{
Storage: storage,
}
b := &SystemBackend{}
actualResp, err := b.handlePoliciesPasswordGenerate(ctx, req, inputData)
if err != nil {
t.Fatalf("no error expected, got: %s", err)
}
assertTrue(t, actualResp != nil, "response is nil")
assertTrue(t, actualResp.Data != nil, "expected data, got nil")
assertHasKey(t, actualResp.Data, "password", "password key not found in data")
password := actualResp.Data["password"].(string)
if len(password) < 6 || password[:6] != "vault." {
t.Fatalf("password %s does not start with prefix 'vault.'", password)
}
passwordWithoutPrefix := password[6:]
passwordLength := len([]rune(passwordWithoutPrefix))
if passwordLength != 20 {
t.Fatalf("password without prefix is %d characters but should be 20", passwordLength)
}
rules := []random.Rule{
random.CharsetRule{
Charset: []rune("abcdefghij"),
MinChars: 20,
},
}
for _, rule := range rules {
if !rule.Pass([]rune(passwordWithoutPrefix)) {
t.Fatalf("password %s does not have the correct characters after prefix", password)
}
}
}
})
t.Run("success empty prefix", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
policyEntry := storageEntryWithPrefix(t, "testemptyprefix",
"length = 20\n"+
"rule \"charset\" {\n"+
"\tcharset=\"abcdefghij\"\n"+
"}", "", "")
storage := makeStorage(t, policyEntry)
inputData := passwordPoliciesFieldData(map[string]interface{}{
"name": "testemptyprefix",
})
req := &logical.Request{
Storage: storage,
}
b := &SystemBackend{}
actualResp, err := b.handlePoliciesPasswordGenerate(ctx, req, inputData)
if err != nil {
t.Fatalf("no error expected, got: %s", err)
}
assertTrue(t, actualResp != nil, "response is nil")
password := actualResp.Data["password"].(string)
passwordLength := len([]rune(password))
if passwordLength != 20 {
t.Fatalf("password is %d characters but should be 20", passwordLength)
}
})
}
func assertTrue(t *testing.T, pass bool, f string, vals ...interface{}) {
@ -5933,6 +6024,17 @@ func storageEntry(t *testing.T, key string, policy string, entropySource string)
}
}
func storageEntryWithPrefix(t *testing.T, key string, policy string, entropySource string, prefix string) *logical.StorageEntry {
return &logical.StorageEntry{
Key: getPasswordPolicyKey(key),
Value: toJson(t, passwordPolicyConfig{
HCLPolicy: policy,
EntropySource: entropySource,
Prefix: prefix,
}),
}
}
func makeStorageMap(entries ...*logical.StorageEntry) map[string]*logical.StorageEntry {
m := map[string]*logical.StorageEntry{}
for _, entry := range entries {