Skip to content

Commit 6bbe5bf

Browse files
Add support for fully qualified SSM ARNs in cross-account parameters #minor (#46)
1 parent 119c86b commit 6bbe5bf

File tree

8 files changed

+318
-20
lines changed

8 files changed

+318
-20
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,5 @@
22
/coverage.*
33
version_wf
44
.DS_Store
5+
6+
.idea

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
GO_VERSION ?= 1.18
1+
GO_VERSION ?= 1.24
22
GO_CI_VERSION = v1.55.0
33
BINARIES = aws-env
44
WD ?= $(shell pwd)

awsenv/file_replacer.go

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,17 @@ func (r *FileReplacer) ReplaceAll(ctx context.Context) error {
7575
}
7676

7777
path := strings.FieldsFunc(line[idx+len(r.prefix):], splitPath)[0]
78+
plainPath := stripARNPrefix(path)
7879

7980
// if we haven't seen the path yet, init the slice
80-
if _, ok := replacementIndices[path]; !ok {
81-
replacementIndices[path] = make([]replacementIndex, 0, 4)
81+
if _, ok := replacementIndices[plainPath]; !ok {
82+
replacementIndices[plainPath] = make([]replacementIndex, 0, 4)
8283
}
8384

84-
replacementIndices[path] = append(replacementIndices[path], replacementIndex{
85-
lineNumber: i,
86-
index: idx,
85+
replacementIndices[plainPath] = append(replacementIndices[plainPath], replacementIndex{
86+
lineNumber: i,
87+
index: idx,
88+
originalPath: path,
8789
})
8890
paths = append(paths, path)
8991
}
@@ -107,7 +109,7 @@ func (r *FileReplacer) ReplaceAll(ctx context.Context) error {
107109

108110
ln := replacement.lineNumber
109111
idx := replacement.index
110-
lines[ln] = fmt.Sprintf("%s%s%s", lines[ln][:idx], value, lines[ln][idx+len(r.prefix)+len(path):])
112+
lines[ln] = fmt.Sprintf("%s%s%s", lines[ln][:idx], value, lines[ln][idx+len(r.prefix)+len(replacement.originalPath):])
111113
}
112114
}
113115

@@ -129,12 +131,13 @@ func (r *FileReplacer) MustReplaceAll(ctx context.Context) {
129131
}
130132

131133
type replacementIndex struct {
132-
lineNumber int
133-
index int
134+
lineNumber int
135+
index int
136+
originalPath string
134137
}
135138

136139
// return false if the given rune isn't an acceptable Parameter Store path
137140
func splitPath(r rune) bool {
138141
return !unicode.IsLetter(r) && !unicode.IsDigit(r) &&
139-
r != '/' && r != '_' && r != '-' && r != '.'
142+
r != '/' && r != '_' && r != '-' && r != '.' && r != ':'
140143
}

awsenv/file_replacer_test.go

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package awsenv
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"io/ioutil"
78
"log"
89
"os"
@@ -66,6 +67,24 @@ mysql_users:
6667
admin_password = "awsenv:/path/to/the/password",
6768
}
6869
)
70+
`
71+
sampleCnfFile5 = `
72+
mysql_users:
73+
(
74+
{
75+
username = "awsenv:arn:aws:ssm:us-east-1:123456789012:parameter/remote/username",
76+
password = "awsenv:arn:aws:ssm:us-east-1:123456789012:parameter/remote/password",
77+
}
78+
)
79+
`
80+
sampleCnfFile6 = `
81+
mysql_users:
82+
(
83+
{
84+
username = "awsenv:/path/to/the/username",
85+
password = "awsenv:arn:aws:ssm:us-east-1:123456789012:parameter/remote/password",
86+
}
87+
)
6988
`
7089
)
7190

@@ -214,6 +233,101 @@ mysql_users:
214233
require.Equal(t, expectedContent, string(f))
215234
}
216235

236+
func TestFileReplacer_ReplaceAll_CrossAccountARN(t *testing.T) {
237+
238+
fileName, cleanup := writeTempFile(sampleCnfFile5)
239+
defer cleanup()
240+
241+
oldContent, err := ioutil.ReadFile(fileName) //nolint: gosec
242+
require.NoError(t, err)
243+
require.Equal(t, sampleCnfFile5, string(oldContent))
244+
245+
// Use mockParamsGetter to simulate SSM's behavior of stripping ARN prefixes from result keys
246+
getter := mockParamsGetter(func(_ context.Context, paths []string) (map[string]string, error) {
247+
store := map[string]string{
248+
"/remote/username": "remote_user",
249+
"/remote/password": "remote_pass",
250+
}
251+
result := make(map[string]string, len(paths))
252+
for _, p := range paths {
253+
plain := stripARNPrefix(p)
254+
val, ok := store[plain]
255+
if !ok {
256+
return nil, fmt.Errorf("not found: %s", p)
257+
}
258+
result[plain] = val
259+
}
260+
return result, nil
261+
})
262+
263+
r := NewFileReplacer(DefaultPrefix, fileName, getter)
264+
265+
ctx := context.Background()
266+
err = r.ReplaceAll(ctx)
267+
require.NoError(t, err, "expected no error")
268+
269+
expectedContent := `
270+
mysql_users:
271+
(
272+
{
273+
username = "remote_user",
274+
password = "remote_pass",
275+
}
276+
)
277+
`
278+
f, err := ioutil.ReadFile(fileName) //nolint: gosec
279+
require.NoError(t, err)
280+
281+
require.Equal(t, expectedContent, string(f))
282+
}
283+
284+
func TestFileReplacer_ReplaceAll_MixedLocalAndCrossAccount(t *testing.T) {
285+
286+
fileName, cleanup := writeTempFile(sampleCnfFile6)
287+
defer cleanup()
288+
289+
oldContent, err := ioutil.ReadFile(fileName) //nolint: gosec
290+
require.NoError(t, err)
291+
require.Equal(t, sampleCnfFile6, string(oldContent))
292+
293+
getter := mockParamsGetter(func(_ context.Context, paths []string) (map[string]string, error) {
294+
store := map[string]string{
295+
"/path/to/the/username": "local_user",
296+
"/remote/password": "remote_pass",
297+
}
298+
result := make(map[string]string, len(paths))
299+
for _, p := range paths {
300+
plain := stripARNPrefix(p)
301+
val, ok := store[plain]
302+
if !ok {
303+
return nil, fmt.Errorf("not found: %s", p)
304+
}
305+
result[plain] = val
306+
}
307+
return result, nil
308+
})
309+
310+
r := NewFileReplacer(DefaultPrefix, fileName, getter)
311+
312+
ctx := context.Background()
313+
err = r.ReplaceAll(ctx)
314+
require.NoError(t, err, "expected no error")
315+
316+
expectedContent := `
317+
mysql_users:
318+
(
319+
{
320+
username = "local_user",
321+
password = "remote_pass",
322+
}
323+
)
324+
`
325+
f, err := ioutil.ReadFile(fileName) //nolint: gosec
326+
require.NoError(t, err)
327+
328+
require.Equal(t, expectedContent, string(f))
329+
}
330+
217331
func writeTempFile(contents string) (string, func()) {
218332

219333
uid, err := uuid.NewV4()
@@ -234,5 +348,5 @@ func writeTempFile(contents string) (string, func()) {
234348
log.Fatal(err)
235349
}
236350

237-
return tmpfile.Name(), func() { os.Remove(fName) } //nolint: errcheck,gosec
351+
return tmpfile.Name(), func() { os.Remove(tmpfile.Name()) } //nolint: errcheck,gosec
238352
}

awsenv/helpers.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
package awsenv
22

3+
import "regexp"
4+
5+
// ssmARNPrefix matches the fully qualified SSM parameter ARN prefix used for cross-account parameters.
6+
// note: this will not cover AWS GovCloud ARNs
7+
//
8+
// example: `arn:aws:ssm:<region>:<account_id>:parameter<parameter_path>`
9+
var ssmARNPrefix = regexp.MustCompile(`arn:aws:ssm:[^:]+:[^:]+:parameter`)
10+
11+
// stripARNPrefix removes the SSM ARN prefix from a parameter path, returning the plain path.
12+
// If the path does not contain an ARN prefix, it is returned unchanged.
13+
func stripARNPrefix(path string) string {
14+
return ssmARNPrefix.ReplaceAllString(path, "")
15+
}
16+
317
func min(x, y int) int {
418
if x < y {
519
return x

awsenv/helpers_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,42 @@ func TestMerge(t *testing.T) {
8383
}
8484
}
8585

86+
func TestStripARNPrefix(t *testing.T) {
87+
t.Parallel()
88+
tests := []struct {
89+
name string
90+
input string
91+
want string
92+
}{
93+
{
94+
name: "plain_path",
95+
input: "/my/param/path",
96+
want: "/my/param/path",
97+
},
98+
{
99+
name: "full_arn",
100+
input: "arn:aws:ssm:us-east-1:123456789012:parameter/my/param/path",
101+
want: "/my/param/path",
102+
},
103+
{
104+
name: "empty_string",
105+
input: "",
106+
want: "",
107+
},
108+
}
109+
110+
for _, test := range tests {
111+
test := test
112+
t.Run(test.name, func(t *testing.T) {
113+
t.Parallel()
114+
got := stripARNPrefix(test.input)
115+
if got != test.want {
116+
t.Errorf("stripARNPrefix(%q) = %q, want %q", test.input, got, test.want)
117+
}
118+
})
119+
}
120+
}
121+
86122
func TestChunk(t *testing.T) {
87123
tests := []struct {
88124
size int

awsenv/replacer.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ func (r *Replacer) applyParamPathValues(srcEnv map[string]string, replaceWithVal
128128
}
129129

130130
lookupValue := strings.TrimPrefix(value, r.prefix)
131+
// values from the env will still include the fully qualified prefix, but the replacement will not
132+
lookupValue = stripARNPrefix(lookupValue)
131133
if val, ok := replaceWithValues[lookupValue]; ok {
132134
srcEnv[name] = val
133135
}
@@ -171,6 +173,9 @@ func fetch(ctx context.Context, ssm ParamsGetter, paths []string) (map[string]st
171173
merge(dest, results)
172174

173175
for _, path := range paths {
176+
// results from GetParams include only the path (not fully qualified), but the original paths include the fully
177+
// qualified name.
178+
path = stripARNPrefix(path)
174179
_, ok := dest[path]
175180
if !ok {
176181
return dest, errors.Errorf("awsenv: param not found: %q", path)

0 commit comments

Comments
 (0)