From 24a5699dba656f9dda5f5c6d9d80a0fedc9a6fba Mon Sep 17 00:00:00 2001 From: mleneveut Date: Wed, 12 Jun 2024 09:49:29 +0200 Subject: [PATCH 1/3] Add support for JWT role --- .../hashivault_jwt_auth_method_config.py | 146 ++++++++++++ .../hashivault/hashivault_jwt_auth_role.py | 219 ++++++++++++++++++ functional/run.sh | 2 + functional/test_jwt_auth_method_config.yml | 53 +++++ functional/test_jwt_auth_role.yml | 52 +++++ 5 files changed, 472 insertions(+) create mode 100644 ansible/modules/hashivault/hashivault_jwt_auth_method_config.py create mode 100644 ansible/modules/hashivault/hashivault_jwt_auth_role.py create mode 100644 functional/test_jwt_auth_method_config.yml create mode 100644 functional/test_jwt_auth_role.yml diff --git a/ansible/modules/hashivault/hashivault_jwt_auth_method_config.py b/ansible/modules/hashivault/hashivault_jwt_auth_method_config.py new file mode 100644 index 00000000..9506db2b --- /dev/null +++ b/ansible/modules/hashivault/hashivault_jwt_auth_method_config.py @@ -0,0 +1,146 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +from ansible.module_utils.hashivault import hashivault_argspec +from ansible.module_utils.hashivault import hashivault_auth_client +from ansible.module_utils.hashivault import hashivault_init +from ansible.module_utils.hashivault import hashiwrapper + +ANSIBLE_METADATA = {'status': ['stableinterface'], 'supported_by': 'community', 'version': '1.1'} +DOCUMENTATION = ''' +--- +module: hashivault_jwt_auth_method_config +version_added: "4.1.1" +short_description: Hashicorp Vault JWT auth method config +description: + - Module to configure an JWT auth mount +options: + mount_point: + description: + - name of the secret engine mount name. + default: jwt + default_role: + description: + - The default role to use if none is provided during login. + oidc_discovery_url: + description: + - The OIDC Discovery URL, without any .well-known component (base path). Cannot be used with "jwks_url" or + "jwt_validation_pubkeys". + oidc_client_id: + description: + - The OAuth Client ID from the provider for OIDC roles. + oidc_client_secret: + description: + - The OAuth Client Secret from the provider for OIDC roles. + bound_issuer: + description: + - The value against which to match the iss claim in a JWT. + jwks_ca_pem: + description: + - The CA certificate or chain of certificates, in PEM format, to use to validate connections to the JWKS + URL. If not set, system certificates are used. + jwks_url: + description: + - JWKS URL to use to authenticate signatures. Cannot be used with "oidc_discovery_url" or + "jwt_validation_pubkeys". + jwt_supported_algs: + description: + - A list of supported signing algorithms. + default: RS256 + jwt_validation_pubkeys: + description: + - A list of PEM-encoded public keys to use to authenticate signatures locally. Cannot be used with + "jwks_url" or "oidc_discovery_url". + oidc_discovery_ca_pem: + description: + - The CA certificate or chain of certificates, in PEM format, to use to validate connections to the OIDC + Discovery URL. If not set, system certificates are used. + provider_config: + description: + - "Configuration options for provider-specific handling. + Providers with specific handling include: Azure, Google." +extends_documentation_fragment: hashivault +''' +EXAMPLES = ''' +--- +- hosts: localhost + tasks: + - hashivault_jwt_auth_method_config: + oidc_discovery_url: "https://accounts.google.com" + oidc_client_id: "123456" + oidc_client_secret: "123456" + default_role: "gmail" +''' + + +def main(): + argspec = hashivault_argspec() + argspec['mount_point'] = dict(required=False, type='str', default='jwt') + argspec['bound_issuer'] = dict(required=False, type='str', default='') + argspec['jwks_ca_pem'] = dict(required=False, type='str', default='') + argspec['jwks_url'] = dict(required=False, type='str') + argspec['jwt_supported_algs'] = dict(required=False, type='list', default=[]) + argspec['jwt_validation_pubkeys'] = dict(required=False, type='list', default=[]) + argspec['oidc_discovery_url'] = dict(required=False, type='str') + argspec['oidc_discovery_ca_pem'] = dict(required=False, type='str', default='') + argspec['oidc_client_id'] = dict(required=False, type='str') + argspec['oidc_client_secret'] = dict(required=False, type='str') + argspec['default_role'] = dict(required=False, type='str') + argspec['provider_config'] = dict(required=False, type='dict') + required_one_of = [['oidc_discovery_url', 'jwks_url']] + module = hashivault_init(argspec, supports_check_mode=True, required_one_of=required_one_of) + result = hashivault_jwt_auth_method_config(module) + if result.get('failed'): + module.fail_json(**result) + else: + module.exit_json(**result) + + +@hashiwrapper +def hashivault_jwt_auth_method_config(module): + params = module.params + mount_point = params.get('mount_point').strip('/') + client = hashivault_auth_client(params) + parameters = [ + 'bound_issuer', + 'jwks_ca_pem', + 'jwks_url', + 'jwt_supported_algs', + 'jwt_validation_pubkeys', + 'oidc_discovery_ca_pem', + 'oidc_discovery_url', + 'oidc_client_id', + 'oidc_client_secret', + 'default_role', + 'provider_config', + ] + desired_state = dict() + for parameter in parameters: + if params.get(parameter) is not None: + desired_state[parameter] = params.get(parameter) + desired_state['path'] = mount_point + + changed = False + current_state = {} + try: + current_state = client.auth.jwt.read_config(path=mount_point)['data'] + except Exception: + changed = True + for key in desired_state.keys(): + current_value = current_state.get(key, None) + if current_value is not None and current_value != desired_state[key]: + changed = True + break + + if changed and not module.check_mode: + client.auth.jwt.configure(**desired_state) + return { + 'changed': changed, + "diff": { + "before": current_state, + "after": desired_state, + } + } + + +if __name__ == '__main__': + main() diff --git a/ansible/modules/hashivault/hashivault_jwt_auth_role.py b/ansible/modules/hashivault/hashivault_jwt_auth_role.py new file mode 100644 index 00000000..6e0d25ac --- /dev/null +++ b/ansible/modules/hashivault/hashivault_jwt_auth_role.py @@ -0,0 +1,219 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +from ansible.module_utils.hashivault import hashivault_argspec +from ansible.module_utils.hashivault import hashivault_auth_client +from ansible.module_utils.hashivault import hashivault_init +from ansible.module_utils.hashivault import hashiwrapper + +ANSIBLE_METADATA = {'status': ['stableinterface'], 'supported_by': 'community', 'version': '1.1'} +DOCUMENTATION = ''' +--- +module: hashivault_jwt_auth_role +version_added: "4.1.1" +short_description: Hashicorp Vault JWT secret engine role +description: + - Module to define an JWT role that vault can generate dynamic credentials for vault +options: + mount_point: + description: + - name of the secret engine mount name. + default: jwt + name: + description: + - name of the role in vault + bound_audiences: + description: + - List of `aud` claims to match against. Any match is sufficient. + user_claim: + description: + - The claim to use to uniquely identify the user; this will be used as the name for the Identity entity + alias created due to a successful login. The claim value must be a string. + default: sub + bound_subject: + description: + - If set, requires that the sub claim matches this value. + bound_claims: + description: + - If set, a map of claims/values to match against. The expected value may be a single string or a list of + strings. + groups_claim: + description: + - The claim to use to uniquely identify the set of groups to which the user belongs; this will be used as + the names for the Identity group aliases created due to a successful login. The claim value must be a + list of strings. + claim_mappings: + description: + - If set, a map of claims (keys) to be copied to specified metadata fields (values). + oidc_scopes: + description: + - If set, a list of OIDC scopes to be used with an OIDC role. The standard scope "openid" is automatically + included and need not be specified. + allowed_redirect_uris: + description: + - The list of allowed values for redirect_uri during JWT logins. + - When using nested namespaces, use url encoding '%2F' instead of '/' + token_ttl: + description: + - The incremental lifetime for generated tokens. This current value of this will be referenced at renewal + time. + token_max_ttl: + description: + - The maximum lifetime for generated tokens. This current value of this will be referenced at renewal time. + token_policies: + description: + - List of policies to encode onto generated tokens. Depending on the auth method, this list may be + supplemented by user/group/other values. + token_bound_cidrs: + description: + - List of CIDR blocks; if set, specifies blocks of IP addresses which can authenticate successfully, and + ties the resulting token to these blocks as well. + token_explicit_max_ttl: + description: + - If set, will encode an explicit max TTL onto the token. This is a hard cap even if token_ttl and + token_max_ttl would otherwise allow a renewal. + token_no_default_policy: + description: + - If set, the default policy will not be set on generated tokens; otherwise it will be added to the policies + set in token_policies. + token_num_uses: + description: + - The maximum number of times a generated token may be used (within its lifetime); 0 means unlimited. + token_period: + description: + - If set, indicates that the token generated using this role should never expire. The token should be + renewed within the duration specified by this value. At each renewal, the token's TTL will be set to + the value of this parameter. + token_type: + description: + - The type of token that should be generated. Can be service, batch, or default to use the mount's tuned + default (which unless changed will be service tokens). For token store roles, there are two additional + possibilities (default-service and default-batch) which specify the type to return unless the client + requests a different type at generation time. +extends_documentation_fragment: hashivault +''' +EXAMPLES = ''' +--- +- hosts: localhost + tasks: + - hashivault_jwt_auth_role: + name: "gmail" + bound_audiences: ["123-456.apps.googleusercontent.com"] + allowed_redirect_uris: ["https://vault.com:8200/ui/vault/auth/jwt/jwt/callback"] + token_policies: ["test"] + +- hosts: localhost + tasks: + - hashivault_jwt_auth_role: + name: nested_ns_role + bound_audiences: ["123-456.apps.googleusercontent.com"] + allowed_redirect_uris: ["https://vault.com:8200/ui/jwt/jwt/callback?namespace=namespaceone%2Fnamespacetwo"] + token_policies: ["test"] +''' + + +def main(): + argspec = hashivault_argspec() + argspec['state'] = dict(required=False, type='str', default='present', choices=['present', 'absent']) + argspec['name'] = dict(required=True, type='str') + argspec['mount_point'] = dict(required=False, type='str', default='jwt') + argspec['user_claim'] = dict(required=False, type='str', default='sub') + argspec['allowed_redirect_uris'] = dict(required=True, type='list') + argspec['bound_audiences'] = dict(required=False, type='list', default=[]) + argspec['bound_subject'] = dict(required=False, type='str', default='') + argspec['bound_claims'] = dict(required=False, type='dict') + argspec['groups_claim'] = dict(required=False, type='str', default='') + argspec['claim_mappings'] = dict(required=False, type='dict') + argspec['oidc_scopes'] = dict(required=False, type='list', default=[]) + argspec['token_ttl'] = dict(required=False, type='int', default=0) + argspec['token_max_ttl'] = dict(required=False, type='int', default=0) + argspec['token_policies'] = dict(required=False, type='list', default=[]) + argspec['policies'] = dict(required=False, type='list', default=[]) + argspec['token_bound_cidrs'] = dict(required=False, type='list', default=[]) + argspec['token_explicit_max_ttl'] = dict(required=False, type='int', default=0) + argspec['token_no_default_policy'] = dict(required=False, type='bool', default=False) + argspec['token_num_uses'] = dict(required=False, type='int', default=0) + argspec['token_period'] = dict(required=False, type='int', default=0) + argspec['token_type'] = dict(required=False, type='str', default='default') + argspec['clock_skew_leeway'] = dict(required=False, type='int', default=0) + argspec['expiration_leeway'] = dict(required=False, type='int', default=0) + argspec['not_before_leeway'] = dict(required=False, type='int', default=0) + argspec['role_type'] = dict(required=False, type='str', default='jwt', choices=['jwt', 'jwt']) + + module = hashivault_init(argspec, supports_check_mode=True) + result = hashivault_jwt_auth_role(module) + if result.get('failed'): + module.fail_json(**result) + else: + module.exit_json(**result) + + +@hashiwrapper +def hashivault_jwt_auth_role(module): + params = module.params + mount_point = params.get('mount_point').strip('/') + name = params.get('name').strip('/') + state = params.get('state') + client = hashivault_auth_client(params) + parameters = [ + 'allowed_redirect_uris', + 'bound_audiences', + 'bound_claims', + 'bound_subject', + 'claim_mappings', + 'groups_claim', + 'oidc_scopes', + 'token_bound_cidrs', + 'token_explicit_max_ttl', + 'token_ttl', + 'token_max_ttl', + 'token_no_default_policy', + 'token_policies', + 'policies', + 'token_type', + 'user_claim', + 'token_period', + 'token_num_uses', + 'clock_skew_leeway', + 'expiration_leeway', + 'not_before_leeway', + 'role_type', + ] + desired_state = dict() + for parameter in parameters: + if params.get(parameter) is not None: + desired_state[parameter] = params.get(parameter) + desired_state['verbose_oidc_logging'] = False + if not desired_state['token_policies'] and desired_state['policies']: + desired_state['token_policies'] = desired_state['policies'] + desired_state.pop('policies', None) + desired_state['path'] = mount_point + + changed = False + current_state = {} + try: + current_state = client.auth.jwt.read_role(name=name, path=mount_point)['data'] + except Exception: + changed = True + for key in desired_state.keys(): + current_value = current_state.get(key, None) + if current_value is not None and current_value != desired_state[key]: + changed = True + break + + if changed and not module.check_mode: + if not current_state and state == 'present': + client.auth.jwt.create_role(name=name, **desired_state) + elif current_state and state == 'absent': + client.auth.jwt.delete_role(name=name) + + return { + 'changed': changed, + "diff": { + "before": current_state, + "after": desired_state, + } + } + + +if __name__ == '__main__': + main() diff --git a/functional/run.sh b/functional/run.sh index 32e92a61..cb552da7 100755 --- a/functional/run.sh +++ b/functional/run.sh @@ -29,6 +29,8 @@ ansible-playbook -v test_azure_auth_role.yml ansible-playbook -v test_k8_auth.yml ansible-playbook -v test_oidc_auth_method_config.yml ansible-playbook -v test_oidc_auth_role.yml +ansible-playbook -v test_jwt_auth_method_config.yml +ansible-playbook -v test_jwt_auth_role.yml ansible-playbook -v test_secret_engine.yml ansible-playbook -v test_secret_list.yml # ansible-playbook -v test_namespace.yml cannot run without enterprise diff --git a/functional/test_jwt_auth_method_config.yml b/functional/test_jwt_auth_method_config.yml new file mode 100644 index 00000000..46972f99 --- /dev/null +++ b/functional/test_jwt_auth_method_config.yml @@ -0,0 +1,53 @@ +--- + +- hosts: localhost + gather_facts: no + vars: + oidc_discovery_url: https://samples.auth0.com/ + oidc_client_id: dont + oidc_client_secret: matter + tasks: + - hashivault_auth_method: + method_type: jwt + state: disabled + failed_when: false + + - name: make sure test fails when no mount exists + hashivault_jwt_auth_method_config: + oidc_discovery_url: "{{ oidc_discovery_url }}" + oidc_client_id: "{{ oidc_client_id }}" + oidc_client_secret: "{{ oidc_client_secret }}" + register: fail_config + failed_when: false + + - assert: { that: "fail_config is not changed" } + + - name: enable oidc auth method + hashivault_auth_method: + method_type: jwt + + - name: successfully configure method + hashivault_jwt_auth_method_config: + oidc_discovery_url: "{{ oidc_discovery_url }}" + oidc_client_id: "{{ oidc_client_id }}" + oidc_client_secret: "{{ oidc_client_secret }}" + register: success_config + + - assert: { that: "success_config is changed" } + + - name: attempt 2nd config with same values + hashivault_jwt_auth_method_config: + oidc_discovery_url: "{{ oidc_discovery_url }}" + oidc_client_id: "{{ oidc_client_id }}" + register: idem_config + + - assert: { that: "idem_config is not changed" } + + - name: attempt 3rd config with different values + hashivault_jwt_auth_method_config: + oidc_discovery_url: "{{ oidc_discovery_url }}" + oidc_client_id: mango + oidc_client_secret: pineapple + register: overwrite_config + + - assert: { that: "overwrite_config is changed" } diff --git a/functional/test_jwt_auth_role.yml b/functional/test_jwt_auth_role.yml new file mode 100644 index 00000000..3954015d --- /dev/null +++ b/functional/test_jwt_auth_role.yml @@ -0,0 +1,52 @@ +--- + +- hosts: localhost + gather_facts: no + vars: + oidc_discovery_url: https://samples.auth0.com/ + oidc_client_id: dont + oidc_client_secret: matter + tasks: + - hashivault_auth_method: + method_type: jwt + state: disabled + failed_when: false + + - name: enable jwt secret engine + hashivault_auth_method: + method_type: jwt + + - name: successfully configure mount + hashivault_jwt_auth_method_config: + oidc_discovery_url: "{{ oidc_discovery_url }}" + oidc_client_id: "{{ oidc_client_id }}" + oidc_client_secret: "{{ oidc_client_secret }}" + + - name: create 1st role + hashivault_jwt_auth_role: + name: "test" + bound_audiences: ["123456"] + allowed_redirect_uris: ["https://123456.com/callback"] + token_policies: ["test"] + register: success_config + + - assert: { that: "success_config is changed" } + + - name: idempotently create role + hashivault_jwt_auth_role: + name: "test" + bound_audiences: ["123456"] + allowed_redirect_uris: ["https://123456.com/callback"] + token_policies: ["test"] + register: idem_config + + - assert: { that: "idem_config is not changed" } + + - name: delete role + hashivault_jwt_auth_role: + name: "test" + state: absent + allowed_redirect_uris: ["https://123456.com/callback"] + register: del_config + + - assert: { that: "del_config is changed" } From 0942ba92b763915420797392d276b0ec14809811 Mon Sep 17 00:00:00 2001 From: mleneveut Date: Wed, 12 Jun 2024 18:04:17 +0200 Subject: [PATCH 2/3] Add an update JWT role possibility : delete then create --- ansible/modules/hashivault/hashivault_jwt_auth_role.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/ansible/modules/hashivault/hashivault_jwt_auth_role.py b/ansible/modules/hashivault/hashivault_jwt_auth_role.py index 6e0d25ac..feb9582e 100644 --- a/ansible/modules/hashivault/hashivault_jwt_auth_role.py +++ b/ansible/modules/hashivault/hashivault_jwt_auth_role.py @@ -98,7 +98,7 @@ - hashivault_jwt_auth_role: name: "gmail" bound_audiences: ["123-456.apps.googleusercontent.com"] - allowed_redirect_uris: ["https://vault.com:8200/ui/vault/auth/jwt/jwt/callback"] + allowed_redirect_uris: ["https://vault.com:8200/ui/vault/auth/jwt/oidc/callback"] token_policies: ["test"] - hosts: localhost @@ -106,7 +106,7 @@ - hashivault_jwt_auth_role: name: nested_ns_role bound_audiences: ["123-456.apps.googleusercontent.com"] - allowed_redirect_uris: ["https://vault.com:8200/ui/jwt/jwt/callback?namespace=namespaceone%2Fnamespacetwo"] + allowed_redirect_uris: ["https://vault.com:8200/ui/jwt/oidc/callback?namespace=namespaceone%2Fnamespacetwo"] token_policies: ["test"] ''' @@ -203,6 +203,9 @@ def hashivault_jwt_auth_role(module): if changed and not module.check_mode: if not current_state and state == 'present': client.auth.jwt.create_role(name=name, **desired_state) + if current_state and state == 'present': + client.auth.jwt.delete_role(name=name) + client.auth.jwt.create_role(name=name, **desired_state) elif current_state and state == 'absent': client.auth.jwt.delete_role(name=name) From e8aec4c387a769855f847d577ab1b0bd67bc627b Mon Sep 17 00:00:00 2001 From: mleneveut Date: Wed, 12 Jun 2024 18:09:41 +0200 Subject: [PATCH 3/3] Add update test --- functional/test_jwt_auth_role.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/functional/test_jwt_auth_role.yml b/functional/test_jwt_auth_role.yml index 3954015d..ff2bb3f1 100644 --- a/functional/test_jwt_auth_role.yml +++ b/functional/test_jwt_auth_role.yml @@ -42,6 +42,16 @@ - assert: { that: "idem_config is not changed" } + - name: update role + hashivault_jwt_auth_role: + name: "test" + bound_audiences: ["123456"] + allowed_redirect_uris: ["https://123456.com/callback"] + token_policies: ["test","test2"] + register: new_config + + - assert: { that: "new_config is changed" } + - name: delete role hashivault_jwt_auth_role: name: "test"