diff --git a/lib/charms/vault_k8s/v0/vault_client.py b/lib/charms/vault_k8s/v0/vault_client.py index d493543c..683408bf 100644 --- a/lib/charms/vault_k8s/v0/vault_client.py +++ b/lib/charms/vault_k8s/v0/vault_client.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from enum import Enum from io import IOBase -from typing import List, Optional, Protocol +from typing import List, Protocol import hvac import requests @@ -28,7 +28,7 @@ # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 21 +LIBPATCH = 22 RAFT_STATE_ENDPOINT = "v1/sys/storage/raft/autopilot/state" @@ -114,7 +114,7 @@ class VaultClientError(Exception): """Base class for exceptions raised by the Vault client.""" -class Vault: +class VaultClient: """Class to interact with Vault through its API.""" def __init__(self, url: str, ca_cert_path: str | None): @@ -169,6 +169,45 @@ def is_sealed(self) -> bool: logging.error("Error while checking Vault seal status: %s", e) raise VaultClientError(e) from e + def read(self, path: str) -> dict: + """Read the data at the given path.""" + try: + data = self._client.read(path) + except VaultError as e: + logger.error("Error while writing data to %s: %s", path, e) + return {} + if data is None: + return {} + if isinstance(data, requests.Response): + data = data.json() + return data.get("data", {}) + + def write(self, path: str, data: dict) -> bool: + """Write the data at the given path.""" + try: + response = self._client.write_data(path, data=data) + except VaultError as e: + logger.error("Error while writing data to %s: %s", path, e) + return False + logger.info("Wrote data to %s: %s", path, response) + return True + + def list(self, path: str) -> List[str]: + """List the keys at the given path.""" + try: + data = self._client.list(path) + except VaultError as e: + logger.error("Error while listing keys at %s: %s", path, e) + return [] + if data is None: + return [] + if isinstance(data, requests.Response): + data = data.json() + try: + return data["data"]["keys"] + except KeyError: + return [] + def needs_migration(self) -> bool: """Return true if the vault needs to be migrated, false otherwise.""" return self._client.seal_status["migration"] # type: ignore -- bad type hint in stubs @@ -247,28 +286,44 @@ def enable_approle_auth_method(self) -> None: except VaultError as e: raise VaultClientError(e) from e - def configure_policy(self, policy_name: str, policy_path: str, **formatting_args: str) -> None: - """Create/update a policy within vault. + def create_or_update_policy_from_file( + self, name: str, path: str, **formatting_args: str + ) -> None: + """Create/update a policy within vault, using the file contents as the policy. Args: - policy_name: Name of the policy to create - policy_path: The path of the file where the policy is defined, ending with .hcl + name: Name of the policy to create + path: The path of the file where the policy is defined, ending with .hcl **formatting_args: Additional arguments to format the policy """ - with open(policy_path, "r") as f: + # TODO: Remove this method when it is no longer needed. Prefer create_or_update_policy. + with open(path, "r") as f: policy = f.read() try: self._client.sys.create_or_update_policy( - name=policy_name, + name=name, policy=policy if not formatting_args else policy.format(**formatting_args), ) except VaultError as e: raise VaultClientError(e) from e - logger.debug("Created or updated charm policy: %s", policy_name) + logger.debug("Created or updated charm policy: %s", name) + + def create_or_update_policy(self, name: str, content: str) -> None: + """Create/update a policy within vault. + + Args: + name: Name of the policy to create + content: The policy content + """ + try: + self._client.sys.create_or_update_policy(name=name, policy=content) + except VaultError as e: + raise VaultClientError(e) from e + logger.debug("Created or updated charm policy: %s", name) - def configure_approle( + def create_or_update_approle( self, - role_name: str, + name: str, token_ttl=None, token_max_ttl=None, policies: List[str] | None = None, @@ -278,7 +333,7 @@ def configure_approle( """Create/update a role within vault associating the supplied policies. Args: - role_name: Name of the role to be created or updated + name: Name of the role to be created or updated policies: The attached list of policy names this approle will have access to token_ttl: Incremental lifetime for generated tokens, provided as a duration string such as "5m" token_max_ttl: Maximum lifetime for generated tokens, provided as a duration string such as "5m" @@ -286,7 +341,7 @@ def configure_approle( cidrs: The list of IP networks that are allowed to authenticate """ self._client.auth.approle.create_or_update_approle( - role_name, + name, bind_secret_id="true", token_ttl=token_ttl, token_max_ttl=token_max_ttl, @@ -294,7 +349,7 @@ def configure_approle( token_bound_cidrs=cidrs, token_period=token_period, ) - response = self._client.auth.approle.read_role_id(role_name) + response = self._client.auth.approle.read_role_id(name) return response["data"]["role_id"] def generate_role_secret_id(self, name: str, cidrs: List[str] | None = None) -> str: @@ -440,16 +495,16 @@ def is_raft_cluster_healthy(self) -> bool: """Check if raft cluster is healthy.""" return self.get_raft_cluster_state()["healthy"] - def remove_raft_node(self, node_id: str) -> None: + def remove_raft_node(self, id: str) -> None: """Remove raft peer.""" try: - self._client.sys.remove_raft_node(server_id=node_id) + self._client.sys.remove_raft_node(server_id=id) except (InternalServerError, ConnectionError) as e: logger.warning("Error while removing raft node: %s", e) return - logger.info("Removed raft node %s", node_id) + logger.info("Removed raft node %s", id) - def is_node_in_raft_peers(self, node_id: str) -> bool: + def is_node_in_raft_peers(self, id: str) -> bool: """Check if node is in raft peers.""" try: raft_config = self._client.sys.read_raft_config() @@ -457,7 +512,7 @@ def is_node_in_raft_peers(self, node_id: str) -> bool: logger.warning("Error while reading raft config: %s", e) return False for peer in raft_config["data"]["config"]["servers"]: - if peer["node_id"] == node_id: + if peer["node_id"] == id: return True return False @@ -480,7 +535,7 @@ def is_common_name_allowed_in_pki_role(self, role: str, mount: str, common_name: logger.warning("Role does not exist on the specified path.") return False - def get_role_max_ttl(self, role: str, mount: str) -> Optional[int]: + def get_role_max_ttl(self, role: str, mount: str) -> int | None: """Get the max ttl for the specified PKI role in seconds.""" try: return ( @@ -515,66 +570,18 @@ def make_latest_pki_issuer_default(self, mount: str) -> None: except (TypeError, KeyError): logger.error("Issuers config is not yet created") - def _get_autounseal_policy_name(self, relation_id: int) -> str: - """Return the policy name for the given relation id.""" - return f"charm-autounseal-{relation_id}" - - def _get_autounseal_approle_name(self, relation_id: int) -> str: - """Return the approle name for the given relation id.""" - return f"charm-autounseal-{relation_id}" - - def _get_autounseal_key_name(self, relation_id: int) -> str: - """Return the key name for the given relation id.""" - return str(relation_id) - - def _create_autounseal_key(self, mount_point: str, relation_id: int) -> str: - """Create a new autounseal key.""" - key_name = self._get_autounseal_key_name(relation_id) + def create_transit_key(self, mount_point: str, key_name: str) -> None: + """Create a new key in the transit backend.""" response = self._client.secrets.transit.create_key(mount_point=mount_point, name=key_name) - logging.debug(f"Created a new autounseal key: {response}") - return key_name - - def _destroy_autounseal_key(self, mount_point, key_name): - """Destroy the autounseal key.""" - self._client.secrets.transit.delete_key(mount_point=mount_point, name=key_name) - - def destroy_autounseal_credentials(self, relation_id: int, mount: str) -> None: - """Destroy the approle and transit key for the given relation id.""" - # Remove the approle - role_name = self._get_autounseal_approle_name(relation_id) - self._client.auth.approle.delete_role(role_name) - # Remove the policy - policy_name = self._get_autounseal_policy_name(relation_id) - self._client.sys.delete_policy(policy_name) - # Remove the transit key - # FIXME: This is currently disabled because we haven't figured out how - # to properly handle destroying the relation, yet. Destroying the key - # without migrating would make it impossible to recover the vault. - # key_name = self.get_autounseal_key_name(relation_id) - # self._destroy_autounseal_key(mount, key_name) - - def create_autounseal_credentials( - self, relation_id: int, mount: str, policy_path: str - ) -> tuple[str, str, str]: - """Create auto-unseal credentials for the given relation id. + logging.debug("Created a new transit key. response=%s", response) - Args: - relation_id: The Juju relation id to use for the approle. - mount: The mount point for the transit backend. - policy_path: Path to a file that contains the autounseal policy. + def delete_role(self, name: str) -> None: + """Delete the approle with the given name.""" + return self._client.auth.approle.delete_role(name) - Returns: - A tuple containing the Role Id, Secret Id and Key Name. - - """ - key_name = self._create_autounseal_key(mount, relation_id) - policy_name = self._get_autounseal_policy_name(relation_id) - self.configure_policy(policy_name, policy_path, mount=mount, key_name=key_name) - - role_name = self._get_autounseal_approle_name(relation_id) - role_id = self.configure_approle(role_name, policies=[policy_name], token_period="60s") - secret_id = self.generate_role_secret_id(role_name) - return key_name, role_id, secret_id + def delete_policy(self, name: str) -> None: + """Delete the policy with the given name.""" + return self._client.sys.delete_policy(name) def generate_pem_bundle(certificate: str, private_key: str) -> str: diff --git a/lib/charms/vault_k8s/v0/vault_tls.py b/lib/charms/vault_k8s/v0/vault_managers.py similarity index 59% rename from lib/charms/vault_k8s/v0/vault_tls.py rename to lib/charms/vault_k8s/v0/vault_managers.py index 2cfac078..79a87141 100644 --- a/lib/charms/vault_k8s/v0/vault_tls.py +++ b/lib/charms/vault_k8s/v0/vault_managers.py @@ -1,14 +1,40 @@ -# Copyright 2024 Canonical Ltd. -# Licensed under the Apache2.0. See LICENSE file in charm source for details. +"""Library for managing Vault Charm features. -"""This file includes methods to manage TLS certificates within the Vault charms.""" +This library encapsulates the business logic for managing the Vault service and +its associated integrations within the context of our charms. + +A Vault Feature Manager will aim to encapsulate as much of the business logic +related to the implementation of a specific feature as reasonably possible. + +A feature, in this context, is any set of related concepts which distinctly +enhance the offering of the Charm by interacting with the Vault Service to +perform related operations. A feature may be optional, or required. Features +include TLS support, PKI and KV backends, and Auto-unseal. + +Feature managers should: + +- Abstract away any implementation specific details such as policy and mount + names. +- Provide a simple interface for the charm to ensure the feature is correctly + configured given the state of the charm. Ideally, this is a single method + called `sync()`. +- Be idempotent. +- Be infrastructure dependent (i.e. no Kubernetes or Machine specific code). +- Catch all expected exceptions, and prevent them from reaching the Charm. + +Feature managers should not: + +- Be concerned with the charm's lifecycle (i.e. Charm status) +- Depend on each other unless the features explicitly require the dependency. +""" import logging import os from abc import ABC, abstractmethod +from dataclasses import dataclass from datetime import timedelta from enum import Enum, auto -from typing import FrozenSet, List, TextIO, Tuple +from typing import FrozenSet, TextIO from charms.certificate_transfer_interface.v0.certificate_transfer import ( CertificateTransferProvides, @@ -24,30 +50,55 @@ generate_private_key, ) from charms.vault_k8s.v0.juju_facade import ( + FacadeError, JujuFacade, NoSuchSecretError, NoSuchStorageError, TransientJujuError, ) -from ops import EventBase, Object -from ops.charm import CharmBase +from charms.vault_k8s.v0.vault_autounseal import ( + AutounsealDetails, + VaultAutounsealProvides, + VaultAutounsealRequires, +) +from charms.vault_k8s.v0.vault_client import ( + AppRole, + Token, + VaultClient, +) +from ops import CharmBase, EventBase, Object, Relation from ops.pebble import PathError # The unique Charmhub library identifier, never change it -LIBID = "61b41a053d9847ce8a14eb02197d12cb" +LIBID = "4a8652e06ecb4eb28c5fdbf220d126bb" # Increment this major API version when introducing breaking changes LIBAPI = 0 # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 14 +LIBPATCH = 1 + + +SEND_CA_CERT_RELATION_NAME = "send-ca-cert" +TLS_CERTIFICATE_ACCESS_RELATION_NAME = "tls-certificates-access" +CA_CERTIFICATE_JUJU_SECRET_LABEL = "self-signed-vault-ca-certificate" + +VAULT_CA_SUBJECT = "Vault self signed CA" +AUTOUNSEAL_POLICY = """path "{mount}/encrypt/{key_name}" {{ + capabilities = ["update"] +}} + +path "{mount}/decrypt/{key_name}" {{ + capabilities = ["update"] +}} +""" class LogAdapter(logging.LoggerAdapter): """Adapter for the logger to prepend a prefix to all log lines.""" - prefix = "vault_tls" + prefix = "vault_managers" def process(self, msg, kwargs): """Decides the format for the prepended text.""" @@ -56,12 +107,6 @@ def process(self, msg, kwargs): logger = LogAdapter(logging.getLogger(__name__), {}) -SEND_CA_CERT_RELATION_NAME = "send-ca-cert" -TLS_CERTIFICATE_ACCESS_RELATION_NAME = "tls-certificates-access" -CA_CERTIFICATE_JUJU_SECRET_LABEL = "self-signed-vault-ca-certificate" - -VAULT_CA_SUBJECT = "Vault self signed CA" - class TLSMode(Enum): """This class defines the different modes of TLS configuration. @@ -207,7 +252,7 @@ def _configure_ca_cert_relation(self, event: EventBase): """Send the CA certificate to the relation.""" self.send_ca_cert() - def _get_certificate_requests(self) -> List[CertificateRequestAttributes]: + def _get_certificate_requests(self) -> list[CertificateRequestAttributes]: if not self.common_name: return [] return [ @@ -451,7 +496,7 @@ def tls_file_pushed_to_workload(self, file: File) -> bool: return self.workload.exists(path=f"{self.tls_directory_path}/{file.name.lower()}.pem") -def generate_vault_ca_certificate() -> Tuple[str, str]: +def generate_vault_ca_certificate() -> tuple[str, str]: """Generate Vault CA certificates valid for 50 years. Returns: @@ -472,7 +517,7 @@ def generate_vault_unit_certificate( sans_dns: FrozenSet[str], ca_certificate: str, ca_private_key: str, -) -> Tuple[str, str]: +) -> tuple[str, str]: """Generate Vault unit certificates valid for 50 years. Args: @@ -504,3 +549,256 @@ def generate_vault_unit_certificate( def existing_certificate_is_self_signed(ca_certificate: Certificate) -> bool: """Return whether the certificate is a self signed certificate generated by the Vault charm.""" return ca_certificate.common_name == VAULT_CA_SUBJECT + + +class VaultNaming: + """Computes names for Vault features. + + This class is used to compute names for Vault features based on the charm's + conventions, such as the key name, policy name, and approle name. It + provides a central place to manage them. + """ + + key_prefix: str = "" + policy_prefix: str = "charm-autounseal-" + approle_prefix: str = "charm-autounseal-" + + @classmethod + def key_name(cls, relation_id: int) -> str: + """Return the key name for the relation.""" + return f"{cls.key_prefix}{relation_id}" + + @classmethod + def policy_name(cls, relation_id: int) -> str: + """Return the policy name for the relation.""" + return f"{cls.policy_prefix}{relation_id}" + + @classmethod + def approle_name(cls, relation_id: int) -> str: + """Return the approle name for the relation.""" + return f"{cls.approle_prefix}{relation_id}" + + +class VaultAutounsealProviderManager: + """Encapsulates the auto-unseal functionality. + + This class provides the business logic for auto-unseal functionality in + Vault charms. It is opinionated, and aims to make the interface to enabling + and managing the feature as simple as possible. Flexibility is sacrificed + for simplicity. + """ + + def __init__( + self, + charm: CharmBase, + client: VaultClient, + provides: VaultAutounsealProvides, + ca_cert: str, + mount_path: str, + ): + self._juju_facade = JujuFacade(charm) + self._model = charm.model + self._client = client + self._provides = provides + self._mount_path = mount_path + self._ca_cert = ca_cert + + @property + def mount_path(self) -> str: + """Return the mount path for the transit backend.""" + return self._mount_path + + def clean_up_credentials(self) -> None: + """Clean up roles and policies that are no longer needed by autounseal. + + This method will remove any roles and policies that are no longer + used by any of the existing relations. It will also detect any orphaned + keys (keys that are not associated with any relation) and log a warning. + """ + self._clean_up_roles() + self._clean_up_policies() + self._detect_and_allow_deletion_of_orphaned_keys() + + def _detect_and_allow_deletion_of_orphaned_keys(self) -> None: + """Detect and allow deletion of autounseal keys that are no longer associated with a Juju autounseal relation. + + The keys themselves are not deleted. This is to prevent an + unrecoverable state if a relation is removed by mistake, or before + migrating the data to a different seal type. + + The keys are marked as `allow_deletion` in vault. This allows the user + to manually delete the keys using the Vault CLI if they are sure the + keys are no longer needed. + + A warning is logged so that the Juju operator is aware of the orphaned + keys and can act accordingly. + """ + existing_keys = self._get_existing_keys() + relation_key_names = [ + VaultNaming.key_name(relation.id) + for relation in self._juju_facade.get_active_relations(self._provides.relation_name) + ] + orphaned_keys = [key for key in existing_keys if key not in relation_key_names] + if not orphaned_keys: + return + logger.warning( + "Orphaned autounseal keys were detected: %s. If you are sure these are no longer needed, you may manually delete them using the vault CLI to suppress this message. To delete a key, use the command `vault delete %s/keys/`.", + orphaned_keys, + self.mount_path, + ) + for key_name in orphaned_keys: + deletion_allowed = self._is_deletion_allowed(key_name) + if not deletion_allowed: + self._allow_key_deletion(key_name) + + def _allow_key_deletion(self, key_name: str) -> None: + self._client.write(f"{self.mount_path}/keys/{key_name}/config", {"deletion_allowed": True}) + logger.info("Key marked as `deletion_allowed`: %s", key_name) + + def _is_deletion_allowed(self, key_name: str) -> bool: + data = self._client.read(f"{self.mount_path}/keys/{key_name}") + return data["deletion_allowed"] + + def _clean_up_roles(self) -> None: + """Delete roles that are no longer associated with an autounseal Juju relation.""" + existing_roles = self._get_existing_roles() + relation_role_names = [ + VaultNaming.approle_name(relation.id) + for relation in self._juju_facade.get_active_relations(self._provides.relation_name) + ] + for role in existing_roles: + if role not in relation_role_names: + self._client.delete_role(role) + logger.info("Removed unused role: %s", role) + + def _clean_up_policies(self) -> None: + """Delete policies that are no longer associated with an autounseal Juju relation.""" + existing_policies = self._get_existing_policies() + relation_policy_names = [ + VaultNaming.policy_name(relation.id) + for relation in self._juju_facade.get_active_relations(self._provides.relation_name) + ] + for policy in existing_policies: + if policy not in relation_policy_names: + self._client.delete_policy(policy) + logger.info("Removed unused policy: %s", policy) + + def _create_key(self, key_name: str) -> None: + response = self._client.create_transit_key(mount_point=self.mount_path, key_name=key_name) + logger.debug("Created a new autounseal key: %s", response) + + def create_credentials(self, relation: Relation, vault_address: str) -> tuple[str, str, str]: + """Create auto-unseal credentials for the given relation. + + Args: + relation: The relation to create the credentials for. + vault_address: The address where this relation can reach the Vault. + + Returns: + A tuple containing the key name, role ID, and approle secret ID. + """ + key_name = VaultNaming.key_name(relation.id) + policy_name = VaultNaming.policy_name(relation.id) + approle_name = VaultNaming.approle_name(relation.id) + self._create_key(key_name) + policy_content = AUTOUNSEAL_POLICY.format(mount=self.mount_path, key_name=key_name) + self._client.create_or_update_policy( + policy_name, + policy_content, + ) + role_id = self._client.create_or_update_approle( + approle_name, + policies=[policy_name], + token_period="60s", + ) + secret_id = self._client.generate_role_secret_id(approle_name) + self._provides.set_autounseal_data( + relation, + vault_address, + self.mount_path, + key_name, + role_id, + secret_id, + self._ca_cert, + ) + return key_name, role_id, secret_id + + def _get_existing_keys(self) -> list[str]: + return self._client.list(f"{self.mount_path}/keys") + + def _get_existing_roles(self) -> list[str]: + output = self._client.list("auth/approle/role") + return [role for role in output if role.startswith(VaultNaming.approle_prefix)] + + def _get_existing_policies(self) -> list[str]: + output = self._client.list("sys/policy") + return [policy for policy in output if policy.startswith(VaultNaming.policy_prefix)] + + +@dataclass +class AutounsealConfigurationDetails: + """Credentials required for configuring auto-unseal on Vault.""" + + address: str + mount_path: str + key_name: str + token: str + ca_cert_path: str + + +class VaultAutounsealRequirerManager: + """Encapsulates the auto-unseal functionality from the Requirer Perspective. + + In other words, this manages the feature from the perspective of the Vault + being auto-unsealed. + """ + + AUTOUNSEAL_TOKEN_SECRET_LABEL = "vault-autounseal-token" + + def __init__( + self, + charm: CharmBase, + requires: VaultAutounsealRequires, + ): + self._juju_facade = JujuFacade(charm) + self._requires = requires + + def get_provider_vault_token( + self, autounseal_details: AutounsealDetails, ca_cert_path: str + ) -> str: + """Retrieve the auto-unseal Vault token, or generate a new one if required. + + Retrieves the last used token from Juju secrets, and validates that it + is still valid. If the token is not valid, a new token is generated and + stored in the Juju secret. A valid token is returned. + + Args: + autounseal_details: The autounseal configuration details. + ca_cert_path: The path to the CA certificate to validate the provider Vault. + + Returns: + A periodic Vault token that can be used for auto-unseal. + + """ + external_vault = VaultClient(url=autounseal_details.address, ca_cert_path=ca_cert_path) + try: + existing_token = self._juju_facade.get_secret_content_values( + "token", label=self.AUTOUNSEAL_TOKEN_SECRET_LABEL + )[0] + except FacadeError: + existing_token = None + # If we don't already have a token, or if the existing token is invalid, + # authenticate with the AppRole details to generate a new token. + if not existing_token or not external_vault.authenticate(Token(existing_token)): + external_vault.authenticate( + AppRole(autounseal_details.role_id, autounseal_details.secret_id) + ) + # NOTE: This is a little hacky. If the token expires, every unit + # will generate a new token, until the leader unit generates a new + # valid token and sets it in the Juju secret. + if self._juju_facade.is_leader: + self._juju_facade.set_app_secret_content( + {"token": external_vault.token}, + label=self.AUTOUNSEAL_TOKEN_SECRET_LABEL, + ) + return external_vault.token diff --git a/src/charm.py b/src/charm.py index ef4d9c58..e9e6bef7 100755 --- a/src/charm.py +++ b/src/charm.py @@ -10,7 +10,6 @@ import json import logging import socket -from dataclasses import dataclass from datetime import datetime from typing import IO, Dict, List, Tuple, cast @@ -33,11 +32,8 @@ TLSCertificatesRequiresV4, ) from charms.traefik_k8s.v2.ingress import IngressPerAppRequirer -from charms.vault_k8s.v0.juju_facade import NoSuchRelationError, TransientJujuError from charms.vault_k8s.v0.vault_autounseal import ( - AutounsealDetails, VaultAutounsealProvides, - VaultAutounsealRequirerRelationBroken, VaultAutounsealRequires, ) from charms.vault_k8s.v0.vault_client import ( @@ -45,7 +41,7 @@ AuditDeviceType, SecretsBackend, Token, - Vault, + VaultClient, VaultClientError, ) from charms.vault_k8s.v0.vault_kv import ( @@ -53,8 +49,15 @@ VaultKvClientDetachedEvent, VaultKvProvides, ) +from charms.vault_k8s.v0.vault_managers import ( + AutounsealConfigurationDetails, + File, + VaultAutounsealProviderManager, + VaultAutounsealRequirerManager, + VaultCertsError, + VaultTLSManager, +) from charms.vault_k8s.v0.vault_s3 import S3, S3Error -from charms.vault_k8s.v0.vault_tls import File, VaultCertsError, VaultTLSManager from jinja2 import Environment, FileSystemLoader from ops import CharmBase, MaintenanceStatus, main from ops.charm import ( @@ -81,7 +84,6 @@ APPROLE_ROLE_NAME = "charm" AUTOUNSEAL_MOUNT_PATH = "charm-autounseal" -AUTOUNSEAL_POLICY_PATH = "src/templates/autounseal_policy.hcl" AUTOUNSEAL_PROVIDES_RELATION_NAME = "vault-autounseal-provides" AUTOUNSEAL_REQUIRES_RELATION_NAME = "vault-autounseal-requires" AUTOUNSEAL_TOKEN_SECRET_LABEL = "vault-autounseal-token" @@ -110,17 +112,6 @@ VAULT_STORAGE_PATH = "/vault/raft" -@dataclass -class AutounsealConfigurationDetails: - """Credentials required for configuring auto-unseal on Vault.""" - - address: str - mount_path: str - key_name: str - token: str - ca_cert_path: str - - @trace_charm( tracing_endpoint="_tracing_endpoint", server_cert="_tracing_server_cert", @@ -205,6 +196,7 @@ def __init__(self, *args): self.vault_autounseal_requires.on.vault_autounseal_details_ready, self.vault_autounseal_provides.on.vault_autounseal_requirer_relation_created, self.vault_autounseal_requires.on.vault_autounseal_provider_relation_broken, + self.vault_autounseal_provides.on.vault_autounseal_requirer_relation_broken, ] for event in configure_events: self.framework.observe(event, self._configure) @@ -221,103 +213,6 @@ def __init__(self, *args): self.framework.observe( self.vault_kv.on.vault_kv_client_detached, self._on_vault_kv_client_detached ) - self.framework.observe( - self.vault_autounseal_provides.on.vault_autounseal_requirer_relation_broken, - self._on_vault_autounseal_requirer_relation_broken, - ) - - def _on_vault_autounseal_requirer_relation_broken( - self, event: VaultAutounsealRequirerRelationBroken - ): - """Handle the case where the Vault auto-unseal requirer relation is broken. - - Specifically, this means that the Vault auto-unseal provider should - remove any configuration that was set for the requirer. - """ - if not self.unit.is_leader(): - return - - vault = self._get_active_vault_client() - if vault is None: - logger.warning("Vault is not active, cannot disable vault autounseal") - return - vault.destroy_autounseal_credentials(event.relation.id, AUTOUNSEAL_MOUNT_PATH) - - def _generate_and_set_autounseal_credentials(self, relation: Relation) -> None: - """If leader, generate new credentials for the auto-unseal requirer. - - These credentials are generated and then set in the relation databag so - that the requiring app can retrieve them, and use them to create tokens - that have the appropriate permissions to use the autounseal key. - """ - if not self.unit.is_leader(): - return - vault = self._get_active_vault_client() - if vault is None: - logger.warning("Vault is not active, cannot generate autounseal credentials") - return - - vault.enable_secrets_engine(SecretsBackend.TRANSIT, AUTOUNSEAL_MOUNT_PATH) - - key_name, approle_id, secret_id = vault.create_autounseal_credentials( - relation.id, - AUTOUNSEAL_MOUNT_PATH, - AUTOUNSEAL_POLICY_PATH, - ) - - self._set_autounseal_relation_data(relation, key_name, approle_id, secret_id) - - def _sync_vault_autounseal(self) -> None: - """Go through all the vault-autounseal relations and send necessary credentials. - - This looks for any outstanding requests for auto-unseal that may have - been missed. If there are any, it generates the credentials and sets - them in the relation databag. - """ - if not self.unit.is_leader(): - logger.debug("Only leader unit can handle a vault-autounseal request") - return - outstanding_requests = self.vault_autounseal_provides.get_outstanding_requests() - for relation in outstanding_requests: - self._generate_and_set_autounseal_credentials(relation) - - def _set_autounseal_relation_data( - self, relation: Relation, key_name: str, approle_id: str, approle_secret_id: str - ) -> None: - """Set the required autounseal data in the relation databag. - - Args: - relation: Relation for which the auto-unseal data is being set - key_name: The vault transit key name used for auto-unseal - approle_id: The AppRole ID which has permission to use this key - approle_secret_id: The AppRole secret ID - """ - vault_address = self._get_relation_api_address(relation) - if not vault_address: - logger.warning("Vault address not available, ignoring request to set autounseal data") - return - ca_cert = ( - self.tls.pull_tls_file_from_workload(File.CA) - if self.tls.ca_certificate_is_saved() - else None - ) - if not ca_cert: - logger.warning("CA certificate not available, ignoring request to set autounseal data") - return - - try: - self.vault_autounseal_provides.set_autounseal_data( - relation, - vault_address, - AUTOUNSEAL_MOUNT_PATH, - key_name, - approle_id, - approle_secret_id, - ca_cert, - ) - except NoSuchRelationError as e: - logger.error("Failed to set autounseal data: %s", e) - return def _on_install(self, event: InstallEvent): """Handle the install charm event.""" @@ -360,7 +255,7 @@ def _on_collect_status(self, event: CollectStatusEvent): # noqa: C901 if not self.unit.is_leader() and not self.tls.tls_file_pushed_to_workload(File.CA): event.add_status(WaitingStatus("Waiting for CA certificate to be shared")) return - vault = Vault( + vault = VaultClient( url=self._api_address, ca_cert_path=self.tls.get_tls_file_path_in_charm(File.CA) ) if not vault.is_api_available(): @@ -418,9 +313,13 @@ def _configure(self, _: EventBase) -> None: # noqa: C901 self._generate_vault_config_file() self._set_pebble_plan() - vault = Vault( - url=self._api_address, ca_cert_path=self.tls.get_tls_file_path_in_charm(File.CA) - ) + try: + vault = VaultClient( + url=self._api_address, ca_cert_path=self.tls.get_tls_file_path_in_charm(File.CA) + ) + except VaultCertsError as e: + logger.error("Failed to get TLS file path: %s", e) + return if not vault.is_api_available(): return if not vault.is_initialized(): @@ -433,9 +332,9 @@ def _configure(self, _: EventBase) -> None: # noqa: C901 if not (vault := self._get_active_vault_client()): return self._configure_pki_secrets_engine() - self._sync_vault_autounseal() - self._sync_vault_kv() - self._sync_vault_pki() + self._sync_vault_autounseal(vault) + self._sync_vault_kv(vault) + self._sync_vault_pki(vault) if vault.is_active_or_standby() and not vault.is_raft_cluster_healthy(): # Log if a raft node starts reporting unhealthy @@ -465,7 +364,7 @@ def _remove_node_from_raft_cluster(self): role_id, secret_id = self._get_approle_auth_secret() if not role_id or not secret_id: return - vault = Vault(url=self._api_address, ca_cert_path=None) + vault = VaultClient(url=self._api_address, ca_cert_path=None) if not vault.is_api_available(): return if not vault.is_initialized(): @@ -476,8 +375,8 @@ def _remove_node_from_raft_cluster(self): except VaultClientError: return vault.authenticate(AppRole(role_id, secret_id)) - if vault.is_node_in_raft_peers(node_id=self._node_id) and vault.get_num_raft_peers() > 1: - vault.remove_raft_node(node_id=self._node_id) + if vault.is_node_in_raft_peers(self._node_id) and vault.get_num_raft_peers() > 1: + vault.remove_raft_node(self._node_id) def _on_new_vault_kv_client_attached(self, event: NewVaultKvClientAttachedEvent): """Handle vault-kv-client attached event.""" @@ -493,7 +392,12 @@ def _on_new_vault_kv_client_attached(self, event: NewVaultKvClientAttachedEvent) if not relation.active: logger.error("Relation is not active for relation id %s", event.relation_id) return + vault = self._get_active_vault_client() + if not vault: + logger.debug("Failed to get initialized Vault") + return self._generate_kv_for_requirer( + vault=vault, relation=relation, app_name=event.app_name, unit_name=event.unit_name, @@ -571,7 +475,7 @@ def _configure_pki_secrets_engine(self) -> None: # noqa: C901 logger.error("Failed to make latest issuer default: %s", e) def _intermediate_ca_exceeds_role_ttl( - self, vault: Vault, intermediate_ca_certificate: Certificate + self, vault: VaultClient, intermediate_ca_certificate: Certificate ) -> bool: """Check if the intermediate CA's remaining validity exceeds the role's max TTL. @@ -632,7 +536,32 @@ def _get_certificate_request(self) -> CertificateRequestAttributes | None: is_ca=True, ) - def _sync_vault_pki(self) -> None: + def _sync_vault_autounseal(self, vault_client: VaultClient) -> None: + """Sync the vault autounseal relation.""" + if not self.unit.is_leader(): + logger.debug("Only leader unit can handle a vault-autounseal request") + return + autounseal_provider_manager = VaultAutounsealProviderManager( + charm=self, + client=vault_client, + provides=self.vault_autounseal_provides, + ca_cert=self.tls.pull_tls_file_from_workload(File.CA), + mount_path=AUTOUNSEAL_MOUNT_PATH, + ) + outstanding_autounseal_requests = self.vault_autounseal_provides.get_outstanding_requests() + if outstanding_autounseal_requests: + vault_client.enable_secrets_engine( + SecretsBackend.TRANSIT, autounseal_provider_manager.mount_path + ) + for relation in outstanding_autounseal_requests: + relation_address = self._get_relation_api_address(relation) + if not relation_address: + logger.warning("Relation address not found for relation %s", relation.id) + continue + autounseal_provider_manager.create_credentials(relation, relation_address) + autounseal_provider_manager.clean_up_credentials() + + def _sync_vault_pki(self, vault: VaultClient) -> None: """Goes through all the vault-pki relations and sends necessary TLS certificate.""" if not self.unit.is_leader(): logger.debug("Only leader unit can handle a vault-pki request") @@ -640,10 +569,11 @@ def _sync_vault_pki(self) -> None: outstanding_pki_requests = self.vault_pki.get_outstanding_certificate_requests() for pki_request in outstanding_pki_requests: self._generate_pki_certificate_for_requirer( + vault=vault, requirer_csr=pki_request, ) - def _sync_vault_kv(self) -> None: + def _sync_vault_kv(self, vault: VaultClient) -> None: """Goes through all the vault-kv relations and sends necessary KV information.""" if not self.unit.is_leader(): logger.debug("Only leader unit can handle a vault-kv request") @@ -660,6 +590,7 @@ def _sync_vault_kv(self) -> None: logger.warning("Relation is not active for relation id %s", kv_request.relation_id) continue self._generate_kv_for_requirer( + vault=vault, relation=relation, app_name=kv_request.app_name, unit_name=kv_request.unit_name, @@ -670,6 +601,7 @@ def _sync_vault_kv(self) -> None: def _generate_kv_for_requirer( self, + vault: VaultClient, relation: Relation, app_name: str, unit_name: str, @@ -684,17 +616,15 @@ def _generate_kv_for_requirer( if not ca_certificate: logger.debug("Vault CA certificate not available") return - vault = self._get_active_vault_client() - if not vault: - logger.debug("Failed to get initialized Vault") - return mount = f"charm-{app_name}-{mount_suffix}" vault.enable_secrets_engine(SecretsBackend.KV_V2, mount) self._ensure_unit_credentials(vault, relation, unit_name, mount, nonce, egress_subnets) self._set_kv_relation_data(relation, mount, ca_certificate, egress_subnets) self._remove_stale_nonce(relation=relation, nonce=nonce) - def _generate_pki_certificate_for_requirer(self, requirer_csr: RequirerCertificateRequest): + def _generate_pki_certificate_for_requirer( + self, vault: VaultClient, requirer_csr: RequirerCertificateRequest + ): """Generate a PKI certificate for a TLS requirer.""" if not self.unit.is_leader(): logger.debug("Only leader unit can handle a vault-pki request") @@ -702,10 +632,6 @@ def _generate_pki_certificate_for_requirer(self, requirer_csr: RequirerCertifica if not self._tls_certificates_pki_relation_created(): logger.debug("TLS Certificates PKI relation not created") return - vault = self._get_active_vault_client() - if not vault: - logger.debug("Failed to get initialized Vault") - return common_name = self._get_config_common_name() if not common_name: logger.error("Common name is not set in the charm config") @@ -754,7 +680,7 @@ def _on_authorize_charm_action(self, event: ActionEvent) -> None: "The secret id provided could not be found by the charm. Please grant the token secret to the charm." ) return - vault = Vault(self._api_address, self.tls.get_tls_file_path_in_charm(File.CA)) + vault = VaultClient(self._api_address, self.tls.get_tls_file_path_in_charm(File.CA)) if not vault.authenticate(Token(token)): event.fail( "The token provided is not valid. Please use a Vault token with the appropriate permissions." @@ -764,10 +690,10 @@ def _on_authorize_charm_action(self, event: ActionEvent) -> None: try: vault.enable_audit_device(device_type=AuditDeviceType.FILE, path="stdout") vault.enable_approle_auth_method() - vault.configure_policy(policy_name=CHARM_POLICY_NAME, policy_path=CHARM_POLICY_PATH) + vault.create_or_update_policy_from_file(name=CHARM_POLICY_NAME, path=CHARM_POLICY_PATH) cidrs = [f"{self._bind_address}/24"] - role_id = vault.configure_approle( - role_name=APPROLE_ROLE_NAME, + role_id = vault.create_or_update_approle( + name=APPROLE_ROLE_NAME, cidrs=cidrs, policies=[CHARM_POLICY_NAME, "default"], token_ttl="1h", @@ -928,7 +854,7 @@ def _on_restore_backup_action(self, event: ActionEvent) -> None: try: if self._approle_secret_set(): role_id, secret_id = self._get_approle_auth_secret() - vault = Vault( + vault = VaultClient( url=self._api_address, ca_cert_path=self.tls.get_tls_file_path_in_charm(File.CA), ) @@ -997,7 +923,7 @@ def _set_kv_relation_data( def _ensure_unit_credentials( self, - vault: Vault, + vault: VaultClient, relation: Relation, unit_name: str, mount: str, @@ -1006,8 +932,10 @@ def _ensure_unit_credentials( ): """Ensure a unit has credentials to access the vault-kv mount.""" policy_name = role_name = mount + "-" + unit_name.replace("/", "-") - vault.configure_policy(policy_name, "src/templates/kv_mount.hcl", mount=mount) - role_id = vault.configure_approle( + vault.create_or_update_policy_from_file( + policy_name, "src/templates/kv_mount.hcl", mount=mount + ) + role_id = vault.create_or_update_approle( role_name, policies=[policy_name], cidrs=egress_subnets, @@ -1028,7 +956,7 @@ def _ensure_unit_credentials( def _create_or_update_kv_secret( self, - vault: Vault, + vault: VaultClient, nonce: str, relation: Relation, role_id: str, @@ -1057,7 +985,7 @@ def _create_or_update_kv_secret( def _create_kv_secret( self, - vault: Vault, + vault: VaultClient, relation: Relation, role_id: str, role_name: str, @@ -1077,7 +1005,7 @@ def _create_kv_secret( def _update_kv_secret( self, - vault: Vault, + vault: VaultClient, relation: Relation, role_name: str, egress_subnets: List[str], @@ -1160,56 +1088,6 @@ def _cluster_address(self) -> str: """ return f"https://{socket.getfqdn()}:{self.VAULT_CLUSTER_PORT}" - def _get_autounseal_configuration(self) -> AutounsealConfigurationDetails | None: - """Retrieve the autounseal configuration details, if available. - - Returns the autounseal configuration details if all the required - information is available, otherwise `None`. - """ - try: - autounseal_details = self.vault_autounseal_requires.get_details() - except TransientJujuError as e: - logger.error("Failed to get autounseal details: %s", e) - return None - if not autounseal_details: - return None - - self.tls.push_autounseal_ca_cert(autounseal_details.ca_certificate) - - return AutounsealConfigurationDetails( - autounseal_details.address, - autounseal_details.mount_path, - autounseal_details.key_name, - self._get_autounseal_vault_token(autounseal_details), - self.tls.get_tls_file_path_in_workload(File.AUTOUNSEAL_CA), - ) - - def _get_autounseal_vault_token(self, autounseal_details: AutounsealDetails) -> str: - """Retrieve the auto-unseal Vault token, or generate a new one if required. - - Retrieves the last used token from Juju secrets, and validates that it - is still valid. If the token is not valid, a new token is generated and - stored in the Juju secret. A valid token is returned. - - Args: - autounseal_details: The autounseal configuration details. - - Returns: - A periodic Vault token that can be used for auto-unseal. - - """ - vault = Vault( - url=autounseal_details.address, - ca_cert_path=self.tls.get_tls_file_path_in_charm(File.AUTOUNSEAL_CA), - ) - existing_token = self._get_juju_secret_field(AUTOUNSEAL_TOKEN_SECRET_LABEL, "token") - # If we don't already have a token, or if the existing token is invalid, - # authenticate with the AppRole details to generate a new token. - if not existing_token or not vault.authenticate(Token(existing_token)): - vault.authenticate(AppRole(autounseal_details.role_id, autounseal_details.secret_id)) - self._set_juju_secret(AUTOUNSEAL_TOKEN_SECRET_LABEL, {"token": vault.token}) - return vault.token - def _get_juju_secret_content(self, label: str) -> Dict[str, str] | None: """Retrieve the latest revision of the secret content from Juju. @@ -1295,6 +1173,8 @@ def _generate_vault_config_file(self) -> None: for node_api_address in self._get_peer_node_api_addresses() ] + autounseal_configuration_details = self._get_vault_autounseal_configuration() + content = _render_vault_config_file( default_lease_ttl=cast(str, self.model.config["default_lease_ttl"]), max_lease_ttl=cast(str, self.model.config["max_lease_ttl"]), @@ -1306,7 +1186,7 @@ def _generate_vault_config_file(self) -> None: raft_storage_path=VAULT_STORAGE_PATH, node_id=self._node_id, retry_joins=retry_joins, - autounseal_details=self._get_autounseal_configuration(), + autounseal_details=autounseal_configuration_details, ) existing_content = "" if self._container.exists(path=VAULT_CONFIG_FILE_PATH): @@ -1322,6 +1202,25 @@ def _generate_vault_config_file(self) -> None: if self._vault_service_is_running(): self._container.restart(self._service_name) + def _get_vault_autounseal_configuration(self) -> AutounsealConfigurationDetails | None: + autounseal_relation_details = self.vault_autounseal_requires.get_details() + if not autounseal_relation_details: + return None + autounseal_requirer_manager = VaultAutounsealRequirerManager( + self, self.vault_autounseal_requires + ) + self.tls.push_autounseal_ca_cert(autounseal_relation_details.ca_certificate) + provider_vault_token = autounseal_requirer_manager.get_provider_vault_token( + autounseal_relation_details, self.tls.get_tls_file_path_in_charm(File.AUTOUNSEAL_CA) + ) + return AutounsealConfigurationDetails( + autounseal_relation_details.address, + autounseal_relation_details.mount_path, + autounseal_relation_details.key_name, + provider_vault_token, + self.tls.get_tls_file_path_in_workload(File.AUTOUNSEAL_CA), + ) + def _push_config_file_to_workload(self, content: str): """Push the config file to the workload.""" self._container.push(path=VAULT_CONFIG_FILE_PATH, source=content) @@ -1412,7 +1311,7 @@ def _restore_vault(self, snapshot: StreamingBody) -> bool: bool: True if the restore was successful, False otherwise. """ for address in self._get_peer_node_api_addresses(): - vault = Vault(address, ca_cert_path=self.tls.get_tls_file_path_in_charm(File.CA)) + vault = VaultClient(address, ca_cert_path=self.tls.get_tls_file_path_in_charm(File.CA)) if vault.is_active(): break else: @@ -1437,7 +1336,7 @@ def _restore_vault(self, snapshot: StreamingBody) -> bool: return True - def _get_active_vault_client(self) -> Vault | None: + def _get_active_vault_client(self) -> VaultClient | None: """Return an initialized vault client. Returns: @@ -1448,7 +1347,7 @@ def _get_active_vault_client(self) -> Vault | None: has not been authorized. """ try: - vault = Vault( + vault = VaultClient( url=self._api_address, ca_cert_path=self.tls.get_tls_file_path_in_charm(File.CA), ) diff --git a/src/container.py b/src/container.py index 20574023..372151a5 100644 --- a/src/container.py +++ b/src/container.py @@ -6,7 +6,7 @@ from typing import TextIO -from charms.vault_k8s.v0.vault_tls import WorkloadBase +from charms.vault_k8s.v0.vault_managers import WorkloadBase from ops import Container as OpsContainer diff --git a/src/templates/autounseal_policy.hcl b/src/templates/autounseal_policy.hcl deleted file mode 100644 index 38945df6..00000000 --- a/src/templates/autounseal_policy.hcl +++ /dev/null @@ -1,7 +0,0 @@ -path "{mount}/encrypt/{key_name}" {{ - capabilities = ["update"] -}} - -path "{mount}/decrypt/{key_name}" {{ - capabilities = ["update"] -}} diff --git a/tests/integration/vault_kv_requirer_operator/src/charm.py b/tests/integration/vault_kv_requirer_operator/src/charm.py index 6f83c458..8a975422 100755 --- a/tests/integration/vault_kv_requirer_operator/src/charm.py +++ b/tests/integration/vault_kv_requirer_operator/src/charm.py @@ -15,7 +15,7 @@ from ops.charm import ActionEvent, CharmBase from ops.framework import EventBase from ops.model import ActiveStatus, SecretNotFoundError -from vault_client import Vault # type: ignore[import-not-found] +from vault_client import VaultClient NONCE_SECRET_LABEL = "vault-kv-nonce" VAULT_KV_SECRET_LABEL = "vault-kv" @@ -118,7 +118,7 @@ def _on_create_secret_action(self, event: ActionEvent): if not secret_key or not secret_value: event.fail("Missing key or value") return - vault = Vault( + vault = VaultClient( url=secret_content["vault-url"], approle_role_id=secret_content["role-id"], ca_certificate=f"{ca_certificate_path}/{VAULT_CA_CERT_FILENAME}", @@ -144,7 +144,7 @@ def _on_get_secret_action(self, event: ActionEvent) -> None: if not secret_key: event.fail("Missing key or value") return - vault = Vault( + vault = VaultClient( url=secret_content["vault-url"], approle_role_id=secret_content["role-id"], ca_certificate=f"{ca_certificate_path}/{VAULT_CA_CERT_FILENAME}", diff --git a/tests/integration/vault_kv_requirer_operator/src/vault_client.py b/tests/integration/vault_kv_requirer_operator/src/vault_client.py index 7b3390af..48c76374 100644 --- a/tests/integration/vault_kv_requirer_operator/src/vault_client.py +++ b/tests/integration/vault_kv_requirer_operator/src/vault_client.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) -class Vault: +class VaultClient: def __init__( self, url: str, ca_certificate: str, approle_role_id: str, approle_secret_id: str ): diff --git a/tests/unit/fixtures.py b/tests/unit/fixtures.py index 0f02910f..12ca6e2f 100644 --- a/tests/unit/fixtures.py +++ b/tests/unit/fixtures.py @@ -6,16 +6,28 @@ import ops.testing as testing import pytest from charms.data_platform_libs.v0.s3 import S3Requirer -from charms.vault_k8s.v0.vault_client import Vault +from charms.vault_k8s.v0.vault_client import ( + VaultClient, +) +from charms.vault_k8s.v0.vault_managers import ( + VaultAutounsealProviderManager, + VaultAutounsealRequirerManager, + VaultTLSManager, +) from charms.vault_k8s.v0.vault_s3 import S3 -from charms.vault_k8s.v0.vault_tls import VaultTLSManager from charm import VaultCharm class VaultCharmFixtures: patcher_tls = patch("charm.VaultTLSManager", autospec=VaultTLSManager) - patcher_vault = patch("charm.Vault", autospec=Vault) + patcher_vault = patch("charm.VaultClient", autospec=VaultClient) + patcher_vault_autounseal_provider_manager = patch( + "charm.VaultAutounsealProviderManager", autospec=VaultAutounsealProviderManager + ) + patcher_vault_autounseal_requirer_manager = patch( + "charm.VaultAutounsealRequirerManager", autospec=VaultAutounsealRequirerManager + ) patcher_s3_requirer = patch("charm.S3Requirer", autospec=S3Requirer) patcher_s3 = patch("charm.S3", autospec=S3) patcher_socket_fqdn = patch("socket.getfqdn") @@ -51,6 +63,12 @@ class VaultCharmFixtures: def setup(self): self.mock_tls = VaultCharmFixtures.patcher_tls.start().return_value self.mock_vault = VaultCharmFixtures.patcher_vault.start().return_value + self.mock_vault_autounseal_manager = ( + VaultCharmFixtures.patcher_vault_autounseal_provider_manager.start().return_value + ) + self.mock_vault_autounseal_requirer_manager = ( + VaultCharmFixtures.patcher_vault_autounseal_requirer_manager.start().return_value + ) self.mock_s3_requirer = VaultCharmFixtures.patcher_s3_requirer.start().return_value self.mock_s3 = VaultCharmFixtures.patcher_s3.start() self.mock_socket_fqdn = VaultCharmFixtures.patcher_socket_fqdn.start() diff --git a/tests/unit/lib/charms/vault_k8s/v0/autounseal_policy_formatted.hcl b/tests/unit/lib/charms/vault_k8s/v0/autounseal_policy_formatted.hcl index f9a2829d..6ff36359 100644 --- a/tests/unit/lib/charms/vault_k8s/v0/autounseal_policy_formatted.hcl +++ b/tests/unit/lib/charms/vault_k8s/v0/autounseal_policy_formatted.hcl @@ -1,7 +1,7 @@ -path "example_mount/encrypt/1" { +path "charm-autounseal/encrypt/1" { capabilities = ["update"] } -path "example_mount/decrypt/1" { +path "charm-autounseal/decrypt/1" { capabilities = ["update"] } diff --git a/tests/unit/lib/charms/vault_k8s/v0/test_vault_client.py b/tests/unit/lib/charms/vault_k8s/v0/test_vault_client.py index 11fe0aae..dc7d1f5e 100644 --- a/tests/unit/lib/charms/vault_k8s/v0/test_vault_client.py +++ b/tests/unit/lib/charms/vault_k8s/v0/test_vault_client.py @@ -12,19 +12,17 @@ AuditDeviceType, SecretsBackend, Token, - Vault, + VaultClient, VaultClientError, ) from hvac.exceptions import Forbidden, InternalServerError, InvalidPath -from charm import AUTOUNSEAL_POLICY_PATH - TEST_PATH = "./tests/unit/lib/charms/vault_k8s/v0" @patch("hvac.api.auth_methods.token.Token.lookup_self") def test_given_token_as_auth_details_when_authenticate_then_token_is_set(_): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.authenticate(Token("some token")) assert vault._client.token == "some token" @@ -35,7 +33,7 @@ def test_given_valid_token_as_auth_details_when_authenticate_then_authentication patch_lookup, ): patch_lookup.return_value = {"data": "random data"} - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") assert vault.authenticate(Token("some token")) @@ -44,7 +42,7 @@ def test_given_invalid_token_as_auth_details_when_authenticate_then_authenticati patch_lookup, ): patch_lookup.side_effect = Forbidden() - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.authenticate(Token("some token")) assert not vault.authenticate(Token("some token")) @@ -54,7 +52,7 @@ def test_given_invalid_token_as_auth_details_when_authenticate_then_authenticati def test_given_approle_as_auth_details_when_authenticate_then_approle_login_is_called( patch_approle_login, _ ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.authenticate(AppRole(role_id="some role id", secret_id="some secret id")) patch_approle_login.assert_called_with( @@ -65,7 +63,7 @@ def test_given_approle_as_auth_details_when_authenticate_then_approle_login_is_c @patch("hvac.api.system_backend.health.Health.read_health_status") def test_given_connection_error_when_is_api_available_then_return_false(patch_health_status): patch_health_status.side_effect = requests.exceptions.ConnectionError() - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") assert not vault.is_api_available() @@ -73,7 +71,7 @@ def test_given_connection_error_when_is_api_available_then_return_false(patch_he @patch("hvac.api.system_backend.health.Health.read_health_status") def test_given_api_returns_when_is_api_available_then_return_true(patch_health_status): patch_health_status.return_value = requests.Response() - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") assert vault.is_api_available() @@ -81,10 +79,10 @@ def test_given_api_returns_when_is_api_available_then_return_true(patch_health_s @patch("hvac.api.system_backend.raft.Raft.read_raft_config") def test_given_node_in_peer_list_when_is_node_in_raft_peers_then_returns_true(patch_health_status): node_id = "whatever node id" - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") patch_health_status.return_value = {"data": {"config": {"servers": [{"node_id": node_id}]}}} - assert vault.is_node_in_raft_peers(node_id=node_id) + assert vault.is_node_in_raft_peers(node_id) @patch("hvac.api.system_backend.raft.Raft.read_raft_config") @@ -92,12 +90,12 @@ def test_given_node_not_in_peer_list_when_is_node_in_raft_peers_then_returns_fal patch_health_status, ): node_id = "whatever node id" - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") patch_health_status.return_value = { "data": {"config": {"servers": [{"node_id": "not our node"}]}} } - assert not vault.is_node_in_raft_peers(node_id=node_id) + assert not vault.is_node_in_raft_peers(node_id) @patch("hvac.api.system_backend.raft.Raft.read_raft_config") @@ -114,7 +112,7 @@ def test_given_1_node_in_raft_cluster_when_get_num_raft_peers_then_returns_1(pat } } - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.get_num_raft_peers() @@ -125,7 +123,7 @@ def test_given_1_node_in_raft_cluster_when_get_num_raft_peers_then_returns_1(pat def test_given_approle_not_in_auth_methods_when_enable_approle_auth_then_approle_is_added_to_auth_methods( patch_enable_auth_method, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.enable_approle_auth_method() @@ -136,7 +134,7 @@ def test_given_approle_not_in_auth_methods_when_enable_approle_auth_then_approle def test_given_audit_device_is_not_yet_enabled_when_enable_audit_device_then_device_is_enabled( patch_enable_audit_device, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.enable_audit_device(device_type=AuditDeviceType.FILE, path="stdout") patch_enable_audit_device.assert_called_once_with( device_type="file", options={"file_path": "stdout"} @@ -147,7 +145,7 @@ def test_given_audit_device_is_not_yet_enabled_when_enable_audit_device_then_dev def test_given_audit_device_is_enabled_when_enable_audit_device_then_nothing_happens( patch_enable_audit_device, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.enable_audit_device(device_type=AuditDeviceType.FILE, path="stdout") patch_enable_audit_device.assert_called_once_with( device_type="file", options={"file_path": "stdout"} @@ -158,9 +156,9 @@ def test_given_audit_device_is_enabled_when_enable_audit_device_then_nothing_hap def test_given_policy_with_mount_when_configure_policy_then_policy_is_formatted_properly( patch_create_policy, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") - vault.configure_policy( - "test-policy", policy_path=f"{TEST_PATH}/kv_with_mount.hcl", mount="example" + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") + vault.create_or_update_policy_from_file( + "test-policy", path=f"{TEST_PATH}/kv_with_mount.hcl", mount="example" ) with open(f"{TEST_PATH}/kv_mounted.hcl", "r") as f: policy = f.read() @@ -174,8 +172,8 @@ def test_given_policy_with_mount_when_configure_policy_then_policy_is_formatted_ def test_given_policy_without_mount_when_configure_policy_then_policy_created_correctly( patch_create_policy, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") - vault.configure_policy("test-policy", policy_path=f"{TEST_PATH}/kv_mounted.hcl") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") + vault.create_or_update_policy_from_file("test-policy", path=f"{TEST_PATH}/kv_mounted.hcl") with open(f"{TEST_PATH}/kv_mounted.hcl", "r") as f: policy = f.read() patch_create_policy.assert_called_with( @@ -190,8 +188,8 @@ def test_given_approle_with_valid_params_when_configure_approle_then_approle_cre patch_create_approle, patch_read_role_id ): patch_read_role_id.return_value = {"data": {"role_id": "1234"}} - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") - assert "1234" == vault.configure_approle( + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") + assert "1234" == vault.create_or_update_approle( "test-approle", policies=["root", "default"], cidrs=["192.168.1.0/24"], @@ -215,7 +213,7 @@ def test_given_approle_with_valid_params_when_configure_approle_then_approle_cre def test_given_secrets_engine_with_valid_params_when_enable_secrets_engine_then_secrets_engine_enabled( patch_enable_secrets_engine, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.enable_secrets_engine(SecretsBackend.KV_V2, "some/path") patch_enable_secrets_engine.assert_called_with( @@ -229,66 +227,18 @@ def test_given_secrets_engine_with_valid_params_when_enable_secrets_engine_then_ def test_when_disable_secrets_engine_then_secrets_engine_disabled( mock_disable_secrets_engine: MagicMock, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") vault.disable_secrets_engine("some/path") mock_disable_secrets_engine.assert_called_with("some/path") -@patch("hvac.api.system_backend.policy.Policy.delete_policy") -@patch("hvac.api.auth_methods.approle.AppRole.delete_role") -def test_when_destroy_autounseal_credentials_then_approle_and_policy_are_deleted( - mock_delete_role: MagicMock, mock_delete_policy: MagicMock -): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") - relation_id = 1 - mount = "example" - vault.destroy_autounseal_credentials(relation_id, mount) - - mock_delete_role.assert_called_with(f"charm-autounseal-{relation_id}") - mock_delete_policy.assert_called_with(f"charm-autounseal-{relation_id}") - - -@patch("hvac.api.system_backend.policy.Policy.create_or_update_policy") -@patch("hvac.api.auth_methods.approle.AppRole.generate_secret_id") -@patch("hvac.api.auth_methods.approle.AppRole.read_role_id") -@patch("hvac.api.auth_methods.approle.AppRole.create_or_update_approle") -@patch("hvac.api.secrets_engines.transit.Transit.create_key") -def test_when_create_autounseal_credentials_then_key_and_approle_and_policy_are_created( - mock_create_key: MagicMock, - mock_create_approle: MagicMock, - mock_read_role_id: MagicMock, - mock_generate_secret_id: MagicMock, - mock_create_policy: MagicMock, -): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") - relation_id = 1 - mount = "example_mount" - vault.create_autounseal_credentials(relation_id, mount, AUTOUNSEAL_POLICY_PATH) - - with open(f"{TEST_PATH}/autounseal_policy_formatted.hcl", "r") as f: - expected_policy = f.read() - mock_create_key.assert_called_with(mount_point=mount, name=str(relation_id)) - mock_create_policy.assert_called_with( - name=f"charm-autounseal-{relation_id}", policy=expected_policy - ) - mock_create_approle.assert_called_with( - f"charm-autounseal-{relation_id}", - bind_secret_id="true", - token_ttl=None, - token_max_ttl=None, - token_policies=[f"charm-autounseal-{relation_id}"], - token_bound_cidrs=None, - token_period="60s", - ) - - @patch("hvac.api.system_backend.health.Health.read_health_status") def test_given_health_status_returns_200_when_is_active_then_return_true(patch_health_status): response = requests.Response() response.status_code = 200 patch_health_status.return_value = response - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") assert vault.is_active_or_standby() @@ -297,7 +247,7 @@ def test_given_health_status_returns_standby_when_is_active_then_return_false(pa response = requests.Response() response.status_code = 429 patch_health_status.return_value = response - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") assert vault.is_active_or_standby() assert not vault.is_active() @@ -307,14 +257,14 @@ def test_given_health_status_returns_5xx_when_is_active_then_return_false(patch_ response = requests.Response() response.status_code = 501 patch_health_status.return_value = response - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") assert not vault.is_active_or_standby() @patch("hvac.api.system_backend.health.Health.read_health_status") def test_given_connection_error_when_is_active_then_return_false(patch_health_status): patch_health_status.side_effect = requests.exceptions.ConnectionError() - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") assert not vault.is_active_or_standby() @@ -322,7 +272,7 @@ def test_given_connection_error_when_is_active_then_return_false(patch_health_st def test_given_no_pki_issuers_when_make_latest_pki_issuer_default_then_vault_client_error_is_raised( patch_read_pki_issuers, ): - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") patch_read_pki_issuers.side_effect = InvalidPath() with pytest.raises(VaultClientError): vault.make_latest_pki_issuer_default(mount="test") @@ -339,7 +289,7 @@ def test_given_existing_pki_issuers_when_make_latest_pki_issuer_default_then_con patch_read.return_value = { "data": {"default_follows_latest_issuer": False, "default": "whatever issuer"} } - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") patch_read_pki_issuers.return_value = {"data": {"keys": ["issuer"]}} mount = "test" vault.make_latest_pki_issuer_default(mount=mount) @@ -363,7 +313,7 @@ def test_given_issuers_config_already_updated_when_make_latest_pki_issuer_defaul patch_read.return_value = { "data": {"default_follows_latest_issuer": True, "default": "whatever issuer"} } - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") patch_read_pki_issuers.return_value = {"data": {"keys": ["issuer"]}} mount = "test" vault.make_latest_pki_issuer_default(mount=mount) @@ -386,7 +336,7 @@ def test_when_remove_raft_node_is_called_and_exception_raised_then_exception_is_ "hvac.api.system_backend.raft.Raft.remove_raft_node", MagicMock(side_effect=exception_raised), ) - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") with expectation: vault.remove_raft_node("node_id") @@ -407,7 +357,7 @@ def test_when_is_node_in_raft_peers_called_and_exception_raised_then_exception_i "hvac.api.system_backend.raft.Raft.read_raft_config", MagicMock(side_effect=exception_raised), ) - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") with expectation: vault.is_node_in_raft_peers("node_id") @@ -428,6 +378,24 @@ def test_when_get_num_raft_peers_called_andexception_raised_then_exception_is_su "hvac.api.system_backend.raft.Raft.read_raft_config", MagicMock(side_effect=exception_raised), ) - vault = Vault(url="http://whatever-url", ca_cert_path="whatever path") + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") with expectation: vault.get_num_raft_peers() + + +@patch("hvac.Client.read") +def test_read(patch_read): + patch_read.return_value = {"data": {"key": "value"}} + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") + result = vault.read("some/path") + assert result == {"key": "value"} + patch_read.assert_called_once_with("some/path") + + +@patch("hvac.Client.list") +def test_list(patch_list): + patch_list.return_value = {"data": {"keys": ["key1", "key2"]}} + vault = VaultClient(url="http://whatever-url", ca_cert_path="whatever path") + result = vault.list("some/path") + assert result == ["key1", "key2"] + patch_list.assert_called_once_with("some/path") diff --git a/tests/unit/lib/charms/vault_k8s/v0/test_vault_managers.py b/tests/unit/lib/charms/vault_k8s/v0/test_vault_managers.py new file mode 100644 index 00000000..72dd52f6 --- /dev/null +++ b/tests/unit/lib/charms/vault_k8s/v0/test_vault_managers.py @@ -0,0 +1,128 @@ +from unittest.mock import MagicMock, patch + +import pytest +from charms.vault_k8s.v0.juju_facade import SecretRemovedError +from charms.vault_k8s.v0.vault_autounseal import AutounsealDetails +from charms.vault_k8s.v0.vault_client import VaultClient +from charms.vault_k8s.v0.vault_managers import ( + AUTOUNSEAL_POLICY, + VaultAutounsealProviderManager, + VaultAutounsealRequirerManager, +) + +from charm import AUTOUNSEAL_MOUNT_PATH + + +class TestVaultAutounsealRequirerManager: + @pytest.mark.parametrize( + "token, token_valid, expected_token", + [ + ("initial token", True, "initial token"), # Token is set and valid + ("initial token", False, "new token"), # Token is set but invalid + (None, None, "new token"), # Token is not set + ], + ) + @patch("charms.vault_k8s.v0.vault_managers.VaultClient") + @patch("charms.vault_k8s.v0.vault_managers.JujuFacade") + def test_when_get_vault_configuration_details_called_then_details_are_retrieved_correctly( + self, + juju_facade_mock, + vault_client_mock, + token, + token_valid, + expected_token, + ): + juju_facade_instance = juju_facade_mock.return_value + charm = MagicMock() + vault_client_instance = vault_client_mock.return_value + vault_client_instance.token = token + + def authenticate(auth_method): + if token and vault_client_instance.authenticate.call_count == 1: + return token_valid + vault_client_instance.token = "new token" + return True + + vault_client_instance.authenticate.side_effect = authenticate + requires = MagicMock() + autounseal_details = AutounsealDetails( + "my_address", + "my_mount_path", + "my_key_name", + "my_role_id", + "my_secret_id", + "my_ca_certificate", + ) + ca_cert_path = "/my/test/path" + if token: + juju_facade_instance.get_secret_content_values.return_value = (token,) + if not token: + juju_facade_instance.get_secret_content_values.side_effect = SecretRemovedError() + + autounseal = VaultAutounsealRequirerManager(charm, requires) + returned_token = autounseal.get_provider_vault_token(autounseal_details, ca_cert_path) + assert returned_token == expected_token + + +class TestVaultAutounsealProviderManager: + def test_when_create_credentials_then_vault_client_called_and_key_name_and_credentials_are_returned( + self, + ): + charm = MagicMock() + provides = MagicMock() + relation_id = 1 + relation = MagicMock() + relation.id = relation_id + vault_client = MagicMock(spec=VaultClient) + expected_key_name = "1" + expected_approle_name = "charm-autounseal-1" + expected_policy_name = "charm-autounseal-1" + vault_client.create_or_update_approle.return_value = "role_id" + vault_client.generate_role_secret_id.return_value = "secret_id" + + autounseal = VaultAutounsealProviderManager( + charm, vault_client, provides, "ca_cert", AUTOUNSEAL_MOUNT_PATH + ) + + key_name, role_id, secret_id = autounseal.create_credentials( + relation, "https://1.2.3.4:8200" + ) + + vault_client.create_or_update_policy.assert_called_once_with( + expected_policy_name, + AUTOUNSEAL_POLICY.format(mount=AUTOUNSEAL_MOUNT_PATH, key_name=expected_key_name), + ) + vault_client.create_or_update_approle.assert_called_once_with( + expected_approle_name, policies=[expected_policy_name], token_period="60s" + ) + vault_client.generate_role_secret_id.assert_called_once_with(expected_approle_name) + assert key_name == str(relation_id) + assert role_id == "role_id" + assert secret_id == "secret_id" + provides.set_autounseal_data.assert_called_once() + + @patch("charms.vault_k8s.v0.vault_managers.JujuFacade") + def test_given_orphaned_credentials_when_clean_up_credentials_then_credentials_removed_and_keys_marked_deletion_allowed( + self, juju_facade_mock + ): + juju_facade_instance = juju_facade_mock.return_value + charm = MagicMock() + provides = MagicMock() + vault_client_mock = MagicMock() + vault_client_mock.list.return_value = ["charm-autounseal-123", "charm-autounseal-321"] + vault_client_mock.read.return_value = {"deletion_allowed": False} + test_relation = MagicMock() + test_relation.id = 123 + provides.get_outstanding_requests.return_value = [test_relation] + juju_facade_instance.get_active_relations.return_value = [test_relation] + autounseal = VaultAutounsealProviderManager( + charm, vault_client_mock, provides, "ca_cert", AUTOUNSEAL_MOUNT_PATH + ) + + autounseal.clean_up_credentials() + + vault_client_mock.delete_role.assert_called_once_with("charm-autounseal-321") + vault_client_mock.delete_policy.assert_called_once_with("charm-autounseal-321") + vault_client_mock.write.assert_called_with( + "charm-autounseal/keys/charm-autounseal-321/config", {"deletion_allowed": True} + ) diff --git a/tests/unit/lib/charms/vault_k8s/v0/test_vault_tls.py b/tests/unit/lib/charms/vault_k8s/v0/test_vault_tls.py index 9b85d3ec..fd9d5e36 100644 --- a/tests/unit/lib/charms/vault_k8s/v0/test_vault_tls.py +++ b/tests/unit/lib/charms/vault_k8s/v0/test_vault_tls.py @@ -17,7 +17,7 @@ generate_csr, generate_private_key, ) -from charms.vault_k8s.v0.vault_tls import CA_CERTIFICATE_JUJU_SECRET_LABEL +from charms.vault_k8s.v0.vault_managers import CA_CERTIFICATE_JUJU_SECRET_LABEL from ops.model import WaitingStatus from charm import VAULT_CHARM_APPROLE_SECRET_LABEL, VaultCharm @@ -25,7 +25,7 @@ TLS_CERTIFICATES_LIB_PATH_V3 = "charms.tls_certificates_interface.v3.tls_certificates" TLS_CERTIFICATES_LIB_PATH_V4 = "charms.tls_certificates_interface.v4.tls_certificates" CERTIFICATE_TRANSFER_LIB_PATH = "charms.certificate_transfer_interface.v0.certificate_transfer" -VAULT_TLS_PATH = "charms.vault_k8s.v0.vault_tls" +VAULT_MANAGERS_PATH = "charms.vault_k8s.v0.vault_managers" VAULT_CA_SUBJECT = "Vault self signed CA" @@ -72,12 +72,12 @@ def test_given_not_leader_and_ca_not_set_when_evaluate_status_then_status_is_wai "Waiting for CA certificate to be accessible in the charm" ) - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) def test_given_unit_is_leader_and_ca_certificate_not_generated_when_configure_then_ca_certificate_is_generated( self, ): @@ -140,12 +140,12 @@ def test_given_unit_is_leader_and_ca_certificate_not_generated_when_configure_th assert open(cert_path).read().startswith("-----BEGIN CERTIFICATE-----") assert open(private_key_path).read().startswith("-----BEGIN RSA PRIVATE KEY-----") - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) def test_given_certificate_access_relation_when_relation_changed_then_new_request_is_created( self, ): @@ -220,12 +220,12 @@ def test_given_certificate_access_relation_when_relation_changed_then_new_reques ) ) - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) def test_given_certificate_access_relation_when_cert_available_then_new_cert_saved( self, ): @@ -309,13 +309,13 @@ def test_given_certificate_access_relation_when_cert_available_then_new_cert_sav assert open(ca_cert_path).read() == str(ca_certificate) assert open(cert_path).read() == str(certificate) - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) - @patch(f"{VAULT_TLS_PATH}.generate_certificate") + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) + @patch(f"{VAULT_MANAGERS_PATH}.generate_certificate") def test_given_certificate_access_relation_when_relation_left_then_previous_state_restored( self, patch_generate_certificate ): @@ -388,12 +388,12 @@ def test_given_certificate_access_relation_when_relation_left_then_previous_stat assert os.path.exists(ca_cert_path) assert open(ca_cert_path).read() == str(certificate) - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) def test_given_self_signed_certificates_already_created_when_update_status_then_new_certificates_are_not_generated( self, ): @@ -475,14 +475,14 @@ def test_given_self_signed_certificates_already_created_when_update_status_then_ assert os.stat(temp_dir + "/cert.pem").st_mtime == modification_time_cert_pem assert os.stat(temp_dir + "/ca.pem").st_mtime == modification_time_ca_pem - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) - @patch(f"{VAULT_TLS_PATH}.generate_ca") - @patch(f"{VAULT_TLS_PATH}.generate_certificate") + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) + @patch(f"{VAULT_MANAGERS_PATH}.generate_ca") + @patch(f"{VAULT_MANAGERS_PATH}.generate_certificate") def test_given_tls_relation_removed_when_configure_self_signed_certificates_then_certs_are_overwritten( self, patch_generate_certificate, patch_generate_ca ): @@ -575,14 +575,14 @@ def test_given_tls_relation_removed_when_configure_self_signed_certificates_then with open(temp_dir + "/cert.pem", "r") as f: assert f.read() == str(self_signed_certificate) - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed") - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active_or_standby", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.authenticate", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available") - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed") + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active_or_standby", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.authenticate", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available") + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) @patch(f"{CERTIFICATE_TRANSFER_LIB_PATH}.CertificateTransferProvides.set_certificate") def test_given_ca_cert_exists_when_certificate_transfer_relation_joins_then_ca_cert_is_advertised( self, set_certificate, is_api_available, is_sealed @@ -658,14 +658,14 @@ def test_given_ca_cert_exists_when_certificate_transfer_relation_joins_then_ca_c relation_id=cert_transfer_relation.id, ) - @patch("charms.vault_k8s.v0.vault_client.Vault.enable_audit_device", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_sealed") - @patch("charms.vault_k8s.v0.vault_client.Vault.is_active_or_standby", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.authenticate", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_initialized", new=Mock) - @patch("charms.vault_k8s.v0.vault_client.Vault.is_api_available") - @patch("charms.vault_k8s.v0.vault_client.Vault.is_raft_cluster_healthy", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.enable_audit_device", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_sealed") + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_active_or_standby", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.authenticate", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_initialized", new=Mock) + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_api_available") + @patch("charms.vault_k8s.v0.vault_client.VaultClient.is_raft_cluster_healthy", new=Mock) @patch(f"{CERTIFICATE_TRANSFER_LIB_PATH}.CertificateTransferProvides.set_certificate") def test_given_ca_cert_is_not_stored_when_certificate_transfer_relation_joins_then_ca_cert_is_not_advertised( self, set_certificate, is_api_available, is_sealed diff --git a/tests/unit/test_charm_authorize_action.py b/tests/unit/test_charm_authorize_action.py index f732a245..5a2d9a3d 100644 --- a/tests/unit/test_charm_authorize_action.py +++ b/tests/unit/test_charm_authorize_action.py @@ -107,7 +107,7 @@ def test_given_when_authorize_charm_then_charm_is_authorized(self): self.mock_vault.configure_mock( **{ "authenticate.return_value": True, - "configure_approle.return_value": "my-role-id", + "create_or_update_approle.return_value": "my-role-id", "generate_role_secret_id.return_value": "my-secret-id", }, ) @@ -140,12 +140,12 @@ def test_given_when_authorize_charm_then_charm_is_authorized(self): device_type=AuditDeviceType.FILE, path="stdout" ) self.mock_vault.enable_approle_auth_method.assert_called_once() - self.mock_vault.configure_policy.assert_called_once_with( - policy_name="charm-access", - policy_path="src/templates/charm_policy.hcl", + self.mock_vault.create_or_update_policy_from_file.assert_called_once_with( + name="charm-access", + path="src/templates/charm_policy.hcl", ) - self.mock_vault.configure_approle.assert_called_once_with( - role_name="charm", + self.mock_vault.create_or_update_approle.assert_called_once_with( + name="charm", cidrs=["1.2.3.4/24"], policies=["charm-access", "default"], token_ttl="1h", diff --git a/tests/unit/test_charm_autounseal_relation_broken.py b/tests/unit/test_charm_autounseal_relation_broken.py deleted file mode 100644 index 3f7a07c6..00000000 --- a/tests/unit/test_charm_autounseal_relation_broken.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - - -import ops.testing as testing - -from tests.unit.fixtures import VaultCharmFixtures - - -class TestCharmAutounsealRelationBroken(VaultCharmFixtures): - def test_when_autounseal_destroy_then_credentials_are_removed(self): - self.mock_vault.configure_mock( - **{ - "is_api_available.return_value": True, - "is_sealed.return_value": False, - "is_active_or_standby.return_value": True, - }, - ) - autounseal_relation = testing.Relation( - endpoint="vault-autounseal-provides", - interface="vault-autounseal", - ) - approle_secret = testing.Secret( - label="vault-approle-auth-details", - tracked_content={"role-id": "role id", "secret-id": "secret id"}, - ) - container = testing.Container( - name="vault", - can_connect=True, - ) - state_in = testing.State( - leader=True, - containers=[container], - relations=[autounseal_relation], - secrets=[approle_secret], - ) - - self.ctx.run(self.ctx.on.relation_broken(autounseal_relation), state_in) - - self.mock_vault.destroy_autounseal_credentials.assert_called_once_with( - autounseal_relation.id, "charm-autounseal" - ) diff --git a/tests/unit/test_charm_configure.py b/tests/unit/test_charm_configure.py index cafd8b3e..704c1f06 100644 --- a/tests/unit/test_charm_configure.py +++ b/tests/unit/test_charm_configure.py @@ -176,9 +176,7 @@ def test_given_certificate_available_when_configure_then_pki_secrets_engine_is_c self.ctx.run(self.ctx.on.pebble_ready(container), state_in) - self.mock_vault.enable_secrets_engine.assert_called_once_with( - SecretsBackend.PKI, "charm-pki" - ) + self.mock_vault.enable_secrets_engine.assert_any_call(SecretsBackend.PKI, "charm-pki") self.mock_vault.import_ca_certificate_and_key.assert_called_once_with( certificate=str(provider_certificate.certificate), private_key=str(private_key), @@ -393,13 +391,24 @@ def test_given_autounseal_details_available_when_configure_then_transit_stanza_g "is_active_or_standby.return_value": True, "get_intermediate_ca.return_value": "", "is_common_name_allowed_in_pki_role.return_value": False, - "create_autounseal_credentials.return_value": ( - key_name, - approle_id, - approle_secret_id, - ), }, ) + self.mock_vault_autounseal_manager.configure_mock( + **{ + "create_credentials.return_value": (key_name, approle_id, approle_secret_id), + } + ) + self.mock_autounseal_requires_get_details.return_value = AutounsealDetails( + "1.2.3.4", + "charm-autounseal", + "key name", + "role id", + "secret id", + "ca cert", + ) + self.mock_vault_autounseal_requirer_manager.get_provider_vault_token.return_value = ( + "some token" + ) self.mock_tls.configure_mock( **{ "pull_tls_file_from_workload.return_value": "my ca", @@ -455,85 +464,8 @@ def test_given_autounseal_details_available_when_configure_then_transit_stanza_g assert actual_config_hcl["seal"]["transit"]["token"] == "some token" assert actual_config_hcl["seal"]["transit"]["key_name"] == "key name" self.mock_vault.authenticate.assert_called_with(AppRole("role id", "secret id")) - self.mock_tls.push_autounseal_ca_cert.assert_called_with("ca cert") - def test_given_outstanding_autounseal_requests_when_configure_then_credentials_are_set( - self, - ): - with tempfile.TemporaryDirectory() as temp_dir: - key_name = "my key" - approle_id = "my approle id" - approle_secret_id = "my approle secret id" - self.mock_vault.configure_mock( - **{ - "is_api_available.return_value": True, - "authenticate.return_value": True, - "is_initialized.return_value": True, - "is_sealed.return_value": False, - "is_active_or_standby.return_value": True, - "is_common_name_allowed_in_pki_role.return_value": False, - "get_intermediate_ca.return_value": "", - "create_autounseal_credentials.return_value": ( - key_name, - approle_id, - approle_secret_id, - ), - }, - ) - self.mock_tls.configure_mock( - **{ - "pull_tls_file_from_workload.return_value": "my ca", - }, - ) - self.mock_autounseal_requires_get_details.return_value = None - vault_config_mount = testing.Mount( - location="/vault/config", - source=temp_dir, - ) - container = testing.Container( - name="vault", - can_connect=True, - mounts={ - "vault-config": vault_config_mount, - }, - ) - peer_relation = testing.PeerRelation( - endpoint="vault-peers", - ) - vault_autounseal_relation = testing.Relation( - endpoint="vault-autounseal-provides", - interface="vault-autounseal", - remote_app_name="vault-autounseal-requirer", - ) - self.mock_get_binding.return_value = MockBinding( - bind_address="myhostname", - ingress_address="myhostname", - ) - relation = MockRelation(id=vault_autounseal_relation.id) - self.mock_autounseal_provides_get_outstanding_requests.return_value = [relation] - approle_secret = testing.Secret( - label="vault-approle-auth-details", - tracked_content={"role-id": "role id", "secret-id": "secret id"}, - ) - state_in = testing.State( - containers=[container], - leader=True, - secrets=[approle_secret], - relations=[peer_relation, vault_autounseal_relation], - config={"common_name": "myhostname.com"}, - ) - - self.ctx.run(self.ctx.on.pebble_ready(container), state_in) - - self.mock_autounseal_provides_set_data.assert_called_with( - relation, - "https://myhostname:8200", - "charm-autounseal", - key_name, - approle_id, - approle_secret_id, - "my ca", - ) + # Test KV def test_given_outstanding_kv_request_when_configure_then_kv_relation_data_is_set( self, @@ -547,7 +479,7 @@ def test_given_outstanding_kv_request_when_configure_then_kv_relation_data_is_se "is_initialized.return_value": True, "is_sealed.return_value": False, "generate_role_secret_id.return_value": "kv role secret id", - "configure_approle.return_value": "kv role id", + "create_or_update_approle.return_value": "kv role id", }, ) self.mock_autounseal_requires_get_details.return_value = None @@ -593,7 +525,7 @@ def test_given_outstanding_kv_request_when_configure_then_kv_relation_data_is_se state_out = self.ctx.run(self.ctx.on.pebble_ready(container), state_in) - self.mock_vault.enable_secrets_engine.assert_called_once_with( + self.mock_vault.enable_secrets_engine.assert_any_call( SecretsBackend.KV_V2, "charm-vault-kv-remote-suffix" ) self.mock_kv_provides_set_ca_certificate.assert_called() @@ -617,7 +549,7 @@ def test_given_related_kv_client_unit_egress_is_updated_when_configure_then_secr "is_initialized.return_value": True, "is_sealed.return_value": False, "generate_role_secret_id.return_value": "new kv role secret id", - "configure_approle.return_value": "kv role id", + "create_or_update_approle.return_value": "kv role id", }, ) self.mock_autounseal_requires_get_details.return_value = None diff --git a/tests/unit/test_charm_remove.py b/tests/unit/test_charm_remove.py index 1104635e..4fabd604 100644 --- a/tests/unit/test_charm_remove.py +++ b/tests/unit/test_charm_remove.py @@ -52,9 +52,7 @@ def test_given_can_connect_when_remove_then_node_removed_from_raft_cluster_data_ f.write("data") self.ctx.run(self.ctx.on.remove(), state_in) - self.mock_vault.remove_raft_node.assert_called_with( - node_id=f"{model_name}-vault-k8s/0" - ) + self.mock_vault.remove_raft_node.assert_called_with(f"{model_name}-vault-k8s/0") assert not os.path.exists(f"{temp_dir}/vault.db") assert not os.path.exists(f"{temp_dir}/raft/raft.db") diff --git a/tox.ini b/tox.ini index a48b7719..a4c1ca1d 100644 --- a/tox.ini +++ b/tox.ini @@ -16,7 +16,7 @@ all_path = {[vars]src_path} {[vars]unit_test_path} {[vars]integration_test_path} [testenv] set_env = - PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path} + PYTHONPATH = {toxinidir}:{toxinidir}/lib:{[vars]src_path}:{[vars]integration_test_path}/vault_kv_requirer_operator/src PYTHONBREAKPOINT=pdb.set_trace PY_COLORS=1 deps =