workload_identity_federation.go 17 KB

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