Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add support for authentication without GCP Service Account #88

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 30 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ Note: GKE or Anthos natively support injecting workload identity for pods. This
# The value must be one of 'gcloud'(default) or 'direct'.
# Refer to the next section for 'direct' injection mode
cloud.google.com/injection-mode: "gcloud"

# optional: Defaults to value inside `service-account-email`
#
cloud.google.com/project: "12345"
```
4. All new pods launched using the Kubernetes `ServiceAccount` will be mutated so that they can impersonate the GCP service account. Below is an example pod spec with the environment variables and volume fields mutated by the webhook.
Expand All @@ -62,13 +66,13 @@ Note: GKE or Anthos natively support injecting workload identity for pods. This
metadata:
name: app-x-pod
namespace: service-a
annotations:
# optional: A comma-separated list of initContainers and container names
# to skip adding volumeMounts and environment variables
cloud.google.com/skip-containers: "init-first,sidecar"
# optional: Defaults to 86400, or value specified in ServiceAccount
# annotation as shown in previous step, for expirationSeconds if not set
cloud.google.com/token-expiration: "86400"
annotations:
# optional: A comma-separated list of initContainers and container names
# to skip adding volumeMounts and environment variables
cloud.google.com/skip-containers: "init-first,sidecar"
# optional: Defaults to 86400, or value specified in ServiceAccount
# annotation as shown in previous step, for expirationSeconds if not set
cloud.google.com/token-expiration: "86400"
spec:
serviceAccountName: app-x
initContainers:
Expand Down Expand Up @@ -167,27 +171,27 @@ To use direct injection mode:
metadata:
name: app-x-pod
namespace: service-a
annotations:
# optional: A comma-separated list of initContainers and container names
# to skip adding volumeMounts and environment variables
cloud.google.com/skip-containers: "init-first,sidecar"
#
# The Generated External Credentials Json is added as an annotation, and mounted into the container filesystem via the DownwardAPI Volume
#
cloud.google.com/external-credentials-json: |-
{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/on-prem-kubernetes/providers/this-cluster",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
"credential_source": {
"file": "/var/run/secrets/sts.googleapis.com/serviceaccount/token",
"format": {
"type": "text"
annotations:
# optional: A comma-separated list of initContainers and container names
# to skip adding volumeMounts and environment variables
cloud.google.com/skip-containers: "init-first,sidecar"
#
# The Generated External Credentials Json is added as an annotation, and mounted into the container filesystem via the DownwardAPI Volume
#
cloud.google.com/external-credentials-json: |-
{
"type": "external_account",
"audience": "//iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/on-prem-kubernetes/providers/this-cluster",
"subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_url": "https://sts.googleapis.com/v1/token",
"service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/[email protected]:generateAccessToken",
"credential_source": {
"file": "/var/run/secrets/sts.googleapis.com/serviceaccount/token",
"format": {
"type": "text"
}
}
}
}
spec:
serviceAccountName: app-x
initContainers:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ spec:
affinity:
{{- toYaml .Values.controllerManager.affinity | nindent 8 }}
{{- end }}
imagePullSecrets:
{{- toYaml .Values.controllerManager.imagePullSecrets | nindent 8 }}
containers:
- args:
- --health-probe-bind-address=:8081
Expand All @@ -54,6 +56,7 @@ spec:
value: {{ .Values.kubernetesClusterDomain }}
image: {{ .Values.controllerManager.manager.image.repository }}:{{ .Values.controllerManager.manager.image.tag
| default (printf "v%v" .Chart.AppVersion) }}
imagePullPolicy: {{ .Values.controllerManager.manager.image.pullPolicy }}
livenessProbe:
httpGet:
path: /healthz
Expand Down Expand Up @@ -89,6 +92,7 @@ spec:
value: {{ .Values.kubernetesClusterDomain }}
image: {{ .Values.controllerManager.kubeRbacProxy.image.repository }}:{{ .Values.controllerManager.kubeRbacProxy.image.tag
| default .Chart.AppVersion }}
imagePullPolicy: {{ .Values.controllerManager.kubeRbacProxy.image.pullPolicy }}
name: kube-rbac-proxy
ports:
- containerPort: 8443
Expand Down
4 changes: 4 additions & 0 deletions charts/gcp-workload-identity-federation-webhook/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ controllerManager:

kubeRbacProxy:
image:
pullPolicy: IfNotPresent
repository: gcr.io/kubebuilder/kube-rbac-proxy
tag: v0.11.0
resources:
Expand All @@ -21,8 +22,11 @@ controllerManager:
cpu: 5m
memory: 64Mi

imagePullSecrets: []

manager:
image:
pullPolicy: IfNotPresent
repository: ghcr.io/pfnet-research/gcp-workload-identity-federation-webhook
# default tag is v{{.Chart.AppVersion}}
# tag: latest
Expand Down
6 changes: 6 additions & 0 deletions webhooks/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,10 @@ const (
//
// Set to 'direct' or 'gcloud' to determine credential injection mode. Defaults to 'gcloud'.
InjectionModeAnnotation = "injection-mode"

//
// Annotations for ServiceAccount
//
// Override GCP Project normally parsed from ServiceAccount.
ProjectAnnotation = "project"
)
7 changes: 4 additions & 3 deletions webhooks/external_account_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type ExternalAccountCredentials struct {
TokenInfoURL string `json:"token_info_url,omitempty"`
// ServiceAccountImpersonationURL is the URL for the service account impersonation request. This is only
// required for workload identity pools when APIs to be accessed have not integrated with UberMint.
ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
ServiceAccountImpersonationURL string `json:"service_account_impersonation_url,omitempty"`
// ServiceAccountImpersonationLifetimeSeconds is the number of seconds the service account impersonation
// token will be valid for.
ServiceAccountImpersonationLifetimeSeconds int `json:"service_account_impersonation_lifetime_seconds,omitempty"`
Expand Down Expand Up @@ -63,9 +63,10 @@ func NewExternalAccountCredentials(aud, gsaEmail string) *ExternalAccountCredent
File: filepath.Join(K8sSATokenMountPath, K8sSATokenName),
Format: CredentialFormat{Type: "text"},
},
ServiceAccountImpersonationURL: fmt.Sprintf("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", gsaEmail),
}

if gsaEmail != "" {
creds.ServiceAccountImpersonationURL = fmt.Sprintf("https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken", gsaEmail)
}
return creds
}

Expand Down
14 changes: 7 additions & 7 deletions webhooks/identityconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ var (
)

type GCPWorkloadIdentityConfig struct {
Project *string
WorkloadIdentityProvider *string
ServiceAccountEmail *string
ServiceAccountEmail string
RunAsUser *int64
InjectionMode InjectionMode

Expand All @@ -44,12 +45,15 @@ func NewGCPWorkloadIdentityConfig(
}

if v, ok := sa.Annotations[filepath.Join(annotationDomain, ServiceAccountEmailAnnotation)]; ok {
cfg.ServiceAccountEmail = &v
cfg.ServiceAccountEmail = v
}

if v, ok := sa.Annotations[filepath.Join(annotationDomain, AudienceAnnotation)]; ok {
cfg.Audience = &v
}
if v, ok := sa.Annotations[filepath.Join(annotationDomain, ProjectAnnotation)]; ok {
cfg.Project = &v
}

if v, ok := sa.Annotations[filepath.Join(annotationDomain, TokenExpirationAnnotation)]; ok {
seconds, err := strconv.ParseInt(v, 10, 64)
Expand Down Expand Up @@ -80,14 +84,10 @@ func NewGCPWorkloadIdentityConfig(
cfg.InjectionMode = UndefinedMode
}

if cfg.WorkloadIdentityProvider == nil && cfg.ServiceAccountEmail == nil {
if cfg.WorkloadIdentityProvider == nil {
return nil, nil
}

if cfg.WorkloadIdentityProvider == nil || cfg.ServiceAccountEmail == nil {
return nil, fmt.Errorf("%s, %s must set at a time", filepath.Join(annotationDomain, WorkloadIdentityProviderAnnotation), filepath.Join(annotationDomain, TokenExpirationAnnotation))
}

if !workloadIdentityProviderRegex.Match([]byte(*cfg.WorkloadIdentityProvider)) {
return nil, fmt.Errorf("%s must be form of %s", filepath.Join(annotationDomain, WorkloadIdentityProviderAnnotation), workloadIdentityProviderFmt)
}
Expand Down
42 changes: 15 additions & 27 deletions webhooks/identityconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
Annotations: map[string]string{},
},
}
idConfig, err := NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err := NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(err).NotTo(HaveOccurred())
Expect(idConfig).To(BeNil())
})
Expand All @@ -39,11 +39,11 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err := NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err := NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(err).NotTo(HaveOccurred())
Expect(idConfig).To(BeEquivalentTo(&GCPWorkloadIdentityConfig{
WorkloadIdentityProvider: &workloadProvider,
ServiceAccountEmail: &saEmail,
ServiceAccountEmail: saEmail,
Audience: nil,
TokenExpirationSeconds: nil,
}))
Expand All @@ -61,11 +61,11 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err := NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err := NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(err).NotTo(HaveOccurred())
Expect(idConfig).To(BeEquivalentTo(&GCPWorkloadIdentityConfig{
WorkloadIdentityProvider: &workloadProvider,
ServiceAccountEmail: &saEmail,
ServiceAccountEmail: saEmail,
Audience: &audience,
TokenExpirationSeconds: &tokenExpiration,
}))
Expand All @@ -84,11 +84,11 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err := NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err := NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(err).NotTo(HaveOccurred())
Expect(idConfig).To(BeEquivalentTo(&GCPWorkloadIdentityConfig{
WorkloadIdentityProvider: &workloadProvider,
ServiceAccountEmail: &saEmail,
ServiceAccountEmail: saEmail,
Audience: &audience,
TokenExpirationSeconds: &tokenExpiration,
InjectionMode: DirectMode,
Expand All @@ -108,11 +108,11 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err := NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err := NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(err).NotTo(HaveOccurred())
Expect(idConfig).To(BeEquivalentTo(&GCPWorkloadIdentityConfig{
WorkloadIdentityProvider: &workloadProvider,
ServiceAccountEmail: &saEmail,
ServiceAccountEmail: saEmail,
Audience: &audience,
TokenExpirationSeconds: &tokenExpiration,
InjectionMode: GCloudMode,
Expand All @@ -134,21 +134,9 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err = NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
Expect(idConfig).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("must set at a time")))

By("without workload-identity-provider annotation")
sa = corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Annotations: map[string]string{
saEmailAnnotation: saEmail,
},
},
}
idConfig, err = NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
Expect(idConfig).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("must set at a time")))
idConfig, err = NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(idConfig).NotTo(BeNil())
Expect(err).To(BeNil())
})
})
When("ServiceAccount with malformed workload-identity-provider annotation", func() {
Expand All @@ -161,7 +149,7 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err = NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err = NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(idConfig).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("must be form of")))
})
Expand All @@ -177,7 +165,7 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err = NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err = NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(idConfig).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("must be positive integer string")))
})
Expand All @@ -193,7 +181,7 @@ var _ = Describe("NewGCPWorkloadIdentityConfig", func() {
},
},
}
idConfig, err = NewGCPWorkloadIdentityConfig(annotaitonDomain, sa)
idConfig, err = NewGCPWorkloadIdentityConfig(annotationDomain, sa)
Expect(idConfig).To(BeNil())
Expect(err).To(MatchError(ContainSubstring("mode must be")))
})
Expand Down
13 changes: 7 additions & 6 deletions webhooks/mutatepod.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,12 @@ func (m *GCPWorkloadIdentityMutator) mutatePod(pod *corev1.Pod, idConfig GCPWork
pod.Annotations = map[string]string{}
}
pod.Annotations[filepath.Join(m.AnnotationDomain, WorkloadIdentityProviderAnnotation)] = *idConfig.WorkloadIdentityProvider
pod.Annotations[filepath.Join(m.AnnotationDomain, ServiceAccountEmailAnnotation)] = *idConfig.ServiceAccountEmail
pod.Annotations[filepath.Join(m.AnnotationDomain, ServiceAccountEmailAnnotation)] = idConfig.ServiceAccountEmail
pod.Annotations[filepath.Join(m.AnnotationDomain, AudienceAnnotation)] = audience
pod.Annotations[filepath.Join(m.AnnotationDomain, TokenExpirationAnnotation)] = fmt.Sprint(expirationSeconds)
if idConfig.InjectionMode == DirectMode {
// Add annotation
credBody, err := buildExternalCredentialsJson(*idConfig.WorkloadIdentityProvider, *idConfig.ServiceAccountEmail)
credBody, err := buildExternalCredentialsJson(*idConfig.WorkloadIdentityProvider, idConfig.ServiceAccountEmail)
if err != nil {
return err
}
Expand All @@ -57,10 +57,11 @@ func (m *GCPWorkloadIdentityMutator) mutatePod(pod *corev1.Pod, idConfig GCPWork
//
// calculate project from service account
//
matches := projectRegex.FindStringSubmatch(*idConfig.ServiceAccountEmail)
project := ""
if len(matches) >= 2 {
project = matches[1] // the group 0 is thw whole match
if idConfig.Project != nil {
project = *idConfig.Project
} else if matches := projectRegex.FindStringSubmatch(idConfig.ServiceAccountEmail); len(matches) >= 2 {
project = matches[1] // the group 0 is the whole match
}

//
Expand All @@ -75,7 +76,7 @@ func (m *GCPWorkloadIdentityMutator) mutatePod(pod *corev1.Pod, idConfig GCPWork
//
if idConfig.InjectionMode == GCloudMode || idConfig.InjectionMode == UndefinedMode {
pod.Spec.InitContainers = prependOrReplaceContainer(pod.Spec.InitContainers, gcloudSetupContainer(
*idConfig.WorkloadIdentityProvider, *idConfig.ServiceAccountEmail, project, m.GcloudImage, idConfig.RunAsUser, m.SetupContainerResources,
*idConfig.WorkloadIdentityProvider, idConfig.ServiceAccountEmail, project, m.GcloudImage, idConfig.RunAsUser, m.SetupContainerResources,
))
}

Expand Down
Loading