Skip to content

Commit

Permalink
add support for user impersonation for k8s modules (#250)
Browse files Browse the repository at this point in the history
add support for user impersonation for k8s modules

SUMMARY

k8s module should not allow user to perform operation using impersonation as describe here
https://kubernetes.io/docs/reference/access-authn-authz/authentication/#user-impersonation
This pull request closes #40

ISSUE TYPE


Feature Pull Request

COMPONENT NAME

ADDITIONAL INFORMATION

Reviewed-by: Mike Graves <[email protected]>
Reviewed-by: Abhijeet Kasurde <None>
Reviewed-by: None <None>
  • Loading branch information
abikouo authored Nov 17, 2021
1 parent b0f1501 commit 39b6c43
Show file tree
Hide file tree
Showing 6 changed files with 304 additions and 8 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
minor_changes:
- k8s - add support for user impersonation. (https://github.com/ansible-collections/kubernetes/core/issues/40).
8 changes: 8 additions & 0 deletions molecule/default/converge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@
tags:
- always

- name: Include user_impersonation.yml
include_tasks:
file: tasks/user_impersonation.yml
apply:
tags: [ user_impersonation, k8s ]
tags:
- always

roles:
- role: helm
tags:
Expand Down
211 changes: 211 additions & 0 deletions molecule/default/tasks/user_impersonation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
- block:
- set_fact:
test_ns: "impersonate"
pod_name: "impersonate-pod"
# this use will have authorization to list/create pods in the namespace
user_01: "authorized-sa-01"
# No authorization attached to this user, will use 'user_01' for impersonation
user_02: "unauthorize-sa-01"

- name: Ensure namespace
kubernetes.core.k8s:
kind: Namespace
name: "{{ test_ns }}"

- name: Get cluster information
kubernetes.core.k8s_cluster_info:
register: cluster_info
no_log: true

- set_fact:
cluster_host: "{{ cluster_info['connection']['host'] }}"

- name: Create Service account
kubernetes.core.k8s:
definition:
apiVersion: v1
kind: ServiceAccount
metadata:
name: "{{ item }}"
namespace: "{{ test_ns }}"
with_items:
- "{{ user_01 }}"
- "{{ user_02 }}"

- name: Read Service Account - user_01
kubernetes.core.k8s_info:
kind: ServiceAccount
namespace: "{{ test_ns }}"
name: "{{ user_01 }}"
register: result

- name: Get secret details
kubernetes.core.k8s_info:
kind: Secret
namespace: '{{ test_ns }}'
name: '{{ result.resources[0].secrets[0].name }}'
no_log: true
register: _secret

- set_fact:
user_01_api_token: "{{ _secret.resources[0]['data']['token'] | b64decode }}"

- name: Read Service Account - user_02
kubernetes.core.k8s_info:
kind: ServiceAccount
namespace: "{{ test_ns }}"
name: "{{ user_02 }}"
register: result

- name: Get secret details
kubernetes.core.k8s_info:
kind: Secret
namespace: '{{ test_ns }}'
name: '{{ result.resources[0].secrets[0].name }}'
no_log: true
register: _secret

- set_fact:
user_02_api_token: "{{ _secret.resources[0]['data']['token'] | b64decode }}"

- name: Create Role to manage pod on the namespace
kubernetes.core.k8s:
namespace: "{{ test_ns }}"
definition:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: pod-manager
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["create", "get", "delete", "list", "patch"]

- name: Attach Role to the user_01
kubernetes.core.k8s:
namespace: "{{ test_ns }}"
definition:
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: pod-manager-binding
subjects:
- kind: ServiceAccount
name: "{{ user_01 }}"
roleRef:
kind: Role
name: pod-manager
apiGroup: rbac.authorization.k8s.io

- name: Create Pod using user_01 credentials
kubernetes.core.k8s:
api_key: "{{ user_01_api_token }}"
host: "{{ cluster_host }}"
validate_certs: no
namespace: "{{ test_ns }}"
name: "{{ pod_name }}"
definition:
apiVersion: v1
kind: Pod
metadata:
labels:
test: "impersonate"
spec:
containers:
- name: c0
image: busybox
command:
- /bin/sh
- -c
- while true;do date;sleep 5; done

- name: Delete Pod using user_02 credentials should failed
kubernetes.core.k8s:
api_key: "{{ user_02_api_token }}"
host: "{{ cluster_host }}"
validate_certs: no
namespace: "{{ test_ns }}"
name: "{{ pod_name }}"
kind: Pod
state: absent
register: delete_pod
ignore_errors: true

- name: Assert that operation has failed
assert:
that:
- delete_pod is failed
- delete_pod.reason == 'Forbidden'

- name: Delete Pod using user_02 credentials and impersonation to user_01
kubernetes.core.k8s:
api_key: "{{ user_02_api_token }}"
host: "{{ cluster_host }}"
validate_certs: no
impersonate_user: "system:serviceaccount:{{ test_ns }}:{{ user_01 }}"
namespace: "{{ test_ns }}"
name: "{{ pod_name }}"
kind: Pod
state: absent
ignore_errors: true
register: delete_pod_2

- name: Assert that operation has failed
assert:
that:
- delete_pod_2 is failed
- delete_pod_2.reason == 'Forbidden'
- '"cannot impersonate resource" in delete_pod_2.msg'

- name: Create Role to impersonate user_01
kubernetes.core.k8s:
namespace: "{{ test_ns }}"
definition:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: sa-impersonate
rules:
- apiGroups: [""]
resources:
- serviceaccounts
verbs:
- impersonate
resourceNames:
- "{{ user_01 }}"

- name: Attach Role to the user_02
kubernetes.core.k8s:
namespace: "{{ test_ns }}"
definition:
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
name: sa-impersonate-binding
subjects:
- kind: ServiceAccount
name: "{{ user_02 }}"
roleRef:
kind: Role
name: sa-impersonate
apiGroup: rbac.authorization.k8s.io

- name: Delete Pod using user_02 credentials should succeed now
kubernetes.core.k8s:
api_key: "{{ user_02_api_token }}"
host: "{{ cluster_host }}"
validate_certs: no
impersonate_user: "system:serviceaccount:{{ test_ns }}:{{ user_01 }}"
namespace: "{{ test_ns }}"
name: "{{ pod_name }}"
kind: Pod
state: absent

always:
- name: Ensure namespace is deleted
kubernetes.core.k8s:
state: absent
kind: Namespace
name: "{{ test_ns }}"
wait: yes
ignore_errors: true
13 changes: 13 additions & 0 deletions plugins/doc_fragments/k8s_auth_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ class ModuleDocFragment(object):
- Please note that the current version of the k8s python client library does not support setting this flag to True yet.
- "The fix for this k8s python library is here: https://github.com/kubernetes-client/python-base/pull/169"
type: bool
impersonate_user:
description:
- Username to impersonate for the operation.
- Can also be specified via K8S_AUTH_IMPERSONATE_USER environment.
type: str
version_added: 2.3.0
impersonate_groups:
description:
- Group(s) to impersonate for the operation.
- "Can also be specified via K8S_AUTH_IMPERSONATE_GROUPS environment. Example: 'Group1,Group2'"
type: list
elements: str
version_added: 2.3.0
notes:
- "To avoid SSL certificate validation errors when C(validate_certs) is I(True), the full
certificate chain for the API server must be provided via C(ca_cert) or in the
Expand Down
2 changes: 2 additions & 0 deletions plugins/module_utils/args_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ def list_dict_str(value):
"no_proxy": {"type": "str"},
"proxy_headers": {"type": "dict", "options": AUTH_PROXY_HEADERS_SPEC},
"persist_config": {"type": "bool"},
"impersonate_user": {},
"impersonate_groups": {"type": "list", "elements": "str"},
}

WAIT_ARG_SPEC = dict(
Expand Down
76 changes: 68 additions & 8 deletions plugins/module_utils/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@
K8S_IMP_ERR = traceback.format_exc()


def configuration_digest(configuration):
def configuration_digest(configuration, **kwargs):
m = hashlib.sha256()
for k in AUTH_ARG_MAP:
if not hasattr(configuration, k):
Expand All @@ -140,10 +140,32 @@ def configuration_digest(configuration):
m.update(content.encode())
else:
m.update(str(v).encode())
for k in kwargs:
content = "{0}: {1}".format(k, kwargs.get(k))
m.update(content.encode())
digest = m.hexdigest()
return digest


class unique_string(str):
_low = None

def __hash__(self):
return id(self)

def __eq__(self, other):
return self is other

def lower(self):
if self._low is None:
lower = str.lower(self)
if str.__eq__(lower, self):
self._low = self
else:
self._low = unique_string(lower)
return self._low


def get_api_client(module=None, **kwargs):
auth = {}

Expand Down Expand Up @@ -219,9 +241,14 @@ def _load_config():
"Failed to set no_proxy due to: %s",
)

configuration = None
if auth_set("username", "password", "host") or auth_set("api_key", "host"):
# We have enough in the parameters to authenticate, no need to load incluster or kubeconfig
pass
arg_init = {}
# api_key will be set later in this function
for key in ("username", "password", "host"):
arg_init[key] = auth.get(key)
configuration = kubernetes.client.Configuration(**arg_init)
elif auth_set("kubeconfig") or auth_set("context"):
try:
_load_config()
Expand All @@ -240,10 +267,11 @@ def _load_config():

# Override any values in the default configuration with Ansible parameters
# As of kubernetes-client v12.0.0, get_default_copy() is required here
try:
configuration = kubernetes.client.Configuration().get_default_copy()
except AttributeError:
configuration = kubernetes.client.Configuration()
if not configuration:
try:
configuration = kubernetes.client.Configuration().get_default_copy()
except AttributeError:
configuration = kubernetes.client.Configuration()

for key, value in iteritems(auth):
if key in AUTH_ARG_MAP.keys() and value is not None:
Expand All @@ -257,14 +285,46 @@ def _load_config():
else:
setattr(configuration, key, value)

digest = configuration_digest(configuration)
api_client = kubernetes.client.ApiClient(configuration)
impersonate_map = {
"impersonate_user": "Impersonate-User",
"impersonate_groups": "Impersonate-Group",
}
api_digest = {}

headers = {}
for arg_name, header_name in impersonate_map.items():
value = None
if module and module.params.get(arg_name) is not None:
value = module.params.get(arg_name)
elif arg_name in kwargs and kwargs.get(arg_name) is not None:
value = kwargs.get(arg_name)
else:
value = os.getenv("K8S_AUTH_{0}".format(arg_name.upper()), None)
if value is not None:
if AUTH_ARG_SPEC[arg_name].get("type") == "list":
value = [x for x in env_value.split(",") if x != ""]
if value:
if isinstance(value, list):
api_digest[header_name] = ",".join(sorted(value))
for v in value:
api_client.set_default_header(
header_name=unique_string(header_name), header_value=v
)
else:
api_digest[header_name] = value
api_client.set_default_header(
header_name=header_name, header_value=value
)

digest = configuration_digest(configuration, **api_digest)
if digest in get_api_client._pool:
client = get_api_client._pool[digest]
return client

try:
client = k8sdynamicclient.K8SDynamicClient(
kubernetes.client.ApiClient(configuration), discoverer=LazyDiscoverer
api_client, discoverer=LazyDiscoverer
)
except Exception as err:
_raise_or_fail(err, "Failed to get client due to %s")
Expand Down

0 comments on commit 39b6c43

Please sign in to comment.