diff --git a/API.md b/API.md
index 1df6509b..0ee24c8e 100644
--- a/API.md
+++ b/API.md
@@ -43,6 +43,7 @@ new MultiStringParameter(scope: Construct, id: string, props: MultiStringParamet
| **Name** | **Description** |
| --- | --- |
| toString | Returns a string representation of this construct. |
+| with | Applies one or more mixins to this construct. |
---
@@ -54,6 +55,27 @@ public toString(): string
Returns a string representation of this construct.
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+Use multiple `with()` calls if subsequent mixins should apply to added
+constructs.
+
+###### `mixins`Required
+
+- *Type:* ...constructs.IMixin[]
+
+The mixins to apply.
+
+---
+
#### Static Functions
| **Name** | **Description** |
@@ -226,6 +248,7 @@ new SopsSecret(scope: Construct, id: string, props: SopsSecretProps)
| **Name** | **Description** |
| --- | --- |
| toString | Returns a string representation of this construct. |
+| with | Applies one or more mixins to this construct. |
| addRotationSchedule | Adds a rotation schedule to the secret. |
| addToResourcePolicy | Adds a statement to the IAM resource policy associated with this secret. |
| applyRemovalPolicy | Apply the given removal policy to this resource. |
@@ -247,6 +270,27 @@ public toString(): string
Returns a string representation of this construct.
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+Use multiple `with()` calls if subsequent mixins should apply to added
+constructs.
+
+###### `mixins`Required
+
+- *Type:* ...constructs.IMixin[]
+
+The mixins to apply.
+
+---
+
##### `addRotationSchedule`
```typescript
@@ -637,6 +681,7 @@ new SopsStringParameter(scope: Construct, id: string, props: SopsStringParameter
| **Name** | **Description** |
| --- | --- |
| toString | Returns a string representation of this construct. |
+| with | Applies one or more mixins to this construct. |
| applyRemovalPolicy | Apply the given removal policy to this resource. |
| grantRead | Grants read (DescribeParameter, GetParameters, GetParameter, GetParameterHistory) permissions on the SSM Parameter. |
| grantWrite | Grants write (PutParameter) permissions on the SSM Parameter. |
@@ -651,6 +696,27 @@ public toString(): string
Returns a string representation of this construct.
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+Use multiple `with()` calls if subsequent mixins should apply to added
+constructs.
+
+###### `mixins`Required
+
+- *Type:* ...constructs.IMixin[]
+
+The mixins to apply.
+
+---
+
##### `applyRemovalPolicy`
```typescript
@@ -928,6 +994,7 @@ new SopsSync(scope: Construct, id: string, props: SopsSyncProps)
| **Name** | **Description** |
| --- | --- |
| toString | Returns a string representation of this construct. |
+| with | Applies one or more mixins to this construct. |
---
@@ -939,6 +1006,27 @@ public toString(): string
Returns a string representation of this construct.
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+Use multiple `with()` calls if subsequent mixins should apply to added
+constructs.
+
+###### `mixins`Required
+
+- *Type:* ...constructs.IMixin[]
+
+The mixins to apply.
+
+---
+
#### Static Functions
| **Name** | **Description** |
@@ -1056,6 +1144,7 @@ new SopsSyncProvider(scope: Construct, id?: string, props?: SopsSyncProviderProp
| **Name** | **Description** |
| --- | --- |
| toString | Returns a string representation of this construct. |
+| with | Applies one or more mixins to this construct. |
| applyRemovalPolicy | Apply the given removal policy to this resource. |
| addEventSource | Adds an event source to this function. |
| addEventSourceMapping | Adds an event source that maps to this AWS Lambda function. |
@@ -1080,6 +1169,7 @@ new SopsSyncProvider(scope: Construct, id?: string, props?: SopsSyncProviderProp
| addMetadata | Use this method to write to the construct tree. |
| dependOn | The SingletonFunction construct cannot be added as a dependency of another construct using node.addDependency(). Use this method instead to declare this as a dependency of another construct. |
| addAgeKey | *No description.* |
+| addAgeKeyFromSsmParameter | Configure the Lambda to fetch an age private key from an SSM Parameter Store SecureString at runtime, rather than injecting it as a plaintext environment variable at synthesis time. |
---
@@ -1091,6 +1181,25 @@ public toString(): string
Returns a string representation of this construct.
+##### `with`
+
+```typescript
+public with(mixins: ...IMixin[]): IConstruct
+```
+
+Applies one or more mixins to this construct.
+
+Mixins are applied in order. The list of constructs is captured at the
+start of the call, so constructs added by a mixin will not be visited.
+Use multiple `with()` calls if subsequent mixins should apply to added
+constructs.
+
+###### `mixins`Required
+
+- *Type:* ...constructs.IMixin[]
+
+---
+
##### `applyRemovalPolicy`
```typescript
@@ -1250,6 +1359,8 @@ public grantInvoke(grantee: IGrantable): Grant
Grant the given identity permissions to invoke this Lambda.
+[disable-awslint:no-grants]
+
###### `grantee`Required
- *Type:* aws-cdk-lib.aws_iam.IGrantable
@@ -1264,6 +1375,8 @@ public grantInvokeCompositePrincipal(compositePrincipal: CompositePrincipal): Gr
Grant multiple principals the ability to invoke this Lambda via CompositePrincipal.
+[disable-awslint:no-grants]
+
###### `compositePrincipal`Required
- *Type:* aws-cdk-lib.aws_iam.CompositePrincipal
@@ -1278,6 +1391,8 @@ public grantInvokeLatestVersion(grantee: IGrantable): Grant
Grant the given identity permissions to invoke the $LATEST version or unqualified version of this Lambda.
+[disable-awslint:no-grants]
+
###### `grantee`Required
- *Type:* aws-cdk-lib.aws_iam.IGrantable
@@ -1292,6 +1407,8 @@ public grantInvokeUrl(grantee: IGrantable): Grant
Grant the given identity permissions to invoke this Lambda Function URL.
+[disable-awslint:no-grants]
+
###### `grantee`Required
- *Type:* aws-cdk-lib.aws_iam.IGrantable
@@ -1306,6 +1423,8 @@ public grantInvokeVersion(grantee: IGrantable, version: IVersion): Grant
Grant the given identity permissions to invoke the given version of this Lambda.
+[disable-awslint:no-grants]
+
###### `grantee`Required
- *Type:* aws-cdk-lib.aws_iam.IGrantable
@@ -1520,6 +1639,36 @@ public addAgeKey(key: SecretValue): void
---
+##### `addAgeKeyFromSsmParameter`
+
+```typescript
+public addAgeKeyFromSsmParameter(param: string | IStringParameter, encryptionKey: IKey): void
+```
+
+Configure the Lambda to fetch an age private key from an SSM Parameter Store SecureString at runtime, rather than injecting it as a plaintext environment variable at synthesis time.
+
+The KMS key used to encrypt the SecureString is required: storing an age
+private key without envelope encryption is considered insecure.
+
+The Lambda is automatically granted `ssm:GetParameter` on the parameter
+and `kms:Decrypt` on the encryption key.
+
+###### `param`Required
+
+- *Type:* string | aws-cdk-lib.aws_ssm.IStringParameter
+
+Parameter name string (e.g. '/sops/age/key') or an `IStringParameter` reference from `aws-cdk-lib/aws-ssm`.
+
+---
+
+###### `encryptionKey`Required
+
+- *Type:* aws-cdk-lib.aws_kms.IKey
+
+KMS key used to encrypt the SecureString parameter.
+
+---
+
#### Static Functions
| **Name** | **Description** |
diff --git a/README.md b/README.md
index a24852b8..4b971406 100644
--- a/README.md
+++ b/README.md
@@ -86,6 +86,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
- [SopsStringParameter — Sops to single SSM ParameterStore Parameter](#sopsstringparameter--sops-to-single-ssm-parameterstore-parameter)
- [MultiStringParameter — Sops to multiple SSM ParameterStore Parameters](#multistringparameter--sops-to-multiple-ssm-parameterstore-parameters)
- [SopsSyncProvider](#sopssyncprovider)
+ - [Age Key from SSM Parameter Store](#age-key-from-ssm-parameter-store)
- [Common configuration options for SopsSecret, SopsStringParameter and MultiStringParameter](#common-configuration-options-for-sopssecret-sopsstringparameter-and-multistringparameter)
- [Considerations](#considerations)
- [UploadType: INLINE / ASSET](#uploadtype-inline--asset)
@@ -299,6 +300,82 @@ const secret = new SopsSecret(this, 'MySecret', {
});
```
+## Age Key from SSM Parameter Store
+
+By default, age private keys are passed to the sync Lambda via the `SOPS_AGE_KEY` environment variable, which means the key must be present at CDK synthesis time and is stored in plaintext in the Lambda configuration.
+
+`addAgeKeyFromSsmParameter` eliminates this exposure: the key stays in SSM Parameter Store and is fetched at deploy time by the Lambda itself, entirely in-memory during decryption.
+
+The SSM parameter **must** be a `SecureString` encrypted with a KMS customer-managed key (CMK). Storing an age private key without envelope encryption is considered insecure, so the KMS key is a required argument.
+
+### Basic usage
+
+```ts
+import { Key } from 'aws-cdk-lib/aws-kms';
+
+const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');
+
+const provider = new SopsSyncProvider(this, 'SopsProvider');
+provider.addAgeKeyFromSsmParameter('/sops/age/private-key', cmk);
+
+const secret = new SopsSecret(this, 'MySecret', {
+ sopsFilePath: 'secrets/encrypted.yaml',
+ sopsProvider: provider,
+});
+```
+
+The construct automatically grants the Lambda `ssm:GetParameter` on the parameter and `kms:Decrypt` on the CMK.
+
+### Using an `IStringParameter` reference
+
+Pass an `IStringParameter` object instead of a plain string if you already have a CDK parameter reference:
+
+```ts
+import { Key } from 'aws-cdk-lib/aws-kms';
+import { StringParameter } from 'aws-cdk-lib/aws-ssm';
+
+const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');
+const keyParam = StringParameter.fromStringParameterName(
+ this,
+ 'AgeKeyParam',
+ '/sops/age/private-key',
+);
+
+const provider = new SopsSyncProvider(this, 'SopsProvider');
+provider.addAgeKeyFromSsmParameter(keyParam, cmk);
+```
+
+### Combining with a static age key
+
+Both methods can be used together. All keys — static and SSM-fetched — are merged and presented to sops during decryption:
+
+```ts
+import { SecretValue } from 'aws-cdk-lib';
+import { Key } from 'aws-cdk-lib/aws-kms';
+
+const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');
+const provider = new SopsSyncProvider(this, 'SopsProvider');
+
+// Statically injected at synthesis time (legacy approach)
+provider.addAgeKey(SecretValue.ssmSecure('/sops/age/legacy-key'));
+
+// Fetched from SSM at deploy time (recommended)
+provider.addAgeKeyFromSsmParameter('/sops/age/current-key', cmk);
+```
+
+### Multiple keys
+
+Call `addAgeKeyFromSsmParameter` multiple times to register additional keys, which is useful for key rotation:
+
+```ts
+import { Key } from 'aws-cdk-lib/aws-kms';
+
+const cmk = Key.fromKeyArn(this, 'SsmCmk', 'arn:aws:kms:us-east-1:111122223333:key/…');
+const provider = new SopsSyncProvider(this, 'SopsProvider');
+provider.addAgeKeyFromSsmParameter('/sops/age/key-v1', cmk);
+provider.addAgeKeyFromSsmParameter('/sops/age/key-v2', cmk);
+```
+
## Common configuration options for SopsSecret, SopsStringParameter and MultiStringParameter
```ts
diff --git a/lambda/internal/client/__snapshots__/ssm_get_parameter.snap b/lambda/internal/client/__snapshots__/ssm_get_parameter.snap
new file mode 100644
index 00000000..87845946
--- /dev/null
+++ b/lambda/internal/client/__snapshots__/ssm_get_parameter.snap
@@ -0,0 +1,8 @@
+
+[TestSsmGetParameter - 1]
+&ssm.GetParameterInput{
+ Name: &"/test/age/key",
+ WithDecryption: &bool(true),
+ noSmithyDocumentSerde: document.NoSerde{},
+}
+---
diff --git a/lambda/internal/client/client.go b/lambda/internal/client/client.go
index c4b8040f..f210b2dd 100644
--- a/lambda/internal/client/client.go
+++ b/lambda/internal/client/client.go
@@ -28,6 +28,7 @@ type SecretsManagerClient interface {
type SsmClient interface {
PutParameter(ctx context.Context, params *ssm.PutParameterInput, optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error)
+ GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error)
}
type AwsClient interface {
@@ -35,6 +36,7 @@ type AwsClient interface {
S3GetObjectETAG(file SopsS3File) (*string, error)
SecretsManagerPutSecretValue(sopsHash string, secretArn string, secretContent *[]byte, binary *bool) (data *secretsmanager.PutSecretValueOutput, err error)
SsmPutParameter(parameterName string, parameterContent *[]byte, keyId string) (response *ssm.PutParameterOutput, err error)
+ SsmGetParameter(parameterName string) (*string, error)
}
type Client struct {
@@ -134,3 +136,20 @@ func (c *Client) SsmPutParameter(parameterName string, parameterContent *[]byte,
}
return c.ssm.PutParameter(c.ctx, input)
}
+
+func (c *Client) SsmGetParameter(parameterName string) (*string, error) {
+ logger := slog.With("Package", "client", "Function", "SsmGetParameter")
+ logger.Info("Fetching parameter", "Name", parameterName)
+
+ resp, err := c.ssm.GetParameter(c.ctx, &ssm.GetParameterInput{
+ Name: ¶meterName,
+ WithDecryption: aws.Bool(true),
+ })
+ if err != nil {
+ return nil, fmt.Errorf("SSM get parameter error:\n%v", err)
+ }
+ if resp.Parameter == nil || resp.Parameter.Value == nil {
+ return nil, fmt.Errorf("SSM parameter value is nil for parameter: %s", parameterName)
+ }
+ return resp.Parameter.Value, nil
+}
diff --git a/lambda/internal/client/client_test.go b/lambda/internal/client/client_test.go
index 53db1aa8..79bae6bc 100644
--- a/lambda/internal/client/client_test.go
+++ b/lambda/internal/client/client_test.go
@@ -11,6 +11,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/aws/aws-sdk-go-v2/service/ssm"
+ ssmTypes "github.com/aws/aws-sdk-go-v2/service/ssm/types"
"github.com/aws/smithy-go/ptr"
"github.com/stretchr/testify/assert"
)
@@ -230,3 +231,68 @@ func TestSsmPutParameter(t *testing.T) {
})
}
}
+
+func TestSsmGetParameter(t *testing.T) {
+ tests := []struct {
+ name string
+ mockClient *MockAwsClient
+ expectedValue *string
+ expectedErrMsg string
+ }{
+ {
+ name: "successful get parameter",
+ mockClient: &MockAwsClient{
+ t: t,
+ snapsFileName: "ssm_get_parameter",
+ GetParameterRet: &ssm.GetParameterOutput{
+ Parameter: &ssmTypes.Parameter{
+ Value: aws.String("AGE-SECRET-KEY-1MOCK"),
+ },
+ },
+ ReturnError: nil,
+ },
+ expectedValue: aws.String("AGE-SECRET-KEY-1MOCK"),
+ },
+ {
+ name: "get parameter error",
+ mockClient: &MockAwsClient{
+ t: t,
+ snapsFileName: "ssm_get_parameter_error",
+ GetParameterRet: nil,
+ ReturnError: fmt.Errorf("mock error"),
+ },
+ expectedErrMsg: "SSM get parameter error:\nmock error",
+ },
+ {
+ name: "nil parameter value",
+ mockClient: &MockAwsClient{
+ t: t,
+ snapsFileName: "ssm_get_parameter_nil_value",
+ GetParameterRet: &ssm.GetParameterOutput{
+ Parameter: &ssmTypes.Parameter{
+ Value: nil,
+ },
+ },
+ ReturnError: nil,
+ },
+ expectedErrMsg: "SSM parameter value is nil",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ client := &Client{
+ ctx: context.Background(),
+ ssm: tt.mockClient,
+ }
+ value, err := client.SsmGetParameter("/test/age/key")
+ if tt.expectedErrMsg != "" {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.expectedErrMsg)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expectedValue, value)
+ }
+ })
+ }
+}
diff --git a/lambda/internal/client/mock.go b/lambda/internal/client/mock.go
index 89ec3d92..61aa1f74 100644
--- a/lambda/internal/client/mock.go
+++ b/lambda/internal/client/mock.go
@@ -25,6 +25,7 @@ type MockAwsClient struct {
GetObjectRet *s3.GetObjectOutput
GetObjectAttributesRet *s3.GetObjectAttributesOutput
PutParameterRet *ssm.PutParameterOutput
+ GetParameterRet *ssm.GetParameterOutput
PutSecretValueRet *secretsmanager.PutSecretValueOutput
}
@@ -49,6 +50,13 @@ func (s *MockAwsClient) PutParameter(ctx context.Context, params *ssm.PutParamet
return s.PutParameterRet, s.ReturnError
}
+func (s *MockAwsClient) GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) {
+ if s.ReturnError == nil {
+ snaps.WithConfig(snaps.Filename(s.snapsFileName)).MatchSnapshot(s.t, params)
+ }
+ return s.GetParameterRet, s.ReturnError
+}
+
func (s *MockAwsClient) PutSecretValue(ctx context.Context, params *secretsmanager.PutSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.PutSecretValueOutput, error) {
if s.ReturnError == nil {
snaps.WithConfig(snaps.Filename(s.snapsFileName)).MatchSnapshot(s.t, params)
@@ -79,3 +87,9 @@ func (m *MockClient) SecretsManagerPutSecretValue(sopsHash string, secretArn str
func (m *MockClient) SsmPutParameter(parameterName string, parameterContent *[]byte, keyId string) (*ssm.PutParameterOutput, error) {
return nil, nil
}
+
+func (m *MockClient) SsmGetParameter(parameterName string) (*string, error) {
+ // Return a non-nil string pointer to avoid nil dereferences when err == nil.
+ empty := ""
+ return &empty, nil
+}
diff --git a/lambda/internal/sops/sops.go b/lambda/internal/sops/sops.go
index 455e2bc2..e0a807f7 100644
--- a/lambda/internal/sops/sops.go
+++ b/lambda/internal/sops/sops.go
@@ -8,6 +8,7 @@ import (
"github.com/getsops/sops/v3/decrypt"
"github.com/markussiebert/cdk-sops-secrets/internal/data"
+ "gopkg.in/yaml.v3"
)
type Format string
@@ -42,6 +43,42 @@ func CreateEncryptedSopsSecret(content []byte, format Format, hash string) (*Enc
}, nil
}
+func (e EncryptedSopsSecret) UsesAgeEncryption() bool {
+ logger := slog.With("Package", "sops", "Function", "UsesAgeEncryption")
+
+ switch e.Format {
+ case JSON, BINARY:
+ var meta struct {
+ SOPS struct {
+ Age []json.RawMessage `json:"age"`
+ } `json:"sops"`
+ }
+ if err := json.Unmarshal(e.Content, &meta); err != nil {
+ logger.Warn("could not parse SOPS JSON metadata, assuming age is present", "error", err)
+ return true
+ }
+ return len(meta.SOPS.Age) > 0
+
+ case YAML:
+ var meta struct {
+ SOPS struct {
+ Age []interface{} `yaml:"age"`
+ } `yaml:"sops"`
+ }
+ if err := yaml.Unmarshal(e.Content, &meta); err != nil {
+ logger.Warn("could not parse SOPS YAML metadata, assuming age is present", "error", err)
+ return true
+ }
+ return len(meta.SOPS.Age) > 0
+
+ case DOTENV:
+ return bytes.Contains(e.Content, []byte("sops_age__list_"))
+
+ default:
+ return false
+ }
+}
+
type DecryptedSopsSecret struct {
content []byte
format Format
diff --git a/lambda/internal/sops/sops_test.go b/lambda/internal/sops/sops_test.go
index c1d2a4d3..0163fb6a 100644
--- a/lambda/internal/sops/sops_test.go
+++ b/lambda/internal/sops/sops_test.go
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/gkampitakis/go-snaps/snaps"
+ "github.com/stretchr/testify/assert"
)
func read(filename string) ([]byte, error) {
@@ -16,6 +17,105 @@ func read(filename string) ([]byte, error) {
return existingContent, nil
}
+func TestUsesAgeEncryption(t *testing.T) {
+ tests := []struct {
+ name string
+ content []byte
+ format Format
+ want bool
+ }{
+ {
+ name: "JSON with age keys",
+ format: JSON,
+ content: []byte(`{"sops":{"age":[{"recipient":"age1xxx","enc":"---BEGIN---"}],"kms":null}}`),
+ want: true,
+ },
+ {
+ name: "JSON with null age",
+ format: JSON,
+ content: []byte(`{"sops":{"age":null,"kms":[{"arn":"arn:aws:kms:..."}]}}`),
+ want: false,
+ },
+ {
+ name: "JSON with empty age array",
+ format: JSON,
+ content: []byte(`{"sops":{"age":[]}}`),
+ want: false,
+ },
+ {
+ name: "YAML with age keys",
+ format: YAML,
+ content: []byte("sops:\n age:\n - recipient: age1xxx\n enc: '---'\n"),
+ want: true,
+ },
+ {
+ name: "YAML with no age key",
+ format: YAML,
+ content: []byte("sops:\n kms:\n - arn: arn:aws:kms:...\n"),
+ want: false,
+ },
+ {
+ name: "YAML with empty age list",
+ format: YAML,
+ content: []byte("sops:\n age: []\n"),
+ want: false,
+ },
+ {
+ name: "dotenv with age metadata",
+ format: DOTENV,
+ content: []byte("KEY=ENC[...]\nsops_age__list_0__map_recipient=age1xxx\n"),
+ want: true,
+ },
+ {
+ name: "dotenv without age metadata",
+ format: DOTENV,
+ content: []byte("KEY=ENC[...]\nsops_kms__list_0__map_arn=arn:aws:kms:...\n"),
+ want: false,
+ },
+ {
+ name: "binary (JSON envelope) with age keys",
+ format: BINARY,
+ content: []byte(`{"data":"ENC[...]","sops":{"age":[{"recipient":"age1xxx","enc":"---"}]}}`),
+ want: true,
+ },
+ {
+ name: "binary (JSON envelope) without age keys",
+ format: BINARY,
+ content: []byte(`{"data":"ENC[...]","sops":{"age":null,"kms":[{"arn":"arn:aws:kms:..."}]}}`),
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ secret := EncryptedSopsSecret{Content: tt.content, Format: tt.format, Hash: "testhash"}
+ assert.Equal(t, tt.want, secret.UsesAgeEncryption())
+ })
+ }
+}
+
+func TestUsesAgeEncryption_RealFiles(t *testing.T) {
+ cases := []struct {
+ file string
+ format Format
+ }{
+ {"testsecret.sops.json", JSON},
+ {"testsecret.sops.yaml", YAML},
+ {"testsecret.sops.env", DOTENV},
+ {"README.sops.binary", BINARY},
+ }
+ for _, tc := range cases {
+ t.Run(tc.file, func(t *testing.T) {
+ content, err := read(tc.file)
+ if err != nil {
+ t.Fatalf("failed to read %s: %v", tc.file, err)
+ }
+ secret := EncryptedSopsSecret{Content: content, Format: tc.format, Hash: "testhash"}
+ assert.True(t, secret.UsesAgeEncryption(), "expected age encryption to be detected in %s", tc.file)
+ })
+ }
+}
+
func TestDecrypt(t *testing.T) {
tests := map[Format]string{
BINARY: "README.sops.binary",
diff --git a/lambda/main.go b/lambda/main.go
index bcf71226..d1a7829d 100644
--- a/lambda/main.go
+++ b/lambda/main.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"log/slog"
+ "os"
+ "strings"
"github.com/aws/aws-lambda-go/cfn"
runtime "github.com/aws/aws-lambda-go/lambda"
@@ -11,6 +13,48 @@ import (
"github.com/markussiebert/cdk-sops-secrets/internal/event"
)
+// staticAgeKey captures the value of SOPS_AGE_KEY at Lambda cold-start so that
+// loadAgeKeysFromSSM can always rebuild the combined key list correctly across
+// warm invocations without duplicating the static portion.
+var staticAgeKey = os.Getenv("SOPS_AGE_KEY")
+
+// loadAgeKeysFromSSM reads parameter names from SOPS_AGE_KEY_PARAMS (newline-
+// separated), fetches each SecureString from SSM Parameter Store with decryption,
+// and sets SOPS_AGE_KEY to the combined value of any static key already present
+// at cold-start plus all SSM-fetched keys. The function is a no-op when
+// SOPS_AGE_KEY_PARAMS is unset or empty.
+func loadAgeKeysFromSSM(clients client.AwsClient) error {
+ logger := slog.With("Package", "main", "Function", "loadAgeKeysFromSSM")
+
+ paramList := os.Getenv("SOPS_AGE_KEY_PARAMS")
+ if paramList == "" {
+ return nil
+ }
+
+ var ageKeys []string
+ if staticAgeKey != "" {
+ ageKeys = append(ageKeys, staticAgeKey)
+ }
+
+ for _, paramName := range strings.Split(paramList, "\n") {
+ paramName = strings.TrimSpace(paramName)
+ if paramName == "" {
+ continue
+ }
+ logger.Info("Fetching age key from SSM Parameter Store", "Parameter", paramName)
+ value, err := clients.SsmGetParameter(paramName)
+ if err != nil {
+ return fmt.Errorf("failed to fetch age key from SSM parameter %s: %v", paramName, err)
+ }
+ if value == nil {
+ return fmt.Errorf("received nil value for SSM parameter %s", paramName)
+ }
+ ageKeys = append(ageKeys, *value)
+ }
+
+ return os.Setenv("SOPS_AGE_KEY", strings.Join(ageKeys, "\n"))
+}
+
func HandleRequestWithClients(clients client.AwsClient, e cfn.Event) (physicalResourceID string, data map[string]interface{}, err error) {
logger := slog.With("Package", "main", "Function", "HandleRequestWithClients")
logger.Debug("Incoming Event", "Event", e)
@@ -36,6 +80,13 @@ func HandleRequestWithClients(clients client.AwsClient, e cfn.Event) (physicalRe
return props.GeneratePhysicalResourceId(), nil, secretEncryptedErr
}
+ // Fetch SSM age keys only when the SOPS file actually uses age encryption.
+ if secretEncrypted.UsesAgeEncryption() {
+ if err := loadAgeKeysFromSSM(clients); err != nil {
+ return props.GeneratePhysicalResourceId(), nil, err
+ }
+ }
+
// Decrypt the secret input with sops
secretDecrypted, secretDecryptedErr := secretEncrypted.Decrypt()
if secretDecryptedErr != nil {
diff --git a/lambda/main_test.go b/lambda/main_test.go
index 602f29f0..cf852d6c 100644
--- a/lambda/main_test.go
+++ b/lambda/main_test.go
@@ -4,7 +4,9 @@ package main
import (
"encoding/json"
+ "fmt"
"os"
+ "strings"
"testing"
"time"
@@ -21,11 +23,12 @@ type putSecretValueCalls map[string]interface{}
type getObjectEtagCalls map[string]client.SopsS3File
type getObjectCalls map[string]client.SopsS3File
type MockAwsClient struct {
- t *testing.T
- putParameter putParameterCalls
- putSecretValue putSecretValueCalls
- getObjectEtag getObjectEtagCalls
- getObject getObjectCalls
+ t *testing.T
+ putParameter putParameterCalls
+ putSecretValue putSecretValueCalls
+ getObjectEtag getObjectEtagCalls
+ getObject getObjectCalls
+ ssmGetParameterValues map[string]string
}
func (m *MockAwsClient) S3GetObject(file client.SopsS3File) ([]byte, error) {
@@ -60,6 +63,17 @@ func (m *MockAwsClient) SsmPutParameter(parameterName string, parameterContent *
return &ssm.PutParameterOutput{Version: 1}, nil
}
+func (m *MockAwsClient) SsmGetParameter(parameterName string) (*string, error) {
+ if m.ssmGetParameterValues == nil {
+ return nil, fmt.Errorf("SsmGetParameter not expected in unit tests")
+ }
+ v, ok := m.ssmGetParameterValues[parameterName]
+ if !ok {
+ return nil, fmt.Errorf("SsmGetParameter: unexpected parameter %q", parameterName)
+ }
+ return &v, nil
+}
+
func TestHandleRequestWithClients(t *testing.T) {
t.Logf("Running at: %s", time.Now().String()) // Forces re-run
os.Setenv("AWS_REGION", "eu-central-1")
@@ -120,3 +134,107 @@ func TestHandleRequestWithClients(t *testing.T) {
})
}
}
+
+func TestLoadAgeKeysFromSSM(t *testing.T) {
+ makeClients := func(params map[string]string) *MockAwsClient {
+ return &MockAwsClient{
+ t: t,
+ putParameter: putParameterCalls{},
+ putSecretValue: putSecretValueCalls{},
+ getObjectEtag: getObjectEtagCalls{},
+ getObject: getObjectCalls{},
+ ssmGetParameterValues: params,
+ }
+ }
+
+ t.Run("noop when SOPS_AGE_KEY_PARAMS is unset", func(t *testing.T) {
+ t.Setenv("SOPS_AGE_KEY_PARAMS", "")
+ t.Setenv("SOPS_AGE_KEY", "static-key")
+
+ err := loadAgeKeysFromSSM(makeClients(nil))
+ assert.NoError(t, err)
+ assert.Equal(t, "static-key", os.Getenv("SOPS_AGE_KEY"))
+ })
+
+ t.Run("single SSM parameter replaces SOPS_AGE_KEY when no static key", func(t *testing.T) {
+ t.Setenv("SOPS_AGE_KEY_PARAMS", "/my/age/key")
+ t.Setenv("SOPS_AGE_KEY", "")
+ staticAgeKey = ""
+
+ err := loadAgeKeysFromSSM(makeClients(map[string]string{
+ "/my/age/key": "AGE-SECRET-KEY-FROM-SSM",
+ }))
+ assert.NoError(t, err)
+ assert.Equal(t, "AGE-SECRET-KEY-FROM-SSM", os.Getenv("SOPS_AGE_KEY"))
+ })
+
+ t.Run("multiple SSM parameters are joined with newline", func(t *testing.T) {
+ t.Setenv("SOPS_AGE_KEY_PARAMS", "/key/one\n/key/two")
+ t.Setenv("SOPS_AGE_KEY", "")
+ staticAgeKey = ""
+
+ err := loadAgeKeysFromSSM(makeClients(map[string]string{
+ "/key/one": "AGE-KEY-ONE",
+ "/key/two": "AGE-KEY-TWO",
+ }))
+ assert.NoError(t, err)
+ got := os.Getenv("SOPS_AGE_KEY")
+ parts := strings.Split(got, "\n")
+ assert.Equal(t, []string{"AGE-KEY-ONE", "AGE-KEY-TWO"}, parts)
+ })
+
+ t.Run("static cold-start key is preserved and prepended", func(t *testing.T) {
+ const coldStartKey = "AGE-SECRET-KEY-COLDSTART"
+ staticAgeKey = coldStartKey
+ t.Setenv("SOPS_AGE_KEY_PARAMS", "/ssm/key")
+ t.Setenv("SOPS_AGE_KEY", coldStartKey)
+
+ err := loadAgeKeysFromSSM(makeClients(map[string]string{
+ "/ssm/key": "AGE-SECRET-KEY-FROM-SSM",
+ }))
+ assert.NoError(t, err)
+ got := os.Getenv("SOPS_AGE_KEY")
+ parts := strings.Split(got, "\n")
+ assert.Equal(t, []string{coldStartKey, "AGE-SECRET-KEY-FROM-SSM"}, parts)
+ })
+
+ t.Run("warm invocation does not accumulate SSM keys", func(t *testing.T) {
+ const coldStartKey = "AGE-SECRET-KEY-COLDSTART"
+ staticAgeKey = coldStartKey
+ t.Setenv("SOPS_AGE_KEY_PARAMS", "/ssm/key")
+
+ clients := makeClients(map[string]string{"/ssm/key": "AGE-SECRET-KEY-FROM-SSM"})
+
+ assert.NoError(t, loadAgeKeysFromSSM(clients))
+ first := os.Getenv("SOPS_AGE_KEY")
+
+ assert.NoError(t, loadAgeKeysFromSSM(clients))
+ second := os.Getenv("SOPS_AGE_KEY")
+
+ assert.Equal(t, first, second, "warm invocation must not duplicate keys")
+ parts := strings.Split(second, "\n")
+ assert.Equal(t, []string{coldStartKey, "AGE-SECRET-KEY-FROM-SSM"}, parts)
+ })
+
+ t.Run("blank lines in SOPS_AGE_KEY_PARAMS are skipped", func(t *testing.T) {
+ t.Setenv("SOPS_AGE_KEY_PARAMS", "\n/real/key\n\n")
+ t.Setenv("SOPS_AGE_KEY", "")
+ staticAgeKey = ""
+
+ err := loadAgeKeysFromSSM(makeClients(map[string]string{
+ "/real/key": "AGE-REAL-KEY",
+ }))
+ assert.NoError(t, err)
+ assert.Equal(t, "AGE-REAL-KEY", os.Getenv("SOPS_AGE_KEY"))
+ })
+
+ t.Run("SSM fetch error is propagated", func(t *testing.T) {
+ t.Setenv("SOPS_AGE_KEY_PARAMS", "/bad/param")
+ t.Setenv("SOPS_AGE_KEY", "")
+ staticAgeKey = ""
+
+ err := loadAgeKeysFromSSM(makeClients(map[string]string{}))
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "/bad/param")
+ })
+}
diff --git a/src/SopsSync.ts b/src/SopsSync.ts
index 39e22d21..18e9829e 100644
--- a/src/SopsSync.ts
+++ b/src/SopsSync.ts
@@ -2,6 +2,7 @@ import * as fs from 'fs';
import * as path from 'path';
import {
Annotations,
+ ArnFormat,
CustomResource,
Duration,
FileSystem,
@@ -21,6 +22,7 @@ import { Code, Runtime, SingletonFunction } from 'aws-cdk-lib/aws-lambda';
import { RetentionDays, ILogGroup } from 'aws-cdk-lib/aws-logs';
import { Asset } from 'aws-cdk-lib/aws-s3-assets';
import { ISecret } from 'aws-cdk-lib/aws-secretsmanager';
+import { IStringParameter } from 'aws-cdk-lib/aws-ssm';
import { Construct } from 'constructs';
import { SopsSyncResourcePropertys } from './LambdaInterface';
@@ -225,6 +227,7 @@ export interface SopsSyncProviderProps {
export class SopsSyncProvider extends SingletonFunction implements IGrantable {
private sopsAgeKeys: SecretValue[];
+ private sopsAgeKeyParams: string[];
constructor(scope: Construct, id?: string, props?: SopsSyncProviderProps) {
if (id === undefined) {
@@ -250,6 +253,9 @@ export class SopsSyncProvider extends SingletonFunction implements IGrantable {
this.sopsAgeKeys.map((secret) => secret.unsafeUnwrap()) ?? []
).join('\n'),
}),
+ SOPS_AGE_KEY_PARAMS: Lazy.string({
+ produce: () => this.sopsAgeKeyParams.join('\n'),
+ }),
},
vpc: props?.vpc,
vpcSubnets: props?.vpcSubnets,
@@ -260,11 +266,54 @@ export class SopsSyncProvider extends SingletonFunction implements IGrantable {
logGroup: props?.logGroup,
});
this.sopsAgeKeys = [];
+ this.sopsAgeKeyParams = [];
}
public addAgeKey(key: SecretValue) {
this.sopsAgeKeys.push(key);
}
+
+ /**
+ * Configure the Lambda to fetch an age private key from an SSM Parameter
+ * Store SecureString at runtime, rather than injecting it as a plaintext
+ * environment variable at synthesis time.
+ *
+ * The KMS key used to encrypt the SecureString is required: storing an age
+ * private key without envelope encryption is considered insecure.
+ *
+ * The Lambda is automatically granted `ssm:GetParameter` on the parameter
+ * and `kms:Decrypt` on the encryption key.
+ *
+ * @param param Parameter name string (e.g. '/sops/age/key') or an
+ * `IStringParameter` reference from `aws-cdk-lib/aws-ssm`.
+ * @param encryptionKey KMS key used to encrypt the SecureString parameter.
+ */
+ public addAgeKeyFromSsmParameter(
+ param: string | IStringParameter,
+ encryptionKey: IKey,
+ ): void {
+ const parameterName =
+ typeof param === 'string' ? param : param.parameterName;
+ this.sopsAgeKeyParams.push(parameterName);
+
+ const paramArn = Stack.of(this).formatArn({
+ service: 'ssm',
+ resource: 'parameter',
+ resourceName: parameterName.startsWith('/')
+ ? parameterName.slice(1)
+ : parameterName,
+ arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
+ });
+
+ this.addToRolePolicy(
+ new PolicyStatement({
+ actions: ['ssm:GetParameter'],
+ resources: [paramArn],
+ }),
+ );
+
+ encryptionKey.grantDecrypt(this);
+ }
}
/**
diff --git a/test/__snapshots__/PARAMETER.integ.snapshot.json b/test/__snapshots__/PARAMETER.integ.snapshot.json
index 90346d1d..f11614a5 100644
--- a/test/__snapshots__/PARAMETER.integ.snapshot.json
+++ b/test/__snapshots__/PARAMETER.integ.snapshot.json
@@ -143,7 +143,8 @@
},
"Environment": {
"Variables": {
- "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3"
+ "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3",
+ "SOPS_AGE_KEY_PARAMS": ""
}
},
"Handler": "bootstrap",
diff --git a/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json b/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json
index 38d06717..d9e78330 100644
--- a/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json
+++ b/test/__snapshots__/PARAMETER_MULTI.integ.snapshot.json
@@ -539,7 +539,8 @@
},
"Environment": {
"Variables": {
- "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3"
+ "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3",
+ "SOPS_AGE_KEY_PARAMS": ""
}
},
"Handler": "bootstrap",
diff --git a/test/__snapshots__/SECRET.integ.snapshot.json b/test/__snapshots__/SECRET.integ.snapshot.json
index f8982115..9220db55 100644
--- a/test/__snapshots__/SECRET.integ.snapshot.json
+++ b/test/__snapshots__/SECRET.integ.snapshot.json
@@ -178,7 +178,8 @@
},
"Environment": {
"Variables": {
- "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3"
+ "SOPS_AGE_KEY": "AGE-SECRET-KEY-1EFUWJ0G2XJTJFWTAM2DGMA4VCK3R05W58FSMHZP3MZQ0ZTAQEAFQC6T7T3",
+ "SOPS_AGE_KEY_PARAMS": ""
}
},
"Handler": "bootstrap",
diff --git a/test/secret.test.ts b/test/secret.test.ts
index 7fe6eb70..77a6db4c 100644
--- a/test/secret.test.ts
+++ b/test/secret.test.ts
@@ -10,6 +10,7 @@ import {
import { Key } from 'aws-cdk-lib/aws-kms';
import { Function, InlineCode, Runtime } from 'aws-cdk-lib/aws-lambda';
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
+import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import {
SopsSecret,
SopsSyncProvider,
@@ -743,3 +744,178 @@ test('Custom LogGroup', () => {
RetentionInDays: 90,
});
});
+
+test('Age Key from SSM Parameter string', () => {
+ const app = new App();
+ const stack = new Stack(app, 'SecretIntegration');
+ const cmk = Key.fromKeyArn(
+ stack,
+ 'SsmCmk',
+ 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333',
+ );
+
+ const provider = new SopsSyncProvider(stack, 'Provider');
+ provider.addAgeKeyFromSsmParameter('/sops/age/private-key', cmk);
+ new SopsSecret(stack, 'SopsSecret', {
+ sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml',
+ sopsProvider: provider,
+ });
+
+ const template = Template.fromStack(stack);
+
+ // SOPS_AGE_KEY_PARAMS env var must be set on the Lambda
+ template.hasResourceProperties('AWS::Lambda::Function', {
+ Environment: Match.objectLike({
+ Variables: Match.objectLike({
+ SOPS_AGE_KEY_PARAMS: '/sops/age/private-key',
+ }),
+ }),
+ });
+
+ // Lambda role must have ssm:GetParameter for the parameter
+ template.hasResource('AWS::IAM::Policy', {
+ Properties: Match.objectLike({
+ PolicyDocument: Match.objectLike({
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Action: 'ssm:GetParameter',
+ Effect: 'Allow',
+ Resource: {
+ 'Fn::Join': [
+ '',
+ [
+ 'arn:',
+ { Ref: 'AWS::Partition' },
+ ':ssm:',
+ { Ref: 'AWS::Region' },
+ ':',
+ { Ref: 'AWS::AccountId' },
+ ':parameter/sops/age/private-key',
+ ],
+ ],
+ },
+ }),
+ ]),
+ }),
+ }),
+ });
+
+ // Lambda role must have kms:Decrypt for the encryption key
+ template.hasResource('AWS::IAM::Policy', {
+ Properties: Match.objectLike({
+ PolicyDocument: Match.objectLike({
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Action: 'kms:Decrypt',
+ Effect: 'Allow',
+ Resource:
+ 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333',
+ }),
+ ]),
+ }),
+ }),
+ });
+});
+
+test('Age Key from SSM IStringParameter reference', () => {
+ const app = new App();
+ const stack = new Stack(app, 'SecretIntegration');
+ const cmk = Key.fromKeyArn(
+ stack,
+ 'SsmCmk',
+ 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333',
+ );
+
+ const provider = new SopsSyncProvider(stack, 'Provider');
+ const keyParam = StringParameter.fromStringParameterName(
+ stack,
+ 'AgeKeyParam',
+ '/sops/age/private-key',
+ );
+ provider.addAgeKeyFromSsmParameter(keyParam, cmk);
+ new SopsSecret(stack, 'SopsSecret', {
+ sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml',
+ sopsProvider: provider,
+ });
+
+ Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', {
+ Environment: Match.objectLike({
+ Variables: Match.objectLike({
+ SOPS_AGE_KEY_PARAMS: '/sops/age/private-key',
+ }),
+ }),
+ });
+});
+
+test('Age Key from SSM combined with static age key', () => {
+ const app = new App();
+ const stack = new Stack(app, 'SecretIntegration');
+ const cmk = Key.fromKeyArn(
+ stack,
+ 'SsmCmk',
+ 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333',
+ );
+
+ const provider = new SopsSyncProvider(stack, 'Provider');
+ provider.addAgeKey(SecretValue.unsafePlainText('STATIC-KEY'));
+ provider.addAgeKeyFromSsmParameter('/sops/age/private-key', cmk);
+
+ new SopsSecret(stack, 'SopsSecret', {
+ sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml',
+ sopsProvider: provider,
+ });
+
+ // Both env vars must be set
+ Template.fromStack(stack).hasResourceProperties('AWS::Lambda::Function', {
+ Environment: Match.objectLike({
+ Variables: Match.objectLike({
+ SOPS_AGE_KEY: 'STATIC-KEY',
+ SOPS_AGE_KEY_PARAMS: '/sops/age/private-key',
+ }),
+ }),
+ });
+});
+
+test('Age Key from SSM Parameter without leading slash gets correct ARN', () => {
+ const app = new App();
+ const stack = new Stack(app, 'SecretIntegration');
+ const cmk = Key.fromKeyArn(
+ stack,
+ 'SsmCmk',
+ 'arn:aws:kms:us-east-1:111122223333:key/aaaabbbb-1234-5678-abcd-111122223333',
+ );
+
+ const provider = new SopsSyncProvider(stack, 'Provider');
+ provider.addAgeKeyFromSsmParameter('sops/age/private-key', cmk);
+ new SopsSecret(stack, 'SopsSecret', {
+ sopsFilePath: 'test-secrets/yaml/sopsfile.enc-kms.yaml',
+ sopsProvider: provider,
+ });
+
+ Template.fromStack(stack).hasResource('AWS::IAM::Policy', {
+ Properties: Match.objectLike({
+ PolicyDocument: Match.objectLike({
+ Statement: Match.arrayWith([
+ Match.objectLike({
+ Action: 'ssm:GetParameter',
+ Effect: 'Allow',
+ Resource: {
+ 'Fn::Join': [
+ '',
+ [
+ 'arn:',
+ { Ref: 'AWS::Partition' },
+ ':ssm:',
+ { Ref: 'AWS::Region' },
+ ':',
+ { Ref: 'AWS::AccountId' },
+ ':parameter/sops/age/private-key',
+ ],
+ ],
+ },
+ }),
+ ]),
+ }),
+ }),
+ });
+});