From 481e1898f356eead0bb54c9dbd581afa8a7fb7a2 Mon Sep 17 00:00:00 2001 From: Nick Moore Date: Tue, 23 Dec 2025 11:01:11 +0000 Subject: [PATCH 1/4] Initial attempt at fetching secrets from Vault KVv2 --- go.mod | 18 ++++++++++++- go.sum | 42 ++++++++++++++++++++++++++--- pkg/env.go | 7 +++++ pkg/main.go | 18 +++++++++++++ pkg/secrets.go | 64 ++++++++++++++++++++++++++++++++++++++++++++- pkg/secrets_test.go | 12 +++++++++ 6 files changed, 155 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 872818e..3e184dc 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,20 @@ require ( github.com/stretchr/testify v1.11.1 ) +require ( + github.com/aws/aws-sdk-go v1.55.7 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/hcl v1.0.1-vault-7 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect +) + require ( dario.cat/mergo v1.0.1 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect @@ -85,6 +99,8 @@ require ( github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/memberlist v0.5.3 // indirect github.com/hashicorp/serf v0.10.1 // indirect + github.com/hashicorp/vault/api v1.22.0 + github.com/hashicorp/vault/api/auth/aws v0.11.0 github.com/huandu/xstrings v1.5.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect @@ -142,7 +158,7 @@ require ( golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.11.0 // indirect + golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.38.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect diff --git a/go.sum b/go.sum index 7e5dbf7..264bf91 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,9 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/aws/aws-lambda-go v1.50.0 h1:0GzY18vT4EsCvIyk3kn3ZH5Jg30NRlgYaai1w0aGPMU= github.com/aws/aws-lambda-go v1.50.0/go.mod h1:dpMpZgvWx5vuQJfBt0zqBha60q7Dd7RfgJv23DymV8A= -github.com/aws/aws-sdk-go v1.55.6 h1:cSg4pvZ3m8dgYcgqB97MrcdjUmZ1BeMYKUxMMB89IPk= -github.com/aws/aws-sdk-go v1.55.6/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +github.com/aws/aws-sdk-go v1.34.0/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0= +github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= +github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= @@ -110,6 +111,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 h1:6lhrsTEnloDPXyeZBvSYvQf8u86jbKehZPVDDlkgDl4= github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500/go.mod h1:S/7n9copUssQ56c7aAgHqftWO4LTf4xY6CGWt8Bc+3M= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -160,6 +163,8 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/ github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-jose/go-jose/v4 v4.1.1 h1:JYhSgy4mXXzAdF3nUx3ygx347LRXJRrpgyU3adRmkAI= +github.com/go-jose/go-jose/v4 v4.1.1/go.mod h1:BdsZGqgdO3b6tTc6LSE56wcDbMMLuPsw5d4ZD5f94kA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -183,7 +188,10 @@ github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redsync/redsync/v4 v4.13.0 h1:49X6GJfnbLGaIpBBREM/zA4uIMDXKAh1NDkvQ1EkZKA= github.com/go-redsync/redsync/v4 v4.13.0/go.mod h1:HMW4Q224GZQz6x1Xc7040Yfgacukdzu7ifTDAKiyErQ= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/googleapis v0.0.0-20180223154316-0cd9801be74a/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s= github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0= @@ -268,6 +276,7 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -285,8 +294,16 @@ github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0 h1:I8bynUKMh9I7JdwtW9voJ0xmHvBpxQtLjrMFDYmhOxY= +github.com/hashicorp/go-secure-stdlib/awsutil v0.3.0/go.mod h1:oKHSQs4ivIfZ3fbXGQOop1XuDfdSb8RIsWTGaAanSfg= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= @@ -300,6 +317,8 @@ github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= github.com/hashicorp/memberlist v0.5.0/go.mod h1:yvyXLpo0QaGE59Y7hDTsTzDD25JYBZ4mHgHUZ8lrOI0= @@ -307,10 +326,17 @@ github.com/hashicorp/memberlist v0.5.3 h1:tQ1jOCypD0WvMemw/ZhhtH+PWpzcftQvgCorLu github.com/hashicorp/memberlist v0.5.3/go.mod h1:h60o12SZn/ua/j0B6iKAZezA4eDaGsIuPO70eOaJ6WE= github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY= github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/hashicorp/vault/api/auth/aws v0.11.0 h1:lWdUxrzvPotg6idNr62al4w97BgI9xTDdzMCTViNH2s= +github.com/hashicorp/vault/api/auth/aws v0.11.0/go.mod h1:PWqdH/xqaudapmnnGP9ip2xbxT/kRW2qEgpqiQff6Gc= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/jmespath/go-jmespath v0.3.0/go.mod h1:9QtRXoHjLGCJ5IBSaohpXITPlowMeeYCZ7fLUTSywik= github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -330,6 +356,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -448,9 +475,12 @@ github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRl github.com/redis/rueidis v1.0.19 h1:s65oWtotzlIFN8eMPhyYwxlwLR1lUdhza2KtWprKYSo= github.com/redis/rueidis v1.0.19/go.mod h1:8B+r5wdnjwK3lTFml5VtxjzGOQAC+5UmujoD12pDrEo= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sercand/kuberesolver/v5 v5.1.1 h1:CYH+d67G0sGBj7q5wLK61yzqJJ8gLLC8aeprPTHb6yY= @@ -478,6 +508,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -572,6 +603,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= @@ -638,8 +670,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -702,10 +734,12 @@ google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9x google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/pkg/env.go b/pkg/env.go index f62463b..377fa13 100644 --- a/pkg/env.go +++ b/pkg/env.go @@ -11,6 +11,9 @@ import ( type secretFetcher interface { FetchFromAWSSecretsManager(ctx context.Context, secretArn string) (string, error) FetchFromAWSSSMParameterStore(ctx context.Context, parameterArn string) (string, error) + FetchFromVault(ctx context.Context, key string) (string, error) + HasVaultConfig() bool + SetVaultConfig(config *VaultKVCredentials) } func loadSensitiveEnv(ctx context.Context, secrets secretFetcher, name string) (string, error) { @@ -19,6 +22,10 @@ func loadSensitiveEnv(ctx context.Context, secrets secretFetcher, name string) ( return "", nil } + if secrets.HasVaultConfig() { + return secrets.FetchFromVault(ctx, envValue) + } + if arn.IsARN(envValue) { parsedArn, err := arn.Parse(envValue) if err != nil { diff --git a/pkg/main.go b/pkg/main.go index a822f55..9de2eea 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -49,6 +49,24 @@ func setupArguments(ctx context.Context, secretFetcher secretFetcher) { panic(errors.New("required environmental variable WRITE_ADDRESS not present, format: https:///loki/api/v1/push")) } + vaultRole, ok := os.LookupEnv("VAULT_ROLE") + if ok { + fmt.Println("using Vault configuration with role: ", vaultRole) + vaultMount, ok := os.LookupEnv("VAULT_MOUNT") + if !ok { + panic(errors.New("VAULT_ROLE provided, but required environment variable VAULT_MOUNT not provided")) + } + vaultPath, ok := os.LookupEnv("VAULT_PATH") + if !ok { + panic(errors.New("VAULT_ROLE provided, but required environment variable VAULT_PATH not provided")) + } + secretFetcher.SetVaultConfig(&VaultKVCredentials{ + role: vaultRole, + mount: vaultMount, + path: vaultPath, + }) + } + var err error writeAddress, err = url.Parse(addr) if err != nil { diff --git a/pkg/secrets.go b/pkg/secrets.go index 74a96b2..e541aa2 100644 --- a/pkg/secrets.go +++ b/pkg/secrets.go @@ -2,17 +2,29 @@ package main import ( "context" + "errors" "fmt" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" "github.com/aws/aws-sdk-go-v2/service/ssm" "github.com/aws/smithy-go/ptr" + vault "github.com/hashicorp/vault/api" + auth "github.com/hashicorp/vault/api/auth/aws" ) +type VaultKVCredentials struct { + role string + mount string + path string +} + var _ secretFetcher = &secretClients{} -type secretClients struct{} +type secretClients struct { + vaultConfig *VaultKVCredentials + vaultData map[string]any +} func (c *secretClients) FetchFromAWSSecretsManager(ctx context.Context, secretArn string) (string, error) { cfg, err := config.LoadDefaultConfig(ctx) @@ -48,3 +60,53 @@ func (c *secretClients) FetchFromAWSSSMParameterStore(ctx context.Context, param return *out.Parameter.Value, nil } + +func (c *secretClients) FetchFromVault(ctx context.Context, key string) (string, error) { + if c.vaultData == nil { + if c.vaultConfig == nil { + return "", errors.New("vault configuration required") + } + config := vault.DefaultConfig() + + client, err := vault.NewClient(config) + if err != nil { + return "", fmt.Errorf("unable to initialize Vault client: %w", err) + } + + awsAuth, err := auth.NewAWSAuth( + auth.WithRole(c.vaultConfig.role), // if not provided, Vault will fall back on looking for a role with the IAM role name if you're using the iam auth type, or the EC2 instance's AMI id if using the ec2 auth type + ) + if err != nil { + return "", fmt.Errorf("unable to initialize AWS auth method: %w", err) + } + + authInfo, err := client.Auth().Login(ctx, awsAuth) + if err != nil { + return "", fmt.Errorf("unable to login to AWS auth method: %w", err) + } + if authInfo == nil { + return "", fmt.Errorf("no auth info was returned after login") + } + + data, err := client.KVv2(c.vaultConfig.mount).Get(ctx, c.vaultConfig.path) + if err != nil { + return "", fmt.Errorf("unable to read secret: %w", err) + } + c.vaultData = data.Data + } + + value, ok := c.vaultData[key].(string) + if !ok { + return "", fmt.Errorf("value type assertion failed: %T %#v", c.vaultData[key], c.vaultData[key]) + } + + return value, nil +} + +func (c *secretClients) HasVaultConfig() bool { + return c.vaultConfig != nil +} + +func (c *secretClients) SetVaultConfig(config *VaultKVCredentials) { + c.vaultConfig = config +} diff --git a/pkg/secrets_test.go b/pkg/secrets_test.go index c9c3a7f..f35c300 100644 --- a/pkg/secrets_test.go +++ b/pkg/secrets_test.go @@ -37,3 +37,15 @@ func (c *testSecretsClient) FetchFromAWSSSMParameterStore(_ context.Context, par return c.ReturnValue, nil } + +func (c *testSecretsClient) FetchFromVault(ctx context.Context, key string) (string, error) { + return "", nil +} + +func (c *testSecretsClient) HasVaultConfig() bool { + return false +} + +func (c *testSecretsClient) SetVaultConfig(config *VaultKVCredentials) { + // TODO +} From dae604b0ee93bf04b5f5a2be84ec6ed3c997e6a4 Mon Sep 17 00:00:00 2001 From: Nick Moore Date: Tue, 23 Dec 2025 10:53:07 +0000 Subject: [PATCH 2/4] Implement basic unit tests for env --- pkg/env_test.go | 41 +++++++++++++++++++++++++++++++++++++++++ pkg/secrets_test.go | 27 ++++++++++++++++++++------- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/pkg/env_test.go b/pkg/env_test.go index 15d1265..8e35c8b 100644 --- a/pkg/env_test.go +++ b/pkg/env_test.go @@ -20,6 +20,7 @@ func Test_loadSensitiveEnv(t *testing.T) { assert.Equal(t, "BAR", value) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) + assert.Equal(t, 0, secretsClient.CallsFetchFromVault) }) t.Run("should not return an error if env is not set", func(t *testing.T) { @@ -31,6 +32,7 @@ func Test_loadSensitiveEnv(t *testing.T) { assert.Empty(t, value) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) + assert.Equal(t, 0, secretsClient.CallsFetchFromVault) }) t.Run("should return an error if the env variable contains an invalid arn", func(t *testing.T) { @@ -43,6 +45,7 @@ func Test_loadSensitiveEnv(t *testing.T) { assert.Empty(t, value) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) + assert.Equal(t, 0, secretsClient.CallsFetchFromVault) }) t.Run("should call FetchFromAWSSecretsManager if the env variable contains a secret ARN", func(t *testing.T) { @@ -57,6 +60,7 @@ func Test_loadSensitiveEnv(t *testing.T) { assert.Equal(t, "bar", value) assert.Equal(t, 1, secretsClient.CallsFetchFromAWSSecretsManager) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) + assert.Equal(t, 0, secretsClient.CallsFetchFromVault) }) t.Run("should call FetchFromAWSSSMParameterStore if the env variable contains a parameter ARN", func(t *testing.T) { @@ -71,5 +75,42 @@ func Test_loadSensitiveEnv(t *testing.T) { assert.Equal(t, "bar", value) assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) assert.Equal(t, 1, secretsClient.CallsFetchFromAWSSSMParameterStore) + assert.Equal(t, 0, secretsClient.CallsFetchFromVault) + }) + + t.Run("should call FetchFromVault if Vault is configured", func(t *testing.T) { + t.Setenv("FOO", "vault_key") + secretsClient := &testSecretsClient{ + VaultConfigured: true, + ExpectedArn: "vault_key", + ReturnValue: "bar", + } + secretsClient.SetVaultConfig(&VaultKVCredentials{ + role: "role_foo", + mount: "mnt_bar", + path: "path_name", + }) + + value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") + assert.NoError(t, err) + assert.Equal(t, "bar", value) + assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) + assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) + assert.Equal(t, 1, secretsClient.CallsFetchFromVault) + }) + + t.Run("should return an error if Vault is intended to be used but the Vault config is not set", func(t *testing.T) { + t.Setenv("FOO", "vault_key") + secretsClient := &testSecretsClient{ + VaultConfigured: true, + } + + value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") + + assert.Error(t, err) + assert.Empty(t, value) + assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) + assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) + assert.Equal(t, 1, secretsClient.CallsFetchFromVault) }) } diff --git a/pkg/secrets_test.go b/pkg/secrets_test.go index f35c300..e6dfbd2 100644 --- a/pkg/secrets_test.go +++ b/pkg/secrets_test.go @@ -6,16 +6,20 @@ import ( ) var ( - _ secretFetcher = &testSecretsClient{} - errInvalidArn = errors.New("invalid arn") + _ secretFetcher = &testSecretsClient{} + errInvalidArn = errors.New("invalid arn") + errNoVaultConfig = errors.New("no vault config") ) type testSecretsClient struct { CallsFetchFromAWSSecretsManager int CallsFetchFromAWSSSMParameterStore int + CallsFetchFromVault int - ExpectedArn string - ReturnValue string + ExpectedArn string + ReturnValue string + VaultConfigured bool + VaultCredentials *VaultKVCredentials } func (c *testSecretsClient) FetchFromAWSSecretsManager(_ context.Context, secretArn string) (string, error) { @@ -39,13 +43,22 @@ func (c *testSecretsClient) FetchFromAWSSSMParameterStore(_ context.Context, par } func (c *testSecretsClient) FetchFromVault(ctx context.Context, key string) (string, error) { - return "", nil + c.CallsFetchFromVault++ + + if c.VaultCredentials == nil { + return "", errNoVaultConfig + } + if c.ExpectedArn != "" && key != c.ExpectedArn { + return "", errInvalidArn + } + + return c.ReturnValue, nil } func (c *testSecretsClient) HasVaultConfig() bool { - return false + return c.VaultConfigured } func (c *testSecretsClient) SetVaultConfig(config *VaultKVCredentials) { - // TODO + c.VaultCredentials = config } From 46b34fecccc47dfd7d1da9e4a1ae01d1aea2c42f Mon Sep 17 00:00:00 2001 From: Nick Moore Date: Tue, 23 Dec 2025 11:13:59 +0000 Subject: [PATCH 3/4] Mark context as unused in test --- pkg/secrets_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/secrets_test.go b/pkg/secrets_test.go index e6dfbd2..c43dee4 100644 --- a/pkg/secrets_test.go +++ b/pkg/secrets_test.go @@ -42,7 +42,7 @@ func (c *testSecretsClient) FetchFromAWSSSMParameterStore(_ context.Context, par return c.ReturnValue, nil } -func (c *testSecretsClient) FetchFromVault(ctx context.Context, key string) (string, error) { +func (c *testSecretsClient) FetchFromVault(_ context.Context, key string) (string, error) { c.CallsFetchFromVault++ if c.VaultCredentials == nil { From 034da686a5f055a821a877b5f5a70a6ee8dc4e1d Mon Sep 17 00:00:00 2001 From: Nick Moore Date: Wed, 25 Mar 2026 15:56:26 +0000 Subject: [PATCH 4/4] Move from secretFetcher interface to Providers Breaks the secretFilter interface into multiple providers, allowing a chained application of them, where applicable. Update unit tests to reflect the new behavior. --- pkg/env.go | 42 +++--------- pkg/env_test.go | 126 ++++++++++++++---------------------- pkg/main.go | 53 +++++++++------- pkg/secrets.go | 151 +++++++++++++++++++++++++++++--------------- pkg/secrets_test.go | 130 ++++++++++++++++++++++++++------------ 5 files changed, 278 insertions(+), 224 deletions(-) diff --git a/pkg/env.go b/pkg/env.go index 377fa13..4a1a390 100644 --- a/pkg/env.go +++ b/pkg/env.go @@ -2,45 +2,23 @@ package main import ( "context" - "fmt" + "errors" "os" - - "github.com/aws/aws-sdk-go-v2/aws/arn" ) -type secretFetcher interface { - FetchFromAWSSecretsManager(ctx context.Context, secretArn string) (string, error) - FetchFromAWSSSMParameterStore(ctx context.Context, parameterArn string) (string, error) - FetchFromVault(ctx context.Context, key string) (string, error) - HasVaultConfig() bool - SetVaultConfig(config *VaultKVCredentials) +// ErrNotApplicable is returned by a Provider when it cannot handle the given key. +var ErrNotApplicable = errors.New("provider not applicable for this key") + +// Provider retrieves a secret value for the given key or reference. +// It returns ErrNotApplicable if it cannot handle the key. +type Provider interface { + Retrieve(ctx context.Context, key string) (string, error) } -func loadSensitiveEnv(ctx context.Context, secrets secretFetcher, name string) (string, error) { +func loadSensitiveEnv(ctx context.Context, provider Provider, name string) (string, error) { envValue, ok := os.LookupEnv(name) if !ok { return "", nil } - - if secrets.HasVaultConfig() { - return secrets.FetchFromVault(ctx, envValue) - } - - if arn.IsARN(envValue) { - parsedArn, err := arn.Parse(envValue) - if err != nil { - return "", fmt.Errorf("error parsing arn: %w", err) - } - - switch parsedArn.Service { - case "secretsmanager": - return secrets.FetchFromAWSSecretsManager(ctx, envValue) - case "ssm": - return secrets.FetchFromAWSSSMParameterStore(ctx, envValue) - default: - return "", fmt.Errorf("environment variable %s set to invalid ARN (unsupported service %s)", name, parsedArn.Service) - } - } - - return envValue, nil + return provider.Retrieve(ctx, envValue) } diff --git a/pkg/env_test.go b/pkg/env_test.go index 8e35c8b..c3e2166 100644 --- a/pkg/env_test.go +++ b/pkg/env_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "testing" "github.com/stretchr/testify/assert" @@ -10,107 +11,74 @@ import ( func Test_loadSensitiveEnv(t *testing.T) { ctx := context.Background() - t.Run("should return env variable if set", func(t *testing.T) { - t.Setenv("FOO", "BAR") - secretsClient := &testSecretsClient{} - - value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") + t.Run("should return empty if env is not set", func(t *testing.T) { + provider := &testProvider{} + value, err := loadSensitiveEnv(ctx, provider, "FOO") + assert.NoError(t, err) + assert.Empty(t, value) + assert.Equal(t, 0, provider.callCount) + }) + t.Run("should call provider.Retrieve with the env value", func(t *testing.T) { + t.Setenv("FOO", "BAR") + provider := &testProvider{returnValue: "BAR"} + value, err := loadSensitiveEnv(ctx, provider, "FOO") assert.NoError(t, err) assert.Equal(t, "BAR", value) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) - assert.Equal(t, 0, secretsClient.CallsFetchFromVault) + assert.Equal(t, 1, provider.callCount) }) - t.Run("should not return an error if env is not set", func(t *testing.T) { - secretsClient := &testSecretsClient{} + t.Run("should propagate provider errors", func(t *testing.T) { + t.Setenv("FOO", "BAR") + expectedErr := errors.New("provider error") + provider := &testProvider{returnError: expectedErr} + _, err := loadSensitiveEnv(ctx, provider, "FOO") + assert.ErrorIs(t, err, expectedErr) + }) +} - value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") +func Test_loadSensitiveEnv_WithChain(t *testing.T) { + ctx := context.Background() + t.Run("should return plain value as-is", func(t *testing.T) { + t.Setenv("FOO", "BAR") + chain := NewChainProvider(&AWSSecretsManagerProvider{}, &AWSSSMProvider{}) + value, err := loadSensitiveEnv(ctx, chain, "FOO") assert.NoError(t, err) - assert.Empty(t, value) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) - assert.Equal(t, 0, secretsClient.CallsFetchFromVault) + assert.Equal(t, "BAR", value) }) - t.Run("should return an error if the env variable contains an invalid arn", func(t *testing.T) { + t.Run("should return an error if the env variable contains an unsupported ARN service", func(t *testing.T) { t.Setenv("FOO", "arn:aws:invalid:eu-west-1:123456789012:ssm/example") - secretsClient := &testSecretsClient{} - - value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") - + chain := NewChainProvider(&AWSSecretsManagerProvider{}, &AWSSSMProvider{}) + value, err := loadSensitiveEnv(ctx, chain, "FOO") assert.Error(t, err) assert.Empty(t, value) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) - assert.Equal(t, 0, secretsClient.CallsFetchFromVault) }) - t.Run("should call FetchFromAWSSecretsManager if the env variable contains a secret ARN", func(t *testing.T) { + t.Run("should route Secrets Manager ARN to SM provider and skip SSM", func(t *testing.T) { t.Setenv("FOO", "arn:aws:secretsmanager:eu-west-1:123456789012:secret:foo") - secretsClient := &testSecretsClient{ - ExpectedArn: "arn:aws:secretsmanager:eu-west-1:123456789012:secret:foo", - ReturnValue: "bar", - } - - value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") + smProvider := &testProvider{returnValue: "bar"} + ssmProvider := &testProvider{} + // SM mock is first; it always handles its call. SSM mock should never be reached. + // We verify routing by checking SSM was not called even though it's in the chain. + chain := NewChainProvider(smProvider, ssmProvider) + value, err := loadSensitiveEnv(ctx, chain, "FOO") assert.NoError(t, err) assert.Equal(t, "bar", value) - assert.Equal(t, 1, secretsClient.CallsFetchFromAWSSecretsManager) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) - assert.Equal(t, 0, secretsClient.CallsFetchFromVault) + assert.Equal(t, 1, smProvider.callCount) + assert.Equal(t, 0, ssmProvider.callCount) }) - t.Run("should call FetchFromAWSSSMParameterStore if the env variable contains a parameter ARN", func(t *testing.T) { - t.Setenv("FOO", "arn:aws:ssm:eu-west-1:123456789012:parameter/foo") - secretsClient := &testSecretsClient{ - ExpectedArn: "arn:aws:ssm:eu-west-1:123456789012:parameter/foo", - ReturnValue: "bar", - } - - value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") - assert.NoError(t, err) - assert.Equal(t, "bar", value) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) - assert.Equal(t, 1, secretsClient.CallsFetchFromAWSSSMParameterStore) - assert.Equal(t, 0, secretsClient.CallsFetchFromVault) - }) - - t.Run("should call FetchFromVault if Vault is configured", func(t *testing.T) { + t.Run("should use Vault provider when it is first in the chain", func(t *testing.T) { t.Setenv("FOO", "vault_key") - secretsClient := &testSecretsClient{ - VaultConfigured: true, - ExpectedArn: "vault_key", - ReturnValue: "bar", - } - secretsClient.SetVaultConfig(&VaultKVCredentials{ - role: "role_foo", - mount: "mnt_bar", - path: "path_name", - }) - - value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") + vaultProvider := &testProvider{returnValue: "bar"} + awsProvider := &testProvider{} + chain := NewChainProvider(vaultProvider, awsProvider) + value, err := loadSensitiveEnv(ctx, chain, "FOO") assert.NoError(t, err) assert.Equal(t, "bar", value) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) - assert.Equal(t, 1, secretsClient.CallsFetchFromVault) - }) - - t.Run("should return an error if Vault is intended to be used but the Vault config is not set", func(t *testing.T) { - t.Setenv("FOO", "vault_key") - secretsClient := &testSecretsClient{ - VaultConfigured: true, - } - - value, err := loadSensitiveEnv(ctx, secretsClient, "FOO") - - assert.Error(t, err) - assert.Empty(t, value) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSecretsManager) - assert.Equal(t, 0, secretsClient.CallsFetchFromAWSSSMParameterStore) - assert.Equal(t, 1, secretsClient.CallsFetchFromVault) + assert.Equal(t, 1, vaultProvider.callCount) + assert.Equal(t, 0, awsProvider.callCount) }) } diff --git a/pkg/main.go b/pkg/main.go index 9de2eea..055501e 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -43,30 +43,12 @@ var ( relabelConfigs []*relabel.Config ) -func setupArguments(ctx context.Context, secretFetcher secretFetcher) { +func setupArguments(ctx context.Context, provider Provider) { addr := os.Getenv("WRITE_ADDRESS") if addr == "" { panic(errors.New("required environmental variable WRITE_ADDRESS not present, format: https:///loki/api/v1/push")) } - vaultRole, ok := os.LookupEnv("VAULT_ROLE") - if ok { - fmt.Println("using Vault configuration with role: ", vaultRole) - vaultMount, ok := os.LookupEnv("VAULT_MOUNT") - if !ok { - panic(errors.New("VAULT_ROLE provided, but required environment variable VAULT_MOUNT not provided")) - } - vaultPath, ok := os.LookupEnv("VAULT_PATH") - if !ok { - panic(errors.New("VAULT_ROLE provided, but required environment variable VAULT_PATH not provided")) - } - secretFetcher.SetVaultConfig(&VaultKVCredentials{ - role: vaultRole, - mount: vaultMount, - path: vaultPath, - }) - } - var err error writeAddress, err = url.Parse(addr) if err != nil { @@ -87,11 +69,11 @@ func setupArguments(ctx context.Context, secretFetcher secretFetcher) { panic(err) } - username, err = loadSensitiveEnv(ctx, secretFetcher, "USERNAME") + username, err = loadSensitiveEnv(ctx, provider, "USERNAME") if err != nil { panic(err) } - password, err = loadSensitiveEnv(ctx, secretFetcher, "PASSWORD") + password, err = loadSensitiveEnv(ctx, provider, "PASSWORD") if err != nil { panic(err) } @@ -100,7 +82,7 @@ func setupArguments(ctx context.Context, secretFetcher secretFetcher) { panic("both username and password must be set if either one is set") } - bearerToken, err = loadSensitiveEnv(ctx, secretFetcher, "BEARER_TOKEN") + bearerToken, err = loadSensitiveEnv(ctx, provider, "BEARER_TOKEN") if err != nil { panic(err) } @@ -321,6 +303,31 @@ func handler(ctx context.Context, ev map[string]interface{}) error { } func main() { - setupArguments(context.Background(), &secretClients{}) + ctx := context.Background() + + providers := []Provider{ + &AWSSecretsManagerProvider{}, + &AWSSSMProvider{}, + } + + if vaultRole, ok := os.LookupEnv("VAULT_ROLE"); ok { + fmt.Println("using Vault configuration with role: ", vaultRole) + vaultMount, ok := os.LookupEnv("VAULT_MOUNT") + if !ok { + panic(errors.New("VAULT_ROLE provided, but required environment variable VAULT_MOUNT not provided")) + } + vaultPath, ok := os.LookupEnv("VAULT_PATH") + if !ok { + panic(errors.New("VAULT_ROLE provided, but required environment variable VAULT_PATH not provided")) + } + // Vault takes precedence — prepend to the chain. + providers = append([]Provider{NewVaultProvider(&VaultKVCredentials{ + role: vaultRole, + mount: vaultMount, + path: vaultPath, + })}, providers...) + } + + setupArguments(ctx, NewChainProvider(providers...)) lambda.Start(handler) } diff --git a/pkg/secrets.go b/pkg/secrets.go index e541aa2..415ac92 100644 --- a/pkg/secrets.go +++ b/pkg/secrets.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" + "github.com/aws/aws-sdk-go-v2/aws/arn" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/secretsmanager" "github.com/aws/aws-sdk-go-v2/service/ssm" @@ -13,68 +14,67 @@ import ( auth "github.com/hashicorp/vault/api/auth/aws" ) -type VaultKVCredentials struct { - role string - mount string - path string +const ( + awsServiceSecretsManager = "secretsmanager" + awsServiceSSM = "ssm" +) + +// ChainProvider tries each Provider in order, skipping those that return ErrNotApplicable. +// If no provider handles the key and it is an ARN, an error is returned. +// Otherwise the key is returned as a plain value. +type ChainProvider struct { + providers []Provider } -var _ secretFetcher = &secretClients{} +var _ Provider = &ChainProvider{} -type secretClients struct { - vaultConfig *VaultKVCredentials - vaultData map[string]any +func NewChainProvider(providers ...Provider) *ChainProvider { + return &ChainProvider{providers: providers} } -func (c *secretClients) FetchFromAWSSecretsManager(ctx context.Context, secretArn string) (string, error) { - cfg, err := config.LoadDefaultConfig(ctx) - if err != nil { - return "", fmt.Errorf("error loading aws config: %w", err) +func (c *ChainProvider) Retrieve(ctx context.Context, key string) (string, error) { + for _, p := range c.providers { + val, err := p.Retrieve(ctx, key) + if errors.Is(err, ErrNotApplicable) { + continue + } + return val, err } - - client := secretsmanager.NewFromConfig(cfg) - out, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ - SecretId: &secretArn, - }) - if err != nil { - return "", fmt.Errorf("error fetching secret %s: %w", secretArn, err) + if parsedArn, err := arn.Parse(key); err == nil { + return "", fmt.Errorf("unsupported ARN service: %s", parsedArn.Service) } + return key, nil +} - return *out.SecretString, nil +type VaultKVCredentials struct { + role string + mount string + path string } -func (c *secretClients) FetchFromAWSSSMParameterStore(ctx context.Context, parameterArn string) (string, error) { - cfg, err := config.LoadDefaultConfig(ctx) - if err != nil { - return "", fmt.Errorf("error loading aws config: %w", err) - } +// VaultProvider retrieves secrets from a Vault KVv2 engine using AWS auth. +// It is always applicable when present in a chain. +type VaultProvider struct { + config *VaultKVCredentials + vaultData map[string]any +} - client := ssm.NewFromConfig(cfg) - out, err := client.GetParameter(ctx, &ssm.GetParameterInput{ - Name: ¶meterArn, - WithDecryption: ptr.Bool(true), - }) - if err != nil { - return "", fmt.Errorf("error fetching SSM parameter %s: %w", parameterArn, err) - } +var _ Provider = &VaultProvider{} - return *out.Parameter.Value, nil +func NewVaultProvider(config *VaultKVCredentials) *VaultProvider { + return &VaultProvider{config: config} } -func (c *secretClients) FetchFromVault(ctx context.Context, key string) (string, error) { - if c.vaultData == nil { - if c.vaultConfig == nil { - return "", errors.New("vault configuration required") - } - config := vault.DefaultConfig() - - client, err := vault.NewClient(config) +func (p *VaultProvider) Retrieve(ctx context.Context, key string) (string, error) { + if p.vaultData == nil { + vaultConfig := vault.DefaultConfig() + client, err := vault.NewClient(vaultConfig) if err != nil { return "", fmt.Errorf("unable to initialize Vault client: %w", err) } awsAuth, err := auth.NewAWSAuth( - auth.WithRole(c.vaultConfig.role), // if not provided, Vault will fall back on looking for a role with the IAM role name if you're using the iam auth type, or the EC2 instance's AMI id if using the ec2 auth type + auth.WithRole(p.config.role), ) if err != nil { return "", fmt.Errorf("unable to initialize AWS auth method: %w", err) @@ -88,25 +88,74 @@ func (c *secretClients) FetchFromVault(ctx context.Context, key string) (string, return "", fmt.Errorf("no auth info was returned after login") } - data, err := client.KVv2(c.vaultConfig.mount).Get(ctx, c.vaultConfig.path) + data, err := client.KVv2(p.config.mount).Get(ctx, p.config.path) if err != nil { return "", fmt.Errorf("unable to read secret: %w", err) } - c.vaultData = data.Data + p.vaultData = data.Data } - value, ok := c.vaultData[key].(string) + value, ok := p.vaultData[key].(string) if !ok { - return "", fmt.Errorf("value type assertion failed: %T %#v", c.vaultData[key], c.vaultData[key]) + return "", fmt.Errorf("value type assertion failed: %T %#v", p.vaultData[key], p.vaultData[key]) } return value, nil } -func (c *secretClients) HasVaultConfig() bool { - return c.vaultConfig != nil +// AWSSecretsManagerProvider retrieves secrets from AWS Secrets Manager. +// Returns ErrNotApplicable for keys that are not Secrets Manager ARNs. +type AWSSecretsManagerProvider struct{} + +var _ Provider = &AWSSecretsManagerProvider{} + +func (p *AWSSecretsManagerProvider) Retrieve(ctx context.Context, key string) (string, error) { + parsedArn, err := arn.Parse(key) + if err != nil || parsedArn.Service != awsServiceSecretsManager { + return "", ErrNotApplicable + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return "", fmt.Errorf("error loading AWS config: %w", err) + } + + client := secretsmanager.NewFromConfig(cfg) + out, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: &key, + }) + if err != nil { + return "", fmt.Errorf("error fetching secret %s: %w", key, err) + } + + return *out.SecretString, nil } -func (c *secretClients) SetVaultConfig(config *VaultKVCredentials) { - c.vaultConfig = config +// AWSSSMProvider retrieves parameters from AWS SSM Parameter Store. +// Returns ErrNotApplicable for keys that are not SSM ARNs. +type AWSSSMProvider struct{} + +var _ Provider = &AWSSSMProvider{} + +func (p *AWSSSMProvider) Retrieve(ctx context.Context, key string) (string, error) { + parsedArn, err := arn.Parse(key) + if err != nil || parsedArn.Service != awsServiceSSM { + return "", ErrNotApplicable + } + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return "", fmt.Errorf("error loading AWS config: %w", err) + } + + client := ssm.NewFromConfig(cfg) + out, err := client.GetParameter(ctx, &ssm.GetParameterInput{ + Name: &key, + WithDecryption: ptr.Bool(true), + }) + if err != nil { + return "", fmt.Errorf("error fetching SSM parameter %s: %w", key, err) + } + + return *out.Parameter.Value, nil } diff --git a/pkg/secrets_test.go b/pkg/secrets_test.go index c43dee4..72da35e 100644 --- a/pkg/secrets_test.go +++ b/pkg/secrets_test.go @@ -3,62 +3,114 @@ package main import ( "context" "errors" -) + "testing" -var ( - _ secretFetcher = &testSecretsClient{} - errInvalidArn = errors.New("invalid arn") - errNoVaultConfig = errors.New("no vault config") + "github.com/stretchr/testify/assert" ) -type testSecretsClient struct { - CallsFetchFromAWSSecretsManager int - CallsFetchFromAWSSSMParameterStore int - CallsFetchFromVault int +var _ Provider = &testProvider{} - ExpectedArn string - ReturnValue string - VaultConfigured bool - VaultCredentials *VaultKVCredentials +// testProvider is a configurable mock Provider for use in tests. +type testProvider struct { + callCount int + returnValue string + returnError error } -func (c *testSecretsClient) FetchFromAWSSecretsManager(_ context.Context, secretArn string) (string, error) { - c.CallsFetchFromAWSSecretsManager++ +func (p *testProvider) Retrieve(_ context.Context, _ string) (string, error) { + p.callCount++ + return p.returnValue, p.returnError +} - if c.ExpectedArn != "" && secretArn != c.ExpectedArn { - return "", errInvalidArn - } +// notApplicableProvider always returns ErrNotApplicable. +type notApplicableProvider struct { + callCount int +} - return c.ReturnValue, nil +func (p *notApplicableProvider) Retrieve(_ context.Context, _ string) (string, error) { + p.callCount++ + return "", ErrNotApplicable } -func (c *testSecretsClient) FetchFromAWSSSMParameterStore(_ context.Context, parameterArn string) (string, error) { - c.CallsFetchFromAWSSSMParameterStore++ +func Test_ChainProvider_Retrieve(t *testing.T) { + ctx := context.Background() - if c.ExpectedArn != "" && parameterArn != c.ExpectedArn { - return "", errInvalidArn - } + t.Run("returns plain value when no provider handles key", func(t *testing.T) { + chain := NewChainProvider(¬ApplicableProvider{}) + val, err := chain.Retrieve(ctx, "plainvalue") + assert.NoError(t, err) + assert.Equal(t, "plainvalue", val) + }) - return c.ReturnValue, nil -} + t.Run("returns plain value with empty chain", func(t *testing.T) { + chain := NewChainProvider() + val, err := chain.Retrieve(ctx, "plainvalue") + assert.NoError(t, err) + assert.Equal(t, "plainvalue", val) + }) + + t.Run("stops at first applicable provider", func(t *testing.T) { + first := &testProvider{returnValue: "first"} + second := &testProvider{returnValue: "second"} + chain := NewChainProvider(first, second) + val, err := chain.Retrieve(ctx, "key") + assert.NoError(t, err) + assert.Equal(t, "first", val) + assert.Equal(t, 1, first.callCount) + assert.Equal(t, 0, second.callCount) + }) -func (c *testSecretsClient) FetchFromVault(_ context.Context, key string) (string, error) { - c.CallsFetchFromVault++ + t.Run("skips providers returning ErrNotApplicable", func(t *testing.T) { + first := ¬ApplicableProvider{} + second := &testProvider{returnValue: "result"} + chain := NewChainProvider(first, second) + val, err := chain.Retrieve(ctx, "key") + assert.NoError(t, err) + assert.Equal(t, "result", val) + assert.Equal(t, 1, first.callCount) + assert.Equal(t, 1, second.callCount) + }) - if c.VaultCredentials == nil { - return "", errNoVaultConfig - } - if c.ExpectedArn != "" && key != c.ExpectedArn { - return "", errInvalidArn - } + t.Run("propagates provider error", func(t *testing.T) { + expectedErr := errors.New("provider error") + chain := NewChainProvider(&testProvider{returnError: expectedErr}) + _, err := chain.Retrieve(ctx, "key") + assert.ErrorIs(t, err, expectedErr) + }) - return c.ReturnValue, nil + t.Run("returns error for unknown ARN service", func(t *testing.T) { + chain := NewChainProvider() + _, err := chain.Retrieve(ctx, "arn:aws:invalid:eu-west-1:123456789012:thing/foo") + assert.Error(t, err) + }) } -func (c *testSecretsClient) HasVaultConfig() bool { - return c.VaultConfigured +func Test_AWSSecretsManagerProvider_Retrieve(t *testing.T) { + ctx := context.Background() + p := &AWSSecretsManagerProvider{} + + t.Run("not applicable for plain value", func(t *testing.T) { + _, err := p.Retrieve(ctx, "plainvalue") + assert.ErrorIs(t, err, ErrNotApplicable) + }) + + t.Run("not applicable for SSM ARN", func(t *testing.T) { + _, err := p.Retrieve(ctx, "arn:aws:ssm:eu-west-1:123456789012:parameter/foo") + assert.ErrorIs(t, err, ErrNotApplicable) + }) } -func (c *testSecretsClient) SetVaultConfig(config *VaultKVCredentials) { - c.VaultCredentials = config +func Test_AWSSSMProvider_Retrieve(t *testing.T) { + ctx := context.Background() + p := &AWSSSMProvider{} + + t.Run("not applicable for plain value", func(t *testing.T) { + _, err := p.Retrieve(ctx, "plainvalue") + assert.ErrorIs(t, err, ErrNotApplicable) + }) + + t.Run("not applicable for Secrets Manager ARN", func(t *testing.T) { + _, err := p.Retrieve(ctx, "arn:aws:secretsmanager:eu-west-1:123456789012:secret:foo") + assert.ErrorIs(t, err, ErrNotApplicable) + }) }