workload_identity_federation.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. /*
  2. Copyright © The ESO Authors
  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. workloadIdentityFederationServiceAccountImpersonationURLFormat = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"
  107. )
  108. func newWorkloadIdentityFederation(kube kclient.Client, wif *esv1.GCPWorkloadIdentityFederation, isClusterKind bool, namespace string) (*workloadIdentityFederation, error) {
  109. satg, err := newSATokenGenerator()
  110. if err != nil {
  111. return nil, err
  112. }
  113. return &workloadIdentityFederation{
  114. kubeClient: kube,
  115. saTokenGenerator: satg,
  116. config: wif,
  117. isClusterKind: isClusterKind,
  118. namespace: namespace,
  119. }, nil
  120. }
  121. func (w *workloadIdentityFederation) TokenSource(ctx context.Context) (oauth2.TokenSource, error) {
  122. if w.config == nil {
  123. return nil, nil
  124. }
  125. invalidConfigErrPrefix := "invalid workloadIdentityFederation config"
  126. count := 0
  127. if w.config.CredConfig != nil {
  128. count++
  129. }
  130. if w.config.ServiceAccountRef != nil {
  131. count++
  132. }
  133. if w.config.AwsSecurityCredentials != nil {
  134. count++
  135. }
  136. if count != 1 {
  137. return nil, fmt.Errorf("%s: exactly one of credConfig, awsSecurityCredentials or serviceAccountRef must be provided", invalidConfigErrPrefix)
  138. }
  139. if (w.config.ServiceAccountRef != nil || w.config.AwsSecurityCredentials != nil) && w.config.Audience == "" {
  140. return nil, fmt.Errorf("%s: audience must be provided, when serviceAccountRef or awsSecurityCredentials is provided", invalidConfigErrPrefix)
  141. }
  142. config, err := w.readCredConfig(ctx)
  143. if err != nil {
  144. return nil, err
  145. }
  146. return externalaccount.NewTokenSource(ctx, *config)
  147. }
  148. func (w *workloadIdentityFederation) getGCPServiceAccountFromAnnotation(ctx context.Context, cfg *externalaccount.Config) error {
  149. if w.config.ServiceAccountRef == nil {
  150. return nil
  151. }
  152. // look up the service account and check if it has a well-known GCP WI annotation.
  153. // If so, use that GCP service account for impersonation.
  154. // Required if you grant secret access to a GCP service account instead of direct resource access.
  155. ns := w.namespace
  156. if w.isClusterKind && w.config.ServiceAccountRef.Namespace != nil {
  157. ns = *w.config.ServiceAccountRef.Namespace
  158. }
  159. key := types.NamespacedName{
  160. Name: w.config.ServiceAccountRef.Name,
  161. Namespace: ns,
  162. }
  163. sa := &corev1.ServiceAccount{}
  164. if err := w.kubeClient.Get(ctx, key, sa); err != nil {
  165. return fmt.Errorf("failed to fetch serviceaccount %q: %w", key, err)
  166. }
  167. gcpSA := sa.Annotations[gcpSAAnnotation]
  168. if gcpSA != "" {
  169. cfg.ServiceAccountImpersonationURL = fmt.Sprintf(workloadIdentityFederationServiceAccountImpersonationURLFormat, gcpSA)
  170. }
  171. return nil
  172. }
  173. // readCredConfig is for loading the json cred config stored in the provided configmap.
  174. func (w *workloadIdentityFederation) readCredConfig(ctx context.Context) (*externalaccount.Config, error) {
  175. if w.config.CredConfig == nil {
  176. return w.generateExternalAccountConfig(ctx, nil)
  177. }
  178. key := types.NamespacedName{
  179. Name: w.config.CredConfig.Name,
  180. Namespace: w.namespace,
  181. }
  182. if w.isClusterKind && w.config.CredConfig.Namespace != "" {
  183. key.Namespace = w.config.CredConfig.Namespace
  184. }
  185. cm := &corev1.ConfigMap{}
  186. if err := w.kubeClient.Get(ctx, key, cm); err != nil {
  187. return nil, fmt.Errorf("failed to fetch external acccount credentials configmap %q: %w", key, err)
  188. }
  189. credKeyName := w.config.CredConfig.Key
  190. credJSON, ok := cm.Data[credKeyName]
  191. if !ok {
  192. return nil, fmt.Errorf("missing key %q in configmap %q", credKeyName, w.config.CredConfig.Name)
  193. }
  194. if credJSON == "" {
  195. return nil, fmt.Errorf("key %q in configmap %q has empty value", credKeyName, w.config.CredConfig.Name)
  196. }
  197. credFile := &credentialsFile{}
  198. if err := json.Unmarshal([]byte(credJSON), credFile); err != nil {
  199. return nil, fmt.Errorf("failed to unmarshal external acccount config in %q: %w", w.config.CredConfig.Name, err)
  200. }
  201. return w.generateExternalAccountConfig(ctx, credFile)
  202. }
  203. func (w *workloadIdentityFederation) generateExternalAccountConfig(ctx context.Context, credFile *credentialsFile) (*externalaccount.Config, error) {
  204. var config = new(externalaccount.Config)
  205. if err := w.updateExternalAccountConfigWithCredFileValues(config, credFile); err != nil {
  206. return nil, err
  207. }
  208. w.updateExternalAccountConfigWithSubjectTokenSupplier(config)
  209. if err := w.updateExternalAccountConfigWithAWSCredentialsSupplier(ctx, config); err != nil {
  210. return nil, err
  211. }
  212. if err := w.getGCPServiceAccountFromAnnotation(ctx, config); err != nil {
  213. return nil, err
  214. }
  215. w.updateExternalAccountConfigWithDefaultValues(config)
  216. if err := validateExternalAccountConfig(config, w.config); err != nil {
  217. return nil, err
  218. }
  219. return config, nil
  220. }
  221. func (w *workloadIdentityFederation) updateExternalAccountConfigWithCredFileValues(config *externalaccount.Config, credFile *credentialsFile) error {
  222. if credFile == nil {
  223. return nil
  224. }
  225. if credFile.Type != externalAccountCredentialType {
  226. return fmt.Errorf("invalid credentials: 'type' field is %q (expected %q)", credFile.Type, externalAccountCredentialType)
  227. }
  228. config.Audience = credFile.Audience
  229. config.SubjectTokenType = credFile.SubjectTokenType
  230. config.TokenURL = credFile.TokenURLExternal
  231. config.TokenInfoURL = credFile.TokenInfoURL
  232. config.ServiceAccountImpersonationURL = credFile.ServiceAccountImpersonationURL
  233. config.ServiceAccountImpersonationLifetimeSeconds = credFile.ServiceAccountImpersonation.TokenLifetimeSeconds
  234. config.ClientSecret = credFile.ClientSecret
  235. config.ClientID = credFile.ClientID
  236. config.QuotaProjectID = credFile.QuotaProjectID
  237. config.UniverseDomain = credFile.UniverseDomain
  238. // disallow using token of operator serviceaccount, not everyone gets
  239. // same access defined to the operator. To use operator serviceaccount
  240. // once has to provide the service account reference explicitly.
  241. if !reflect.ValueOf(credFile.CredentialSource).IsZero() &&
  242. credFile.CredentialSource.File != autoMountedServiceAccountTokenPath {
  243. config.CredentialSource = &credFile.CredentialSource
  244. }
  245. return nil
  246. }
  247. func (w *workloadIdentityFederation) updateExternalAccountConfigWithDefaultValues(config *externalaccount.Config) {
  248. config.Scopes = gsmapiv1.DefaultAuthScopes()
  249. if w.config.Audience != "" {
  250. config.Audience = w.config.Audience
  251. }
  252. if config.SubjectTokenType == "" {
  253. config.SubjectTokenType = workloadIdentitySubjectTokenType
  254. }
  255. if config.TokenURL == "" {
  256. config.TokenURL = workloadIdentityTokenURL
  257. }
  258. if config.TokenInfoURL == "" {
  259. config.TokenInfoURL = workloadIdentityTokenInfoURL
  260. }
  261. if config.UniverseDomain == "" {
  262. config.UniverseDomain = defaultUniverseDomain
  263. }
  264. }
  265. func (w *workloadIdentityFederation) updateExternalAccountConfigWithAWSCredentialsSupplier(ctx context.Context, config *externalaccount.Config) error {
  266. awsCredentialsSupplier, err := w.readAWSSecurityCredentials(ctx)
  267. if err != nil {
  268. return err
  269. }
  270. if awsCredentialsSupplier != nil {
  271. config.AwsSecurityCredentialsSupplier = awsCredentialsSupplier
  272. config.SubjectTokenType = workloadIdentitySubjectTokenTypeAWS
  273. }
  274. return nil
  275. }
  276. func (w *workloadIdentityFederation) updateExternalAccountConfigWithSubjectTokenSupplier(config *externalaccount.Config) {
  277. if w.config.ServiceAccountRef == nil {
  278. return
  279. }
  280. ns := w.namespace
  281. if w.isClusterKind && w.config.ServiceAccountRef.Namespace != nil {
  282. ns = *w.config.ServiceAccountRef.Namespace
  283. }
  284. config.SubjectTokenSupplier = &k8sSATokenReader{
  285. audience: w.config.Audience,
  286. subjectTokenType: workloadIdentitySubjectTokenType,
  287. saTokenGenerator: w.saTokenGenerator,
  288. saAudience: w.config.ServiceAccountRef.Audiences,
  289. serviceAccount: types.NamespacedName{
  290. Name: w.config.ServiceAccountRef.Name,
  291. Namespace: ns,
  292. },
  293. }
  294. }
  295. func (w *workloadIdentityFederation) readAWSSecurityCredentials(ctx context.Context) (*awsSecurityCredentialsReader, error) {
  296. awsCreds := w.config.AwsSecurityCredentials
  297. if awsCreds == nil {
  298. return nil, nil
  299. }
  300. key := types.NamespacedName{
  301. Name: awsCreds.AwsCredentialsSecretRef.Name,
  302. Namespace: w.namespace,
  303. }
  304. if w.isClusterKind && awsCreds.AwsCredentialsSecretRef.Namespace != "" {
  305. key.Namespace = awsCreds.AwsCredentialsSecretRef.Namespace
  306. }
  307. secret := &corev1.Secret{}
  308. if err := w.kubeClient.Get(ctx, key, secret); err != nil {
  309. return nil, fmt.Errorf("failed to fetch AwsSecurityCredentials secret %q: %w", key, err)
  310. }
  311. accessKeyID := string(secret.Data[awsAccessKeyIDKeyName])
  312. secretAccessKey := string(secret.Data[awsSecretAccessKeyKeyName])
  313. sessionToken := string(secret.Data[awsSessionTokenKeyName])
  314. if accessKeyID == "" || secretAccessKey == "" {
  315. return nil, fmt.Errorf("%s and %s keys must be present in AwsSecurityCredentials secret", awsAccessKeyIDKeyName, awsSecretAccessKeyKeyName)
  316. }
  317. return &awsSecurityCredentialsReader{
  318. region: w.config.AwsSecurityCredentials.Region,
  319. awsSecurityCredentials: &externalaccount.AwsSecurityCredentials{
  320. AccessKeyID: accessKeyID,
  321. SecretAccessKey: secretAccessKey,
  322. SessionToken: sessionToken,
  323. },
  324. }, nil
  325. }
  326. // validateExternalAccountConfig is for validating the external_account credentials configurations, based on
  327. // suggestions made at https://cloud.google.com/docs/authentication/client-libraries#external-credentials.
  328. func validateExternalAccountConfig(config *externalaccount.Config, wif *esv1.GCPWorkloadIdentityFederation) error {
  329. var errs []error
  330. errs = append(errs, fmt.Errorf("invalid %s config", externalAccountCredentialType))
  331. if config.Audience == "" {
  332. errs = append(errs, fmt.Errorf("audience is empty"))
  333. }
  334. if config.ServiceAccountImpersonationURL != "" &&
  335. !serviceAccountImpersonationURLRegex.MatchString(config.ServiceAccountImpersonationURL) {
  336. errs = append(errs, fmt.Errorf("service_account_impersonation_url \"%s\" does not have expected value", config.ServiceAccountImpersonationURL))
  337. }
  338. if config.TokenURL != workloadIdentityTokenURL {
  339. errs = append(errs, fmt.Errorf("token_url \"%s\" must match %s", config.TokenURL, workloadIdentityTokenURL))
  340. }
  341. if config.CredentialSource != nil {
  342. errs = append(errs, validateCredConfigCredentialSource(config.CredentialSource, wif)...)
  343. }
  344. if len(errs) > 1 {
  345. return errors.Join(errs...)
  346. }
  347. return nil
  348. }
  349. func validateCredConfigCredentialSource(credSource *externalaccount.CredentialSource, wif *esv1.GCPWorkloadIdentityFederation) []error {
  350. var errs []error
  351. // restricting the use of executables from security standpoint, since executables can't be validated.
  352. if credSource.Executable != nil {
  353. errs = append(errs, fmt.Errorf("credential_source.executable.command is not allowed"))
  354. }
  355. if credSource.File == "" && credSource.URL == "" && credSource.EnvironmentID == "" {
  356. 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"))
  357. }
  358. if credSource.EnvironmentID == "" && credSource.URL != wif.ExternalTokenEndpoint {
  359. errs = append(errs, fmt.Errorf("credential_source.url \"%s\" does not match with the configured %s externalTokenEndpoint", credSource.URL, wif.ExternalTokenEndpoint))
  360. }
  361. errs = append(errs, validateCredConfigAWSCredentialSource(credSource)...)
  362. return errs
  363. }
  364. func validateCredConfigAWSCredentialSource(credSource *externalaccount.CredentialSource) []error {
  365. var errs []error
  366. if credSource.EnvironmentID != "" {
  367. if !strings.HasPrefix(strings.ToLower(credSource.EnvironmentID), awsEnvironmentIDPrefix) {
  368. errs = append(errs, fmt.Errorf("credential_source.environment_id \"%s\" must start with %s", credSource.EnvironmentID, awsEnvironmentIDPrefix))
  369. }
  370. if !awsSTSTokenURLRegex.MatchString(credSource.URL) {
  371. errs = append(errs, fmt.Errorf("credential_source.aws.url \"%s\" does not have expected value", credSource.URL))
  372. }
  373. if !awsRegionURLRegex.MatchString(credSource.RegionURL) {
  374. errs = append(errs, fmt.Errorf("credential_source.aws.region_url \"%s\" does not have expected value", credSource.RegionURL))
  375. }
  376. if credSource.IMDSv2SessionTokenURL != "" && !awsSessionTokenURLRegex.MatchString(credSource.IMDSv2SessionTokenURL) {
  377. errs = append(errs, fmt.Errorf("credential_source.aws.imdsv2_session_token_url \"%s\" does not have expected value", credSource.IMDSv2SessionTokenURL))
  378. }
  379. }
  380. return errs
  381. }
  382. func (r *k8sSATokenReader) SubjectToken(ctx context.Context, options externalaccount.SupplierOptions) (string, error) {
  383. if options.Audience != r.audience || options.SubjectTokenType != r.subjectTokenType {
  384. return "", fmt.Errorf(
  385. "invalid subject token request, audience is %s(expected %s) and subject_token_type is %s(expected %s)",
  386. options.Audience,
  387. r.audience,
  388. options.SubjectTokenType,
  389. r.subjectTokenType,
  390. )
  391. }
  392. resp, err := r.saTokenGenerator.Generate(ctx, r.saAudience, r.serviceAccount.Name, r.serviceAccount.Namespace)
  393. metrics.ObserveAPICall(constants.ProviderGCPSM, constants.CallGCPSMGenerateSAToken, err)
  394. if err != nil {
  395. return "", fmt.Errorf(errFetchPodToken, err)
  396. }
  397. return resp.Status.Token, nil
  398. }
  399. // AwsRegion returns the AWS region for workload identity federation.
  400. func (a *awsSecurityCredentialsReader) AwsRegion(_ context.Context, _ externalaccount.SupplierOptions) (string, error) {
  401. return a.region, nil
  402. }
  403. // AwsSecurityCredentials returns AWS security credentials for workload identity federation.
  404. func (a *awsSecurityCredentialsReader) AwsSecurityCredentials(_ context.Context, _ externalaccount.SupplierOptions) (*externalaccount.AwsSecurityCredentials, error) {
  405. return a.awsSecurityCredentials, nil
  406. }