Skip to content

Commit 63a540b

Browse files
committed
Add revoke Vault token functionality to OIDC lookup creds
1 parent 6f0a4de commit 63a540b

2 files changed

Lines changed: 195 additions & 114 deletions

File tree

src/awx_plugins/credentials/hashivault.py

Lines changed: 175 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import os
55
import pathlib
66
import time
7+
from os.path import join
78
from urllib.parse import urljoin
89

910
from awx_plugins.interfaces._temporary_private_django_api import ( # noqa: WPS436
@@ -481,6 +482,48 @@ def workload_identity_auth(**kwargs):
481482
return {'role': kwargs.get('jwt_role'), 'jwt': workload_identity_token}
482483

483484

485+
def revoke_token(token, **kwargs):
486+
"""
487+
Revoke a Vault token using the token revoke-self endpoint.
488+
489+
This minimizes the lifetime of tokens obtained through JWT authentication,
490+
improving the security posture by ensuring tokens are only valid for the
491+
duration of the credential operation.
492+
493+
Args:
494+
token: The Vault client token to revoke
495+
**kwargs: Additional arguments (url, cacert, namespace)
496+
497+
Returns:
498+
None. Revocation is best-effort and exceptions are suppressed to avoid
499+
failing the overall credential operation.
500+
"""
501+
if not token:
502+
return
503+
504+
try:
505+
url = join(kwargs['url'], 'v1')
506+
cacert = kwargs.get('cacert')
507+
508+
request_kwargs = {'timeout': 10}
509+
510+
sess = requests.Session()
511+
sess.mount(url, requests.adapters.HTTPAdapter(max_retries=3))
512+
sess.headers['X-Vault-Token'] = token
513+
if kwargs.get('namespace'):
514+
sess.headers['X-Vault-Namespace'] = kwargs['namespace']
515+
516+
request_url = join(url, 'auth/token/revoke-self')
517+
518+
with CertFiles(cacert) as cert:
519+
request_kwargs['verify'] = cert
520+
sess.post(request_url, **request_kwargs)
521+
# Best effort - don't check response status as token may already be
522+
# expired/revoked, which is acceptable
523+
except Exception:
524+
pass
525+
526+
484527
def method_auth(**kwargs):
485528
# get auth method specific params
486529
request_kwargs = {'json': kwargs['auth_param'], 'timeout': 30}
@@ -523,126 +566,144 @@ def method_auth(**kwargs):
523566

524567

525568
def kv_backend(**kwargs):
526-
token = handle_auth(**kwargs)
527-
url = kwargs['url']
528-
secret_path = kwargs['secret_path']
529-
secret_backend = kwargs.get('secret_backend')
530-
secret_key = kwargs.get('secret_key')
531-
cacert = kwargs.get('cacert')
532-
api_version = kwargs['api_version']
533-
534-
request_kwargs = {
535-
'timeout': 30,
536-
'allow_redirects': False,
537-
}
538-
539-
sess = requests.Session()
540-
sess.mount(url, requests.adapters.HTTPAdapter(max_retries=5))
541-
sess.headers['Authorization'] = f'Bearer {token}'
542-
# Compatibility header for older installs of Hashicorp Vault
543-
sess.headers['X-Vault-Token'] = token
544-
if kwargs.get('namespace'):
545-
sess.headers['X-Vault-Namespace'] = kwargs['namespace']
546-
547-
if api_version == 'v2':
548-
if kwargs.get('secret_version'):
549-
request_kwargs['params'] = { # type: ignore[assignment] # FIXME
550-
'version': kwargs['secret_version'],
551-
}
552-
if secret_backend:
553-
path_segments = [secret_backend, 'data', secret_path]
569+
try:
570+
token = handle_auth(**kwargs)
571+
572+
url = kwargs['url']
573+
secret_path = kwargs['secret_path']
574+
secret_backend = kwargs.get('secret_backend')
575+
secret_key = kwargs.get('secret_key')
576+
cacert = kwargs.get('cacert')
577+
api_version = kwargs['api_version']
578+
579+
request_kwargs = {
580+
'timeout': 30,
581+
'allow_redirects': False,
582+
}
583+
584+
sess = requests.Session()
585+
sess.mount(url, requests.adapters.HTTPAdapter(max_retries=5))
586+
sess.headers['Authorization'] = f'Bearer {token}'
587+
# Compatibility header for older installs of Hashicorp Vault
588+
sess.headers['X-Vault-Token'] = token
589+
if kwargs.get('namespace'):
590+
sess.headers['X-Vault-Namespace'] = kwargs['namespace']
591+
592+
if api_version == 'v2':
593+
if kwargs.get('secret_version'):
594+
request_kwargs['params'] = { # type: ignore[assignment] # FIXME
595+
'version': kwargs['secret_version'],
596+
}
597+
if secret_backend:
598+
path_segments = [secret_backend, 'data', secret_path]
599+
else:
600+
try:
601+
mount_point, *path = pathlib.Path(
602+
secret_path.lstrip(os.sep),
603+
).parts
604+
'/'.join(path)
605+
except Exception:
606+
mount_point, path = secret_path, []
607+
# https://www.vaultproject.io/api/secret/kv/kv-v2.html#read-secret-version
608+
path_segments = [mount_point, 'data'] + path
609+
elif secret_backend:
610+
path_segments = [secret_backend, secret_path]
554611
else:
555-
try:
556-
mount_point, *path = pathlib.Path(
557-
secret_path.lstrip(os.sep),
558-
).parts
559-
'/'.join(path)
560-
except Exception:
561-
mount_point, path = secret_path, []
562-
# https://www.vaultproject.io/api/secret/kv/kv-v2.html#read-secret-version
563-
path_segments = [mount_point, 'data'] + path
564-
elif secret_backend:
565-
path_segments = [secret_backend, secret_path]
566-
else:
567-
path_segments = [secret_path]
612+
path_segments = [secret_path]
568613

569-
request_url = urljoin(url, '/'.join(['v1'] + path_segments)).rstrip('/')
570-
with CertFiles(cacert) as cert:
571-
request_kwargs['verify'] = cert
572-
request_retries = 0
573-
while request_retries < 5:
574-
response = sess.get(request_url, **request_kwargs)
575-
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
576-
if response.status_code == 412:
577-
request_retries += 1
578-
time.sleep(1)
579-
else:
580-
break
581-
raise_for_status(response)
582-
583-
json = response.json()
584-
if api_version == 'v2':
585-
json = json['data']
586-
587-
if secret_key:
588-
try:
589-
if (
590-
(secret_key != 'data')
591-
and ( # noqa: S105; not a password
592-
secret_key not in json['data']
614+
request_url = urljoin(url, '/'.join(['v1'] + path_segments)).rstrip(
615+
'/',
616+
)
617+
with CertFiles(cacert) as cert:
618+
request_kwargs['verify'] = cert
619+
request_retries = 0
620+
while request_retries < 5:
621+
response = sess.get(request_url, **request_kwargs)
622+
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
623+
if response.status_code == 412:
624+
request_retries += 1
625+
time.sleep(1)
626+
else:
627+
break
628+
raise_for_status(response)
629+
630+
json = response.json()
631+
if api_version == 'v2':
632+
json = json['data']
633+
634+
if secret_key:
635+
try:
636+
if (
637+
(secret_key != 'data')
638+
and ( # noqa: S105; not a password
639+
secret_key not in json['data']
640+
)
641+
and ('data' in json['data'])
642+
):
643+
return json['data']['data'][secret_key]
644+
return json['data'][secret_key]
645+
except KeyError:
646+
raise RuntimeError(
647+
f'{secret_key} is not present at {secret_path}',
593648
)
594-
and ('data' in json['data'])
595-
):
596-
return json['data']['data'][secret_key]
597-
return json['data'][secret_key]
598-
except KeyError:
599-
raise RuntimeError(f'{secret_key} is not present at {secret_path}')
600-
return json['data']
649+
return json['data']
650+
finally:
651+
# Only revoke ephemeral vault tokens
652+
if 'workload_identity_token' in kwargs:
653+
# Revoke token to minimize token lifetime and improve security posture
654+
revoke_token(token, **kwargs)
601655

602656

603657
def ssh_backend(**kwargs):
604-
token = handle_auth(**kwargs)
605-
url = urljoin(kwargs['url'], 'v1')
606-
secret_path = kwargs['secret_path']
607-
role = kwargs['role']
608-
cacert = kwargs.get('cacert')
609-
610-
request_kwargs = {
611-
'timeout': 30,
612-
'allow_redirects': False,
613-
}
614-
615-
request_kwargs['json'] = { # type: ignore[assignment] # FIXME
616-
'public_key': kwargs['public_key'],
617-
}
618-
if kwargs.get('valid_principals'):
619-
request_kwargs['json'][ # type: ignore[index] # FIXME
620-
'valid_principals'
621-
] = kwargs['valid_principals']
622-
623-
sess = requests.Session()
624-
sess.mount(url, requests.adapters.HTTPAdapter(max_retries=5))
625-
sess.headers['Authorization'] = f'Bearer {token}'
626-
if kwargs.get('namespace'):
627-
sess.headers['X-Vault-Namespace'] = kwargs['namespace']
628-
# Compatibility header for older installs of Hashicorp Vault
629-
sess.headers['X-Vault-Token'] = token
630-
# https://www.vaultproject.io/api/secret/ssh/index.html#sign-ssh-key
631-
request_url = '/'.join([url, secret_path, 'sign', role]).rstrip('/')
632-
633-
with CertFiles(cacert) as cert:
634-
request_kwargs['verify'] = cert
635-
request_retries = 0
636-
while request_retries < 5:
637-
resp = sess.post(request_url, **request_kwargs)
638-
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
639-
if resp.status_code == 412:
640-
request_retries += 1
641-
time.sleep(1)
642-
else:
643-
break
644-
raise_for_status(resp)
645-
return resp.json()['data']['signed_key']
658+
try:
659+
token = handle_auth(**kwargs)
660+
661+
url = urljoin(kwargs['url'], 'v1')
662+
secret_path = kwargs['secret_path']
663+
role = kwargs['role']
664+
cacert = kwargs.get('cacert')
665+
666+
request_kwargs = {
667+
'timeout': 30,
668+
'allow_redirects': False,
669+
}
670+
671+
request_kwargs['json'] = { # type: ignore[assignment] # FIXME
672+
'public_key': kwargs['public_key'],
673+
}
674+
if kwargs.get('valid_principals'):
675+
request_kwargs['json'][ # type: ignore[index] # FIXME
676+
'valid_principals'
677+
] = kwargs['valid_principals']
678+
679+
sess = requests.Session()
680+
sess.mount(url, requests.adapters.HTTPAdapter(max_retries=5))
681+
sess.headers['Authorization'] = f'Bearer {token}'
682+
if kwargs.get('namespace'):
683+
sess.headers['X-Vault-Namespace'] = kwargs['namespace']
684+
# Compatibility header for older installs of Hashicorp Vault
685+
sess.headers['X-Vault-Token'] = token
686+
# https://www.vaultproject.io/api/secret/ssh/index.html#sign-ssh-key
687+
request_url = '/'.join([url, secret_path, 'sign', role]).rstrip('/')
688+
689+
with CertFiles(cacert) as cert:
690+
request_kwargs['verify'] = cert
691+
request_retries = 0
692+
while request_retries < 5:
693+
resp = sess.post(request_url, **request_kwargs)
694+
# https://developer.hashicorp.com/vault/docs/enterprise/consistency
695+
if resp.status_code == 412:
696+
request_retries += 1
697+
time.sleep(1)
698+
else:
699+
break
700+
raise_for_status(resp)
701+
return resp.json()['data']['signed_key']
702+
finally:
703+
# Only revoke ephemeral vault tokens
704+
if 'workload_identity_token' in kwargs:
705+
# Revoke token to minimize token lifetime and improve security posture
706+
revoke_token(token, **kwargs)
646707

647708

648709
hashivault_kv_plugin = CredentialPlugin(

tests/unit/credentials/hashivault_test.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,23 @@ def test_non_oidc_plugins_have_no_internal_fields(
393393
field for field in plugin.inputs['fields'] if field.get('internal')
394394
]
395395
assert internal_fields == []
396+
397+
398+
def test_revoke_token_simple(mocker: MockerFixture) -> None:
399+
"""Test ``revoke_token()`` hits the correct endpoint, with all network calls mocked."""
400+
mock_session = mocker.MagicMock()
401+
mock_session.headers = {}
402+
mock_post = mocker.MagicMock()
403+
mock_session.post = mock_post
404+
mocker.patch('requests.Session', return_value=mock_session)
405+
mocker.patch.object(hashivault, 'CertFiles', return_value=mocker.MagicMock())
406+
407+
kwargs = {
408+
'url': 'https://vault.example.com',
409+
}
410+
411+
hashivault.revoke_token('test_token', **kwargs) # type: ignore[no-untyped-call]
412+
413+
assert mock_session.headers['X-Vault-Token'] == 'test_token'
414+
mock_post.assert_called_once()
415+
assert 'auth/token/revoke-self' in mock_post.call_args[0][0]

0 commit comments

Comments
 (0)