From 352bbd9199055a4686df578f577d16be2801c51a Mon Sep 17 00:00:00 2001 From: Michael Grosser Date: Sun, 12 Dec 2021 12:33:27 -0800 Subject: [PATCH 1/7] support impersonation via aut_options and in kubectl config --- README.md | 18 +++++++++++++++++ lib/kubeclient.rb | 16 +++++++++++++++ lib/kubeclient/config.rb | 10 ++++++++++ test/config/impersonate.kubeconfig | 22 +++++++++++++++++++++ test/test_config.rb | 13 +++++++++++++ test/test_kubeclient.rb | 31 ++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+) create mode 100644 test/config/impersonate.kubeconfig diff --git a/README.md b/README.md index dc627510..9b8024bb 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,24 @@ You can read it as follows: puts config.context.namespace ``` +### Impersonation + +Impersonation is supported when loading a kubectl config and via the Ruby API, for example: + +```ruby +client = Kubeclient::Client.new( + context.api_endpoint, 'v1', + auth_options: { + as: "admin", + as_groups: ["system:masters"], + as_uid: "123", # optional + as_user_extra: { + "reason" => ["admin access"] + } + } +) +``` + ### Supported kubernetes versions We try to support the last 3 minor versions, matching the [official support policy for Kubernetes](https://github.com/kubernetes/community/blob/master/contributors/design-proposals/release/versioning.md#supported-releases-and-component-skew). diff --git a/lib/kubeclient.rb b/lib/kubeclient.rb index ff5b91f9..7fb96d38 100644 --- a/lib/kubeclient.rb +++ b/lib/kubeclient.rb @@ -137,6 +137,7 @@ def initialize_client( @as = as validate_bearer_token_file + configure_impersonation_headers end def configure_faraday(&block) @@ -744,6 +745,21 @@ def validate_bearer_token_file raise ArgumentError, "Token file #{file} cannot be read" unless File.readable?(file) end + # following https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation + def configure_impersonation_headers + return unless (auth_as = @auth_options[:as]) + @headers[:'Impersonate-User'] = auth_as + if (auth_as_groups = @auth_options[:as_groups]) + @headers[:'Impersonate-Group'] = Array(auth_as_groups).join + end + if (auth_as_uid = @auth_options[:as_uid]) + @headers[:'Impersonate-Uid'] = auth_as_uid + end + @auth_options[:as_user_extra]&.each do |k, v| + @headers[:"Impersonate-Extra-#{k}"] = Array(v).join + end + end + def return_or_yield_to_watcher(watcher, &block) return watcher unless block_given? diff --git a/lib/kubeclient/config.rb b/lib/kubeclient/config.rb index 2493faa3..0b51de22 100644 --- a/lib/kubeclient/config.rb +++ b/lib/kubeclient/config.rb @@ -183,6 +183,16 @@ def fetch_user_auth_options(user) options[attr.to_sym] = user[attr] if user.key?(attr) end end + + # TODO: allow setting Impersonate-Uid from here or comment on why it is not possible + [ + ['as', :as], + ['as-groups', :as_groups], + ['as-user-extra', :as_user_extra] + ].each do |k, v| + options[v] = user[k] if user.key?(k) + end + options end diff --git a/test/config/impersonate.kubeconfig b/test/config/impersonate.kubeconfig new file mode 100644 index 00000000..8a94aff1 --- /dev/null +++ b/test/config/impersonate.kubeconfig @@ -0,0 +1,22 @@ +apiVersion: v1 +clusters: +- cluster: + server: https://localhost:8443 + insecure-skip-tls-verify: true + name: localhost:8443 +contexts: +- context: + cluster: localhost:8443 + namespace: default + user: impersonate + name: localhost/impersonate +current-context: localhost/impersonate +kind: Config +preferences: {} +users: +- name: impersonate + user: + as: foo + as-groups: [bar, baz] + as-user-extra: + reason: [foo] diff --git a/test/test_config.rb b/test/test_config.rb index 49ed8263..3666e614 100644 --- a/test/test_config.rb +++ b/test/test_config.rb @@ -233,6 +233,19 @@ def test_oidc_auth_provider config.context(config.contexts.first) end + def test_impersonate + parsed = YAML.safe_load(File.read(config_file('impersonate.kubeconfig'))) + config = Kubeclient::Config.new(parsed, nil) + assert_equal( + { + as: 'foo', + as_groups: ['bar', 'baz'], + as_user_extra: { 'reason' => ['foo'] } + }, + config.context(config.contexts.first).auth_options + ) + end + private def check_context(context, ssl: true, custom_ca: true, client_cert: true) diff --git a/test/test_kubeclient.rb b/test/test_kubeclient.rb index ad38cf89..055dd2d3 100644 --- a/test/test_kubeclient.rb +++ b/test/test_kubeclient.rb @@ -841,6 +841,37 @@ def test_api_bearer_token_file_success assert_equal(1, pods.size) end + def test_impersonate + stub_request(:get, 'http://localhost:8080/api/v1/pods') + .with( + headers: { + Authorization: 'Bearer valid_token', + 'Impersonate-Extra-Reason': 'baz', + 'Impersonate-Group': 'bar', + 'Impersonate-User': 'foo', + 'Impersonate-Uid': '123' + } + ) + .to_return(body: { items: [] }.to_json) + stub_request(:get, %r{/api/v1$}) + .with(headers: { Authorization: 'Bearer valid_token' }) + .to_return(body: open_test_file('core_api_resource_list.json')) + + client = Kubeclient::Client.new( + 'http://localhost:8080/api/', + 'v1', + auth_options: { + bearer_token: 'valid_token', + as: 'foo', + as_groups: ['bar'], + as_user_extra: { 'reason' => ['baz'] }, + as_uid: '123' + } + ) + + client.get_pods + end + def test_proxy_url stub_core_api_list From 443a5844e80db0ef342b15c2b06272aaced7bed5 Mon Sep 17 00:00:00 2001 From: Lukas Dolezal Date: Fri, 20 Jan 2023 14:57:37 +0100 Subject: [PATCH 2/7] fix: add headers to faraday client --- lib/kubeclient.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/kubeclient.rb b/lib/kubeclient.rb index 7fb96d38..5a866329 100644 --- a/lib/kubeclient.rb +++ b/lib/kubeclient.rb @@ -368,7 +368,8 @@ def create_faraday_client client_key: @ssl_options[:client_key], verify: @ssl_options[:verify_ssl] != OpenSSL::SSL::VERIFY_NONE, verify_mode: @ssl_options[:verify_ssl] - } + }, + headers: @headers } Faraday.new(url, options) do |connection| From 57d371ae4066451785796795a29a2545e820e345 Mon Sep 17 00:00:00 2001 From: Lukas Dolezal Date: Fri, 20 Jan 2023 18:23:00 +0100 Subject: [PATCH 3/7] fix: handle multi value impersonation fields --- README.md | 3 +++ lib/kubeclient.rb | 16 +++++++++------ lib/kubeclient/config.rb | 17 ++++++++-------- test/test_kubeclient.rb | 42 ++++++++++++++++++++++++++++------------ 4 files changed, 51 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9b8024bb..dcb43a22 100644 --- a/README.md +++ b/README.md @@ -455,6 +455,9 @@ client = Kubeclient::Client.new( ) ``` +Note that only one group and one value per each extra field are currently supported. Using list of multiple values +will result in `ArgumentError`. + ### Supported kubernetes versions We try to support the last 3 minor versions, matching the [official support policy for Kubernetes](https://github.com/kubernetes/community/blob/master/contributors/design-proposals/release/versioning.md#supported-releases-and-component-skew). diff --git a/lib/kubeclient.rb b/lib/kubeclient.rb index 5a866329..d898fc79 100644 --- a/lib/kubeclient.rb +++ b/lib/kubeclient.rb @@ -750,14 +750,18 @@ def validate_bearer_token_file def configure_impersonation_headers return unless (auth_as = @auth_options[:as]) @headers[:'Impersonate-User'] = auth_as - if (auth_as_groups = @auth_options[:as_groups]) - @headers[:'Impersonate-Group'] = Array(auth_as_groups).join + if (as_groups = @auth_options[:as_groups]) + # Faraday joins multi-value headers with commas, which is not same as having + # multiple headers with the same name, as required by the k8s API + raise ArgumentError, 'Multiple as_groups are not supported' if as_groups.count > 1 + @headers[:'Impersonate-Group'] = as_groups[0] end - if (auth_as_uid = @auth_options[:as_uid]) - @headers[:'Impersonate-Uid'] = auth_as_uid + if (as_uid = @auth_options[:as_uid]) + @headers[:'Impersonate-Uid'] = as_uid end - @auth_options[:as_user_extra]&.each do |k, v| - @headers[:"Impersonate-Extra-#{k}"] = Array(v).join + @auth_options[:as_user_extra]&.each do |extra_name, values| + raise ArgumentError, 'Multivalue as_user_extra fields are not supported' if values.count > 1 + @headers[:"Impersonate-Extra-#{extra_name}"] = values[0] end end diff --git a/lib/kubeclient/config.rb b/lib/kubeclient/config.rb index 0b51de22..f9aa79ae 100644 --- a/lib/kubeclient/config.rb +++ b/lib/kubeclient/config.rb @@ -60,6 +60,7 @@ def context(context_name = nil) client_cert_data = fetch_user_cert_data(user) client_key_data = fetch_user_key_data(user) auth_options = fetch_user_auth_options(user) + auth_options.merge!(fetch_user_impersonate_options(user)) ssl_options = {} @@ -183,16 +184,14 @@ def fetch_user_auth_options(user) options[attr.to_sym] = user[attr] if user.key?(attr) end end + options + end - # TODO: allow setting Impersonate-Uid from here or comment on why it is not possible - [ - ['as', :as], - ['as-groups', :as_groups], - ['as-user-extra', :as_user_extra] - ].each do |k, v| - options[v] = user[k] if user.key?(k) - end - + def fetch_user_impersonate_options(user) + options = {} + options[:as] = user['as'] if user.key?('as') + options[:as_groups] = user['as-groups'] if user.key?('as-groups') + options[:as_user_extra] = user['as-user-extra'] if user.key?('as-user-extra') options end diff --git a/test/test_kubeclient.rb b/test/test_kubeclient.rb index 055dd2d3..a9007c46 100644 --- a/test/test_kubeclient.rb +++ b/test/test_kubeclient.rb @@ -842,17 +842,18 @@ def test_api_bearer_token_file_success end def test_impersonate - stub_request(:get, 'http://localhost:8080/api/v1/pods') - .with( - headers: { - Authorization: 'Bearer valid_token', - 'Impersonate-Extra-Reason': 'baz', - 'Impersonate-Group': 'bar', - 'Impersonate-User': 'foo', - 'Impersonate-Uid': '123' - } - ) - .to_return(body: { items: [] }.to_json) + pods_stub = stub_request(:get, 'http://localhost:8080/api/v1/pods') + .with( + headers: { + Authorization: 'Bearer valid_token', + 'Impersonate-Extra-Reason': 'reason-1', + 'Impersonate-Extra-Scopes': 'scope-1', + 'Impersonate-Group': 'bar', + 'Impersonate-User': 'foo', + 'Impersonate-Uid': '123' + } + ) + .to_return(body: { items: [] }.to_json) stub_request(:get, %r{/api/v1$}) .with(headers: { Authorization: 'Bearer valid_token' }) .to_return(body: open_test_file('core_api_resource_list.json')) @@ -864,12 +865,29 @@ def test_impersonate bearer_token: 'valid_token', as: 'foo', as_groups: ['bar'], - as_user_extra: { 'reason' => ['baz'] }, + as_user_extra: { 'reason' => ['reason-1'], 'scopes' => ['scope-1'] }, as_uid: '123' } ) client.get_pods + assert_requested(pods_stub) + end + + def test_impersonate_limitations + assert_raises(ArgumentError) do + Kubeclient::Client.new( + 'http://localhost:8080/api/', + 'v1', + auth_options: { + bearer_token: 'valid_token', + as: 'foo', + as_groups: ['bar', 'baz'], + as_user_extra: { 'reason' => ['reason-1', 'reason-2'] }, + as_uid: '123' + } + ) + end end def test_proxy_url From 0e5ca4244fa38c6d807bf22df9da849b85d4be4b Mon Sep 17 00:00:00 2001 From: Lukas Dolezal Date: Mon, 6 Feb 2023 12:56:03 +0100 Subject: [PATCH 4/7] add changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50fd1afa..6a8b892f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ Kubeclient release versioning follows [SemVer](https://semver.org/). ## Unreleased — to become 5.y.z +### Added +- Added impersonation support. Limited to at most 1 group in `as_groups` and 1 value for each `as_user_extra` field. (#600) + ### Changed - `Kubeclient::Client.new` now always requires an api version, use for example: `Kubeclient::Client.new(uri, 'v1')` From fdd812f9f3b05d154380a2d76fb15bf328174c10 Mon Sep 17 00:00:00 2001 From: Lukas Dolezal Date: Mon, 6 Feb 2023 13:12:38 +0100 Subject: [PATCH 5/7] add unit test for watch --- test/test_watch.rb | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/test/test_watch.rb b/test/test_watch.rb index 9f56ac1d..6ff41060 100644 --- a/test/test_watch.rb +++ b/test/test_watch.rb @@ -281,6 +281,36 @@ def test_watch_finish_when_response_connection_open server[:server].close end + def test_watch_impersonation_headers + stub_core_api_list + watch_stub = stub_request(:get, %r{/watch/pods}) + .with( + headers: { + 'Impersonate-User' => 'admin', + 'Impersonate-Group' => 'system:masters', + 'Impersonate-Extra-Reason' => 'admin access', + 'Impersonate-Extra-Scope' => 'foo' + } + ) + .to_return(body: open_test_file('watch_stream.json'), status: 200) + + client = Kubeclient::Client.new( + 'http://localhost:8080/api/', + 'v1', + auth_options: { + as: 'admin', + as_groups: ['system:masters'], + as_user_extra: { + 'reason' => ['admin access'], + 'scope' => ['foo'] + } + } + ) + yielded = [] + client.watch_pods { |notice| yielded << notice.type } + assert_requested(watch_stub) + end + private def start_http_server(host: '127.0.0.1', port: Random.rand(1000..10_999)) From c484376d8d9cf368e679e6bc234e4d4f4ad34a01 Mon Sep 17 00:00:00 2001 From: Lukas Dolezal Date: Mon, 6 Feb 2023 13:39:34 +0100 Subject: [PATCH 6/7] add unit test for empty config values --- .../impersonate-empty-groups.kubeconfig | 22 ++++++++++++++ test/test_config.rb | 13 ++++++++ test/test_kubeclient.rb | 30 +++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 test/config/impersonate-empty-groups.kubeconfig diff --git a/test/config/impersonate-empty-groups.kubeconfig b/test/config/impersonate-empty-groups.kubeconfig new file mode 100644 index 00000000..c3d756a0 --- /dev/null +++ b/test/config/impersonate-empty-groups.kubeconfig @@ -0,0 +1,22 @@ +apiVersion: v1 +clusters: +- cluster: + server: https://localhost:8443 + insecure-skip-tls-verify: true + name: localhost:8443 +contexts: +- context: + cluster: localhost:8443 + namespace: default + user: impersonate + name: localhost/impersonate +current-context: localhost/impersonate +kind: Config +preferences: {} +users: +- name: impersonate + user: + as: foo + as-groups: [] + as-user-extra: + reason: [] diff --git a/test/test_config.rb b/test/test_config.rb index 3666e614..da9e7333 100644 --- a/test/test_config.rb +++ b/test/test_config.rb @@ -246,6 +246,19 @@ def test_impersonate ) end + def test_impersonate_empty_groups + parsed = YAML.safe_load(File.read(config_file('impersonate-empty-groups.kubeconfig'))) + config = Kubeclient::Config.new(parsed, nil) + assert_equal( + { + as: 'foo', + as_groups: [], + as_user_extra: { 'reason' => [] } + }, + config.context(config.contexts.first).auth_options + ) + end + private def check_context(context, ssl: true, custom_ca: true, client_cert: true) diff --git a/test/test_kubeclient.rb b/test/test_kubeclient.rb index a9007c46..6d41a682 100644 --- a/test/test_kubeclient.rb +++ b/test/test_kubeclient.rb @@ -874,6 +874,36 @@ def test_impersonate assert_requested(pods_stub) end + def test_impersonate_empty_groups + pods_headers = nil + pods_stub = stub_request(:get, 'http://localhost:8080/api/v1/pods') + .with { |request| pods_headers = request.headers } + .to_return(body: { items: [] }.to_json) + stub_request(:get, %r{/api/v1$}) + .with(headers: { Authorization: 'Bearer valid_token' }) + .to_return(body: open_test_file('core_api_resource_list.json')) + + client = Kubeclient::Client.new( + 'http://localhost:8080/api/', + 'v1', + auth_options: { + bearer_token: 'valid_token', + as: 'foo', + as_groups: [], + as_user_extra: { 'reason' => [], 'scopes' => [] }, + as_uid: '123' + } + ) + + client.get_pods + assert_requested(pods_stub) + assert_includes(pods_headers, 'Impersonate-User') + assert_includes(pods_headers, 'Impersonate-Uid') + refute_includes(pods_headers, 'Impersonate-Groups') + refute_includes(pods_headers, 'Impersonate-Extra-Reason') + refute_includes(pods_headers, 'Impersonate-Extra-Scopes') + end + def test_impersonate_limitations assert_raises(ArgumentError) do Kubeclient::Client.new( From 87bea818e2b8c777d56db331d5a9ad5e4434233c Mon Sep 17 00:00:00 2001 From: Lukas Dolezal Date: Mon, 6 Feb 2023 13:53:42 +0100 Subject: [PATCH 7/7] add test for watch with empty groups --- test/test_watch.rb | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/test_watch.rb b/test/test_watch.rb index 6ff41060..a2d1a36e 100644 --- a/test/test_watch.rb +++ b/test/test_watch.rb @@ -311,6 +311,34 @@ def test_watch_impersonation_headers assert_requested(watch_stub) end + def test_watch_impersonation_headers_empty_values + stub_core_api_list + watch_headers = nil + watch_stub = stub_request(:get, %r{/watch/pods}) + .with { |request| watch_headers = request.headers } + .to_return(body: open_test_file('watch_stream.json'), status: 200) + + client = Kubeclient::Client.new( + 'http://localhost:8080/api/', + 'v1', + auth_options: { + as: 'admin', + as_groups: [], + as_user_extra: { + 'reason' => [], + 'scope' => [] + } + } + ) + yielded = [] + client.watch_pods { |notice| yielded << notice.type } + assert_requested(watch_stub) + assert_includes(watch_headers, 'Impersonate-User') + refute_includes(watch_headers, 'Impersonate-Group') + refute_includes(watch_headers, 'Impersonate-Extra-Reason') + refute_includes(watch_headers, 'Impersonate-Extra-Scope') + end + private def start_http_server(host: '127.0.0.1', port: Random.rand(1000..10_999))