From 311634616142940a0d552cd856cffc8b899f4f7b Mon Sep 17 00:00:00 2001 From: Eric Chiang Date: Thu, 18 Feb 2016 09:26:36 -0800 Subject: [PATCH] *: add webhook implementation of authorizer.Authorizer plugin --- cmd/kube-apiserver/app/options/options.go | 5 +- cmd/kube-apiserver/app/server.go | 2 +- pkg/apiserver/authz.go | 36 +- pkg/apiserver/authz_test.go | 92 +++- plugin/pkg/auth/authorizer/doc.go | 18 + .../pkg/auth/authorizer/webhook/certs_test.go | 211 ++++++++ .../pkg/auth/authorizer/webhook/gencerts.sh | 102 ++++ plugin/pkg/auth/authorizer/webhook/webhook.go | 180 +++++++ .../auth/authorizer/webhook/webhook_test.go | 473 ++++++++++++++++++ 9 files changed, 1087 insertions(+), 32 deletions(-) create mode 100644 plugin/pkg/auth/authorizer/doc.go create mode 100644 plugin/pkg/auth/authorizer/webhook/certs_test.go create mode 100755 plugin/pkg/auth/authorizer/webhook/gencerts.sh create mode 100644 plugin/pkg/auth/authorizer/webhook/webhook.go create mode 100644 plugin/pkg/auth/authorizer/webhook/webhook_test.go diff --git a/cmd/kube-apiserver/app/options/options.go b/cmd/kube-apiserver/app/options/options.go index 5b4b0e9dede..c2418ecce56 100644 --- a/cmd/kube-apiserver/app/options/options.go +++ b/cmd/kube-apiserver/app/options/options.go @@ -47,7 +47,7 @@ type APIServer struct { AdvertiseAddress net.IP AllowPrivileged bool AuthorizationMode string - AuthorizationPolicyFile string + AuthorizationConfig apiserver.AuthorizationConfig BasicAuthFile string CloudConfigFile string CloudProvider string @@ -214,7 +214,8 @@ func (s *APIServer) AddFlags(fs *pflag.FlagSet) { fs.BoolVar(&s.ServiceAccountLookup, "service-account-lookup", s.ServiceAccountLookup, "If true, validate ServiceAccount tokens exist in etcd as part of authentication.") fs.StringVar(&s.KeystoneURL, "experimental-keystone-url", s.KeystoneURL, "If passed, activates the keystone authentication plugin") fs.StringVar(&s.AuthorizationMode, "authorization-mode", s.AuthorizationMode, "Ordered list of plug-ins to do authorization on secure port. Comma-delimited list of: "+strings.Join(apiserver.AuthorizationModeChoices, ",")) - fs.StringVar(&s.AuthorizationPolicyFile, "authorization-policy-file", s.AuthorizationPolicyFile, "File with authorization policy in csv format, used with --authorization-mode=ABAC, on the secure port.") + fs.StringVar(&s.AuthorizationConfig.PolicyFile, "authorization-policy-file", s.AuthorizationConfig.PolicyFile, "File with authorization policy in csv format, used with --authorization-mode=ABAC, on the secure port.") + fs.StringVar(&s.AuthorizationConfig.WebhookConfigFile, "authorization-webhook-config-file", s.AuthorizationConfig.WebhookConfigFile, "File with webhook configuration in kubeconfig format, used with --authorization-mode=Webhook. The API server will query the remote service to determine access on the API server's secure port.") fs.StringVar(&s.AdmissionControl, "admission-control", s.AdmissionControl, "Ordered list of plug-ins to do admission control of resources into cluster. Comma-delimited list of: "+strings.Join(admission.GetPlugins(), ", ")) fs.StringVar(&s.AdmissionControlConfigFile, "admission-control-config-file", s.AdmissionControlConfigFile, "File with admission control configuration.") fs.StringSliceVar(&s.EtcdServerList, "etcd-servers", s.EtcdServerList, "List of etcd servers to watch (http://ip:port), comma separated. Mutually exclusive with -etcd-config") diff --git a/cmd/kube-apiserver/app/server.go b/cmd/kube-apiserver/app/server.go index 3f6ee3a703e..6143f23294a 100644 --- a/cmd/kube-apiserver/app/server.go +++ b/cmd/kube-apiserver/app/server.go @@ -406,7 +406,7 @@ func Run(s *options.APIServer) error { } authorizationModeNames := strings.Split(s.AuthorizationMode, ",") - authorizer, err := apiserver.NewAuthorizerFromAuthorizationConfig(authorizationModeNames, s.AuthorizationPolicyFile) + authorizer, err := apiserver.NewAuthorizerFromAuthorizationConfig(authorizationModeNames, s.AuthorizationConfig) if err != nil { glog.Fatalf("Invalid Authorization Config: %v", err) } diff --git a/pkg/apiserver/authz.go b/pkg/apiserver/authz.go index b556b101305..88e8f6284e5 100644 --- a/pkg/apiserver/authz.go +++ b/pkg/apiserver/authz.go @@ -23,6 +23,7 @@ import ( "k8s.io/kubernetes/pkg/auth/authorizer" "k8s.io/kubernetes/pkg/auth/authorizer/abac" "k8s.io/kubernetes/pkg/auth/authorizer/union" + "k8s.io/kubernetes/plugin/pkg/auth/authorizer/webhook" ) // Attributes implements authorizer.Attributes interface. @@ -60,15 +61,28 @@ const ( ModeAlwaysAllow string = "AlwaysAllow" ModeAlwaysDeny string = "AlwaysDeny" ModeABAC string = "ABAC" + ModeWebhook string = "Webhook" ) // Keep this list in sync with constant list above. -var AuthorizationModeChoices = []string{ModeAlwaysAllow, ModeAlwaysDeny, ModeABAC} +var AuthorizationModeChoices = []string{ModeAlwaysAllow, ModeAlwaysDeny, ModeABAC, ModeWebhook} + +type AuthorizationConfig struct { + // Options for ModeABAC + + // Path to a ABAC policy file. + PolicyFile string + + // Options for ModeWebhook + + // Kubeconfig file for Webhook authorization plugin. + WebhookConfigFile string +} // NewAuthorizerFromAuthorizationConfig returns the right sort of union of multiple authorizer.Authorizer objects // based on the authorizationMode or an error. authorizationMode should be a comma separated values // of AuthorizationModeChoices. -func NewAuthorizerFromAuthorizationConfig(authorizationModes []string, authorizationPolicyFile string) (authorizer.Authorizer, error) { +func NewAuthorizerFromAuthorizationConfig(authorizationModes []string, config AuthorizationConfig) (authorizer.Authorizer, error) { if len(authorizationModes) == 0 { return nil, errors.New("Atleast one authorization mode should be passed") @@ -88,23 +102,35 @@ func NewAuthorizerFromAuthorizationConfig(authorizationModes []string, authoriza case ModeAlwaysDeny: authorizers = append(authorizers, NewAlwaysDenyAuthorizer()) case ModeABAC: - if authorizationPolicyFile == "" { + if config.PolicyFile == "" { return nil, errors.New("ABAC's authorization policy file not passed") } - abacAuthorizer, err := abac.NewFromFile(authorizationPolicyFile) + abacAuthorizer, err := abac.NewFromFile(config.PolicyFile) if err != nil { return nil, err } authorizers = append(authorizers, abacAuthorizer) + case ModeWebhook: + if config.WebhookConfigFile == "" { + return nil, errors.New("Webhook's configuration file not passed") + } + webhookAuthorizer, err := webhook.New(config.WebhookConfigFile) + if err != nil { + return nil, err + } + authorizers = append(authorizers, webhookAuthorizer) default: return nil, fmt.Errorf("Unknown authorization mode %s specified", authorizationMode) } authorizerMap[authorizationMode] = true } - if !authorizerMap[ModeABAC] && authorizationPolicyFile != "" { + if !authorizerMap[ModeABAC] && config.PolicyFile != "" { return nil, errors.New("Cannot specify --authorization-policy-file without mode ABAC") } + if !authorizerMap[ModeWebhook] && config.WebhookConfigFile != "" { + return nil, errors.New("Cannot specify --authorization-webhook-config-file without mode Webhook") + } return union.New(authorizers...), nil } diff --git a/pkg/apiserver/authz_test.go b/pkg/apiserver/authz_test.go index 72d33802ae1..5ea6045a703 100644 --- a/pkg/apiserver/authz_test.go +++ b/pkg/apiserver/authz_test.go @@ -41,31 +41,75 @@ func TestNewAlwaysDenyAuthorizer(t *testing.T) { // NewAuthorizerFromAuthorizationConfig has multiple return possibilities. This test // validates that errors are returned only when proper. func TestNewAuthorizerFromAuthorizationConfig(t *testing.T) { - // Unknown modes should return errors - if _, err := NewAuthorizerFromAuthorizationConfig([]string{"DoesNotExist"}, ""); err == nil { - t.Errorf("NewAuthorizerFromAuthorizationConfig using a fake mode should have returned an error") + + examplePolicyFile := "../auth/authorizer/abac/example_policy_file.jsonl" + + tests := []struct { + modes []string + config AuthorizationConfig + wantErr bool + msg string + }{ + { + // Unknown modes should return errors + modes: []string{"DoesNotExist"}, + wantErr: true, + msg: "using a fake mode should have returned an error", + }, + { + // ModeAlwaysAllow and ModeAlwaysDeny should return without authorizationPolicyFile + // but error if one is given + modes: []string{ModeAlwaysAllow, ModeAlwaysDeny}, + msg: "returned an error for valid config", + }, + { + // ModeABAC requires a policy file + modes: []string{ModeAlwaysAllow, ModeAlwaysDeny, ModeABAC}, + wantErr: true, + msg: "specifying ABAC with no policy file should return an error", + }, + { + // ModeABAC should not error if a valid policy path is provided + modes: []string{ModeAlwaysAllow, ModeAlwaysDeny, ModeABAC}, + config: AuthorizationConfig{PolicyFile: examplePolicyFile}, + msg: "errored while using a valid policy file", + }, + { + + // Authorization Policy file cannot be used without ModeABAC + modes: []string{ModeAlwaysAllow, ModeAlwaysDeny}, + config: AuthorizationConfig{PolicyFile: examplePolicyFile}, + wantErr: true, + msg: "should have errored when Authorization Policy File is used without ModeABAC", + }, + { + // Atleast one authorizationMode is necessary + modes: []string{}, + config: AuthorizationConfig{PolicyFile: examplePolicyFile}, + wantErr: true, + msg: "should have errored when no authorization modes are passed", + }, + { + // ModeWebhook requires at minimum a target. + modes: []string{ModeWebhook}, + wantErr: true, + msg: "should have errored when config was empty with ModeWebhook", + }, + { + // Cannot provide webhook flags without ModeWebhook + modes: []string{ModeAlwaysAllow}, + config: AuthorizationConfig{WebhookConfigFile: "authz_webhook_config.yml"}, + wantErr: true, + msg: "should have errored when Webhook config file is used without ModeWebhook", + }, } - // ModeAlwaysAllow and ModeAlwaysDeny should return without authorizationPolicyFile - // but error if one is given - if _, err := NewAuthorizerFromAuthorizationConfig([]string{ModeAlwaysAllow, ModeAlwaysDeny}, ""); err != nil { - t.Errorf("NewAuthorizerFromAuthorizationConfig returned an error: %s", err) - } - - // ModeABAC requires a policy file - if _, err := NewAuthorizerFromAuthorizationConfig([]string{ModeAlwaysAllow, ModeAlwaysDeny, ModeABAC}, ""); err == nil { - t.Errorf("NewAuthorizerFromAuthorizationConfig using a fake mode should have returned an error") - } - // ModeABAC should not error if a valid policy path is provided - if _, err := NewAuthorizerFromAuthorizationConfig([]string{ModeAlwaysAllow, ModeAlwaysDeny, ModeABAC}, "../auth/authorizer/abac/example_policy_file.jsonl"); err != nil { - t.Errorf("NewAuthorizerFromAuthorizationConfig errored while using a valid policy file: %s", err) - } - // Authorization Policy file cannot be used without ModeABAC - if _, err := NewAuthorizerFromAuthorizationConfig([]string{ModeAlwaysAllow, ModeAlwaysDeny}, "../auth/authorizer/abac/example_policy_file.jsonl"); err == nil { - t.Errorf("NewAuthorizerFromAuthorizationConfig should have errored when Authorization Policy File is used without ModeABAC") - } - // Atleast one authorizationMode is necessary - if _, err := NewAuthorizerFromAuthorizationConfig([]string{}, "../auth/authorizer/abac/example_policy_file.jsonl"); err == nil { - t.Errorf("NewAuthorizerFromAuthorizationConfig should have errored when no authorization modes are passed") + for _, tt := range tests { + _, err := NewAuthorizerFromAuthorizationConfig(tt.modes, tt.config) + if tt.wantErr && (err == nil) { + t.Errorf("NewAuthorizerFromAuthorizationConfig %s", tt.msg) + } else if !tt.wantErr && (err != nil) { + t.Errorf("NewAuthorizerFromAuthorizationConfig %s: %v", tt.msg, err) + } } } diff --git a/plugin/pkg/auth/authorizer/doc.go b/plugin/pkg/auth/authorizer/doc.go new file mode 100644 index 00000000000..40ee1c8e439 --- /dev/null +++ b/plugin/pkg/auth/authorizer/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package authorizer contains implementations for pkg/auth/authorizer interfaces +package authorizer diff --git a/plugin/pkg/auth/authorizer/webhook/certs_test.go b/plugin/pkg/auth/authorizer/webhook/certs_test.go new file mode 100644 index 00000000000..c26ba703f2a --- /dev/null +++ b/plugin/pkg/auth/authorizer/webhook/certs_test.go @@ -0,0 +1,211 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This file was generated using openssl by the gencerts.sh script +// and holds raw certificates for the webhook tests. + +package webhook + +var caKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA6IVXGPX5yP2Q6TAlQXIQsavzSqZ973iZvpQBGTI6M98gTSVm +eBYE3o7S8e6WTI3DCnWwqc8Md1rT92FtaQLwv+uMNXijLio5RVBqjUEbunD5In/+ +T/y5sE9P3CzcWy6CEhIvORAZj6UlvgZzbRwI91+EVFR5jd8JU0e/L9Ds1jLZFyQw +Kc1ADo+Tj9O4l0WtpRlrhzTgoor4C3fAQZm0mq+llTnxCmw+lhy8t88bPG1cMwdd +DtUTbpetc++2JZ62Q3F1nqcX1EcHDidR0x3j+3357BLkXRK4MQsWLYLzeZ3X1ghW +XT062H866PcIV+MX4H58spMN5cVYk5YTneGihQIDAQABAoIBAHU7FQieq4ssXK1U ++tOeQNBzUzxl6MSd11YApPUhH7sbWdvLaXhOEbJr6+rSUbDTIGzbnXBf1XcvsgLd +eh4hv2PjzFMBObSC0VEjFDWXh/VeFB3SzlNhpfVAZ5EohQjrz+RwiqKIfXqw1vCR +rAxswBCIdd1WodpngvocCEaBXYc4MblaPhJDVtxQe8ndEakkSDlX9Z3qIaIGyXRa +NvY/yURVuXhwDDd7C2QBT6CXGWhldAg7xrRVTcIoqAUfZCgfis0H8cQOa1cGNsbW +t/oHm1fYTxMKFPhWQG0oimx+XJ07BeGgraDRLnxxNnGWTg/W33bc0ZCxCVT0Q5p9 +kMMfQUECgYEA9cewTK4ZRKC4bTdwqLTh3cyMkbyN4kBHmB1mS2FV/T0l4oZThM// +OZ6KFnRCuvfuJIOa70s2bqUYky8NTQAidnnbTW2nZ/E5JdeIBs1fAfadAqiPdmkf +MhvjBF/XfLnbCuXx3jA7GmNCpunJysuLtQzwlQlZLojN231uS+3LFbkCgYEA8jCC +MgKYaDWssQbT7zfk5MxyZIH3F9N8K2RBIDSVuMo/E1LCIJ06/k+4jdv8nAWYJXcN +eyLG7l0SXqrpMBSc9+ZTJgmbo0Mw+npvJHbJvAtD/XOSPjlIqkzPAUrxuiBYxa5S +IfKZibygXKAbQMEwY7I4sTbBtIyiQmo9csxt2S0CgYEAiBi1VSCquUfOGBw09BaF +Y85aoHCqmHhDrMXK2T7i4MG1csQzBz4t8/gIOvrR4LpdUjbV2l/pmkctXoMVeGf0 +rWo4t51ar8HxhTTeC/Y4/9tRgiFYn5cCQTsT8F4p8tTvqA9AaWqHr8r7I3Yd2X/w +sqahqcVtbskuRLYmF0FrzXECgYAeiR0xPwCGSxYt78Vy6OI0Ms7Ne1FzMJf8RJSt +gdPKy70uK4YMZKaWf+iuAimUZmQrfRo3B0h7r0JsqzHhfQfZfbHIHvf/mq4nNp6i +w1NmISl+YD71F3Xg+vQynodhx0hKDFOQsizHn/+8DffBr1nxh/v75AKCSCUBKLH8 +sme7NQKBgDHQac2TmDSelE2uXTGxEVDQs/EpdJh7oCTLQ99Xud/DsaCOrt2s7aRX +1FEohsCaUnqwS07/iH2o6Qb/qOteufB9I7FG85nAvqmP5dI4crGNNa8Rl6fXJaR8 +TUwpZmylTKEJ9zLt2PADglyDrQ2D+1WNzh966Oo9c+kZt4WJM0aF +-----END RSA PRIVATE KEY-----`) + +var caCert = []byte(`-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIJAKK9m2Cfg5uhMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV +BAMMEHdlYmhvb2tfYXV0aHpfY2EwIBcNMTYwMjE2MjM0NDI4WhgPMjI4OTEyMDEy +MzQ0MjhaMBsxGTAXBgNVBAMMEHdlYmhvb2tfYXV0aHpfY2EwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQDohVcY9fnI/ZDpMCVBchCxq/NKpn3veJm+lAEZ +Mjoz3yBNJWZ4FgTejtLx7pZMjcMKdbCpzwx3WtP3YW1pAvC/64w1eKMuKjlFUGqN +QRu6cPkif/5P/LmwT0/cLNxbLoISEi85EBmPpSW+BnNtHAj3X4RUVHmN3wlTR78v +0OzWMtkXJDApzUAOj5OP07iXRa2lGWuHNOCiivgLd8BBmbSar6WVOfEKbD6WHLy3 +zxs8bVwzB10O1RNul61z77YlnrZDcXWepxfURwcOJ1HTHeP7ffnsEuRdErgxCxYt +gvN5ndfWCFZdPTrYfzro9whX4xfgfnyykw3lxViTlhOd4aKFAgMBAAGjUDBOMB0G +A1UdDgQWBBSumZL6MMwmFGyhQAwl/v0lYDzdZjAfBgNVHSMEGDAWgBSumZL6MMwm +FGyhQAwl/v0lYDzdZjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAG +6k+bZxKYq4PVZHWTKA7RSjv95FMMr4RSFwKn/n8TUD44ANWYqDrEfVmxAMn3NVK9 +ckA8mIRym4IGiWD9eBGgPNNtbAq8Wl/9+5qbDMerpXuRnG3wNY7RU75Rl008m52r +c2i86ZPUi2fAJZyMf5StWE21oKiDYYQqlB6xxsIj6OHhf7536vEysoztNX5FpS2n +q8wG0EhJVhG+Qyww8IlZA5Cjoh71Eqkcwb4cuLjPypxmLm0ywZ/6KgzV+IF+CT2v +TJIpMokDUKlRi9cWSqkWXFE6xbCmhrrwKYsi0X6Vvi7a0pmOnSzKCQl8jN8u4A9R +xar2YeJ6mCCzSAPM69DP +-----END CERTIFICATE-----`) + +var badCAKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAon7dRV4Br10dLcf8zgs/hOHouELveFr8tuWVIFivxSdnac2k +6dM4iQ2uYS9nTXxNhyJJ/TX/MHEYc4gSXoqUbtx9jE3VA4mCKDhO7cJtCYxq0QV/ +PlQCiAPjn5nUMt9ACdii7/uTFDl46bK9K6ajvKHfHoWeYaJsF54kxBq5IMj+QaB2 +nc+pba00bGG09sYcHyD37QH+ugx64x+21xMYj2LB/uPoqZM0kj1GHPxAs8GqFq2P +gwkv589AlHqt2iMCTAqED2jcg4FeS2r1DeYHwGyGAPfWTdA8RZ+gZ/P0Gj91T+4B +9srR7BybUFjf1KxEcvPXBvP5r8OwOiYjS8hx/wIDAQABAoIBAQCVBQ9bfDjDX/tQ +buVS+FHKRXss8IW4tIiqGqXGQk7/2YEnMKaaoVBpsBhJnDV6hBJ9aV69TnW3MSCh +YxqlhSVW/fJNZ1uAoOyygeEwfmuMpC+ZfRcSS+z+W8K2LVbDSKXr4babqvVZSNOw +TnDZxTrH1RNPZG65T0Ed77P7/B3nB7aeB2UMuHMQNZ3KrYDTck2R2uTGp+29TplN +blS4VAg2/9KqFr7jkS3/C4jjxVd7d9mm0VdAvLcvENVXqSTYV8xDp+VLTnmtXi5f +LXcopS+zKtKqT7MM7RA2sKrmSfrQBIXW2E1kfDFtpZHajhDutdYkSTH665W1G23M +dIgy3ajhAoGBANE4AhMUVfQqXUCU0UjUDxiOy/8XcKiW/dKhRR1DOQY24J/k+UWv +PEGVcBW4tgalYkTl/AW6hsNfubZaJuw05cHIKdL3df6ug7BUiJpmIv3sjrvPRYvA +WY1UTb3EJrswGz8S2l5+2S3WFTCfK7S6N6Stfi1x6rMJBuOss7HGqdh3AoGBAMbU +WavRqGRsvJFfE5bahXbFpkGWT++BTMP+lzK31z24JjmJdwO+ABWU4/xaXayA4skH +PrzlYUcGJWIedb6W4dvz0sA59yflQzYmREkQPE+wbyor003y7mB8LpFiCnfaFhRn +hoowkyIY+xM4UeDXWWt3DhBElgfA8fYZdiNJEhy5AoGBAMwYUw3BvMffu/CQPElL +dR6DzsUeXKxZ/2pGIGIXfb1uM1pHyFQOSj3ARgMqmYeKNn73zA7akzRsYYJeF7I9 +OBT96q7+8IBuRdDx5gCYunHzHppf7HwUPEf+gYgpnY7lsu6ouZWNMNfiC/HOlJhN +QJLJHFnA0y+sEqhvhSxbnLypAoGBALHCZ+kVKFegX3YYaosUEv589obsu8qE7vzL +QKI3elfTq1kFbUILPEgPNUUIBXeUQy03LP/0k2PMOt/eG6apfoQHGQSCzlT8w3pF +/AbWXRVhyAEL7X5jEntwirGv1WwRrmvPopkplGGHs/EbCRjbbzaE2i3xI7EK70f2 +u4gQbAEBAoGAVR4u8g5Tx2Gunzh7tfJJ5e3xGBGS3Yq+JqUVNI6t6KIAPh0rM+aD +9tDgcwn8Vn5YU7YkqA2T8OOFsbJfrfZ7y7+oeMFukuIyxgmy9n/V/tCIrV/lR7A5 +3iYhanTUbQswx19pSRgsXi7fo9Fi/dmUwyHi18uz5FdLyCTsMbf3uA8= +-----END RSA PRIVATE KEY-----`) + +var badCACert = []byte(`-----BEGIN CERTIFICATE----- +MIIDCzCCAfOgAwIBAgIJAPqJyUfmRxGLMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV +BAMMEHdlYmhvb2tfYXV0aHpfY2EwIBcNMTYwMjE2MjM0NDI4WhgPMjI4OTEyMDEy +MzQ0MjhaMBsxGTAXBgNVBAMMEHdlYmhvb2tfYXV0aHpfY2EwggEiMA0GCSqGSIb3 +DQEBAQUAA4IBDwAwggEKAoIBAQCift1FXgGvXR0tx/zOCz+E4ei4Qu94Wvy25ZUg +WK/FJ2dpzaTp0ziJDa5hL2dNfE2HIkn9Nf8wcRhziBJeipRu3H2MTdUDiYIoOE7t +wm0JjGrRBX8+VAKIA+OfmdQy30AJ2KLv+5MUOXjpsr0rpqO8od8ehZ5homwXniTE +GrkgyP5BoHadz6ltrTRsYbT2xhwfIPftAf66DHrjH7bXExiPYsH+4+ipkzSSPUYc +/ECzwaoWrY+DCS/nz0CUeq3aIwJMCoQPaNyDgV5LavUN5gfAbIYA99ZN0DxFn6Bn +8/QaP3VP7gH2ytHsHJtQWN/UrERy89cG8/mvw7A6JiNLyHH/AgMBAAGjUDBOMB0G +A1UdDgQWBBS6IGeGHZCylibt0GzY0dP6C0J9VjAfBgNVHSMEGDAWgBS6IGeGHZCy +libt0GzY0dP6C0J9VjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAi +A1dp75kbePFZsUNjxN6B/Pv0vSoaOjQkc4hpxKbI4VRCuPGmMRFYTlKCzoZ53OqQ +2Jmu1Zbzel/bV5vXrW0BOfUpfWYzd/usIJEuTgU8ijBIB+IHAXYwwxeKRcz3C+7+ +9RBMF7gSg9pU2hrSvjhh7Q96IMJ42Z7tI3WD8SZaQLjY1NW1jrQVsg66ktdMke7x +zC8oIRIBH4W6l5s7jtZx1k305NE04pigcFLxCxOmicKd66ysI5hAZkD7y0dgwgtL +IqCQy6t7uJDydRiNRfPFr9Eg7uOu83JGw11f3bGVhJVCbzHyKddvkQsQbdaMHRgZ +zgmWLORg+ls1H1oaJiNW +-----END CERTIFICATE-----`) + +var serverKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAtegsP499au5ZxlwM26rk3TnRgakchQi/9bhfMr0LaEKng1lR +XopzzGuGeZQswzbx7iiH89JzFkurZoEmZwtS4Aybit92VOSv0EUnyx7WR3V21ObZ +iQO0rr0UmG84NjdzATkqF+R5Z+HN9shwgBI4PR1j/ybCt7jNz+OM/VmqsgzoKLoa +bGrx7LCTPk8y5G8AoPOrIAP+9WHJsKQSRT8Lru4lYqseBxvhjqo8NRqzZLg79ldY +aKFqa2N2zr5qp94sG3/zihNDxjZvyyn9c8qvPBL0xOyayvOJG8eZUmjQpUMv7Jk3 +qFmdMgGaDJRw0Qg6+/Zt6MHNs6Rbb8hmwuMSpwIDAQABAoIBAQCjzeFijwzKKL4w +0B1IBhi3WeReFPG4nkt1ssQPBYrrJPKBZgHO13A1STI78wFn/OdYpajfF8hI8HT1 +BiGVsu27Eb9TC60b/x6OtmeCEk+044LRbtu+9NZUb7HHHogI0l++X0KXZ0coE38L +1izwNvfrmLa+QaIgHMtAg9EnJwJ993n4L31GovWh8MGmVyJX/F92y+agNwWkNYYp +iLWFyon+HbNVL13WOOYnYEdA8Me3+Gucy1EOfWMF7mgmuO2vcfnxXd6b16VjAwtE +jGCQfzgpWGHLpgwoBgDmnPUbdNPUT3MbA9jqG2mlnBSBQveYgKrmFdDYnAjnCM4L +uF2ztBzhAoGBAOYc3sF3YjpIIMsyH9omqtfOuxO+oZkpb2vB9kgdXCDcG870M+BC +bNzV7DCSV8QAUqjKQK1r3gq62UZMLXZbG8x5UnM8/EK0X1CSqygwSWjGpYxIQEhh +O2lq69WipkNDnX1ZmrvEdHD2cxqkkXZ7bdRKRasrFJgvJa3XbiJ18KYxAoGBAMpe +/72EcX9oL3KT8tJSpvasrw17p/XkMMCxTp3IDb3krF/4k5bYF61F68/LNSy3xkos +ZrPUK/U160iuHSYCpMq4pPmlWgKq4hmUMOt+8Yy622zDlugarq9VLqvSdGHm+r6F +5fHilXB0UsTXXOuLZWLcSQ0MBgiaVCLb2AmXZhhXAoGAEjSchw/r7JKCTbE0hezj +PVm0wVYmsNhvYUYiNwhjnpHrfU8iv45h0IL4QcuCOBaSc5o0zcOn+I9Z207xldiV +dXLvzAA6MQjWNai08+QGGs0EkfmxZEiVC70S1X8dylqSHjW1oT9kuv80khoNDCOt +x8rsgiNRaMzqHTvbEczk8jECgYB2Od+wSULBSw2FI5fVdcHjFGlEODycs44j1LH4 +DZqxmHl3q9IVavMSIGouQCo1kLuAM8ZgQpDXtYNaN5YB0cOSRyLiUc5vBoQGq4OU +4Nme/L8aIH315TiuZ9ZXPSEO3REZ40G9+UCSrPJ52tOHLC2z/ruSqraPqhGDN+pT +WCamCwKBgEPa+kVrPs0khQH8+sbFbU9ifj4fhPAiSwj2fKuXFro2mE205vAMHye/ +SYs/mPzYzKSd7F+7Zk6oVrgFVskTiReW3phF+cIl+CdcnIenF0jW1PVgGw8znu+P +SbHSdqV+tB7AW2J7sH8TZtfMUPAK2MJ4S+1uaHK86K79ym4Rz0E2 +-----END RSA PRIVATE KEY-----`) + +var serverCert = []byte(`-----BEGIN CERTIFICATE----- +MIIC/zCCAeegAwIBAgIJAN7rkfhaX8FZMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV +BAMMEHdlYmhvb2tfYXV0aHpfY2EwIBcNMTYwMjE2MjM0NDI4WhgPMjI4OTEyMDEy +MzQ0MjhaMB8xHTAbBgNVBAMMFHdlYmhvb2tfYXV0aHpfc2VydmVyMIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtegsP499au5ZxlwM26rk3TnRgakchQi/ +9bhfMr0LaEKng1lRXopzzGuGeZQswzbx7iiH89JzFkurZoEmZwtS4Aybit92VOSv +0EUnyx7WR3V21ObZiQO0rr0UmG84NjdzATkqF+R5Z+HN9shwgBI4PR1j/ybCt7jN +z+OM/VmqsgzoKLoabGrx7LCTPk8y5G8AoPOrIAP+9WHJsKQSRT8Lru4lYqseBxvh +jqo8NRqzZLg79ldYaKFqa2N2zr5qp94sG3/zihNDxjZvyyn9c8qvPBL0xOyayvOJ +G8eZUmjQpUMv7Jk3qFmdMgGaDJRw0Qg6+/Zt6MHNs6Rbb8hmwuMSpwIDAQABo0Aw +PjAJBgNVHRMEAjAAMAsGA1UdDwQEAwIF4DATBgNVHSUEDDAKBggrBgEFBQcDATAP +BgNVHREECDAGhwR/AAABMA0GCSqGSIb3DQEBCwUAA4IBAQCZHB9UCl2CfylWP3db +xUamawnRoTYlsOcUh4f2tlHMY+vYiEStN+LECk62YpeaHl/nz/lk7g1Jx9aua39z +wFIHiXYhwSWOtgmzpbxYLye1yajKXbbA1T7mEZJTjewDB9i1LcB9W3EV5VJ8Y1GY +AYKuKQ4Cb1HrqLsrw/1PDm0VouWzf2ESv8CBvAv/pYLVfwgS6WsUqn9wycpLEnqQ +RK66/AoiOaxUIjEP0O1q6pi6Mag7XAfeNtx8J0VGt4cRG4rvWCbKVUyvKfUCkipN +gJu09S+KIz3x1CJLRuJX9tB+cFnnykDLQ2IKg7x44O83ikNk8+Di3iT/awCguWPE +rHh5 +-----END CERTIFICATE-----`) + +var clientKey = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEA5ij4WXWGvbmfAYhEafKRvLEHSkUCYIDjwQAlnHoLf/lz+Fh2 +DEv4lcBaycwk3+LVUGKgYOg91txYJvGD3HcmVThXZvcgJd4V9Ll3aY/6xVRCenWi +UNgVQVQITGkMn09ZkSXbZCK4wqz9oTVh0Ti5a7apOS2V07yL0q7vw003v5TBqzC/ +FgRwE0bv1rKYYQ80WbDlYkkYGf216zQTwS4g/nShCZAX9eqSfbBg6B/A3OwpbIfx +09BWuwWhp5QnS4w002gGWavRFNzu8pUHUv6zMN8OKpasv+Na+ZB+gMt4+e2Y7qNz +76QL23eGwc6oWn8lQBtkDLmLIa6jbWX067U76QIDAQABAoIBAQCJpGzJSzC2W8DM +sMqBNdCUMKZ0cwq13b7W2BimGJKyCOOi3HxUZEaYf/2Leyt+PPBm72SML7dzvDh3 +qa269gKVqmkSqa2vF763qQbRuYo14msTQzA7+s3TUMbZs2UaDOE6nZIzs1QdEElp +1DvYXHz+/rD7Adj9VF+mMnouqQoy5kgJTnVZ8sOyl/9R6F67xKBIvcrtPfqVZzuG +2hGAMUnawxFUajQC7BynIeCWrk79SUmQgilyNgRdY6+rGh2uRupIxuiAukPtuag1 +Li+wnNl1UGECtv9ZnnboKvg2334k5vhYScGRJbwbr7Zt3ZaNd0Z/DE9kTtnhBS7v +9qWdc7CBAoGBAPR4hz1fhHFiPmMEAGuiNms6WdyIfyonIRYas8ZDKUQGdxn/aO8a +CURktHRlm6iYT+j1cbf3RnLEN9pNr3V2EySOMc+rXUNifcP7Vl53akAQmISUfQWG +UfwaNLicbavf6m9UCiwWByAZghqDZSLiwmLHIjGcSJQiFuhZryioDydxAoGBAPED +q1Z7oNhzwRYie9OB5ylnrCH8G3yFl8egBmQrPJKIQHA9mAGg01LEJwQNoWewyAWx +jfeFtWvIgZkj49cluZgHYyF81jApaNraxtXAgIwC1n7oAIttmeklZ/V1HntknG3Y +ow2bV/NA3aPOTPYxW8oDv7U9lvwve7kIFxeWjE/5AoGASfXI3G1wUSkqvKPySJ3b +ntcZZpm49xS9csWDS+D3tAfMsoXNxkB3O0TIP0qaLAhgbJcM314k5wWr7BSCl6Ow +KOgH887hOUirycXZHF0+PMGIktulcy1u0jlPZ+aTW2MztpiTN0E2yKRO8xx7VXGK +431hP+cLIh2qFoNDdaZaZ1ECgYEArw++PWQxMefqgVxs2vXJZY7TPiA0Ct+ynqKC +4fFx3vGu9JgYuF4MAVtPB6eq7HlA4LnWZ8ssOuz6DbU/AoB5bY84FxPpNDRv4D/3 +Gz3nYUuSZ72234+tsuaju2vlxzUOVs97qB+E48Di/N+VkWHKzVKpxkjFScpnsL/K +niyRIGkCgYEAriuxbOCczL/j6u2Xq1ngEsGg+RXjtOYGoJWo7B8qlVL4nF8w1Nbd +FxEmOChQgUnBdwb93qHCSq0Fidf7OfewrfJJkstWIh3zPS4umLZo7R3YblncpdfT +M197uckIWccZml2jF/c7nvK+MjwDRhkOl2a6HzMxcdBwYUJmSwmIZ4k= +-----END RSA PRIVATE KEY-----`) + +var clientCert = []byte(`-----BEGIN CERTIFICATE----- +MIIC7jCCAdagAwIBAgIJAN7rkfhaX8FaMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV +BAMMEHdlYmhvb2tfYXV0aHpfY2EwIBcNMTYwMjE2MjM0NDI4WhgPMjI4OTEyMDEy +MzQ0MjhaMB8xHTAbBgNVBAMMFHdlYmhvb2tfYXV0aHpfY2xpZW50MIIBIjANBgkq +hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5ij4WXWGvbmfAYhEafKRvLEHSkUCYIDj +wQAlnHoLf/lz+Fh2DEv4lcBaycwk3+LVUGKgYOg91txYJvGD3HcmVThXZvcgJd4V +9Ll3aY/6xVRCenWiUNgVQVQITGkMn09ZkSXbZCK4wqz9oTVh0Ti5a7apOS2V07yL +0q7vw003v5TBqzC/FgRwE0bv1rKYYQ80WbDlYkkYGf216zQTwS4g/nShCZAX9eqS +fbBg6B/A3OwpbIfx09BWuwWhp5QnS4w002gGWavRFNzu8pUHUv6zMN8OKpasv+Na ++ZB+gMt4+e2Y7qNz76QL23eGwc6oWn8lQBtkDLmLIa6jbWX067U76QIDAQABoy8w +LTAJBgNVHRMEAjAAMAsGA1UdDwQEAwIF4DATBgNVHSUEDDAKBggrBgEFBQcDAjAN +BgkqhkiG9w0BAQsFAAOCAQEA2IZNhkVrSTAIeP2N2WzOHqbFbGyO+NA8G9Hb5fiX +e1YS2Ku3ERYNr+HLxNHCsXiSUKjjBmXMc4z0XaHJznEKEbotZftjTlTQlHi3/5vm +dIG18pmO/E5ebVXl6pU96v/hBd8N5rWp9WUKgP0y59r/JA+oNpmd10A+RyaOyrFK +rBm8Z8rvDYMrXSpOwx9BNDuhqzbdG8MYw5vO55Er3hwTXoapsMqSh5s9+OFFpUJi +2uEoQlwWiYRtQj6g4wgr4woDEbv8XxsHqGfs+GSnmRsB69xRI24lEtC+nS6Rz3Sh +YWeN0gD8PsQC1KJVv6xCGo1yXSEwytRMB23XYtAZahLdLg== +-----END CERTIFICATE-----`) diff --git a/plugin/pkg/auth/authorizer/webhook/gencerts.sh b/plugin/pkg/auth/authorizer/webhook/gencerts.sh new file mode 100755 index 00000000000..7983d38796f --- /dev/null +++ b/plugin/pkg/auth/authorizer/webhook/gencerts.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# Copyright 2016 The Kubernetes Authors All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +# gencerts.sh generates the certificates for the webhook authz plugin tests. +# +# It is not expected to be run often (there is no go generate rule), and mainly +# exists for documentation purposes. + +cat > server.conf << EOF +[req] +req_extensions = v3_req +distinguished_name = req_distinguished_name +[req_distinguished_name] +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +extendedKeyUsage = serverAuth +subjectAltName = @alt_names +[alt_names] +IP.1 = 127.0.0.1 +EOF + +cat > client.conf << EOF +[req] +req_extensions = v3_req +distinguished_name = req_distinguished_name +[req_distinguished_name] +[ v3_req ] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment +extendedKeyUsage = clientAuth +EOF + +# Create a certificate authority +openssl genrsa -out caKey.pem 2048 +openssl req -x509 -new -nodes -key caKey.pem -days 100000 -out caCert.pem -subj "/CN=webhook_authz_ca" + +# Create a second certificate authority +openssl genrsa -out badCAKey.pem 2048 +openssl req -x509 -new -nodes -key badCAKey.pem -days 100000 -out badCACert.pem -subj "/CN=webhook_authz_ca" + +# Create a server certiticate +openssl genrsa -out serverKey.pem 2048 +openssl req -new -key serverKey.pem -out server.csr -subj "/CN=webhook_authz_server" -config server.conf +openssl x509 -req -in server.csr -CA caCert.pem -CAkey caKey.pem -CAcreateserial -out serverCert.pem -days 100000 -extensions v3_req -extfile server.conf + +# Create a client certiticate +openssl genrsa -out clientKey.pem 2048 +openssl req -new -key clientKey.pem -out client.csr -subj "/CN=webhook_authz_client" -config client.conf +openssl x509 -req -in client.csr -CA caCert.pem -CAkey caKey.pem -CAcreateserial -out clientCert.pem -days 100000 -extensions v3_req -extfile client.conf + +outfile=certs_test.go + +cat > $outfile << EOF +/* +Copyright 2016 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +EOF + +echo "// This file was generated using openssl by the gencerts.sh script" >> $outfile +echo "// and holds raw certificates for the webhook tests." >> $outfile +echo "" >> $outfile +echo "package webhook" >> $outfile +for file in caKey caCert badCAKey badCACert serverKey serverCert clientKey clientCert; do + data=$(cat ${file}.pem) + echo "" >> $outfile + echo "var $file = []byte(\`$data\`)" >> $outfile +done + +# Clean up after we're done. +rm *.pem +rm *.csr +rm *.srl +rm *.conf diff --git a/plugin/pkg/auth/authorizer/webhook/webhook.go b/plugin/pkg/auth/authorizer/webhook/webhook.go new file mode 100644 index 00000000000..7d064c53734 --- /dev/null +++ b/plugin/pkg/auth/authorizer/webhook/webhook.go @@ -0,0 +1,180 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package webhook implements the authorizer.Authorizer interface using HTTP webhooks. +package webhook + +import ( + "errors" + "fmt" + + "k8s.io/kubernetes/pkg/api" + "k8s.io/kubernetes/pkg/api/unversioned" + "k8s.io/kubernetes/pkg/apimachinery/registered" + "k8s.io/kubernetes/pkg/apis/authorization/v1beta1" + "k8s.io/kubernetes/pkg/auth/authorizer" + client "k8s.io/kubernetes/pkg/client/unversioned" + "k8s.io/kubernetes/pkg/client/unversioned/clientcmd" + "k8s.io/kubernetes/pkg/runtime" + "k8s.io/kubernetes/pkg/runtime/serializer/json" + "k8s.io/kubernetes/pkg/runtime/serializer/versioning" + + _ "k8s.io/kubernetes/pkg/apis/authorization/install" +) + +var ( + encodeVersions = []unversioned.GroupVersion{v1beta1.SchemeGroupVersion} + decodeVersions = []unversioned.GroupVersion{v1beta1.SchemeGroupVersion} + + requireEnabled = []unversioned.GroupVersion{v1beta1.SchemeGroupVersion} +) + +// Ensure Webhook implements the authorizer.Authorizer interface. +var _ authorizer.Authorizer = (*WebhookAuthorizer)(nil) + +type WebhookAuthorizer struct { + restClient *client.RESTClient +} + +// New creates a new WebhookAuthorizer from the provided kubeconfig file. +// +// The config's cluster field is used to refer to the remote service, user refers to the returned authorizer. +// +// # clusters refers to the remote service. +// clusters: +// - name: name-of-remote-authz-service +// cluster: +// certificate-authority: /path/to/ca.pem # CA for verifying the remote service. +// server: https://authz.example.com/authorize # URL of remote service to query. Must use 'https'. +// +// # users refers to the API server's webhook configuration. +// users: +// - name: name-of-api-server +// user: +// client-certificate: /path/to/cert.pem # cert for the webhook plugin to use +// client-key: /path/to/key.pem # key matching the cert +// +// For additional HTTP configuration, refer to the kubeconfig documentation +// http://kubernetes.io/v1.1/docs/user-guide/kubeconfig-file.html. +func New(kubeConfigFile string) (*WebhookAuthorizer, error) { + + for _, groupVersion := range requireEnabled { + if !registered.IsEnabledVersion(groupVersion) { + return nil, fmt.Errorf("webhook authz plugin requires enabling extension resource: %s", groupVersion) + } + } + + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = kubeConfigFile + loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + + clientConfig, err := loader.ClientConfig() + if err != nil { + return nil, err + } + serializer := json.NewSerializer(json.DefaultMetaFactory, api.Scheme, runtime.ObjectTyperToTyper(api.Scheme), false) + clientConfig.ContentConfig.Codec = versioning.NewCodecForScheme(api.Scheme, serializer, encodeVersions, decodeVersions) + + restClient, err := client.UnversionedRESTClientFor(clientConfig) + if err != nil { + return nil, err + } + + // TODO(ericchiang): Can we ensure remote service is reachable? + + return &WebhookAuthorizer{restClient}, nil +} + +// Authorize makes a REST request to the remote service describing the attempted action as a JSON +// serialized api.authorization.v1beta1.SubjectAccessReview object. An example request body is +// provided bellow. +// +// { +// "apiVersion": "authorization.k8s.io/v1beta1", +// "kind": "SubjectAccessReview", +// "spec": { +// "resourceAttributes": { +// "namespace": "kittensandponies", +// "verb": "GET", +// "group": "group3", +// "resource": "pods" +// }, +// "user": "jane", +// "group": [ +// "group1", +// "group2" +// ] +// } +// } +// +// The remote service is expected to fill the SubjectAccessReviewStatus field to either allow or +// disallow access. A permissive response would return: +// +// { +// "apiVersion": "authorization.k8s.io/v1beta1", +// "kind": "SubjectAccessReview", +// "status": { +// "allowed": true +// } +// } +// +// To disallow access, the remote service would return: +// +// { +// "apiVersion": "authorization.k8s.io/v1beta1", +// "kind": "SubjectAccessReview", +// "status": { +// "allowed": false, +// "reason": "user does not have read access to the namespace" +// } +// } +// +func (w *WebhookAuthorizer) Authorize(attr authorizer.Attributes) (err error) { + r := &v1beta1.SubjectAccessReview{ + Spec: v1beta1.SubjectAccessReviewSpec{ + User: attr.GetUserName(), + Groups: attr.GetGroups(), + }, + } + if attr.IsResourceRequest() { + r.Spec.ResourceAttributes = &v1beta1.ResourceAttributes{ + Namespace: attr.GetNamespace(), + Verb: attr.GetVerb(), + Group: attr.GetAPIGroup(), + Resource: attr.GetResource(), + } + } else { + r.Spec.NonResourceAttributes = &v1beta1.NonResourceAttributes{ + Path: attr.GetPath(), + Verb: attr.GetVerb(), + } + } + result := w.restClient.Post().Body(r).Do() + if err := result.Error(); err != nil { + return err + } + + if err := result.Into(r); err != nil { + return err + } + if r.Status.Allowed { + return nil + } + if r.Status.Reason != "" { + return errors.New(r.Status.Reason) + } + return errors.New("unauthorized") +} diff --git a/plugin/pkg/auth/authorizer/webhook/webhook_test.go b/plugin/pkg/auth/authorizer/webhook/webhook_test.go new file mode 100644 index 00000000000..2690eb7c8bf --- /dev/null +++ b/plugin/pkg/auth/authorizer/webhook/webhook_test.go @@ -0,0 +1,473 @@ +/* +Copyright 2016 The Kubernetes Authors All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package webhook + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "testing" + "text/template" + + "k8s.io/kubernetes/pkg/api/unversioned" + "k8s.io/kubernetes/pkg/apis/authorization/v1beta1" + "k8s.io/kubernetes/pkg/auth/authorizer" + "k8s.io/kubernetes/pkg/auth/user" + "k8s.io/kubernetes/pkg/client/unversioned/clientcmd/api/v1" + "k8s.io/kubernetes/pkg/util" +) + +func TestNewFromConfig(t *testing.T) { + dir, err := ioutil.TempDir("", "") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + + data := struct { + CA string + Cert string + Key string + }{ + CA: filepath.Join(dir, "ca.pem"), + Cert: filepath.Join(dir, "clientcert.pem"), + Key: filepath.Join(dir, "clientkey.pem"), + } + + files := []struct { + name string + data []byte + }{ + {data.CA, caCert}, + {data.Cert, clientCert}, + {data.Key, clientKey}, + } + for _, file := range files { + if err := ioutil.WriteFile(file.name, file.data, 0400); err != nil { + t.Fatal(err) + } + } + + tests := []struct { + msg string + configTmpl string + wantErr bool + }{ + { + msg: "a single cluster and single user", + configTmpl: ` +clusters: +- cluster: + certificate-authority: {{ .CA }} + server: https://authz.example.com + name: foobar +users: +- name: a cluster + user: + client-certificate: {{ .Cert }} + client-key: {{ .Key }} +`, + wantErr: false, + }, + { + msg: "multiple clusters with no context", + configTmpl: ` +clusters: +- cluster: + certificate-authority: {{ .CA }} + server: https://authz.example.com + name: foobar +- cluster: + certificate-authority: a bad certificate path + server: https://authz.example.com + name: barfoo +users: +- name: a name + user: + client-certificate: {{ .Cert }} + client-key: {{ .Key }} +`, + wantErr: false, + }, + { + msg: "multiple clusters with a context", + configTmpl: ` +clusters: +- cluster: + certificate-authority: a bad certificate path + server: https://authz.example.com + name: foobar +- cluster: + certificate-authority: {{ .CA }} + server: https://authz.example.com + name: barfoo +users: +- name: a name + user: + client-certificate: {{ .Cert }} + client-key: {{ .Key }} +contexts: +- name: default + context: + cluster: barfoo + user: a name +current-context: default +`, + wantErr: false, + }, + { + msg: "cluster with bad certificate path specified", + configTmpl: ` +clusters: +- cluster: + certificate-authority: a bad certificate path + server: https://authz.example.com + name: foobar +- cluster: + certificate-authority: {{ .CA }} + server: https://authz.example.com + name: barfoo +users: +- name: a name + user: + client-certificate: {{ .Cert }} + client-key: {{ .Key }} +contexts: +- name: default + context: + cluster: foobar + user: a name +current-context: default +`, + wantErr: true, + }, + } + + for _, tt := range tests { + // Use a closure so defer statements trigger between loop iterations. + err := func() error { + tempfile, err := ioutil.TempFile("", "") + if err != nil { + return err + } + p := tempfile.Name() + defer os.Remove(p) + + tmpl, err := template.New("test").Parse(tt.configTmpl) + if err != nil { + return fmt.Errorf("failed to parse test template: %v", err) + } + if err := tmpl.Execute(tempfile, data); err != nil { + return fmt.Errorf("failed to execute test template: %v", err) + } + // Create a new authorizer + _, err = New(p) + return err + }() + if err != nil && !tt.wantErr { + t.Errorf("failed to load plugin from config %q: %v", tt.msg, err) + } + if err == nil && tt.wantErr { + t.Errorf("wanted an error when loading config, did not get one: %q", tt.msg) + } + } +} + +// Service mocks a remote service. +type Service interface { + Review(*v1beta1.SubjectAccessReview) +} + +// NewTestServer wraps a Service as an httptest.Server. +func NewTestServer(s Service, cert, key, caCert []byte) (*httptest.Server, error) { + var tlsConfig *tls.Config + if cert != nil { + cert, err := tls.X509KeyPair(cert, key) + if err != nil { + return nil, err + } + tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}} + } + + if caCert != nil { + rootCAs := x509.NewCertPool() + rootCAs.AppendCertsFromPEM(caCert) + if tlsConfig == nil { + tlsConfig = &tls.Config{} + } + tlsConfig.ClientCAs = rootCAs + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + } + + serveHTTP := func(w http.ResponseWriter, r *http.Request) { + var review v1beta1.SubjectAccessReview + if err := json.NewDecoder(r.Body).Decode(&review); err != nil { + http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest) + return + } + s.Review(&review) + type status struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason"` + } + resp := struct { + APIVersion string `json:"apiVersion"` + Status status `json:"status"` + }{ + APIVersion: v1beta1.SchemeGroupVersion.String(), + Status: status{review.Status.Allowed, review.Status.Reason}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } + + server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP)) + server.TLS = tlsConfig + server.StartTLS() + return server, nil +} + +// A service that can be set to allow all or deny all authorization requests. +type mockService struct { + allow bool +} + +func (m *mockService) Review(r *v1beta1.SubjectAccessReview) { + r.Status.Allowed = m.allow +} +func (m *mockService) Allow() { m.allow = true } +func (m *mockService) Deny() { m.allow = false } + +// newAuthorizer creates a temporary kubeconfig file from the provided arguments and attempts to load +// a new WebhookAuthorizer from it. +func newAuthorizer(callbackURL string, clientCert, clientKey, ca []byte) (*WebhookAuthorizer, error) { + tempfile, err := ioutil.TempFile("", "") + if err != nil { + return nil, err + } + p := tempfile.Name() + defer os.Remove(p) + config := v1.Config{ + Clusters: []v1.NamedCluster{ + { + Cluster: v1.Cluster{Server: callbackURL, CertificateAuthorityData: ca}, + }, + }, + AuthInfos: []v1.NamedAuthInfo{ + { + AuthInfo: v1.AuthInfo{ClientCertificateData: clientCert, ClientKeyData: clientKey}, + }, + }, + } + if err := json.NewEncoder(tempfile).Encode(config); err != nil { + return nil, err + } + return New(p) +} + +func TestTLSConfig(t *testing.T) { + tests := []struct { + test string + clientCert, clientKey, clientCA []byte + serverCert, serverKey, serverCA []byte + wantErr bool + }{ + { + test: "TLS setup between client and server", + clientCert: clientCert, clientKey: clientKey, clientCA: caCert, + serverCert: serverCert, serverKey: serverKey, serverCA: caCert, + }, + { + test: "Server does not require client auth", + clientCA: caCert, + serverCert: serverCert, serverKey: serverKey, + }, + { + test: "Server does not require client auth, client provides it", + clientCert: clientCert, clientKey: clientKey, clientCA: caCert, + serverCert: serverCert, serverKey: serverKey, + }, + { + test: "Client does not trust server", + clientCert: clientCert, clientKey: clientKey, + serverCert: serverCert, serverKey: serverKey, + wantErr: true, + }, + { + test: "Server does not trust client", + clientCert: clientCert, clientKey: clientKey, clientCA: caCert, + serverCert: serverCert, serverKey: serverKey, serverCA: badCACert, + wantErr: true, + }, + { + // Plugin does not support insecure configurations. + test: "Server is using insecure connection", + wantErr: true, + }, + } + for _, tt := range tests { + // Use a closure so defer statements trigger between loop iterations. + func() { + service := new(mockService) + + server, err := NewTestServer(service, tt.serverCert, tt.serverKey, tt.serverCA) + if err != nil { + t.Errorf("%s: failed to create server: %v", tt.test, err) + return + } + defer server.Close() + + wh, err := newAuthorizer(server.URL, tt.clientCert, tt.clientKey, tt.clientCA) + if err != nil { + t.Errorf("%s: failed to create client: %v", tt.test, err) + return + } + + attr := authorizer.AttributesRecord{User: &user.DefaultInfo{}} + + // Allow all and see if we get an error. + service.Allow() + err = wh.Authorize(attr) + if tt.wantErr { + if err == nil { + t.Errorf("expected error making authorization request: %v", err) + } + return + } + if err != nil { + t.Errorf("%s: failed to authorize with AllowAll policy: %v", tt.test, err) + return + } + + service.Deny() + if err := wh.Authorize(attr); err == nil { + t.Errorf("%s: incorrectly authorized with DenyAll policy", tt.test) + } + }() + } +} + +// recorderService records all access review requests. +type recorderService struct { + last v1beta1.SubjectAccessReview + err error +} + +func (rec *recorderService) Review(r *v1beta1.SubjectAccessReview) { + rec.last = v1beta1.SubjectAccessReview{} + rec.last = *r + r.Status.Allowed = true +} + +func (rec *recorderService) Last() (v1beta1.SubjectAccessReview, error) { + return rec.last, rec.err +} + +func TestWebhook(t *testing.T) { + serv := new(recorderService) + s, err := NewTestServer(serv, serverCert, serverKey, caCert) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + wh, err := newAuthorizer(s.URL, clientCert, clientKey, caCert) + if err != nil { + t.Fatal(err) + } + + expTypeMeta := unversioned.TypeMeta{ + APIVersion: "authorization.k8s.io/v1beta1", + Kind: "SubjectAccessReview", + } + + tests := []struct { + attr authorizer.Attributes + want v1beta1.SubjectAccessReview + }{ + { + attr: authorizer.AttributesRecord{User: &user.DefaultInfo{}}, + want: v1beta1.SubjectAccessReview{ + TypeMeta: expTypeMeta, + Spec: v1beta1.SubjectAccessReviewSpec{ + NonResourceAttributes: &v1beta1.NonResourceAttributes{}, + }, + }, + }, + { + attr: authorizer.AttributesRecord{User: &user.DefaultInfo{Name: "jane"}}, + want: v1beta1.SubjectAccessReview{ + TypeMeta: expTypeMeta, + Spec: v1beta1.SubjectAccessReviewSpec{ + User: "jane", + NonResourceAttributes: &v1beta1.NonResourceAttributes{}, + }, + }, + }, + { + attr: authorizer.AttributesRecord{ + User: &user.DefaultInfo{ + Name: "jane", + UID: "1", + Groups: []string{"group1", "group2"}, + }, + Verb: "GET", + Namespace: "kittensandponies", + APIGroup: "group3", + Resource: "pods", + ResourceRequest: true, + Path: "/foo", + }, + want: v1beta1.SubjectAccessReview{ + TypeMeta: expTypeMeta, + Spec: v1beta1.SubjectAccessReviewSpec{ + User: "jane", + Groups: []string{"group1", "group2"}, + ResourceAttributes: &v1beta1.ResourceAttributes{ + Verb: "GET", + Namespace: "kittensandponies", + Group: "group3", + Resource: "pods", + }, + }, + }, + }, + } + + for i, tt := range tests { + if err := wh.Authorize(tt.attr); err != nil { + t.Errorf("case %d: authorization failed: %v", i, err) + continue + } + + gotAttr, err := serv.Last() + if err != nil { + t.Errorf("case %d: failed to deserialize webhook request: %v", i, err) + continue + } + if !reflect.DeepEqual(gotAttr, tt.want) { + t.Errorf("case %d: got != want:\n%s", i, util.ObjectGoPrintDiff(gotAttr, tt.want)) + } + } +}