|
4 | 4 | import os |
5 | 5 | import pathlib |
6 | 6 | import time |
| 7 | +from os.path import join |
7 | 8 | from urllib.parse import urljoin |
8 | 9 |
|
9 | 10 | from awx_plugins.interfaces._temporary_private_django_api import ( # noqa: WPS436 |
@@ -481,6 +482,48 @@ def workload_identity_auth(**kwargs): |
481 | 482 | return {'role': kwargs.get('jwt_role'), 'jwt': workload_identity_token} |
482 | 483 |
|
483 | 484 |
|
| 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 | + |
484 | 527 | def method_auth(**kwargs): |
485 | 528 | # get auth method specific params |
486 | 529 | request_kwargs = {'json': kwargs['auth_param'], 'timeout': 30} |
@@ -523,126 +566,144 @@ def method_auth(**kwargs): |
523 | 566 |
|
524 | 567 |
|
525 | 568 | 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] |
554 | 611 | 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] |
568 | 613 |
|
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}', |
593 | 648 | ) |
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) |
601 | 655 |
|
602 | 656 |
|
603 | 657 | 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) |
646 | 707 |
|
647 | 708 |
|
648 | 709 | hashivault_kv_plugin = CredentialPlugin( |
|
0 commit comments