Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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')`

Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
23 changes: 22 additions & 1 deletion lib/kubeclient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def initialize_client(
@as = as

validate_bearer_token_file
configure_impersonation_headers
end

def configure_faraday(&block)
Expand Down Expand Up @@ -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|
Expand Down Expand Up @@ -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?

Expand Down
9 changes: 9 additions & 0 deletions lib/kubeclient/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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'
Expand Down
22 changes: 22 additions & 0 deletions test/config/impersonate-empty-groups.kubeconfig
Original file line number Diff line number Diff line change
@@ -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: []
22 changes: 22 additions & 0 deletions test/config/impersonate.kubeconfig
Original file line number Diff line number Diff line change
@@ -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]
26 changes: 26 additions & 0 deletions test/test_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
79 changes: 79 additions & 0 deletions test/test_kubeclient.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
58 changes: 58 additions & 0 deletions test/test_watch.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down