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')` diff --git a/README.md b/README.md index dc627510..dcb43a22 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,27 @@ 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"] + } + } +) +``` + +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 ff5b91f9..d898fc79 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) @@ -367,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| @@ -744,6 +746,25 @@ 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 (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 (as_uid = @auth_options[:as_uid]) + @headers[:'Impersonate-Uid'] = as_uid + end + @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 + 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..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 = {} @@ -186,6 +187,14 @@ def fetch_user_auth_options(user) options 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 + def fetch_token_from_provider(auth_provider) case auth_provider['name'] when 'gcp' 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/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..da9e7333 100644 --- a/test/test_config.rb +++ b/test/test_config.rb @@ -233,6 +233,32 @@ 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 + + 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 ad38cf89..6d41a682 100644 --- a/test/test_kubeclient.rb +++ b/test/test_kubeclient.rb @@ -841,6 +841,85 @@ def test_api_bearer_token_file_success assert_equal(1, pods.size) end + def test_impersonate + 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')) + + client = Kubeclient::Client.new( + 'http://localhost:8080/api/', + 'v1', + auth_options: { + bearer_token: 'valid_token', + as: 'foo', + as_groups: ['bar'], + as_user_extra: { 'reason' => ['reason-1'], 'scopes' => ['scope-1'] }, + as_uid: '123' + } + ) + + client.get_pods + 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( + '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 stub_core_api_list diff --git a/test/test_watch.rb b/test/test_watch.rb index 9f56ac1d..a2d1a36e 100644 --- a/test/test_watch.rb +++ b/test/test_watch.rb @@ -281,6 +281,64 @@ 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 + + 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))