diff --git a/Makefile b/Makefile index d591596..83d2c65 100644 --- a/Makefile +++ b/Makefile @@ -47,16 +47,16 @@ help: ## Display this help. .PHONY: manifests manifests: $(CONTROLLER_GEN) ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. - $(CONTROLLER_GEN) rbac:roleName=acrpull-controller crd paths="./..." output:dir=config/helm/templates output:crd:artifacts:config=config/helm/templates + $(shell cygpath -u "$(GOBIN)/controller-gen-v0.14.0") rbac:roleName=acrpull-controller crd paths="./api/v1beta1" paths="./api/v1beta2" paths="./internal/controller" output:dir=config/helm/templates output:crd:artifacts:config=config/helm/templates mv config/helm/templates/role.yaml config/helm/templates/controller_role.yaml .PHONY: generate generate: $(CONTROLLER_GEN) mocks ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. - $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." + $(shell cygpath -u "$(GOBIN)/controller-gen-v0.14.0") object:headerFile="hack/boilerplate.go.txt" paths="./api/v1beta1" paths="./api/v1beta2" .PHONY: mocks -mocks: $(MOCKGEN) ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. - $(MOCKGEN) -source=pkg/authorizer/interfaces.go > pkg/authorizer/mock_authorizer/interfaces.go +mocks: ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. + $(shell cygpath -u "$(GOBIN)/mockgen") -source=pkg/authorizer/interfaces.go > pkg/authorizer/mock_authorizer/interfaces.go .PHONY: fmt fmt: ## Run go fmt against code. diff --git a/api/v1beta2/acrpullbinding_types.go b/api/v1beta2/acrpullbinding_types.go index 9613d35..849b12c 100644 --- a/api/v1beta2/acrpullbinding_types.go +++ b/api/v1beta2/acrpullbinding_types.go @@ -122,6 +122,11 @@ type AuthenticationMethod struct { // WorkloadIdentity uses Azure Workload Identity to authenticate with Azure. WorkloadIdentity *WorkloadIdentityAuth `json:"workloadIdentity,omitempty"` + + // +kubebuilder:validation:Optional + + // Federated Identity Creationals for Cross Cloud + FederatedIdentity *FederatedIdentityAuth `json:"federatedIdentity,omitempty"` } // +kubebuilder:validation:XValidation:rule="[has(self.clientID), has(self.resourceID)].exists_one(x, x)", message="only client or resource ID can be set" @@ -171,6 +176,64 @@ type WorkloadIdentityAuth struct { TenantID string `json:"tenantID,omitempty"` } +// +kubebuilder:validation:XValidation:rule="(has(self.sourceClientID) && has(self.SourceTenantID))", message="custom client and tenant identifiers must be provided together, if at all" + +type FederatedIdentityAuth struct { + // +kubebuilder:validation:Required + // +kubebuilder:example="1b461305-28be-5271-beda-bd9fd2e24251" + + // SourceClienTid holds the identified of identity residing on the tenant where request has been made. + SourceClientID string `json:"sourceClientID,omitempty"` + + // +kubebuilder:validation:Required + // +kubebuilder:example="72f988bf-86f1-41af-91ab-2d7cd011db47" + + // TenantID holds an optional tenant identifier of a federated identity. + // Specify this identifier if multiple identities are federated with the + // service account and the identity to use for image pulling is not the + // default identity stored in the service account's annotations. The + // client and tenant ID must be specified together. + SourceTenantID string `json:"sourceTenantID,omitempty"` + + // +kubebuilder:validation:Required + // +kubebuilder:example="1b461305-28be-5271-beda-bd9fd2e24251" + + // ClientID holds an optional client identifier of a federated identity. + // Specify this identifier if multiple identities are federated with the + // service account and the identity to use for image pulling is not the + // default identity stored in the service account's annotations. The + // client and tenant ID must be specified together. + TargetClientID string `json:"targetClientID,omitempty"` + + // +kubebuilder:validation:Required + // +kubebuilder:example="72f988bf-86f1-41af-91ab-2d7cd011db47" + + // TenantID holds an optional tenant identifier of a federated identity. + // Specify this identifier if multiple identities are federated with the + // service account and the identity to use for image pulling is not the + // default identity stored in the service account's annotations. The + // client and tenant ID must be specified together. + TargetTenantID string `json:"targetTenantID,omitempty"` + + // +kubebuilder:validation:Required + // +kubebuilder:example="72f988bf-86f1-41af-91ab-2d7cd011db47" + + // TenantID holds an optional tenant identifier of a federated identity. + // Specify this identifier if multiple identities are federated with the + // service account and the identity to use for image pulling is not the + // default identity stored in the service account's annotations. The + // client and tenant ID must be specified together. + Scope string `json:"scope,omitempty"` + + // +kubebuilder:validation:Required + // +kubebuilder:validation:Enum=PublicCloud;USGovernmentCloud;ChinaCloud;AirgappedCloud + // +kubebuilder:default=PublicCloud + // +kubebuilder:example=PublicCloud + + // Environment specifies the Azure Cloud environment in which the ACR is deployed. + TargetEnvironment AzureEnvironmentType `json:"targetEnvironment,omitempty"` +} + // AcrPullBindingStatus defines the observed state of AcrPullBinding type AcrPullBindingStatus struct { // +kubebuilder:validation:Optional diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index a2ea52a..d58e5c5 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -179,6 +179,11 @@ func (in *AuthenticationMethod) DeepCopyInto(out *AuthenticationMethod) { *out = new(WorkloadIdentityAuth) **out = **in } + if in.FederatedIdentity != nil { + in, out := &in.FederatedIdentity, &out.FederatedIdentity + *out = new(FederatedIdentityAuth) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AuthenticationMethod. @@ -191,6 +196,21 @@ func (in *AuthenticationMethod) DeepCopy() *AuthenticationMethod { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FederatedIdentityAuth) DeepCopyInto(out *FederatedIdentityAuth) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FederatedIdentityAuth. +func (in *FederatedIdentityAuth) DeepCopy() *FederatedIdentityAuth { + if in == nil { + return nil + } + out := new(FederatedIdentityAuth) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagedIdentityAuth) DeepCopyInto(out *ManagedIdentityAuth) { *out = *in diff --git a/config/helm/templates/acrpull.microsoft.com_acrpullbindings.yaml b/config/helm/templates/acrpull.microsoft.com_acrpullbindings.yaml index 6e444be..6daff11 100644 --- a/config/helm/templates/acrpull.microsoft.com_acrpullbindings.yaml +++ b/config/helm/templates/acrpull.microsoft.com_acrpullbindings.yaml @@ -139,6 +139,66 @@ spec: description: Auth determines how we will authenticate to the Azure Container Registry. Only one method may be provided. properties: + federatedIdentity: + description: Federated Identity Creationals for Cross Cloud + properties: + scope: + description: |- + TenantID holds an optional tenant identifier of a federated identity. + Specify this identifier if multiple identities are federated with the + service account and the identity to use for image pulling is not the + default identity stored in the service account's annotations. The + client and tenant ID must be specified together. + example: 72f988bf-86f1-41af-91ab-2d7cd011db47 + type: string + sourceClientID: + description: SourceClienTid holds the identified of identity + residing on the tenant where request has been made. + example: 1b461305-28be-5271-beda-bd9fd2e24251 + type: string + sourceTenantID: + description: |- + TenantID holds an optional tenant identifier of a federated identity. + Specify this identifier if multiple identities are federated with the + service account and the identity to use for image pulling is not the + default identity stored in the service account's annotations. The + client and tenant ID must be specified together. + example: 72f988bf-86f1-41af-91ab-2d7cd011db47 + type: string + targetClientID: + description: |- + ClientID holds an optional client identifier of a federated identity. + Specify this identifier if multiple identities are federated with the + service account and the identity to use for image pulling is not the + default identity stored in the service account's annotations. The + client and tenant ID must be specified together. + example: 1b461305-28be-5271-beda-bd9fd2e24251 + type: string + targetEnvironment: + default: PublicCloud + description: Environment specifies the Azure Cloud environment + in which the ACR is deployed. + enum: + - PublicCloud + - USGovernmentCloud + - ChinaCloud + - AirgappedCloud + example: PublicCloud + type: string + targetTenantID: + description: |- + TenantID holds an optional tenant identifier of a federated identity. + Specify this identifier if multiple identities are federated with the + service account and the identity to use for image pulling is not the + default identity stored in the service account's annotations. The + client and tenant ID must be specified together. + example: 72f988bf-86f1-41af-91ab-2d7cd011db47 + type: string + type: object + x-kubernetes-validations: + - message: custom client and tenant identifiers must be provided + together, if at all + rule: (has(self.sourceClientID) && has(self.SourceTenantID)) managedIdentity: description: ManagedIdentity uses Azure Managed Identity to authenticate with Azure. diff --git a/internal/controller/acrpullbinding_v1beta2_controller.go b/internal/controller/acrpullbinding_v1beta2_controller.go index 969613a..f11b84a 100644 --- a/internal/controller/acrpullbinding_v1beta2_controller.go +++ b/internal/controller/acrpullbinding_v1beta2_controller.go @@ -20,6 +20,10 @@ import ( crclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/reconcile" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + ) type CoreOpts struct { @@ -99,7 +103,27 @@ func NewV1beta2Reconciler(opts *V1beta2ReconcilerOpts) *PullBindingReconciler { }, CreatePullCredential: func(ctx context.Context, binding *msiacrpullv1beta2.AcrPullBinding, serviceAccount *corev1.ServiceAccount) (string, time.Time, error) { var tenantId, clientId, token string - if binding.Spec.Auth.WorkloadIdentity != nil { + if binding.Spec.Auth.FederatedIdentity != nil { + fmt.Printf("Using Federated Identity Credentials %s %s\n", binding.Spec.Auth.FederatedIdentity.SourceTenantID, binding.Spec.Auth.FederatedIdentity.SourceClientID) + + tenantId = binding.Spec.Auth.FederatedIdentity.SourceTenantID + clientId = binding.Spec.Auth.FederatedIdentity.SourceClientID + + msiCred, err := azidentity.NewDefaultAzureCredential(nil) + + if err != nil { + return "", time.Time{}, fmt.Errorf("failed to get MSI token: %w", err) + } + + msiToken, err := msiCred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{binding.Spec.Auth.FederatedIdentity.Scope + "/.default"}}) + fmt.Printf("MSI Token scope: %s\n", binding.Spec.Auth.FederatedIdentity.Scope ) + + if err != nil { + return "", time.Time{}, fmt.Errorf("Msi Token: %v", err) + } + + token = msiToken.Token + } else if binding.Spec.Auth.WorkloadIdentity != nil { if binding.Spec.Auth.WorkloadIdentity.TenantID != "" { tenantId = binding.Spec.Auth.WorkloadIdentity.TenantID clientId = binding.Spec.Auth.WorkloadIdentity.ClientID @@ -131,7 +155,33 @@ func NewV1beta2Reconciler(opts *V1beta2ReconcilerOpts) *PullBindingReconciler { return "", time.Time{}, fmt.Errorf("failed to retrieve ARM token: %v", err) } - acrToken, err := opts.exchangeArmTokenForAcrToken(ctx, armToken, binding.Spec.ACR) + var exchangeToken azcore.AccessToken + + if binding.Spec.Auth.FederatedIdentity != nil { + env := environment(binding.Spec.Auth.FederatedIdentity.TargetEnvironment) + + assertionFunc := func(ctx context.Context) (string, error) { + return armToken.Token, nil + } + + // Token 2 + ficCred, err := azidentity.NewClientAssertionCredential(binding.Spec.Auth.FederatedIdentity.TargetTenantID, binding.Spec.Auth.FederatedIdentity.TargetClientID, assertionFunc, nil) + if err != nil { + return "", time.Time{}, fmt.Errorf("WW Token: %v", err) + } + + ficToken, err := ficCred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{ env.Services[cloud.ResourceManager].Audience + "/.default"}}) + if err != nil { + return "", time.Time{}, fmt.Errorf("WW Token: %v", err) + } + + exchangeToken = ficToken + + } else { + exchangeToken = armToken + } + + acrToken, err := opts.exchangeArmTokenForAcrToken(ctx, exchangeToken, binding.Spec.ACR) if err != nil { return "", time.Time{}, fmt.Errorf("failed to retrieve ACR token: %v", err) } @@ -251,3 +301,16 @@ func refreshBoundary(refresh, expiry time.Time, ttlRotationFraction float64) tim func needsRefresh(now func() time.Time, refresh, expiry time.Time, ttlRotationFraction float64) bool { return now().After(refreshBoundary(refresh, expiry, ttlRotationFraction)) } + +func environment(input msiacrpullv1beta2.AzureEnvironmentType) cloud.Configuration { + switch input { + case msiacrpullv1beta2.AzureEnvironmentPublicCloud: + return cloud.AzurePublic + case msiacrpullv1beta2.AzureEnvironmentUSGovernmentCloud: + return cloud.AzureGovernment + case msiacrpullv1beta2.AzureEnvironmentChinaCloud: + return cloud.AzureChina + default: + panic(fmt.Errorf("unsupported msiacrpullv1beta2.AzureEnvironmentType: %s", input)) + } +} \ No newline at end of file diff --git a/pkg/authorizer/mock_authorizer/interfaces.go b/pkg/authorizer/mock_authorizer/interfaces.go index 386f0e4..06a34f0 100644 --- a/pkg/authorizer/mock_authorizer/interfaces.go +++ b/pkg/authorizer/mock_authorizer/interfaces.go @@ -1,10 +1,5 @@ // Code generated by MockGen. DO NOT EDIT. // Source: pkg/authorizer/interfaces.go -// -// Generated by this command: -// -// mockgen-v0.4.0 -source=pkg/authorizer/interfaces.go -// // Package mock_authorizer is a generated GoMock package. package mock_authorizer @@ -14,7 +9,7 @@ import ( reflect "reflect" azcore "github.com/Azure/azure-sdk-for-go/sdk/azcore" - gomock "go.uber.org/mock/gomock" + gomock "github.com/golang/mock/gomock" ) // MockInterface is a mock of Interface interface. @@ -50,7 +45,7 @@ func (m *MockInterface) AcquireACRAccessToken(ctx context.Context, identityResou } // AcquireACRAccessToken indicates an expected call of AcquireACRAccessToken. -func (mr *MockInterfaceMockRecorder) AcquireACRAccessToken(ctx, identityResourceID, clientID, acrFQDN, scope any) *gomock.Call { +func (mr *MockInterfaceMockRecorder) AcquireACRAccessToken(ctx, identityResourceID, clientID, acrFQDN, scope interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AcquireACRAccessToken", reflect.TypeOf((*MockInterface)(nil).AcquireACRAccessToken), ctx, identityResourceID, clientID, acrFQDN, scope) } diff --git a/pkg/authorizer/token_retriever.go b/pkg/authorizer/token_retriever.go index ecad4ec..88e23a3 100644 --- a/pkg/authorizer/token_retriever.go +++ b/pkg/authorizer/token_retriever.go @@ -10,6 +10,8 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + ctrl "sigs.k8s.io/controller-runtime" + msiacrpullv1beta2 "github.com/Azure/msi-acrpull/api/v1beta2" ) @@ -36,6 +38,7 @@ func ARMTokenForBinding(ctx context.Context, spec msiacrpullv1beta2.AcrPullBindi var credential azcore.TokenCredential var err error + switch { case spec.Auth.ManagedIdentity != nil: var id azidentity.ManagedIDKind @@ -45,7 +48,7 @@ func ARMTokenForBinding(ctx context.Context, spec msiacrpullv1beta2.AcrPullBindi id = azidentity.ResourceID(spec.Auth.ManagedIdentity.ResourceID) } credential, err = azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{ID: id}) - case spec.Auth.WorkloadIdentity != nil: + case spec.Auth.WorkloadIdentity != nil || spec.Auth.FederatedIdentity != nil: // n.b. the built-in azidentity.WorkloadIdentityCredential assumes we're loading a service account token // from a file in a Pod, where the Kubernetes API server is rotating it, etc. Unfortunately that is not // our use-case here, and we certainly don't want to centralize every service account token we ever mint @@ -68,7 +71,15 @@ func ARMTokenForBinding(ctx context.Context, spec msiacrpullv1beta2.AcrPullBindi // this should never happen with the validation we have on the CRD panic(fmt.Errorf("programmer error: ACRPullBinding.Spec.Auth has no method: %#v", spec.Auth)) } - return credential.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{env.Services[cloud.ResourceManager].Audience + "/.default"}}) + + // Default Audience to the Resource Manager audience for the environment + var audience string = env.Services[cloud.ResourceManager].Audience + + if spec.Auth.FederatedIdentity != nil { + audience = "api://AzureADTokenExchange" + } + + return credential.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{audience + "/.default"}}) } func environment(input msiacrpullv1beta2.AzureEnvironmentType, config *msiacrpullv1beta2.AirgappedCloudConfiguration) cloud.Configuration {