workload_identity_federation.go 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. /*
  2. Copyright © 2025 ESO Maintainer Team
  3. Licensed under the Apache License, Version 2.0 (the "License");
  4. you may not use this file except in compliance with the License.
  5. You may obtain a copy of the License at
  6. https://www.apache.org/licenses/LICENSE-2.0
  7. Unless required by applicable law or agreed to in writing, software
  8. distributed under the License is distributed on an "AS IS" BASIS,
  9. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. See the License for the specific language governing permissions and
  11. limitations under the License.
  12. */
  13. package secretmanager
  14. import (
  15. "context"
  16. "encoding/json"
  17. "errors"
  18. "fmt"
  19. "reflect"
  20. "regexp"
  21. "strings"
  22. gsmapiv1 "cloud.google.com/go/secretmanager/apiv1"
  23. "golang.org/x/oauth2"
  24. "golang.org/x/oauth2/google/externalaccount"
  25. corev1 "k8s.io/api/core/v1"
  26. "k8s.io/apimachinery/pkg/types"
  27. kclient "sigs.k8s.io/controller-runtime/pkg/client"
  28. esv1 "github.com/external-secrets/external-secrets/apis/externalsecrets/v1"
  29. "github.com/external-secrets/external-secrets/runtime/constants"
  30. "github.com/external-secrets/external-secrets/runtime/metrics"
  31. )
  32. // workloadIdentityFederation holds the clients and generators needed
  33. // to create a gcp oauth token.
  34. type workloadIdentityFederation struct {
  35. kubeClient kclient.Client
  36. saTokenGenerator saTokenGenerator
  37. config *esv1.GCPWorkloadIdentityFederation
  38. isClusterKind bool
  39. namespace string
  40. }
  41. // k8sSATokenReader holds the data for generating the federated token.
  42. type k8sSATokenReader struct {
  43. audience string
  44. subjectTokenType string
  45. saTokenGenerator saTokenGenerator
  46. saAudience []string
  47. serviceAccount types.NamespacedName
  48. }
  49. type awsSecurityCredentialsReader struct {
  50. region string
  51. awsSecurityCredentials *externalaccount.AwsSecurityCredentials
  52. }
  53. // credentialsFile is the unmarshalled representation of a credentials file.
  54. // sourced from https://github.com/golang/oauth2/blob/master/google/google.go#L108-L144
  55. // as the type is not exported.
  56. type credentialsFile struct {
  57. Type string `json:"type"`
  58. // Service Account fields
  59. ClientEmail string `json:"client_email"`
  60. PrivateKeyID string `json:"private_key_id"`
  61. PrivateKey string `json:"private_key"`
  62. AuthURL string `json:"auth_uri"`
  63. TokenURL string `json:"token_uri"`
  64. ProjectID string `json:"project_id"`
  65. UniverseDomain string `json:"universe_domain"`
  66. // User Credential fields
  67. // (These typically come from gcloud auth.)
  68. ClientSecret string `json:"client_secret"`
  69. ClientID string `json:"client_id"`
  70. RefreshToken string `json:"refresh_token"`
  71. // External Account fields
  72. Audience string `json:"audience"`
  73. SubjectTokenType string `json:"subject_token_type"`
  74. TokenURLExternal string `json:"token_url"`
  75. TokenInfoURL string `json:"token_info_url"`
  76. ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
  77. ServiceAccountImpersonation serviceAccountImpersonationInfo `json:"service_account_impersonation"`
  78. Delegates []string `json:"delegates"`
  79. CredentialSource externalaccount.CredentialSource `json:"credential_source"`
  80. QuotaProjectID string `json:"quota_project_id"`
  81. WorkforcePoolUserProject string `json:"workforce_pool_user_project"`
  82. // External Account Authorized User fields
  83. RevokeURL string `json:"revoke_url"`
  84. // Service account impersonation
  85. SourceCredentials *credentialsFile `json:"source_credentials"`
  86. }
  87. type serviceAccountImpersonationInfo struct {
  88. TokenLifetimeSeconds int `json:"token_lifetime_seconds"`
  89. }
  90. var (
  91. awsSTSTokenURLRegex = regexp.MustCompile(`^http://(metadata\.google\.internal|169\.254\.169\.254|\[fd00:ec2::254\])/latest/meta-data/iam/security-credentials$`)
  92. awsRegionURLRegex = regexp.MustCompile(`^http://(metadata\.google\.internal|169\.254\.169\.254|\[fd00:ec2::254\])/latest/meta-data/placement/availability-zone$`)
  93. awsSessionTokenURLRegex = regexp.MustCompile(`^http://(metadata\.google\.internal|169\.254\.169\.254|\[fd00:ec2::254\])/latest/api/token$`)
  94. serviceAccountImpersonationURLRegex = regexp.MustCompile(`^https://iamcredentials\.googleapis\.com/v1/projects/-/serviceAccounts/(\S+):generateAccessToken$`)
  95. )
  96. const (
  97. // autoMountedServiceAccountTokenPath is the kubernetes service account token filepath
  98. // made available by automountServiceAccountToken option in pod spec.
  99. autoMountedServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
  100. // externalAccountCredentialType is the external account type indicator in the credentials files.
  101. externalAccountCredentialType = "external_account"
  102. awsEnvironmentIDPrefix = "aws"
  103. awsAccessKeyIDKeyName = "aws_access_key_id"
  104. awsSecretAccessKeyKeyName = "aws_secret_access_key"
  105. awsSessionTokenKeyName = "aws_session_token"
  106. )
  107. func newWorkloadIdentityFederation(kube kclient.Client, wif *esv1.GCPWorkloadIdentityFederation, isClusterKind bool, namespace string) (*workloadIdentityFederation, error) {
  108. satg, err := newSATokenGenerator()
  109. if err != nil {
  110. return nil, err
  111. }
  112. return &workloadIdentityFederation{
  113. kubeClient: kube,
  114. saTokenGenerator: satg,
  115. config: wif,
  116. isClusterKind: isClusterKind,
  117. namespace: namespace,
  118. }, nil
  119. }
  120. func (w *workloadIdentityFederation) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
  121. if w.config == nil {
  122. return nil, nil
  123. }
  124. invalidConfigErrPrefix := "invalid workloadIdentityFederation config"
  125. count := 0
  126. if w.config.CredConfig != nil {
  127. count++
  128. }
  129. if w.config.ServiceAccountRef != nil {
  130. count++
  131. }
  132. if w.config.AwsSecurityCredentials != nil {
  133. count++
  134. }
  135. if count != 1 {
  136. return nil, fmt.Errorf("%s: exactly one of credConfig, awsSecurityCredentials or serviceAccountRef must be provided", invalidConfigErrPrefix)
  137. }
  138. if (w.config.ServiceAccountRef != nil || w.config.AwsSecurityCredentials != nil) && w.config.Audience == "" {
  139. return nil, fmt.Errorf("%s: audience must be provided, when serviceAccountRef or awsSecurityCredentials is provided", invalidConfigErrPrefix)
  140. }
  141. config, err := w.readCredConfig(ctx)
  142. if err != nil {
  143. return nil, err
  144. }
  145. return externalaccount.NewTokenSource(ctx, *config)
  146. }
  147. // readCredConfig is for loading the json cred config stored in the provided configmap.
  148. func (w *workloadIdentityFederation) readCredConfig(ctx context.Context) (*externalaccount.Config, error) {
  149. if w.config.CredConfig == nil {
  150. return w.generateExternalAccountConfig(ctx, nil)
  151. }
  152. key := types.NamespacedName{
  153. Name: w.config.CredConfig.Name,
  154. Namespace: w.namespace,
  155. }
  156. if w.isClusterKind && w.config.CredConfig.Namespace != "" {
  157. key.Namespace = w.config.CredConfig.Namespace
  158. }
  159. cm := &corev1.ConfigMap{}
  160. if err := w.kubeClient.Get(ctx, key, cm); err != nil {
  161. return nil, fmt.Errorf("failed to fetch external acccount credentials configmap %q: %w", key, err)
  162. }
  163. credKeyName := w.config.CredConfig.Key
  164. credJSON, ok := cm.Data[credKeyName]
  165. if !ok {
  166. return nil, fmt.Errorf("missing key %q in configmap %q", credKeyName, w.config.CredConfig.Name)
  167. }
  168. if credJSON == "" {
  169. return nil, fmt.Errorf("key %q in configmap %q has empty value", credKeyName, w.config.CredConfig.Name)
  170. }
  171. credFile := &credentialsFile{}
  172. if err := json.Unmarshal([]byte(credJSON), credFile); err != nil {
  173. return nil, fmt.Errorf("failed to unmarshal external acccount config in %q: %w", w.config.CredConfig.Name, err)
  174. }
  175. return w.generateExternalAccountConfig(ctx, credFile)
  176. }
  177. func (w *workloadIdentityFederation) generateExternalAccountConfig(ctx context.Context, credFile *credentialsFile) (*externalaccount.Config, error) {
  178. var config = new(externalaccount.Config)
  179. if err := w.updateExternalAccountConfigWithCredFileValues(config, credFile); err != nil {
  180. return nil, err
  181. }
  182. w.updateExternalAccountConfigWithSubjectTokenSupplier(config)
  183. if err := w.updateExternalAccountConfigWithAWSCredentialsSupplier(ctx, config); err != nil {
  184. return nil, err
  185. }
  186. w.updateExternalAccountConfigWithDefaultValues(config)
  187. if err := validateExternalAccountConfig(config, w.config); err != nil {
  188. return nil, err
  189. }
  190. return config, nil
  191. }
  192. func (w *workloadIdentityFederation) updateExternalAccountConfigWithCredFileValues(config *externalaccount.Config, credFile *credentialsFile) error {
  193. if credFile == nil {
  194. return nil
  195. }
  196. if credFile.Type != externalAccountCredentialType {
  197. return fmt.Errorf("invalid credentials: 'type' field is %q (expected %q)", credFile.Type, externalAccountCredentialType)
  198. }
  199. config.Audience = credFile.Audience
  200. config.SubjectTokenType = credFile.SubjectTokenType
  201. config.TokenURL = credFile.TokenURLExternal
  202. config.TokenInfoURL = credFile.TokenInfoURL
  203. config.ServiceAccountImpersonationURL = credFile.ServiceAccountImpersonationURL
  204. config.ServiceAccountImpersonationLifetimeSeconds = credFile.ServiceAccountImpersonation.TokenLifetimeSeconds
  205. config.ClientSecret = credFile.ClientSecret
  206. config.ClientID = credFile.ClientID
  207. config.QuotaProjectID = credFile.QuotaProjectID
  208. config.UniverseDomain = credFile.UniverseDomain
  209. // disallow using token of operator serviceaccount, not everyone gets
  210. // same access defined to the operator. To use operator serviceaccount
  211. // once has to provide the service account reference explicitly.
  212. if !reflect.ValueOf(credFile.CredentialSource).IsZero() &&
  213. credFile.CredentialSource.File != autoMountedServiceAccountTokenPath {
  214. config.CredentialSource = &credFile.CredentialSource
  215. }
  216. return nil
  217. }
  218. func (w *workloadIdentityFederation) updateExternalAccountConfigWithDefaultValues(config *externalaccount.Config) {
  219. config.Scopes = gsmapiv1.DefaultAuthScopes()
  220. if w.config.Audience != "" {
  221. config.Audience = w.config.Audience
  222. }
  223. if config.SubjectTokenType == "" {
  224. config.SubjectTokenType = workloadIdentitySubjectTokenType
  225. }
  226. if config.TokenURL == "" {
  227. config.TokenURL = workloadIdentityTokenURL
  228. }
  229. if config.TokenInfoURL == "" {
  230. config.TokenInfoURL = workloadIdentityTokenInfoURL
  231. }
  232. if config.UniverseDomain == "" {
  233. config.UniverseDomain = defaultUniverseDomain
  234. }
  235. }
  236. func (w *workloadIdentityFederation) updateExternalAccountConfigWithAWSCredentialsSupplier(ctx context.Context, config *externalaccount.Config) error {
  237. awsCredentialsSupplier, err := w.readAWSSecurityCredentials(ctx)
  238. if err != nil {
  239. return err
  240. }
  241. if awsCredentialsSupplier != nil {
  242. config.AwsSecurityCredentialsSupplier = awsCredentialsSupplier
  243. config.SubjectTokenType = workloadIdentitySubjectTokenTypeAWS
  244. }
  245. return nil
  246. }
  247. func (w *workloadIdentityFederation) updateExternalAccountConfigWithSubjectTokenSupplier(config *externalaccount.Config) {
  248. if w.config.ServiceAccountRef == nil {
  249. return
  250. }
  251. ns := w.namespace
  252. if w.isClusterKind && w.config.ServiceAccountRef.Namespace != nil {
  253. ns = *w.config.ServiceAccountRef.Namespace
  254. }
  255. config.SubjectTokenSupplier = &k8sSATokenReader{
  256. audience: w.config.Audience,
  257. subjectTokenType: workloadIdentitySubjectTokenType,
  258. saTokenGenerator: w.saTokenGenerator,
  259. saAudience: w.config.ServiceAccountRef.Audiences,
  260. serviceAccount: types.NamespacedName{
  261. Name: w.config.ServiceAccountRef.Name,
  262. Namespace: ns,
  263. },
  264. }
  265. }
  266. func (w *workloadIdentityFederation) readAWSSecurityCredentials(ctx context.Context) (*awsSecurityCredentialsReader, error) {
  267. awsCreds := w.config.AwsSecurityCredentials
  268. if awsCreds == nil {
  269. return nil, nil
  270. }
  271. key := types.NamespacedName{
  272. Name: awsCreds.AwsCredentialsSecretRef.Name,
  273. Namespace: w.namespace,
  274. }
  275. if w.isClusterKind && awsCreds.AwsCredentialsSecretRef.Namespace != "" {
  276. key.Namespace = awsCreds.AwsCredentialsSecretRef.Namespace
  277. }
  278. secret := &corev1.Secret{}
  279. if err := w.kubeClient.Get(ctx, key, secret); err != nil {
  280. return nil, fmt.Errorf("failed to fetch AwsSecurityCredentials secret %q: %w", key, err)
  281. }
  282. accessKeyID := string(secret.Data[awsAccessKeyIDKeyName])
  283. secretAccessKey := string(secret.Data[awsSecretAccessKeyKeyName])
  284. sessionToken := string(secret.Data[awsSessionTokenKeyName])
  285. if accessKeyID == "" || secretAccessKey == "" {
  286. return nil, fmt.Errorf("%s and %s keys must be present in AwsSecurityCredentials secret", awsAccessKeyIDKeyName, awsSecretAccessKeyKeyName)
  287. }
  288. return &awsSecurityCredentialsReader{
  289. region: w.config.AwsSecurityCredentials.Region,
  290. awsSecurityCredentials: &externalaccount.AwsSecurityCredentials{
  291. AccessKeyID: accessKeyID,
  292. SecretAccessKey: secretAccessKey,
  293. SessionToken: sessionToken,
  294. },
  295. }, nil
  296. }
  297. // validateExternalAccountConfig is for validating the external_account credentials configurations, based on
  298. // suggestions made at https://cloud.google.com/docs/authentication/client-libraries#external-credentials.
  299. func validateExternalAccountConfig(config *externalaccount.Config, wif *esv1.GCPWorkloadIdentityFederation) error {
  300. var errs []error
  301. errs = append(errs, fmt.Errorf("invalid %s config", externalAccountCredentialType))
  302. if config.Audience == "" {
  303. errs = append(errs, fmt.Errorf("audience is empty"))
  304. }
  305. if config.ServiceAccountImpersonationURL != "" &&
  306. !serviceAccountImpersonationURLRegex.MatchString(config.ServiceAccountImpersonationURL) {
  307. errs = append(errs, fmt.Errorf("service_account_impersonation_url \"%s\" does not have expected value", config.ServiceAccountImpersonationURL))
  308. }
  309. if config.TokenURL != workloadIdentityTokenURL {
  310. errs = append(errs, fmt.Errorf("token_url \"%s\" must match %s", config.TokenURL, workloadIdentityTokenURL))
  311. }
  312. if config.CredentialSource != nil {
  313. errs = append(errs, validateCredConfigCredentialSource(config.CredentialSource, wif)...)
  314. }
  315. if len(errs) > 1 {
  316. return errors.Join(errs...)
  317. }
  318. return nil
  319. }
  320. func validateCredConfigCredentialSource(credSource *externalaccount.CredentialSource, wif *esv1.GCPWorkloadIdentityFederation) []error {
  321. var errs []error
  322. // restricting the use of executables from security standpoint, since executables can't be validated.
  323. if credSource.Executable != nil {
  324. errs = append(errs, fmt.Errorf("credential_source.executable.command is not allowed"))
  325. }
  326. if credSource.File == "" && credSource.URL == "" && credSource.EnvironmentID == "" {
  327. errs = append(errs, fmt.Errorf("one of credential_source.file, credential_source.url, credential_source.aws.url or credential_source_environment_id should be provided"))
  328. }
  329. if credSource.EnvironmentID == "" && credSource.URL != wif.ExternalTokenEndpoint {
  330. errs = append(errs, fmt.Errorf("credential_source.url \"%s\" does not match with the configured %s externalTokenEndpoint", credSource.URL, wif.ExternalTokenEndpoint))
  331. }
  332. errs = append(errs, validateCredConfigAWSCredentialSource(credSource)...)
  333. return errs
  334. }
  335. func validateCredConfigAWSCredentialSource(credSource *externalaccount.CredentialSource) []error {
  336. var errs []error
  337. if credSource.EnvironmentID != "" {
  338. if !strings.HasPrefix(strings.ToLower(credSource.EnvironmentID), awsEnvironmentIDPrefix) {
  339. errs = append(errs, fmt.Errorf("credential_source.environment_id \"%s\" must start with %s", credSource.EnvironmentID, awsEnvironmentIDPrefix))
  340. }
  341. if !awsSTSTokenURLRegex.MatchString(credSource.URL) {
  342. errs = append(errs, fmt.Errorf("credential_source.aws.url \"%s\" does not have expected value", credSource.URL))
  343. }
  344. if !awsRegionURLRegex.MatchString(credSource.RegionURL) {
  345. errs = append(errs, fmt.Errorf("credential_source.aws.region_url \"%s\" does not have expected value", credSource.RegionURL))
  346. }
  347. if credSource.IMDSv2SessionTokenURL != "" && !awsSessionTokenURLRegex.MatchString(credSource.IMDSv2SessionTokenURL) {
  348. errs = append(errs, fmt.Errorf("credential_source.aws.imdsv2_session_token_url \"%s\" does not have expected value", credSource.IMDSv2SessionTokenURL))
  349. }
  350. }
  351. return errs
  352. }
  353. func (r *k8sSATokenReader) SubjectToken(ctx context.Context, options externalaccount.SupplierOptions) (string, error) {
  354. if options.Audience != r.audience || options.SubjectTokenType != r.subjectTokenType {
  355. return "", fmt.Errorf("invalid subject token request, audience is %s(expected %s) and subject_token_type is %s(expected %s)", options.Audience, r.audience, options.SubjectTokenType, r.subjectTokenType)
  356. }
  357. resp, err := r.saTokenGenerator.Generate(ctx, r.saAudience, r.serviceAccount.Name, r.serviceAccount.Namespace)
  358. metrics.ObserveAPICall(constants.ProviderGCPSM, constants.CallGCPSMGenerateSAToken, err)
  359. if err != nil {
  360. return "", fmt.Errorf(errFetchPodToken, err)
  361. }
  362. return resp.Status.Token, nil
  363. }
  364. // AwsRegion returns the AWS region for workload identity federation.
  365. func (a *awsSecurityCredentialsReader) AwsRegion(_ context.Context, _ externalaccount.SupplierOptions) (string, error) {
  366. return a.region, nil
  367. }
  368. // AwsSecurityCredentials returns AWS security credentials for workload identity federation.
  369. func (a *awsSecurityCredentialsReader) AwsSecurityCredentials(_ context.Context, _ externalaccount.SupplierOptions) (*externalaccount.AwsSecurityCredentials, error) {
  370. return a.awsSecurityCredentials, nil
  371. }