From 39b6c43ab7e3d707434dfb7db9854d6d6ee4a4c3 Mon Sep 17 00:00:00 2001 From: abikouo <79859644+abikouo@users.noreply.github.com> Date: Wed, 17 Nov 2021 14:25:06 +0100 Subject: [PATCH] add support for user impersonation for k8s modules (#250) 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 Reviewed-by: Abhijeet Kasurde Reviewed-by: None --- ...250-k8s-add-support-for-impersonation.yaml | 2 + molecule/default/converge.yml | 8 + molecule/default/tasks/user_impersonation.yml | 211 ++++++++++++++++++ plugins/doc_fragments/k8s_auth_options.py | 13 ++ plugins/module_utils/args_common.py | 2 + plugins/module_utils/common.py | 76 ++++++- 6 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 changelogs/fragments/250-k8s-add-support-for-impersonation.yaml create mode 100644 molecule/default/tasks/user_impersonation.yml diff --git a/changelogs/fragments/250-k8s-add-support-for-impersonation.yaml b/changelogs/fragments/250-k8s-add-support-for-impersonation.yaml new file mode 100644 index 00000000..e717747f --- /dev/null +++ b/changelogs/fragments/250-k8s-add-support-for-impersonation.yaml @@ -0,0 +1,2 @@ +minor_changes: + - k8s - add support for user impersonation. (https://github.com/ansible-collections/kubernetes/core/issues/40). diff --git a/molecule/default/converge.yml b/molecule/default/converge.yml index 032220b2..9cc7bccc 100644 --- a/molecule/default/converge.yml +++ b/molecule/default/converge.yml @@ -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: diff --git a/molecule/default/tasks/user_impersonation.yml b/molecule/default/tasks/user_impersonation.yml new file mode 100644 index 00000000..1883f26d --- /dev/null +++ b/molecule/default/tasks/user_impersonation.yml @@ -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 diff --git a/plugins/doc_fragments/k8s_auth_options.py b/plugins/doc_fragments/k8s_auth_options.py index f3aaeee8..7d1e3e13 100644 --- a/plugins/doc_fragments/k8s_auth_options.py +++ b/plugins/doc_fragments/k8s_auth_options.py @@ -119,6 +119,19 @@ options: - 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 diff --git a/plugins/module_utils/args_common.py b/plugins/module_utils/args_common.py index f1f1734b..27f4eacc 100644 --- a/plugins/module_utils/args_common.py +++ b/plugins/module_utils/args_common.py @@ -32,6 +32,8 @@ AUTH_ARG_SPEC = { "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( diff --git a/plugins/module_utils/common.py b/plugins/module_utils/common.py index b306b7ff..1be5513c 100644 --- a/plugins/module_utils/common.py +++ b/plugins/module_utils/common.py @@ -127,7 +127,7 @@ except ImportError as e: 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): @@ -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 = {} @@ -219,9 +241,14 @@ def get_api_client(module=None, **kwargs): "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() @@ -240,10 +267,11 @@ def get_api_client(module=None, **kwargs): # 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: @@ -257,14 +285,46 @@ def get_api_client(module=None, **kwargs): 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")