diff --git a/lib/charms/vault_k8s/v0/vault_kv.py b/lib/charms/vault_k8s/v0/vault_kv.py new file mode 100644 index 00000000..5966fa5a --- /dev/null +++ b/lib/charms/vault_k8s/v0/vault_kv.py @@ -0,0 +1,529 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for the vault-kv relation. + +This library contains the Requires and Provides classes for handling the vault-kv +interface. + +## Getting Started +From a charm directory, fetch the library using `charmcraft`: + +```shell +charmcraft fetch-lib charms.vault_k8s.v0.vault_kv +``` + +### Requirer charm +The requirer charm is the charm requiring a secret value store. In this example, the requirer charm +is requiring a secret value store. + +```python +import secrets + +from charms.vault_k8s.v0 import vault_kv +from ops.charm import CharmBase, InstallEvent +from ops.main import main +from ops.model import ActiveStatus, BlockedStatus + +NONCE_SECRET_LABEL = "nonce" + + +class ExampleRequirerCharm(CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.interface = vault_kv.VaultKvRequires( + self, + "vault-kv", + "my-suffix", + ) + + self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.interface.on.connected, self._on_connected) + self.framework.observe(self.interface.on.ready, self._on_ready) + self.framework.observe(self.interface.on.gone_away, self._on_gone_away) + self.framework.observe(self.on.update_status, self._on_update_status) + + def _on_install(self, event: InstallEvent): + self.unit.add_secret( + {"nonce": secrets.token_hex(16)}, + label=NONCE_SECRET_LABEL, + description="Nonce for vault-kv relation", + ) + self.unit.status = BlockedStatus("Waiting for vault-kv relation") + + def _on_connected(self, event: vault_kv.VaultKvConnectedEvent): + relation = self.model.get_relation(event.relation_name, event.relation_id) + egress_subnet = str(self.model.get_binding(relation).network.interfaces[0].subnet) + self.interface.request_credentials(relation, egress_subnet, self.get_nonce()) + + def _on_ready(self, event: vault_kv.VaultKvReadyEvent): + relation = self.model.get_relation(event.relation_name, event.relation_id) + if relation is None: + return + vault_url = self.interface.get_vault_url(relation) + ca_certificate = self.interface.get_ca_certificate(relation) + mount = self.interface.get_mount(relation) + + unit_credentials = self.interface.get_unit_credentials(relation) + # unit_credentials is a juju secret id + secret = self.model.get_secret(id=unit_credentials) + secret_content = secret.get_content() + role_id = secret_content["role-id"] + role_secret_id = secret_content["role-secret-id"] + + self._configure(vault_url, ca_certificate, mount, role_id, role_secret_id) + + self.unit.status = ActiveStatus() + + def _on_gone_away(self, event: vault_kv.VaultKvGoneAwayEvent): + self.unit.status = BlockedStatus("Waiting for vault-kv relation") + + def _configure( + self, + vault_url: str, + ca_certificate: str, + mount: str, + role_id: str, + role_secret_id: str, + ): + pass + + def _on_update_status(self, event): + # Check somewhere that egress subnet has not changed i.e. pod has not been rescheduled + # Update status might not be the best place + binding = self.model.get_binding("vault-kv") + if binding is not None: + egress_subnet = str(binding.network.interfaces[0].subnet) + self.interface.request_credentials(event.relation, egress_subnet, self.get_nonce()) + + def get_nonce(self): + secret = self.model.get_secret(label=NONCE_SECRET_LABEL) + nonce = secret.get_content()["nonce"] + return nonce + + +if __name__ == "__main__": + main(ExampleRequirerCharm) +``` + +You can integrate both charms by running: + +```bash +juju integrate +``` +""" + +import json +import logging +from collections.abc import Iterable, Mapping +from typing import Any, Dict, Optional, Union + +import ops +from interface_tester.schema_base import DataBagSchema # type: ignore[import] +from pydantic import BaseModel, Field, Json, ValidationError + +logger = logging.getLogger(__name__) + + +# The unique Charmhub library identifier, never change it +LIBID = "591d6d2fb6a54853b4bb53ef16ef603a" + +# 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 = 1 + +PYDEPS = ["pydantic", "pytest-interface-tester"] + + +class VaultKvProviderSchema(BaseModel): + """Provider side of the vault-kv interface.""" + + vault_url: str = Field(description="The URL of the Vault server to connect to.") + mount: str = Field( + description=( + "The KV mount available for the requirer application, " + "respecting the pattern 'charm--'." + ) + ) + ca_certificate: str = Field( + description="The CA certificate to use when validating the Vault server's certificate." + ) + credentials: Json[Mapping[str, str]] = Field( + description=( + "Mapping of unit name and credentials for that unit." + " Credentials are a juju secret containing a 'role-id' and a 'role-secret-id'." + ) + ) + + +class AppVaultKvRequirerSchema(BaseModel): + """App schema of the requirer side of the vault-kv interface.""" + + mount_suffix: str = Field( + description="Suffix to append to the mount name to get the KV mount." + ) + + +class UnitVaultKvRequirerSchema(BaseModel): + """Unit schema of the requirer side of the vault-kv interface.""" + + egress_subnet: str = Field(description="Egress subnet to use, in CIDR notation.") + nonce: str = Field( + description="Uniquely identifying value for this unit. `secrets.token_hex(16)` is recommended." + ) + + +class ProviderSchema(DataBagSchema): + """The schema for the provider side of this interface.""" + + app: VaultKvProviderSchema + + +class RequirerSchema(DataBagSchema): + """The schema for the requirer side of this interface.""" + + app: AppVaultKvRequirerSchema + unit: UnitVaultKvRequirerSchema + + +def is_requirer_data_valid(app_data: dict, unit_data: dict) -> bool: + """Return whether the requirer data is valid.""" + try: + RequirerSchema( + app=AppVaultKvRequirerSchema(**app_data), + unit=UnitVaultKvRequirerSchema(**unit_data), + ) + return True + except ValidationError as e: + logger.debug("Invalid data: %s", e) + return False + + +def is_provider_data_valid(data: dict) -> bool: + """Return whether the provider data is valid.""" + try: + ProviderSchema(app=VaultKvProviderSchema(**data)) + return True + except ValidationError as e: + logger.debug("Invalid data: %s", e) + return False + + +class NewVaultKvClientAttachedEvent(ops.EventBase): + """New vault kv client attached event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + mount_suffix: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.mount_suffix = mount_suffix + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "mount_suffix": self.mount_suffix, + } + + def restore(self, snapshot: Dict[str, Any]): + """Restore the value state from a given snapshot.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + self.mount_suffix = snapshot["mount_suffix"] + + +class VaultKvProviderEvents(ops.ObjectEvents): + """List of events that the Vault Kv provider charm can leverage.""" + + new_vault_kv_client_attached = ops.EventSource(NewVaultKvClientAttachedEvent) + + +class VaultKvProvides(ops.Object): + """Class to be instanciated by the providing side of the relation.""" + + on = VaultKvProviderEvents() + + def __init__( + self, + charm: ops.CharmBase, + relation_name: str, + ) -> None: + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_relation_changed, + ) + + def _on_relation_changed(self, event: ops.RelationChangedEvent): + """Handle client changed relation. + + This handler will emit a new_vault_kv_client_attached event if at least one unit data is + valid. + """ + if event.app is None: + logger.debug("No remote application yet") + return + + app_data = dict(event.relation.data[event.app]) + + any_valid = False + for unit in event.relation.units: + if not is_requirer_data_valid(app_data, dict(event.relation.data[unit])): + logger.debug("Invalid data from unit %r", unit.name) + continue + any_valid = True + + if any_valid: + self.on.new_vault_kv_client_attached.emit( + event.relation.id, + event.relation.name, + event.relation.data[event.app]["mount_suffix"], + ) + + def set_vault_url(self, relation: ops.Relation, vault_url: str): + """Set the vault_url on the relation.""" + if not self.charm.unit.is_leader(): + return + + relation.data[self.charm.app]["vault_url"] = vault_url + + def set_ca_certificate(self, relation: ops.Relation, ca_certificate: str): + """Set the ca_certificate on the relation.""" + if not self.charm.unit.is_leader(): + return + + relation.data[self.charm.app]["ca_certificate"] = ca_certificate + + def set_mount(self, relation: ops.Relation, mount: str): + """Set the mount on the relation.""" + if not self.charm.unit.is_leader(): + return + + relation.data[self.charm.app]["mount"] = mount + + def set_unit_credentials(self, relation: ops.Relation, nonce: str, secret: ops.Secret): + """Set the unit credentials on the relation.""" + if not self.charm.unit.is_leader(): + return + + credentials = self.get_credentials(relation) + if secret.id is None: + logger.debug( + "Secret id is None, not updating the relation '%s:%d' for nonce %r", + relation.name, + relation.id, + nonce, + ) + return + credentials[nonce] = secret.id + relation.data[self.charm.app]["credentials"] = json.dumps(credentials, sort_keys=True) + + def remove_unit_credentials(self, relation: ops.Relation, nonce: Union[str, Iterable[str]]): + """Remove nonce(s) from the relation.""" + if not self.charm.unit.is_leader(): + return + + if isinstance(nonce, str): + nonce = [nonce] + + credentials = self.get_credentials(relation) + + for n in nonce: + credentials.pop(n, None) + + relation.data[self.charm.app]["credentials"] = json.dumps(credentials, sort_keys=True) + + def get_credentials(self, relation: ops.Relation) -> dict: + """Get the unit credentials from the relation.""" + return json.loads(relation.data[self.charm.app].get("credentials", "{}")) + + +class VaultKvConnectedEvent(ops.EventBase): + """VaultKvConnectedEvent Event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + } + + def restore(self, snapshot: Dict[str, Any]): + """Restore the value state from a given snapshot.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + + +class VaultKvReadyEvent(ops.EventBase): + """VaultKvReadyEvent Event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + } + + def restore(self, snapshot: Dict[str, Any]): + """Restore the value state from a given snapshot.""" + super().restore(snapshot) + self.relation_id = snapshot["relation_id"] + self.relation_name = snapshot["relation_name"] + + +class VaultKvGoneAwayEvent(ops.EventBase): + """VaultKvGoneAwayEvent Event.""" + + pass + + +class VaultKvRequireEvents(ops.ObjectEvents): + """List of events that the Vault Kv requirer charm can leverage.""" + + connected = ops.EventSource(VaultKvConnectedEvent) + ready = ops.EventSource(VaultKvReadyEvent) + gone_away = ops.EventSource(VaultKvGoneAwayEvent) + + +class VaultKvRequires(ops.Object): + """Class to be instanciated by the requiring side of the relation.""" + + on = VaultKvRequireEvents() + + def __init__( + self, + charm: ops.CharmBase, + relation_name: str, + mount_suffix: str, + ) -> None: + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.mount_suffix = mount_suffix + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_vault_kv_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_vault_kv_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_vault_kv_relation_broken, + ) + + def _set_unit_nonce(self, relation: ops.Relation, nonce: str): + """Set the nonce on the relation.""" + relation.data[self.charm.unit]["nonce"] = nonce + + def _set_unit_egress_subnet(self, relation: ops.Relation, egress_subnet: str): + """Set the egress_subnet on the relation.""" + relation.data[self.charm.unit]["egress_subnet"] = egress_subnet + + def _on_vault_kv_relation_joined(self, event: ops.RelationJoinedEvent): + """Handle relation joined. + + Set the secret backend in the application databag if we are the leader. + Always update the egress_subnet in the unit databag. + """ + if self.charm.unit.is_leader(): + event.relation.data[self.charm.app]["mount_suffix"] = self.mount_suffix + self.on.connected.emit( + event.relation.id, + event.relation.name, + ) + + def _on_vault_kv_relation_changed(self, event: ops.RelationChangedEvent): + """Handle relation changed.""" + if event.app is None: + logger.debug("No remote application yet") + return + + if ( + is_provider_data_valid(dict(event.relation.data[event.app])) + and self.get_unit_credentials(event.relation) is not None + ): + self.on.ready.emit( + event.relation.id, + event.relation.name, + ) + + def _on_vault_kv_relation_broken(self, event: ops.RelationBrokenEvent): + """Handle relation broken.""" + self.on.gone_away.emit() + + def request_credentials(self, relation: ops.Relation, egress_subnet: str, nonce: str) -> None: + """Request credentials from the vault-kv relation. + + Generated secret ids are tied to the unit egress_subnet, so if the egress_subnet + changes a new secret id must be generated. + + A change in egress_subnet can happen when the pod is rescheduled to a different + node by the underlying substrate without a change from Juju. + """ + self._set_unit_egress_subnet(relation, egress_subnet) + self._set_unit_nonce(relation, nonce) + + def get_vault_url(self, relation: ops.Relation) -> Optional[str]: + """Return the vault_url from the relation.""" + if relation.app is None: + return None + return relation.data[relation.app].get("vault_url") + + def get_ca_certificate(self, relation: ops.Relation) -> Optional[str]: + """Return the ca_certificate from the relation.""" + if relation.app is None: + return None + return relation.data[relation.app].get("ca_certificate") + + def get_mount(self, relation: ops.Relation) -> Optional[str]: + """Return the mount from the relation.""" + if relation.app is None: + return None + return relation.data[relation.app].get("mount") + + def get_unit_credentials(self, relation: ops.Relation) -> Optional[str]: + """Return the unit credentials from the relation. + + Unit credentials are stored in the relation data as a Juju secret id. + """ + nonce = relation.data[self.charm.unit].get("nonce") + if nonce is None or relation.app is None: + return None + return json.loads(relation.data[relation.app].get("credentials", "{}")).get(nonce) diff --git a/metadata.yaml b/metadata.yaml index 4004430a..31ac3568 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -49,6 +49,10 @@ peers: vault-peers: interface: vault-peer +provides: + vault-kv: + interface: vault-kv + assumes: - juju >= 3.1 - k8s-api diff --git a/requirements.txt b/requirements.txt index ea2dc3d2..4c33909b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ jinja2 jsonschema lightkube lightkube-models +pydantic +pytest-interface-tester pyhcl requests jsonschema diff --git a/src/charm.py b/src/charm.py index 421221a5..861cb537 100755 --- a/src/charm.py +++ b/src/charm.py @@ -21,6 +21,7 @@ generate_csr, generate_private_key, ) +from charms.vault_k8s.v0.vault_kv import NewVaultKvClientAttachedEvent, VaultKvProvides from jinja2 import Environment, FileSystemLoader from ops.charm import ( CharmBase, @@ -34,6 +35,8 @@ ActiveStatus, MaintenanceStatus, ModelError, + Relation, + Secret, SecretNotFoundError, WaitingStatus, ) @@ -51,6 +54,8 @@ TLS_KEY_FILE_PATH = "/vault/certs/key.pem" TLS_CA_FILE_PATH = "/vault/certs/ca.pem" PEER_RELATION_NAME = "vault-peers" +KV_RELATION_NAME = "vault-kv" +KV_SECRET_PREFIX = "kv-creds-" def render_vault_config_file( @@ -133,6 +138,7 @@ def __init__(self, *args): charm=self, ports=[ServicePort(name="vault", port=self.VAULT_PORT)], ) + self.vault_kv = VaultKvProvides(self, KV_RELATION_NAME) self.framework.observe(self.on.install, self._on_install) self.framework.observe(self.on.vault_pebble_ready, self._on_config_changed) self.framework.observe(self.on.config_changed, self._on_config_changed) @@ -140,6 +146,9 @@ def __init__(self, *args): self.on[PEER_RELATION_NAME].relation_created, self._on_peer_relation_created ) self.framework.observe(self.on.remove, self._on_remove) + self.framework.observe( + self.vault_kv.on.new_vault_kv_client_attached, self._on_new_vault_kv_client_attached + ) def _on_install(self, event: InstallEvent): """Handler triggered when the charm is installed. @@ -296,6 +305,189 @@ def _on_remove(self, event: RemoveEvent): pass self._delete_vault_data() + def _on_new_vault_kv_client_attached(self, event: NewVaultKvClientAttachedEvent): + """Handler triggered when a new vault-kv client is attached.""" + if not self.unit.is_leader(): + logger.debug("Only leader unit can configure a vault-kv client, skipping") + return + + if not self._is_peer_relation_created(): + logger.debug("Peer relation not created, deferring event") + event.defer() + return + + try: + root_token, _ = self._get_initialization_secret_from_peer_relation() + except PeerSecretError: + logger.debug("Vault initialization secret not set in peer relation, deferring event") + event.defer() + return + + try: + ( + _, + _, + ca_certificate, + ) = self._get_certificates_secret_in_peer_relation() + except PeerSecretError: + logger.debug("Vault certificate secret not set in peer relation, deferring event") + event.defer() + return + + relation = self.model.get_relation(event.relation_name, event.relation_id) + + if relation is None or relation.app is None: + logger.warning( + "Relation or remote application is missing," + "this should not happen, skipping event" + ) + return + + vault = Vault(url=self._api_address) + vault.set_token(token=root_token) + + if not vault.is_api_available(): + logger.debug("Vault is not available, deferring event") + event.defer() + return + + vault.enable_approle_auth() + + mount = "charm-" + relation.app.name + "-" + event.mount_suffix + vault.configure_kv_mount(mount) + self.vault_kv.set_mount(relation, mount) + vault_url = self._get_relation_api_address(relation) + if vault_url is not None: + self.vault_kv.set_vault_url(relation, vault_url) + self.vault_kv.set_ca_certificate(relation, ca_certificate) + + nonces = [] + for unit in relation.units: + egress_subnet = relation.data[unit].get("egress_subnet") + nonce = relation.data[unit].get("nonce") + if egress_subnet is None or nonce is None: + logger.debug( + "Skipping configuring access for unit %r, egress_subnet or nonce are missing", + unit.name, + ) + continue + nonces.append(nonce) + self._ensure_unit_credentials(vault, relation, unit.name, mount, nonce, egress_subnet) + + # Remove any stale nonce + credential_nonces = self.vault_kv.get_credentials(relation).keys() + stale_nonces = set(credential_nonces) - set(nonces) + self.vault_kv.remove_unit_credentials(relation, stale_nonces) + + def _ensure_unit_credentials( + self, + vault: Vault, + relation: Relation, + unit_name: str, + mount: str, + nonce: str, + egress_subnet: str, + ): + """Ensures a unit has credentials to access the vault-kv mount.""" + policy_name = role_name = mount + "-" + unit_name.replace("/", "-") + vault.configure_kv_policy(policy_name, mount) + role_id = vault.configure_approle(role_name, [egress_subnet], [policy_name]) + secret = self._create_or_update_kv_secret( + vault, + relation, + role_id, + role_name, + egress_subnet, + ) + self.vault_kv.set_unit_credentials(relation, nonce, secret) + + def _create_or_update_kv_secret( + self, + vault: Vault, + relation: Relation, + role_id: str, + role_name: str, + egress_subnet: str, + ) -> Secret: + """Create or update a KV secret for a unit. + + Fetch secret id from peer relation, if it exists, update the secret, + otherwise create it. + """ + label = KV_SECRET_PREFIX + role_name + secret_id = self._get_vault_kv_secret_in_peer_relation(label) + if secret_id is None: + return self._create_kv_secret( + vault, relation, role_id, role_name, egress_subnet, label + ) + else: + return self._update_kv_secret( + vault, relation, role_name, egress_subnet, label, secret_id + ) + + def _create_kv_secret( + self, + vault: Vault, + relation: Relation, + role_id: str, + role_name: str, + egress_subnet: str, + label: str, + ) -> Secret: + """Create a vault kv secret, store its id in the peer relation and return it.""" + role_secret_id = vault.generate_role_secret_id(role_name, [egress_subnet]) + secret = self.app.add_secret( + {"role-id": role_id, "role-secret-id": role_secret_id}, + label=label, + ) + if secret.id is None: + raise RuntimeError(f"Unexpected error, just created secret {label!r} has no id") + self._set_vault_kv_secret_in_peer_relation(label, secret.id) + secret.grant(relation) + return secret + + def _update_kv_secret( + self, + vault: Vault, + relation: Relation, + role_name: str, + egress_subnet: str, + label: str, + secret_id: str, + ) -> Secret: + """Update a vault kv secret if the unit subnet is not in the cidr list.""" + secret = self.model.get_secret(id=secret_id, label=label) + secret.grant(relation) + credentials = secret.get_content() + role_secret_id_data = vault.read_role_secret(role_name, credentials["role-secret-id"]) + # if unit subnet is already in cidr_list, skip + if egress_subnet in role_secret_id_data["cidr_list"]: + return secret + credentials["role-secret-id"] = vault.generate_role_secret_id(role_name, [egress_subnet]) + secret.set_content(credentials) + return secret + + def _get_vault_kv_secrets_in_peer_relation(self) -> Dict[str, str]: + """Return the vault kv secrets from the peer relation.""" + if not self._is_peer_relation_created(): + raise RuntimeError("Peer relation not created") + relation = self.model.get_relation(PEER_RELATION_NAME) + secrets = json.loads(relation.data[self.app].get("vault-kv-secrets", "{}")) # type: ignore[union-attr] # noqa: E501 + return secrets + + def _get_vault_kv_secret_in_peer_relation(self, label: str) -> Optional[str]: + """Return the vault kv secret id associated to input label from peer relation.""" + return self._get_vault_kv_secrets_in_peer_relation().get(label) + + def _set_vault_kv_secret_in_peer_relation(self, label: str, secret_id: str): + """Set the vault kv secret in the peer relation.""" + if not self._is_peer_relation_created(): + raise RuntimeError("Peer relation not created") + secrets = self._get_vault_kv_secrets_in_peer_relation() + secrets[label] = secret_id + relation = self.model.get_relation(PEER_RELATION_NAME) + relation.data[self.app].update({"vault-kv-secrets": json.dumps(secrets, sort_keys=True)}) # type: ignore[union-attr] # noqa: E501 + def _delete_vault_data(self) -> None: """Delete Vault's data.""" try: @@ -317,6 +509,16 @@ def _vault_service_is_running(self) -> bool: return False return True + def _get_relation_api_address(self, relation: Relation) -> Optional[str]: + """Fetches api address from relation and returns it. + + Example: "https://10.152.183.20:8200" + """ + binding = self.model.get_binding(relation) + if binding is None: + return None + return f"https://{binding.network.ingress_address}:{self.VAULT_PORT}" + @property def _api_address(self) -> str: """Returns the API address. diff --git a/src/templates/kv_mount.hcl b/src/templates/kv_mount.hcl new file mode 100644 index 00000000..c877fc34 --- /dev/null +++ b/src/templates/kv_mount.hcl @@ -0,0 +1,6 @@ +path "{mount}/*" {{ + capabilities = ["create", "read", "update", "delete", "list"] +}} +path "sys/internal/ui/mounts/{mount}" {{ + capabilities = ["read"] +}} diff --git a/src/vault.py b/src/vault.py index 3d7f3ac8..61f3e1c5 100644 --- a/src/vault.py +++ b/src/vault.py @@ -82,3 +82,47 @@ def get_num_raft_peers(self) -> int: """Returns the number of raft peers.""" raft_config = self._client.sys.read_raft_config() return len(raft_config["data"]["config"]["servers"]) + + def enable_approle_auth(self) -> None: + """Enable the AppRole authentication method in Vault, if not already enabled.""" + if "approle/" not in self._client.sys.list_auth_methods(): + self._client.sys.enable_auth_method("approle") + logger.info("Enabled approle auth method") + + def configure_kv_mount(self, name: str): + """Ensure a KV mount is enabled.""" + if name + "/" not in self._client.sys.list_mounted_secrets_engines(): + self._client.sys.enable_secrets_engine( + backend_type="kv-v2", + description="Charm created KV backend", + path=name, + ) + + def configure_kv_policy(self, policy: str, mount: str): + """Create/update a policy within vault to access the KV mount.""" + with open("src/templates/kv_mount.hcl", "r") as fd: + mount_policy = fd.read() + self._client.sys.create_or_update_policy(policy, mount_policy.format(mount=mount)) + + def configure_approle(self, name: str, cidrs: List[str], policies: List[str]) -> str: + """Create/update a role within vault associating the supplied policies.""" + self._client.auth.approle.create_or_update_approle( + name, + token_ttl="60s", + token_max_ttl="60s", + token_policies=policies, + bind_secret_id="true", + token_bound_cidrs=cidrs, + ) + 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]) -> str: + """Generate a new secret tied to an AppRole.""" + response = self._client.auth.approle.generate_secret_id(name, cidr_list=cidrs) + return response["data"]["secret_id"] + + def read_role_secret(self, name: str, id: str) -> dict: + """Get definition of a secret tied to an AppRole.""" + response = self._client.auth.approle.read_secret_id(name, id) + return response["data"] diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index add178a8..c5ad488c 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -926,3 +926,185 @@ def test_given_service_is_running_when_on_remove_then_service_is_stopped( self.harness.charm.on.remove.emit() patch_stop_service.assert_called_with("vault") + + def setup_vault_kv_relation(self, nb_units: int = 1) -> tuple: + app_name = "consumer" + unit_name = app_name + "/0" + relation_name = "vault-kv" + + host_ip = "10.20.20.1" + self.harness.add_network(host_ip, endpoint="vault-kv") + self.harness.set_leader() + rel_id = self.harness.add_relation(relation_name, app_name) + units = {} + for unit_id in range(nb_units): + unit_name = app_name + "/" + str(unit_id) + egress_subnet = f"10.20.20.{20 + unit_id}/32" + self.harness.add_relation_unit(rel_id, unit_name) + self.harness.update_relation_data( + rel_id, unit_name, {"egress_subnet": egress_subnet, "nonce": str(unit_id)} + ) + units[unit_name] = egress_subnet + + return ( + app_name, + host_ip, + relation_name, + rel_id, + units, + ) + + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_unit_credentials") + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_ca_certificate") + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_mount") + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_vault_url") + @patch("vault.Vault.generate_role_secret_id") + @patch("vault.Vault.configure_approle") + @patch("vault.Vault.configure_kv_policy") + @patch("vault.Vault.configure_kv_mount") + @patch("vault.Vault.enable_approle_auth") + @patch("vault.Vault.is_api_available") + def test_given_unit_is_leader_when_secret_kv_is_complete_then_provider_side_is_filled( + self, + _, + enable_approle_auth, + __, + ___, + configure_approle, + generate_role_secret_id, + set_vault_url, + set_mount, + set_ca_certificate, + set_unit_credentials, + ): + peer_relation_id = self._set_peer_relation() + self._set_initialization_secret_in_peer_relation( + relation_id=peer_relation_id, + root_token="root token content", + unseal_keys=["unseal_keys"], + ) + self._set_certificate_secret_in_peer_relation( + relation_id=peer_relation_id, + certificate="certificate content", + private_key="private key content", + ca_certificate="ca certificate content", + ) + ( + app_name, + _, + _, + rel_id, + _, + ) = self.setup_vault_kv_relation(nb_units=3) + + configure_approle.return_value = "12345678" + generate_role_secret_id.return_value = "11111111" + + mount_suffix = "dummy" + self.harness.update_relation_data(rel_id, app_name, {"mount_suffix": mount_suffix}) + + enable_approle_auth.assert_called() + set_vault_url.assert_called() + set_mount.assert_called() + set_ca_certificate.assert_called() + set_unit_credentials.assert_called() + + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_unit_credentials") + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_ca_certificate") + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_vault_url") + @patch("charms.vault_k8s.v0.vault_kv.VaultKvProvides.set_mount") + @patch("vault.Vault.generate_role_secret_id") + @patch("vault.Vault.configure_approle") + @patch("vault.Vault.configure_kv_policy") + @patch("vault.Vault.configure_kv_mount") + @patch("vault.Vault.enable_approle_auth") + @patch("vault.Vault.is_api_available") + def test_given_unit_is_not_leader_when_secret_kv_is_complete_then_no_data_is_updated( + self, + _, + enable_approle_auth, + __, + ___, + configure_approle, + generate_role_secret_id, + set_mount, + set_vault_url, + set_ca_certificate, + set_unit_credentials, + ): + ( + app_name, + _, + _, + rel_id, + _, + ) = self.setup_vault_kv_relation(nb_units=3) + self.harness.set_leader(False) + + configure_approle.return_value = "12345678" + generate_role_secret_id.return_value = "11111111" + + mount_suffix = "dummy" + self.harness.update_relation_data(rel_id, app_name, {"mount_suffix": mount_suffix}) + + enable_approle_auth.assert_not_called() + set_mount.assert_not_called() + set_vault_url.assert_not_called() + set_ca_certificate.assert_not_called() + set_unit_credentials.assert_not_called() + + @patch("vault.Vault.read_role_secret") + @patch("vault.Vault.generate_role_secret_id") + @patch("vault.Vault.configure_approle") + @patch("vault.Vault.configure_kv_policy") + @patch("vault.Vault.configure_kv_mount") + @patch("vault.Vault.is_api_available") + @patch("vault.Vault.enable_approle_auth") + def test_given_unit_is_leader_when_related_unit_egress_is_updated_then_secret_content_is_updated( # noqa: E501 + self, + _, + __, + ___, + ____, + configure_approle, + generate_role_secret_id, + read_role_secret, + ): + peer_relation_id = self._set_peer_relation() + self._set_initialization_secret_in_peer_relation( + relation_id=peer_relation_id, + root_token="root token content", + unseal_keys=["unseal_keys"], + ) + self._set_certificate_secret_in_peer_relation( + relation_id=peer_relation_id, + certificate="certificate content", + private_key="private key content", + ca_certificate="ca certificate content", + ) + ( + app_name, + _, + _, + rel_id, + units, + ) = self.setup_vault_kv_relation(nb_units=3) + + configure_approle.return_value = "12345678" + generate_role_secret_id.return_value = "11111111" + + mount_suffix = "dummy" + self.harness.update_relation_data(rel_id, app_name, {"mount_suffix": mount_suffix}) + # choose an unit to update + unit = next(iter(units.keys())) + + # Mock read to actually return a comparable cidr_list + def mock_read_role_secret(role_name, _): + unit_name = "/".join(role_name.split("-")[-2:]) + return {"cidr_list": [units[unit_name]]} + + read_role_secret.side_effect = mock_read_role_secret + # get current role secret id from unit's secret + with patch("ops.Secret.set_content") as set_content: + self.harness.update_relation_data(rel_id, unit, {"egress_subnet": "10.20.20.240/32"}) + assert set_content.call_count == 1 diff --git a/tests/unit/test_vault.py b/tests/unit/test_vault.py index dee32aee..fbb1d3a3 100644 --- a/tests/unit/test_vault.py +++ b/tests/unit/test_vault.py @@ -102,3 +102,27 @@ def test_given_1_node_in_raft_cluster_when_get_num_raft_peers_then_returns_1( vault.get_num_raft_peers() self.assertEqual(3, vault.get_num_raft_peers()) + + @patch("hvac.api.system_backend.auth.Auth.enable_auth_method") + @patch("hvac.api.system_backend.auth.Auth.list_auth_methods") + def test_given_approle_not_in_auth_methods_when_enable_approle_auth_then_approle_is_added_to_auth_methods( # noqa: E501 + self, patch_list_auth_methods, patch_enable_auth_method + ): + patch_list_auth_methods.return_value = {} + vault = Vault(url="http://whatever-url") + + vault.enable_approle_auth() + + patch_enable_auth_method.assert_called_with("approle") + + @patch("hvac.api.system_backend.auth.Auth.enable_auth_method") + @patch("hvac.api.system_backend.auth.Auth.list_auth_methods") + def test_given_approle_in_auth_methods_when_enable_approle_auth_then_approle_is_not_added_to_auth_methods( # noqa: E501 + self, patch_list_auth_methods, patch_enable_auth_method + ): + patch_list_auth_methods.return_value = {"approle/": "whatever"} + vault = Vault(url="http://whatever-url") + + vault.enable_approle_auth() + + patch_enable_auth_method.assert_not_called() diff --git a/tests/unit/test_vault_kv.py b/tests/unit/test_vault_kv.py new file mode 100644 index 00000000..6ffd04d1 --- /dev/null +++ b/tests/unit/test_vault_kv.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import json +import textwrap +import unittest +from unittest.mock import patch + +from charms.vault_k8s.v0 import vault_kv +from ops import testing +from ops.charm import CharmBase + + +class VaultKvProviderCharm(CharmBase): + metadata_yaml = textwrap.dedent( + """ + name: vault-kv-provider + containers: + vault: + resource: vault-image + provides: + vault-kv: + interface: vault-kv + """ + ) + + def __init__(self, *args): + super().__init__(*args) + self.interface = vault_kv.VaultKvProvides(self, "vault-kv") + self.framework.observe( + self.interface.on.new_vault_kv_client_attached, self._on_new_vault_kv_client_attached + ) + + def _on_new_vault_kv_client_attached(self, event: vault_kv.NewVaultKvClientAttachedEvent): + pass + + +class VaultKvRequirerCharm(CharmBase): + metadata_yaml = textwrap.dedent( + """ + name: vault-kv-requirer + containers: + my-app: + resource: my-app-image + requires: + vault-kv: + interface: vault-kv + """ + ) + + def __init__(self, *args): + super().__init__(*args) + self.interface = vault_kv.VaultKvRequires(self, "vault-kv", "dummy") + self.framework.observe(self.interface.on.connected, self._on_connected) + self.framework.observe(self.interface.on.ready, self._on_ready) + self.framework.observe(self.interface.on.gone_away, self._on_gone_away) + + def _on_connected(self, event: vault_kv.VaultKvConnectedEvent): + pass + + def _on_ready(self, event: vault_kv.VaultKvReadyEvent): + pass + + def _on_gone_away(self, event: vault_kv.VaultKvGoneAwayEvent): + pass + + +class TestVaultKvProvides(unittest.TestCase): + def setUp(self): + self.harness = testing.Harness( + VaultKvProviderCharm, meta=VaultKvProviderCharm.metadata_yaml + ) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def setup_relation(self, leader: bool = True) -> tuple: + remote_app = "vault-kv-requires" + remote_unit = remote_app + "/0" + rel_name = "vault-kv" + self.harness.set_leader(leader) + rel_id = self.harness.add_relation(rel_name, remote_app) + relation = self.harness.model.get_relation(rel_name, rel_id) + assert relation + self.harness.add_relation_unit(rel_id, remote_unit) + self.harness.update_relation_data( + rel_id, + remote_unit, + key_values={"nonce": "abcd", "egress_subnet": "10.0.0.1/32"}, + ) + return remote_app, remote_unit, relation, rel_id + + @patch("test_vault_kv.VaultKvProviderCharm._on_new_vault_kv_client_attached") + def test_given_unit_joined_when_all_data_present_then_new_client_attached_fired( + self, _on_new_vault_kv_client_attached + ): + remote_app, _, _, rel_id = self.setup_relation() + + suffix = "dummy" + self.harness.update_relation_data(rel_id, remote_app, {"mount_suffix": suffix}) + args, _ = _on_new_vault_kv_client_attached.call_args + event = args[0] + + assert isinstance(event, vault_kv.NewVaultKvClientAttachedEvent) + assert args[0].mount_suffix == suffix + + @patch("test_vault_kv.VaultKvProviderCharm._on_new_vault_kv_client_attached") + def test_given_unit_joined_when_missing_data_then_new_client_attached_is_never_fired( + self, _on_new_vault_kv_client_attached + ): + self.setup_relation() + _on_new_vault_kv_client_attached.assert_not_called() + + def test_given_unit_is_leader_when_setting_vault_url_then_relation_data_is_updated( + self, + ): + _, _, relation, rel_id = self.setup_relation() + vault_url = "https://vault.example.com" + self.harness.charm.interface.set_vault_url(relation, vault_url) + + assert ( + self.harness.get_relation_data(rel_id, self.harness.charm.app.name)["vault_url"] + == vault_url + ) + + def test_given_unit_is_not_leader_when_setting_vault_url_then_relation_data_is_not_updated( + self, + ): + _, _, relation, rel_id = self.setup_relation(leader=False) + vault_url = "https://vault.example.com" + self.harness.charm.interface.set_vault_url(relation, vault_url) + + assert "vault_url" not in self.harness.get_relation_data( + rel_id, self.harness.charm.app.name + ) + + def test_given_unit_is_leader_when_setting_mount_then_relation_data_is_updated( + self, + ): + _, _, relation, rel_id = self.setup_relation() + mount = "charm-vault-kv-requires-dummy" + self.harness.charm.interface.set_mount(relation, mount) + + assert ( + self.harness.get_relation_data(rel_id, self.harness.charm.app.name)["mount"] == mount + ) + + def test_given_unit_is_not_leader_when_setting_mount_then_relation_data_is_not_updated( + self, + ): + _, _, relation, rel_id = self.setup_relation(leader=False) + mount = "charm-vault-kv-requires-dummy" + self.harness.charm.interface.set_mount(relation, mount) + + assert "mount" not in self.harness.get_relation_data(rel_id, self.harness.charm.app.name) + + def test_given_unit_is_leader_when_setting_credentials_then_relation_data_is_updated( + self, + ): + _, remote_unit, relation, rel_id = self.setup_relation() + unit_name = remote_unit.replace("/", "-") + secret = self.harness.charm.app.add_secret({"role-id": "111", "role-secret-id": "222"}) + self.harness.charm.interface.set_unit_credentials(relation, unit_name, secret) + + assert json.loads( + self.harness.get_relation_data(rel_id, self.harness.charm.app.name)["credentials"] + ) == {unit_name: secret.id} + + def test_given_unit_is_not_leader_when_setting_credentials_then_relation_data_is_not_updated( + self, + ): + _, remote_unit, relation, rel_id = self.setup_relation(leader=False) + unit_name = remote_unit.replace("/", "-") + secret = self.harness.charm.app.add_secret({"role-id": "111", "role-secret-id": "222"}) + self.harness.charm.interface.set_unit_credentials(relation, unit_name, secret) + + assert "credentials" not in self.harness.get_relation_data( + rel_id, self.harness.charm.app.name + ) + + def test_given_secret_is_missing_id_when_setting_credentials_then_relation_data_is_not_updated( + self, + ): + """Secret._id is None when the secret has been looked up by label.""" + _, remote_unit, relation, rel_id = self.setup_relation() + unit_name = remote_unit.replace("/", "-") + secret = self.harness.charm.app.add_secret({"role-id": "111", "role-secret-id": "222"}) + secret._id = None + self.harness.charm.interface.set_unit_credentials(relation, unit_name, secret) + + assert "credentials" not in self.harness.get_relation_data( + rel_id, self.harness.charm.app.name + ) + + +class TestVaultKvRequires(unittest.TestCase): + def setUp(self): + self.harness = testing.Harness( + VaultKvRequirerCharm, meta=VaultKvRequirerCharm.metadata_yaml + ) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def setup_relation(self, leader: bool = True) -> tuple: + remote_app = "vault-kv-provides" + remote_unit = remote_app + "/0" + rel_name = "vault-kv" + self.harness.set_leader(leader) + rel_id = self.harness.add_relation(rel_name, remote_app) + relation = self.harness.model.get_relation(rel_name, rel_id) + assert relation + self.harness.add_relation_unit(rel_id, remote_unit) + self.harness.charm.interface.request_credentials(relation, "10.20.20.1/32", "abcd") + return remote_app, remote_unit, relation, rel_id + + @patch("test_vault_kv.VaultKvRequirerCharm._on_connected") + def test_given_unit_leader_when_unit_joined_then_connected_event_fired_and_all_relation_data_is_updated( # noqa: E501 + self, _on_connected + ): + rel_id = self.setup_relation()[-1] + + args, _ = _on_connected.call_args + event = args[0] + + app_relation_data = self.harness.get_relation_data(rel_id, self.harness.charm.app.name) + assert isinstance(event, vault_kv.VaultKvConnectedEvent) + assert app_relation_data["mount_suffix"] == self.harness.charm.interface.mount_suffix + + @patch("test_vault_kv.VaultKvRequirerCharm._on_connected") + def test_given_unit_joined_is_not_leader_when_relation_joined_then_connected_is_fired_and_mount_suffix_is_not_updated( # noqa: E501 + self, _on_connected + ): + rel_id = self.setup_relation(leader=False)[-1] + + args, _ = _on_connected.call_args + event = args[0] + + app_relation_data = self.harness.get_relation_data(rel_id, self.harness.charm.app.name) + assert isinstance(event, vault_kv.VaultKvConnectedEvent) + assert "mount_suffix" not in app_relation_data + + @patch("test_vault_kv.VaultKvRequirerCharm._on_gone_away") + def test_given_all_units_departed_when_relation_broken_then_gone_away_event_fired( + self, _on_gone_away + ): + rel_id = self.setup_relation()[-1] + self.harness.remove_relation(rel_id) + args, _ = _on_gone_away.call_args + event = args[0] + + assert isinstance(event, vault_kv.VaultKvGoneAwayEvent) + + @patch("test_vault_kv.VaultKvRequirerCharm._on_ready") + def test_given_relation_changed_when_all_data_present_then_ready_event_fired(self, _on_ready): + remote_app, _, _, rel_id = self.setup_relation() + self.harness.update_relation_data( + rel_id, + remote_app, + { + "vault_url": "https://vault.example.com", + "ca_certificate": "ca certificate data", + "mount": "charm-vault-kv-requires-dummy", + "credentials": json.dumps({"abcd": "dummy"}), + }, + ) + + args, _ = _on_ready.call_args + event = args[0] + + assert isinstance(event, vault_kv.VaultKvReadyEvent) + + @patch("test_vault_kv.VaultKvRequirerCharm._on_ready") + def test_given_relation_changed_when_data_missing_then_ready_event_never_fired( + self, _on_ready + ): + self.setup_relation() + _on_ready.assert_not_called() diff --git a/tox.ini b/tox.ini index f45ca7f6..292402d6 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,8 @@ envlist = lint, static, unit src_path = {toxinidir}/src/ unit_test_path = {toxinidir}/tests/unit/ integration_test_path = {toxinidir}/tests/integration/ -all_path = {[vars]src_path} {[vars]unit_test_path} {[vars]integration_test_path} +library_path = {toxinidir}/lib/charms/vault_k8s/ +all_path = {[vars]src_path} {[vars]library_path} {[vars]unit_test_path} {[vars]integration_test_path} [testenv] setenv = @@ -46,7 +47,7 @@ commands = [testenv:unit] description = Run unit tests commands = - coverage run --source={[vars]src_path} -m pytest {[vars]unit_test_path} -v --tb native -s {posargs} + coverage run --source={[vars]src_path},{[vars]library_path} -m pytest {[vars]unit_test_path} -v --tb native -s {posargs} coverage report [testenv:integration]