Skip to content

Commit 9573fba

Browse files
committed
feat: KSA support with AKS identity bindings
1 parent 073a97b commit 9573fba

File tree

10 files changed

+1010
-27
lines changed

10 files changed

+1010
-27
lines changed

cmd/acr-credential-provider/main.go

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,20 @@ import (
3131
"k8s.io/component-base/logs"
3232
"k8s.io/klog/v2"
3333

34+
"sigs.k8s.io/cloud-provider-azure/cmd/acr-credential-provider/pkg/config"
3435
"sigs.k8s.io/cloud-provider-azure/pkg/version"
3536
)
3637

3738
func main() {
3839
rand.Seed(time.Now().UnixNano())
3940

40-
var RegistryMirrorStr string
41+
var (
42+
RegistryMirrorStr string
43+
IBSNIName string
44+
IBDefaultClient string
45+
IBDefaultTenant string
46+
IBAPIIP string
47+
)
4148

4249
command := &cobra.Command{
4350
Use: "acr-credential-provider configFile",
@@ -54,7 +61,12 @@ func main() {
5461
},
5562
Version: version.Get().GitVersion,
5663
RunE: func(_ *cobra.Command, args []string) error {
57-
if err := NewCredentialProvider(args[0], RegistryMirrorStr).Run(context.TODO()); err != nil {
64+
ibConfig, err := config.ParseIdentityBindingsConfig(IBSNIName, IBDefaultClient, IBDefaultTenant, IBAPIIP)
65+
if err != nil {
66+
klog.Errorf("Error parsing identity bindings config: %v", err)
67+
return err
68+
}
69+
if err := NewCredentialProvider(args[0], RegistryMirrorStr, ibConfig).Run(context.TODO()); err != nil {
5870
klog.Errorf("Error running acr credential provider: %v", err)
5971
return err
6072
}
@@ -68,6 +80,14 @@ func main() {
6880
// Flags
6981
command.Flags().StringVarP(&RegistryMirrorStr, "registry-mirror", "r", "",
7082
"Mirror a source registry host to a target registry host, and image pull credential will be requested to the target registry host when the image is from source registry host")
83+
command.Flags().StringVar(&IBSNIName, config.FlagIBSNIName, "",
84+
"SNI name for identity bindings")
85+
command.Flags().StringVar(&IBDefaultClient, config.FlagIBDefaultClient, "",
86+
"Default Azure AD client ID for identity bindings")
87+
command.Flags().StringVar(&IBDefaultTenant, config.FlagIBDefaultTenant, "",
88+
"Default Azure AD tenant ID for identity bindings")
89+
command.Flags().StringVar(&IBAPIIP, config.FlagIBAPIIP, "",
90+
"API server IP address for identity bindings endpoint")
7191

7292
logs.AddFlags(command.Flags())
7393
if err := func() error {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
Copyright 2021 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package config
18+
19+
import (
20+
"fmt"
21+
"net"
22+
"strings"
23+
24+
"sigs.k8s.io/cloud-provider-azure/pkg/credentialprovider"
25+
)
26+
27+
const (
28+
// Flag names for identity bindings configuration
29+
FlagIBSNIName = "ib-sni-name"
30+
FlagIBDefaultClient = "ib-default-client-id"
31+
FlagIBDefaultTenant = "ib-default-tenant-id"
32+
FlagIBAPIIP = "ib-apiserver-ip"
33+
)
34+
35+
// ParseIdentityBindingsConfig parses and validates identity bindings configuration from individual parameters
36+
func ParseIdentityBindingsConfig(sniName, defaultClientID, defaultTenantID, apiServerIP string) (credentialprovider.IdentityBindingsConfig, error) {
37+
38+
// Validate SNI name
39+
if sniName != "" {
40+
if strings.HasPrefix(sniName, "https://") || strings.HasPrefix(sniName, "http://") {
41+
return credentialprovider.IdentityBindingsConfig{}, fmt.Errorf("--%s must not contain protocol prefix (https:// or http://), got: %s",
42+
FlagIBSNIName, sniName)
43+
}
44+
if apiServerIP == "" {
45+
return credentialprovider.IdentityBindingsConfig{}, fmt.Errorf("--%s must be set when --%s is provided", FlagIBAPIIP, FlagIBSNIName)
46+
}
47+
}
48+
49+
// Validate client ID requires SNI name
50+
if defaultClientID != "" && sniName == "" {
51+
return credentialprovider.IdentityBindingsConfig{}, fmt.Errorf("--%s must be set when --%s is provided", FlagIBSNIName, FlagIBDefaultClient)
52+
}
53+
54+
// Validate tenant ID requires SNI name
55+
if defaultTenantID != "" && sniName == "" {
56+
return credentialprovider.IdentityBindingsConfig{}, fmt.Errorf("--%s must be set when --%s is provided", FlagIBSNIName, FlagIBDefaultTenant)
57+
}
58+
59+
// Validate API server IP
60+
if apiServerIP != "" {
61+
if net.ParseIP(apiServerIP) == nil {
62+
return credentialprovider.IdentityBindingsConfig{}, fmt.Errorf("--%s must be a valid IP address, got: %s",
63+
FlagIBAPIIP, apiServerIP)
64+
}
65+
if sniName == "" {
66+
return credentialprovider.IdentityBindingsConfig{}, fmt.Errorf("--%s must be set when --%s is provided", FlagIBSNIName, FlagIBAPIIP)
67+
}
68+
}
69+
70+
return credentialprovider.IdentityBindingsConfig{
71+
SNIName: sniName,
72+
DefaultClientID: defaultClientID,
73+
DefaultTenantID: defaultTenantID,
74+
APIServerIP: apiServerIP,
75+
}, nil
76+
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
Copyright 2021 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package config
18+
19+
import (
20+
"strings"
21+
"testing"
22+
23+
"sigs.k8s.io/cloud-provider-azure/pkg/credentialprovider"
24+
)
25+
26+
func TestParseIdentityBindingsConfig(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
sniName string
30+
defaultClientID string
31+
defaultTenantID string
32+
apiServerIP string
33+
wantConfig credentialprovider.IdentityBindingsConfig
34+
wantErr bool
35+
errContains string
36+
}{
37+
{
38+
name: "empty config",
39+
wantConfig: credentialprovider.IdentityBindingsConfig{},
40+
wantErr: false,
41+
},
42+
{
43+
name: "valid config with all fields",
44+
sniName: "api.example.com",
45+
defaultClientID: "client-123",
46+
defaultTenantID: "tenant-456",
47+
apiServerIP: "10.0.0.1",
48+
wantConfig: credentialprovider.IdentityBindingsConfig{
49+
SNIName: "api.example.com",
50+
DefaultClientID: "client-123",
51+
DefaultTenantID: "tenant-456",
52+
APIServerIP: "10.0.0.1",
53+
},
54+
wantErr: false,
55+
},
56+
{
57+
name: "valid config with SNI name and API server IP only",
58+
sniName: "api.example.com",
59+
apiServerIP: "10.0.0.1",
60+
wantConfig: credentialprovider.IdentityBindingsConfig{
61+
SNIName: "api.example.com",
62+
APIServerIP: "10.0.0.1",
63+
},
64+
wantErr: false,
65+
},
66+
{
67+
name: "SNI name with https:// prefix",
68+
sniName: "https://api.example.com",
69+
apiServerIP: "10.0.0.1",
70+
wantErr: true,
71+
errContains: "must not contain protocol prefix",
72+
},
73+
{
74+
name: "SNI name with http:// prefix",
75+
sniName: "http://api.example.com",
76+
apiServerIP: "10.0.0.1",
77+
wantErr: true,
78+
errContains: "must not contain protocol prefix",
79+
},
80+
{
81+
name: "SNI name without API server IP",
82+
sniName: "api.example.com",
83+
wantErr: true,
84+
errContains: "ib-apiserver-ip must be set",
85+
},
86+
{
87+
name: "API server IP without SNI name",
88+
apiServerIP: "10.0.0.1",
89+
wantErr: true,
90+
errContains: "ib-sni-name must be set",
91+
},
92+
{
93+
name: "client ID without SNI name",
94+
defaultClientID: "client-123",
95+
wantErr: true,
96+
errContains: "ib-sni-name must be set",
97+
},
98+
{
99+
name: "tenant ID without SNI name",
100+
defaultTenantID: "tenant-456",
101+
wantErr: true,
102+
errContains: "ib-sni-name must be set",
103+
},
104+
{
105+
name: "invalid API server IP - hostname",
106+
sniName: "api.example.com",
107+
apiServerIP: "invalid-hostname",
108+
wantErr: true,
109+
errContains: "must be a valid IP address",
110+
},
111+
{
112+
name: "invalid API server IP - malformed",
113+
sniName: "api.example.com",
114+
apiServerIP: "999.999.999.999",
115+
wantErr: true,
116+
errContains: "must be a valid IP address",
117+
},
118+
{
119+
name: "valid IPv6 address",
120+
sniName: "api.example.com",
121+
apiServerIP: "2001:db8::1",
122+
wantConfig: credentialprovider.IdentityBindingsConfig{
123+
SNIName: "api.example.com",
124+
APIServerIP: "2001:db8::1",
125+
},
126+
wantErr: false,
127+
},
128+
}
129+
130+
for _, tt := range tests {
131+
t.Run(tt.name, func(t *testing.T) {
132+
gotConfig, err := ParseIdentityBindingsConfig(tt.sniName, tt.defaultClientID, tt.defaultTenantID, tt.apiServerIP)
133+
if (err != nil) != tt.wantErr {
134+
t.Errorf("ParseIdentityBindingsConfig() error = %v, wantErr %v", err, tt.wantErr)
135+
return
136+
}
137+
if tt.wantErr {
138+
if err == nil {
139+
t.Errorf("ParseIdentityBindingsConfig() expected error containing %q, got nil", tt.errContains)
140+
} else if !strings.Contains(err.Error(), tt.errContains) {
141+
t.Errorf("ParseIdentityBindingsConfig() error = %v, want error containing %q", err, tt.errContains)
142+
}
143+
return
144+
}
145+
if gotConfig.SNIName != tt.wantConfig.SNIName {
146+
t.Errorf("ParseIdentityBindingsConfig() SNIName = %v, want %v", gotConfig.SNIName, tt.wantConfig.SNIName)
147+
}
148+
if gotConfig.DefaultClientID != tt.wantConfig.DefaultClientID {
149+
t.Errorf("ParseIdentityBindingsConfig() DefaultClientID = %v, want %v", gotConfig.DefaultClientID, tt.wantConfig.DefaultClientID)
150+
}
151+
if gotConfig.DefaultTenantID != tt.wantConfig.DefaultTenantID {
152+
t.Errorf("ParseIdentityBindingsConfig() DefaultTenantID = %v, want %v", gotConfig.DefaultTenantID, tt.wantConfig.DefaultTenantID)
153+
}
154+
if gotConfig.APIServerIP != tt.wantConfig.APIServerIP {
155+
t.Errorf("ParseIdentityBindingsConfig() APIServerIP = %v, want %v", gotConfig.APIServerIP, tt.wantConfig.APIServerIP)
156+
}
157+
})
158+
}
159+
}

cmd/acr-credential-provider/plugin.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,17 @@ func init() {
4646
type ExecPlugin struct {
4747
configFile string
4848
RegistryMirrorStr string
49+
IBConfig credentialprovider.IdentityBindingsConfig
4950
plugin credentialprovider.CredentialProvider
5051
}
5152

5253
// NewCredentialProvider returns an instance of execPlugin that fetches
5354
// credentials based on the provided plugin implementing the CredentialProvider interface.
54-
func NewCredentialProvider(configFile string, registryMirrorStr string) *ExecPlugin {
55+
func NewCredentialProvider(configFile string, registryMirrorStr string, ibConfig credentialprovider.IdentityBindingsConfig) *ExecPlugin {
5556
return &ExecPlugin{
5657
configFile: configFile,
5758
RegistryMirrorStr: registryMirrorStr,
59+
IBConfig: ibConfig,
5860
}
5961
}
6062

@@ -92,7 +94,7 @@ func (e *ExecPlugin) runPlugin(ctx context.Context, r io.Reader, w io.Writer, ar
9294

9395
if e.plugin == nil {
9496
// acr provider plugin are decided at runtime by the request information.
95-
e.plugin, err = credentialprovider.NewAcrProvider(request, e.RegistryMirrorStr, e.configFile)
97+
e.plugin, err = credentialprovider.NewAcrProvider(request, e.RegistryMirrorStr, e.configFile, e.IBConfig)
9698
if err != nil {
9799
return err
98100
}

cmd/acr-credential-provider/plugin_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import (
2626

2727
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2828
v1 "k8s.io/kubelet/pkg/apis/credentialprovider/v1"
29+
30+
"sigs.k8s.io/cloud-provider-azure/pkg/credentialprovider"
2931
)
3032

3133
type fakePlugin struct {
@@ -94,7 +96,7 @@ func Test_runPlugin(t *testing.T) {
9496
if err != nil {
9597
t.Fatalf("Unexpected error when writing to temp file: %v", err)
9698
}
97-
p := NewCredentialProvider(configFile.Name(), "mcr.microsoft.com:fakeacrname.azurecr.io")
99+
p := NewCredentialProvider(configFile.Name(), "mcr.microsoft.com:fakeacrname.azurecr.io", credentialprovider.IdentityBindingsConfig{})
98100
p.plugin = &fakePlugin{}
99101
out := &bytes.Buffer{}
100102

pkg/credentialprovider/azure_credentials.go

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,7 @@ type acrProvider struct {
6666
registryMirror map[string]string // Registry mirror relation: source registry -> target registry
6767
}
6868

69-
type getTokenCredentialFunc func(req *v1.CredentialProviderRequest, config *providerconfig.AzureClientConfig) (azcore.TokenCredential, error)
70-
71-
func NewAcrProvider(req *v1.CredentialProviderRequest, registryMirrorStr string, configFile string) (CredentialProvider, error) {
69+
func NewAcrProvider(req *v1.CredentialProviderRequest, registryMirrorStr string, configFile string, ibConfig IdentityBindingsConfig) (CredentialProvider, error) {
7270
config, err := configloader.Load[providerconfig.AzureClientConfig](context.Background(), nil, &configloader.FileLoaderConfig{FilePath: configFile})
7371
if err != nil {
7472
return nil, fmt.Errorf("failed to load config: %w", err)
@@ -83,20 +81,27 @@ func NewAcrProvider(req *v1.CredentialProviderRequest, registryMirrorStr string,
8381
return nil, err
8482
}
8583

86-
var getTokenCredential getTokenCredentialFunc
87-
88-
// kubelet is responsible for checking the service account token emptiness when service account token is enabled, and only when service account token provide is enabled,
89-
// service account token is set in the request, so we can safely check the service account token emptiness to decide which credential to use.
90-
if len(req.ServiceAccountToken) != 0 {
91-
klog.V(2).Infof("Using service account token to authenticate ACR for image %s", req.Image)
92-
getTokenCredential = getServiceAccountTokenCredential
84+
var credential azcore.TokenCredential
85+
if ibConfig.SNIName != "" {
86+
klog.V(2).Infof("Using identity bindings token credential for image %s", req.Image)
87+
credential, err = GetIdentityBindingsTokenCredential(req, config, ibConfig)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed to get identity bindings token credential for image %s: %w", req.Image, err)
90+
}
91+
} else if len(req.ServiceAccountToken) != 0 {
92+
// Use service account token credential
93+
klog.V(2).Infof("Using service account token credential for image %s", req.Image)
94+
credential, err = getServiceAccountTokenCredential(req, config)
95+
if err != nil {
96+
return nil, fmt.Errorf("failed to get service account token credential for image %s: %w", req.Image, err)
97+
}
9398
} else {
99+
// Use managed identity
94100
klog.V(2).Infof("Using managed identity to authenticate ACR for image %s", req.Image)
95-
getTokenCredential = getManagedIdentityCredential
96-
}
97-
credential, err := getTokenCredential(req, config)
98-
if err != nil {
99-
return nil, fmt.Errorf("failed to get token credential for image %s: %w", req.Image, err)
101+
credential, err = getManagedIdentityCredential(req, config)
102+
if err != nil {
103+
return nil, fmt.Errorf("failed to get token credential for image %s: %w", req.Image, err)
104+
}
100105
}
101106

102107
return &acrProvider{

0 commit comments

Comments
 (0)