Browse Source

feat: inject kube context as STS session tags (#6311)

Co-authored-by: Gergely Bräutigam <gergely.brautigam@sap.com>
Signed-off-by: Robert Childress <robert.childress@cfacorp.com>
Robert Childress 2 months ago
parent
commit
6e13565ee6

+ 30 - 0
apis/externalsecrets/v1/secretstore_aws_types.go

@@ -92,6 +92,21 @@ type Tag struct {
 	Value string `json:"value"`
 }
 
+// SessionTagsPolicy defines how STS session tags are handled.
+// +kubebuilder:validation:Enum=None;Simple;Custom
+type SessionTagsPolicy string
+
+const (
+	// SessionTagsPolicyNone is the default behavior - no session tags are added.
+	SessionTagsPolicyNone SessionTagsPolicy = "None"
+	// SessionTagsPolicySimple automatically adds esoNamespace, esoStoreName, and esoStoreKind
+	// session tags.
+	SessionTagsPolicySimple SessionTagsPolicy = "Simple"
+	// SessionTagsPolicyCustom adds the tags defined in CustomSessionTags in addition to
+	// the esoNamespace, esoStoreName, and esoStoreKind tags.
+	SessionTagsPolicyCustom SessionTagsPolicy = "Custom"
+)
+
 // AWSProvider configures a store to sync secrets with AWS.
 type AWSProvider struct {
 	// Service defines which service should be used to fetch the secrets
@@ -129,6 +144,21 @@ type AWSProvider struct {
 	// +optional
 	TransitiveTagKeys []string `json:"transitiveTagKeys,omitempty"`
 
+	// SessionTagsPolicy controls whether and how STS session tags are added when assuming roles.
+	// None (default): no tags are added.
+	// Simple: automatically adds esoNamespace (from the ExternalSecret), esoStoreName, and esoStoreKind tags.
+	// Custom: adds esoNamespace, esoStoreName, and esoStoreKind plus any tags defined in CustomSessionTags.
+	// Note: the IAM role must have sts:TagSession permission when using Simple or Custom.
+	// +optional
+	// +kubebuilder:default=None
+	SessionTagsPolicy SessionTagsPolicy `json:"sessionTagsPolicy,omitempty"`
+
+	// CustomSessionTags defines additional STS session tags to include when SessionTagsPolicy is Custom.
+	// These are merged with the automatically injected esoNamespace, esoStoreName, and esoStoreKind tags.
+	// +optional
+	// +kubebuilder:validation:XValidation:rule="!('esoNamespace' in self) && !('esoStoreName' in self) && !('esoStoreKind' in self)",message="customSessionTags cannot contain automatically injected reserved keys: esoNamespace, esoStoreName, esoStoreKind"
+	CustomSessionTags map[string]string `json:"customSessionTags,omitempty"`
+
 	// Prefix adds a prefix to all retrieved values.
 	// +optional
 	Prefix string `json:"prefix,omitempty"`

+ 7 - 0
apis/externalsecrets/v1/zz_generated.deepcopy.go

@@ -123,6 +123,13 @@ func (in *AWSProvider) DeepCopyInto(out *AWSProvider) {
 		*out = make([]string, len(*in))
 		copy(*out, *in)
 	}
+	if in.CustomSessionTags != nil {
+		in, out := &in.CustomSessionTags, &out.CustomSessionTags
+		*out = make(map[string]string, len(*in))
+		for key, val := range *in {
+			(*out)[key] = val
+		}
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AWSProvider.

+ 25 - 0
config/crds/bases/external-secrets.io_clustersecretstores.yaml

@@ -513,6 +513,18 @@ spec:
                                 type: object
                             type: object
                         type: object
+                      customSessionTags:
+                        additionalProperties:
+                          type: string
+                        description: |-
+                          CustomSessionTags defines additional STS session tags to include when SessionTagsPolicy is Custom.
+                          These are merged with the automatically injected esoNamespace, esoStoreName, and esoStoreKind tags.
+                        type: object
+                        x-kubernetes-validations:
+                        - message: 'customSessionTags cannot contain automatically
+                            injected reserved keys: esoNamespace, esoStoreName, esoStoreKind'
+                          rule: '!(''esoNamespace'' in self) && !(''esoStoreName''
+                            in self) && !(''esoStoreKind'' in self)'
                       externalID:
                         description: AWS External ID set on assumed IAM roles
                         type: string
@@ -570,6 +582,19 @@ spec:
                           - value
                           type: object
                         type: array
+                      sessionTagsPolicy:
+                        default: None
+                        description: |-
+                          SessionTagsPolicy controls whether and how STS session tags are added when assuming roles.
+                          None (default): no tags are added.
+                          Simple: automatically adds esoNamespace (from the ExternalSecret), esoStoreName, and esoStoreKind tags.
+                          Custom: adds esoNamespace, esoStoreName, and esoStoreKind plus any tags defined in CustomSessionTags.
+                          Note: the IAM role must have sts:TagSession permission when using Simple or Custom.
+                        enum:
+                        - None
+                        - Simple
+                        - Custom
+                        type: string
                       transitiveTagKeys:
                         description: AWS STS assume role transitive session tags.
                           Required when multiple rules are used with the provider

+ 25 - 0
config/crds/bases/external-secrets.io_secretstores.yaml

@@ -513,6 +513,18 @@ spec:
                                 type: object
                             type: object
                         type: object
+                      customSessionTags:
+                        additionalProperties:
+                          type: string
+                        description: |-
+                          CustomSessionTags defines additional STS session tags to include when SessionTagsPolicy is Custom.
+                          These are merged with the automatically injected esoNamespace, esoStoreName, and esoStoreKind tags.
+                        type: object
+                        x-kubernetes-validations:
+                        - message: 'customSessionTags cannot contain automatically
+                            injected reserved keys: esoNamespace, esoStoreName, esoStoreKind'
+                          rule: '!(''esoNamespace'' in self) && !(''esoStoreName''
+                            in self) && !(''esoStoreKind'' in self)'
                       externalID:
                         description: AWS External ID set on assumed IAM roles
                         type: string
@@ -570,6 +582,19 @@ spec:
                           - value
                           type: object
                         type: array
+                      sessionTagsPolicy:
+                        default: None
+                        description: |-
+                          SessionTagsPolicy controls whether and how STS session tags are added when assuming roles.
+                          None (default): no tags are added.
+                          Simple: automatically adds esoNamespace (from the ExternalSecret), esoStoreName, and esoStoreKind tags.
+                          Custom: adds esoNamespace, esoStoreName, and esoStoreKind plus any tags defined in CustomSessionTags.
+                          Note: the IAM role must have sts:TagSession permission when using Simple or Custom.
+                        enum:
+                        - None
+                        - Simple
+                        - Custom
+                        type: string
                       transitiveTagKeys:
                         description: AWS STS assume role transitive session tags.
                           Required when multiple rules are used with the provider

+ 46 - 0
deploy/crds/bundle.yaml

@@ -2747,6 +2747,16 @@ spec:
                                   type: object
                               type: object
                           type: object
+                        customSessionTags:
+                          additionalProperties:
+                            type: string
+                          description: |-
+                            CustomSessionTags defines additional STS session tags to include when SessionTagsPolicy is Custom.
+                            These are merged with the automatically injected esoNamespace, esoStoreName, and esoStoreKind tags.
+                          type: object
+                          x-kubernetes-validations:
+                            - message: 'customSessionTags cannot contain automatically injected reserved keys: esoNamespace, esoStoreName, esoStoreKind'
+                              rule: '!(''esoNamespace'' in self) && !(''esoStoreName'' in self) && !(''esoStoreKind'' in self)'
                         externalID:
                           description: AWS External ID set on assumed IAM roles
                           type: string
@@ -2802,6 +2812,19 @@ spec:
                               - value
                             type: object
                           type: array
+                        sessionTagsPolicy:
+                          default: None
+                          description: |-
+                            SessionTagsPolicy controls whether and how STS session tags are added when assuming roles.
+                            None (default): no tags are added.
+                            Simple: automatically adds esoNamespace (from the ExternalSecret), esoStoreName, and esoStoreKind tags.
+                            Custom: adds esoNamespace, esoStoreName, and esoStoreKind plus any tags defined in CustomSessionTags.
+                            Note: the IAM role must have sts:TagSession permission when using Simple or Custom.
+                          enum:
+                            - None
+                            - Simple
+                            - Custom
+                          type: string
                         transitiveTagKeys:
                           description: AWS STS assume role transitive session tags. Required when multiple rules are used with the provider
                           items:
@@ -15000,6 +15023,16 @@ spec:
                                   type: object
                               type: object
                           type: object
+                        customSessionTags:
+                          additionalProperties:
+                            type: string
+                          description: |-
+                            CustomSessionTags defines additional STS session tags to include when SessionTagsPolicy is Custom.
+                            These are merged with the automatically injected esoNamespace, esoStoreName, and esoStoreKind tags.
+                          type: object
+                          x-kubernetes-validations:
+                            - message: 'customSessionTags cannot contain automatically injected reserved keys: esoNamespace, esoStoreName, esoStoreKind'
+                              rule: '!(''esoNamespace'' in self) && !(''esoStoreName'' in self) && !(''esoStoreKind'' in self)'
                         externalID:
                           description: AWS External ID set on assumed IAM roles
                           type: string
@@ -15055,6 +15088,19 @@ spec:
                               - value
                             type: object
                           type: array
+                        sessionTagsPolicy:
+                          default: None
+                          description: |-
+                            SessionTagsPolicy controls whether and how STS session tags are added when assuming roles.
+                            None (default): no tags are added.
+                            Simple: automatically adds esoNamespace (from the ExternalSecret), esoStoreName, and esoStoreKind tags.
+                            Custom: adds esoNamespace, esoStoreName, and esoStoreKind plus any tags defined in CustomSessionTags.
+                            Note: the IAM role must have sts:TagSession permission when using Simple or Custom.
+                          enum:
+                            - None
+                            - Simple
+                            - Custom
+                          type: string
                         transitiveTagKeys:
                           description: AWS STS assume role transitive session tags. Required when multiple rules are used with the provider
                           items:

+ 60 - 0
docs/api/spec.md

@@ -292,6 +292,37 @@ SecretsManager
 </tr>
 <tr>
 <td>
+<code>sessionTagsPolicy</code></br>
+<em>
+<a href="#external-secrets.io/v1.SessionTagsPolicy">
+SessionTagsPolicy
+</a>
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>SessionTagsPolicy controls whether and how STS session tags are added when assuming roles.
+None (default): no tags are added.
+Simple: automatically adds esoNamespace (from the ExternalSecret), esoStoreName, and esoStoreKind tags.
+Custom: adds esoNamespace, esoStoreName, and esoStoreKind plus any tags defined in CustomSessionTags.
+Note: the IAM role must have sts:TagSession permission when using Simple or Custom.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>customSessionTags</code></br>
+<em>
+map[string]string
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>CustomSessionTags defines additional STS session tags to include when SessionTagsPolicy is Custom.
+These are merged with the automatically injected esoNamespace, esoStoreName, and esoStoreKind tags.</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>prefix</code></br>
 <em>
 string
@@ -10664,6 +10695,35 @@ bool
 </tr>
 </tbody>
 </table>
+<h3 id="external-secrets.io/v1.SessionTagsPolicy">SessionTagsPolicy
+(<code>string</code> alias)</p></h3>
+<p>
+(<em>Appears on:</em>
+<a href="#external-secrets.io/v1.AWSProvider">AWSProvider</a>)
+</p>
+<p>
+<p>SessionTagsPolicy defines how STS session tags are handled.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Value</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody><tr><td><p>&#34;Custom&#34;</p></td>
+<td><p>SessionTagsPolicyCustom adds the tags defined in CustomSessionTags in addition to
+the esoNamespace, esoStoreName, and esoStoreKind tags.</p>
+</td>
+</tr><tr><td><p>&#34;None&#34;</p></td>
+<td><p>SessionTagsPolicyNone is the default behavior - no session tags are added.</p>
+</td>
+</tr><tr><td><p>&#34;Simple&#34;</p></td>
+<td><p>SessionTagsPolicySimple automatically adds esoNamespace, esoStoreName, and esoStoreKind
+session tags.</p>
+</td>
+</tr></tbody>
+</table>
 <h3 id="external-secrets.io/v1.StoreGeneratorSourceRef">StoreGeneratorSourceRef
 </h3>
 <p>

+ 82 - 0
docs/provider/aws-access.md

@@ -193,3 +193,85 @@ Use the following environment variables to point the controller to your custom e
 | AWS_STS_ENDPOINT            | Endpoint for the Security Token Service. The controller uses this endpoint when creating a session and when doing `assumeRole` or `assumeRoleWithWebIdentity` calls. |
 | AWS_ECR_ENDPOINT            | Endpoint for the ECR Service. The controller uses this endpoint to fetch authorization tokens from ECR.                                                              |
 | AWS_ECR_PUBLIC_ENDPOINT     | Endpoint for the Public ECR Service. The controller uses this endpoint to fetch authorization tokens from ECR.                                                       |
+
+## STS Session Tags
+
+You can have ESO automatically include Kubernetes context data into [STS session tags](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html) when assuming an IAM role. These tags can be used in IAM policy conditions to implement attribute-based access control (ABAC).
+
+The behavior is controlled by setting the optional `spec.provider.aws.sessionTagsPolicy` field on a SecretStore, which can be set to one of the following values:
+
+| Policy   | Description |
+| -------- | ----------- |
+| `None`   | Default. No session tags are added. |
+| `Simple` | Automatically adds `esoNamespace`, `esoStoreName`, and `esoStoreKind` tags. |
+| `Custom` | Adds the same three built-in tags plus any additional tags defined in `customSessionTags`. |
+
+The automatically added tags are derived from the store configuration and the namespace of the ExternalSecret:
+
+| Tag            | Value |
+| -------------- | ----- |
+| `esoNamespace` | The namespace of the `ExternalSecret` making the request. |
+| `esoStoreName` | The name of the `SecretStore` or `ClusterSecretStore`. |
+| `esoStoreKind` | The kind of the store (`SecretStore` or `ClusterSecretStore`). |
+
+Session tags are configured per secret store. If using `spec.dataFrom[].sourceRef.storeRef` to reference secrets from multiple different stores, each store must be configured with the desired `sessionTagsPolicy` independently. Although the session tags for each secret will have the name and kind of the specified secret store, they'll all share the same namespace which comes from the ExternalSecret.
+
+### Simple Policy
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: team-b-store
+  namespace: team-b
+spec:
+  provider:
+    aws:
+      service: SecretsManager
+      region: eu-central-1
+      role: team-b
+      sessionTagsPolicy: Simple
+```
+
+Session tags will include `esoNamespace=team-b`, `esoStoreName=team-b-store`, and `esoStoreKind=SecretStore`.
+
+### Custom Policy
+
+```yaml
+apiVersion: external-secrets.io/v1
+kind: SecretStore
+metadata:
+  name: team-b-store
+  namespace: team-b
+spec:
+  provider:
+    aws:
+      service: SecretsManager
+      region: eu-central-1
+      role: team-b
+      sessionTagsPolicy: Custom
+      customSessionTags:
+        env: production
+        team: platform
+```
+
+Session tags will include the three automatically added tags, plus `env=production` and `team=platform`.
+
+**NOTE:** Custom tags with empty keys or empty values are silently ignored. Built-in tags (`esoNamespace`, `esoStoreName`, `esoStoreKind`) will always be included even when the sessionTagsPolicy is `Custom`. They cannot be overridden via `customSessionTags`.
+
+### Required IAM Permissions
+
+When session tags are enabled, the role trust policy must allow `sts:TagSession`:
+
+```json
+{
+  "Version": "2012-10-17",
+  "Statement": [
+    {
+      "Effect": "Allow",
+      "Principal": { "AWS": "arn:aws:iam::111122223333:role/eso-controller" },
+      "Action": ["sts:AssumeRole", "sts:TagSession"]
+    }
+  ]
+}
+```

+ 39 - 0
pkg/controllers/secretstore/client_manager_test.go

@@ -203,6 +203,45 @@ func TestManagerGet(t *testing.T) {
 				assert.Nil(t, v)
 			},
 		},
+		{
+			name:    "sourceRef overrides storeRef if both configured",
+			wantErr: false,
+			fields: fields{
+				client: fakeclient.NewClientBuilder().
+					WithScheme(scheme).
+					WithObjects(defaultStore, otherStore).
+					Build(),
+				clientMap: make(map[clientKey]*clientVal),
+			},
+			args: args{
+				storeRef: esv1.SecretStoreRef{
+					Name: defaultStore.Name,
+					Kind: esv1.SecretStoreKind,
+				},
+				sourceRef: &esv1.StoreGeneratorSourceRef{
+					SecretStoreRef: &esv1.SecretStoreRef{
+						Name: otherStore.Name,
+						Kind: esv1.SecretStoreKind,
+					},
+				},
+				namespace: testNamespace,
+			},
+			clientConstructor: func(_ context.Context, store esv1.GenericStore, _ client.Client, _ string) (esv1.SecretsClient, error) {
+				// Assert that NewClient receives the *referenced* store, not the primary
+				assert.Equal(t, otherStore.Name, store.GetName(),
+					"NewClient should receive the sourceRef store, not the primary storeRef")
+				assert.Equal(t, esv1.SecretStoreKind, store.GetKind())
+				return clientB, nil
+			},
+			verify: func(sc esv1.SecretsClient) {
+				assert.NotNil(t, sc)
+				assert.Same(t, sc, clientB)
+			},
+			afterClose: func() {
+				_, ok := mgr.clientMap[provKey]
+				assert.False(t, ok)
+			},
+		},
 		{
 			name:    "retrieve cached client when store matches",
 			wantErr: false,

+ 35 - 0
providers/v1/aws/provider.go

@@ -148,6 +148,15 @@ func newClient(ctx context.Context, store esv1.GenericStore, kube client.Client,
 	storeSpec := store.GetSpec()
 	var cfg *aws.Config
 
+	switch prov.SessionTagsPolicy {
+	case esv1.SessionTagsPolicyNone:
+		// no op
+	case esv1.SessionTagsPolicySimple:
+		prov.SessionTags = buildSessionTags(nil, namespace, store.GetName(), store.GetKind())
+	case esv1.SessionTagsPolicyCustom:
+		prov.SessionTags = buildSessionTags(prov.CustomSessionTags, namespace, store.GetName(), store.GetKind())
+	}
+
 	// allow SecretStore controller validation to pass
 	// when using referent namespace.
 	if awsutil.IsReferentSpec(prov.Auth) && namespace == "" &&
@@ -220,6 +229,32 @@ func newClient(ctx context.Context, store esv1.GenericStore, kube client.Client,
 	return nil, fmt.Errorf(errUnknownProviderService, prov.Service)
 }
 
+// buildSessionTags constructs a list of session tags for the AWS STS session.
+// It always includes the esoNamespace and esoStoreName tags.
+// When customTags is provided (Custom mode), those are merged in as well.
+func buildSessionTags(customTags map[string]string, namespace, storeName, storeKind string) []*esv1.Tag {
+	m := make(map[string]string)
+
+	for k, v := range customTags {
+		if k != "" && v != "" {
+			m[k] = v
+		}
+	}
+	m["esoNamespace"] = namespace
+	m["esoStoreName"] = storeName
+	m["esoStoreKind"] = storeKind
+
+	newTags := make([]*esv1.Tag, 0, len(m))
+	for k, v := range m {
+		newTags = append(newTags, &esv1.Tag{
+			Key:   k,
+			Value: v,
+		})
+	}
+
+	return newTags
+}
+
 // Add this type at package level.
 type fixedDelayer struct {
 	delay time.Duration

+ 91 - 0
providers/v1/aws/provider_test.go

@@ -19,6 +19,7 @@ package aws
 import (
 	"context"
 	"fmt"
+	"sort"
 	"strings"
 	"testing"
 
@@ -540,3 +541,93 @@ func ErrorContains(out error, want string) bool {
 	}
 	return strings.Contains(out.Error(), want)
 }
+
+func TestBuildSessionTags(t *testing.T) {
+	tests := []struct {
+		name       string
+		customTags map[string]string
+		namespace  string
+		storeName  string
+		storeKind  string
+		expected   []*esv1.Tag
+	}{
+		{
+			name:       "simple mode - no custom tags, adds namespace and store",
+			customTags: nil,
+			namespace:  "test-namespace",
+			storeName:  "test-store",
+			storeKind:  "TestSecretStore",
+			expected: []*esv1.Tag{
+				{Key: "esoNamespace", Value: "test-namespace"},
+				{Key: "esoStoreName", Value: "test-store"},
+				{Key: "esoStoreKind", Value: "TestSecretStore"},
+			},
+		},
+		{
+			name: "custom mode - merges custom tags with namespace and store",
+			customTags: map[string]string{
+				"env":  "prod",
+				"team": "platform",
+			},
+			namespace: "test-namespace",
+			storeName: "test-store",
+			storeKind: "TestSecretStore",
+			expected: []*esv1.Tag{
+				{Key: "env", Value: "prod"},
+				{Key: "esoNamespace", Value: "test-namespace"},
+				{Key: "esoStoreName", Value: "test-store"},
+				{Key: "esoStoreKind", Value: "TestSecretStore"},
+				{Key: "team", Value: "platform"},
+			},
+		},
+		{
+			name: "custom mode - empty key/value pairs are skipped",
+			customTags: map[string]string{
+				"valid":  "tag",
+				"":       "no-key",
+				"no-val": "",
+			},
+			namespace: "test-namespace",
+			storeName: "test-store",
+			storeKind: "TestSecretStore",
+			expected: []*esv1.Tag{
+				{Key: "esoNamespace", Value: "test-namespace"},
+				{Key: "esoStoreName", Value: "test-store"},
+				{Key: "esoStoreKind", Value: "TestSecretStore"},
+				{Key: "valid", Value: "tag"},
+			},
+		},
+		{
+			name: "custom mode - esoNamespace/esoStoreName in custom tags are overridden",
+			customTags: map[string]string{
+				"esoNamespace": "should-be-overridden",
+				"esoStoreName": "should-be-overridden",
+			},
+			namespace: "real-namespace",
+			storeName: "real-store",
+			storeKind: "TestSecretStore",
+			expected: []*esv1.Tag{
+				{Key: "esoNamespace", Value: "real-namespace"},
+				{Key: "esoStoreName", Value: "real-store"},
+				{Key: "esoStoreKind", Value: "TestSecretStore"},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			result := buildSessionTags(tt.customTags, tt.namespace, tt.storeName, tt.storeKind)
+
+			sortTags(result)
+			sortTags(tt.expected)
+
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func sortTags(tags []*esv1.Tag) {
+	sort.Slice(tags, func(i, j int) bool {
+		return tags[i].Key < tags[j].Key
+	})
+}

+ 2 - 0
tests/__snapshot__/clustersecretstore-v1.yaml

@@ -67,6 +67,7 @@ spec:
             key: string
             name: string
             namespace: string
+      customSessionTags: {}
       externalID: string
       prefix: string
       region: string
@@ -78,6 +79,7 @@ spec:
       sessionTags:
       - key: string
         value: string
+      sessionTagsPolicy: "None"
       transitiveTagKeys: [] # minItems 0 of type string
     azurekv:
       authSecretRef:

+ 2 - 0
tests/__snapshot__/secretstore-v1.yaml

@@ -67,6 +67,7 @@ spec:
             key: string
             name: string
             namespace: string
+      customSessionTags: {}
       externalID: string
       prefix: string
       region: string
@@ -78,6 +79,7 @@ spec:
       sessionTags:
       - key: string
         value: string
+      sessionTagsPolicy: "None"
       transitiveTagKeys: [] # minItems 0 of type string
     azurekv:
       authSecretRef: