From 5d2f3013ab550fb0f0f0777b47c24cdb24bc4151 Mon Sep 17 00:00:00 2001 From: Guillaume Boutry Date: Wed, 2 Aug 2023 15:22:39 +0200 Subject: [PATCH] feat: add secrets backend relation --- lib/charms/vault_k8s/v0/vault_kv.py | 369 ++++++++++++++++++++++++++++ metadata.yaml | 2 + src/charm.py | 85 ++++++- src/vault.py | 47 +++- tests/unit/test_charm.py | 92 +++++++ 5 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 lib/charms/vault_k8s/v0/vault_kv.py 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..cc7076a9 --- /dev/null +++ b/lib/charms/vault_k8s/v0/vault_kv.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Contains the VaultKVProvides class.""" + +import json +import logging +from typing import Any, Dict, Optional + +import ops + +logger = logging.getLogger(__name__) + + +# The unique Charmhub library identifier, never change it +LIBID = "to_fill" + +# 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 + + +class HasVaultKvClientsEvent(ops.EventBase): + """Has VaultKvClients Event.""" + + pass + + +class ReadyVaultKvClientsEvent(ops.EventBase): + """Ready VaultKvClients Event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + secret_backend: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.secret_backend = secret_backend + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "secret_backend": self.secret_backend, + } + + 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.secret_backend = snapshot["secret_backend"] + + +class DepartedVaultKvClientsEvent(ops.EventBase): + """Departed VaultKvClients Event.""" + + pass + + +class GoneAwayVaultKvClientsEvent(ops.EventBase): + """GoneAway VaultKvClients Event.""" + + pass + + +class VaultKvProviderEvents(ops.ObjectEvents): + """List of events that the Vault Kv provider charm can leverage.""" + + has_vault_kv_clients = ops.EventSource(HasVaultKvClientsEvent) + ready_vault_kv_clients = ops.EventSource(ReadyVaultKvClientsEvent) + departed_vault_kv_clients = ops.EventSource(DepartedVaultKvClientsEvent) + gone_away_vault_kv_clients = ops.EventSource(GoneAwayVaultKvClientsEvent) + + +class VaultKvProvides(ops.Object): + """Class to be instanciated by the providing side of the relation.""" + + on = VaultKvProviderEvents() + _stored = ops.StoredState() + + 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_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_departed, + self._on_vault_kv_relation_departed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_vault_kv_relation_broken, + ) + + def _on_vault_kv_relation_joined(self, event: ops.RelationJoinedEvent): + """Handle client joined relation. + + Args: + event: The event that triggered the handler. + """ + self.on.has_vault_kv_clients.emit() + + def _on_vault_kv_relation_changed(self, event: ops.RelationChangedEvent): + """Handle client changed relation.""" + if event.app is None: + logger.debug("No remote application yet") + return + + secret_backend = event.relation.data[event.app].get("secret_backend") + + if secret_backend is not None: + self.on.ready_vault_kv_clients.emit( + event.relation.id, + event.relation.name, + secret_backend, + ) + + def _on_vault_kv_relation_departed(self, event: ops.RelationDepartedEvent): + """Handle client departed relation.""" + self.on.departed_vault_kv_clients.emit() + + def _on_vault_kv_relation_broken(self, event: ops.RelationBrokenEvent): + """Handle client broken relation.""" + self.on.gone_away_vault_kv_clients.emit() + + 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_kv_mountpoint(self, relation: ops.Relation, kv_mountpoint: str): + """Set the kv_mountpoint on the relation.""" + if not self.charm.unit.is_leader(): + return + + relation.data[self.charm.app]["kv_mountpoint"] = kv_mountpoint + + def set_unit_credentials( + self, relation: ops.Relation, name: str, role_id: str, role_secret_id: str + ): + """Set the unit credentials on the relation.""" + if not self.charm.unit.is_leader(): + return + + credentials = self.get_credentials(relation) + credentials[name] = {"role_id": role_id, "role_secret_id": role_secret_id} + + relation.data[self.charm.app]["credentials"] = json.dumps(credentials, sort_keys=True) + + def get_role_secret_id(self, relation: ops.Relation, name: str) -> Optional[str]: + """Get the role_secret_id from the relation.""" + credentials = self.get_credentials(relation) + unit_credentials = credentials.get(name, {}) + return unit_credentials.get("role_secret_id") + + 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.""" + + pass + + +class VaultKvReadyEvent(ops.EventBase): + """VaultKvReadyEvent Event.""" + + def __init__( + self, + handle: ops.Handle, + relation_id: int, + relation_name: str, + vault_url: str, + kv_mountpoint: str, + role_id: str, + role_secret_id: str, + ): + super().__init__(handle) + self.relation_id = relation_id + self.relation_name = relation_name + self.vault_url = vault_url + self.kv_mountpoint = kv_mountpoint + self.role_id = role_id + self.role_secret_id = role_secret_id + + def snapshot(self) -> dict: + """Return snapshot data that should be persisted.""" + return { + "relation_id": self.relation_id, + "relation_name": self.relation_name, + "vault_url": self.vault_url, + "kv_mountpoint": self.kv_mountpoint, + "role_id": self.role_id, + "role_secret_id": self.role_secret_id, + } + + 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.vault_url = snapshot["vault_url"] + self.kv_mountpoint = snapshot["kv_mountpoint"] + self.role_id = snapshot["role_id"] + self.role_secret_id = snapshot["role_secret_id"] + + +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() + _stored = ops.StoredState() + + def __init__( + self, + charm: ops.CharmBase, + relation_name: str, + secret_backend: str, + egress_subnet: str, + ) -> None: + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.secret_backend = secret_backend + self.egress_subnet = egress_subnet + 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, + ) + + self._unit_name = self.charm.unit.name.replace("/", "-") + self._update_unit_egress_subnet() + + def _update_unit_egress_subnet(self, force: bool = False): + """Update egress_subnet for every instance of the relation. + + Secret ids are generated based on the egress_subnet, so if the egress_subnet changes + a new secret id must be generated. + + A change in egress_subnet can happend when the pod is rescheduled to a different node by + the underlying substrate without a change from Juju. + """ + for relation in self.model.relations[self.relation_name]: + unit_databag = relation.data[self.charm.unit] + unit_egress_subnet = unit_databag.get("egress_subnet", self.egress_subnet) + if force or unit_egress_subnet != self.egress_subnet: + unit_databag["egress_subnet"] = self.egress_subnet + + def _on_vault_kv_relation_created(self, event: ops.RelationCreatedEvent): + pass + + 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. + """ + self.on.connected.emit() + if self.charm.unit.is_leader(): + event.relation.data[self.charm.app]["secret_backend"] = self.secret_backend + self._update_unit_egress_subnet(force=True) + + 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 + + vault_url = self.vault_url(event.relation) + kv_mountpoint = self.kv_mountpoint(event.relation) + unit_credentials = self.unit_credentials(event.relation) + if unit_credentials is None: + return + role_id = unit_credentials.get("role_id") + role_secret_id = unit_credentials.get("role_secret_id") + if all((vault_url, kv_mountpoint, role_id, role_secret_id)): + self.on.ready.emit( + event.relation.id, + event.relation.name, + vault_url, + kv_mountpoint, + role_id, + role_secret_id, + ) + + def _on_vault_kv_relation_broken(self, event: ops.RelationBrokenEvent): + """Handle relation broken.""" + self.on.gone_away.emit() + + def 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 kv_mountpoint(self, relation: ops.Relation) -> Optional[str]: + """Return the kv_mountpoint from the relation.""" + if relation.app is None: + return None + return relation.data[relation.app].get("kv_mountpoint") + + def unit_credentials(self, relation: ops.Relation) -> Optional[dict]: + """Return the unit credentials from the relation.""" + if relation.app is None: + return None + return json.loads(relation.data[relation.app].get("credentials", "{}")).get( + self._unit_name + ) + + def role_id(self, relation: ops.Relation) -> Optional[str]: + """Return the role_id from the relation.""" + credentials = self.unit_credentials(relation) + if credentials is None: + return None + return credentials.get("role_id") + + def role_secret_id(self, relation: ops.Relation) -> Optional[str]: + """Return the role_secret_id from the relation.""" + credentials = self.unit_credentials(relation) + if credentials is None: + return None + return credentials.get("role_secret_id") diff --git a/metadata.yaml b/metadata.yaml index bf355da0..699b7a21 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -35,6 +35,8 @@ storage: provides: certificates: interface: tls-certificates + secrets: + interface: vault-kv peers: peers: diff --git a/src/charm.py b/src/charm.py index 5a9171bd..8f94e8dc 100755 --- a/src/charm.py +++ b/src/charm.py @@ -9,6 +9,7 @@ import json import logging +from typing import Optional from charms.observability_libs.v1.kubernetes_service_patch import ( KubernetesServicePatch, @@ -18,17 +19,23 @@ CertificateCreationRequestEvent, TLSCertificatesProvidesV2, ) +from charms.vault_k8s.v0.vault_kv import ( + GoneAwayVaultKvClientsEvent, + ReadyVaultKvClientsEvent, + VaultKvProvides, +) from ops.charm import ActionEvent, CharmBase, ConfigChangedEvent from ops.framework import StoredState from ops.main import main -from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus +from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, Relation from ops.pebble import Layer -from vault import Vault +from vault import SECRET_BACKEND_SHARED_HCL, Vault logger = logging.getLogger(__name__) VAULT_STORAGE_PATH = "/srv" +KV_RELATION = "secrets" class VaultCharm(CharmBase): @@ -48,6 +55,7 @@ def __init__(self, *args): role_id=self._stored.role_id, # type: ignore[arg-type] secret_id=self._stored.secret_id, # type: ignore[arg-type] ) + self.vault_kv = VaultKvProvides(self, KV_RELATION) self._service_name = self._container_name = "vault" self._container = self.unit.get_container(self._container_name) self.framework.observe( @@ -57,6 +65,9 @@ def __init__(self, *args): self.framework.observe(self.on.vault_pebble_ready, self._on_config_changed) self.framework.observe(self.on.config_changed, self._on_config_changed) self.framework.observe(self.on.authorise_charm_action, self._on_authorise_charm_action) + self.framework.observe( + self.vault_kv.on.ready_vault_kv_clients, self._on_ready_vault_kv_clients + ) self.service_patcher = KubernetesServicePatch( charm=self, ports=[ServicePort(name="vault", port=self.VAULT_PORT)], @@ -119,6 +130,17 @@ def _bind_address(self) -> str: self.model.get_binding(peer_relation).network.bind_address # type: ignore[arg-type, union-attr] # noqa: E501 ) + def _api_address(self, relation: Relation) -> Optional[str]: + """Fetches api address from relation and returns it. + + Returns: + str: API address + """ + binding = self.model.get_binding(relation) + if binding is None: + return None + return f"http://{binding.network.ingress_address}:{self.VAULT_PORT}" + @property def _vault_layer(self) -> Layer: """Returns pebble layer to start Vault. @@ -203,6 +225,65 @@ def _on_authorise_charm_action(self, event: ActionEvent) -> None: self._stored.secret_id = secret_id self.unit.status = ActiveStatus() + def _on_ready_vault_kv_clients(self, event: ReadyVaultKvClientsEvent) -> None: + """Configure secret backend for related application. + + Args: + event: ReadyVaultKvClientsEvent + + Returns: + None + """ + if not self.unit.is_leader(): + return + + if not event.secret_backend.startswith("charm-"): + logger.debug("Skipping secret backend configuration for non-charm application") + return + + relation = self.model.get_relation(event.relation_name, event.relation_id) + + if relation is None: + logger.debug("Relation not found") + return + + self.vault.configure_secret_backend(event.secret_backend) + self.vault_kv.set_kv_mountpoint(relation, event.secret_backend) + vault_url = self._api_address(relation) + if vault_url is not None: + self.vault_kv.set_vault_url(relation, vault_url) + + for unit in relation.units: + egress_subnet = relation.data[unit].get("egress_subnet") + if egress_subnet is None: + logger.debug( + f"Skipping configuring access for unit {unit.name!r}, egress_subnet missing" + ) + continue + unit_name = unit.name.replace("/", "-") + policy_name = role_name = "charm-" + unit_name + + self.vault.configure_policy( + policy_name, SECRET_BACKEND_SHARED_HCL.format(backend=event.secret_backend) + ) + approle_id = self.vault.configure_approle(role_name, [egress_subnet], [policy_name]) + + role_secret_id = self.vault_kv.get_role_secret_id(relation, unit_name) + + if role_secret_id is not None: + role_secret_id_data = self.vault.read_role_secret_id(role_name, role_secret_id) + # if unit subnet is already in cidr_list, skip + if egress_subnet in role_secret_id_data["cidr_list"]: + continue + + role_secret_id = self.vault.generate_role_secret_id(role_name, [egress_subnet]) + + self.vault_kv.set_unit_credentials(relation, unit_name, approle_id, role_secret_id) + + def _on_goneaway_vault_kv_clients(self, event: GoneAwayVaultKvClientsEvent): + # clean up + pass + if __name__ == "__main__": # pragma: no cover main(VaultCharm) diff --git a/src/vault.py b/src/vault.py index 63892413..30ab5abf 100644 --- a/src/vault.py +++ b/src/vault.py @@ -5,7 +5,7 @@ """Contains all the specificities to communicate with Vault through its API.""" import logging -from typing import Optional, Tuple +from typing import List, Optional, Tuple import hvac # type: ignore[import] import requests @@ -20,6 +20,14 @@ CHARM_PKI_MOUNT_POINT = "charm-pki-local" CHARM_PKI_ROLE = "local" +SECRET_BACKEND_SHARED_HCL = """ +path "{backend}/*" {{ + capabilities = ["create", "read", "update", "delete", "list"] +}} +path "sys/internal/ui/mounts/{backend}" {{ + capabilities = ["read"] +}} +""" logger = logging.getLogger(__name__) @@ -254,3 +262,40 @@ def _issue_certificate(self, **config) -> dict: raise RuntimeError(response.get("warnings", "unknown error")) logger.info(f"Issued certificate with role {CHARM_PKI_ROLE} for config: {config}") return response["data"] + + def configure_secret_backend(self, name: str): + """Ensure a KV backend is enabled.""" + if "{}/".format(name) not in self._client.sys.list_mounted_secrets_engines(): + self._client.sys.enable_secrets_engine( + backend_type="kv", + description="Charm created KV backend", + path=name, + options={"version": 1}, + ) + + def configure_policy(self, name: str, hcl: str): + """Create/update a role within vault associating the supplied policies.""" + self._client.sys.create_or_update_policy(name, hcl) + + 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 id for an AppRole.""" + response = self._client.auth.approle.generate_secret_id(name, cidr_list=cidrs) + return response["data"]["secret_id"] + + def read_role_secret_id(self, name: str, id: str) -> dict: + """Get the secret id for 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 8b96d054..6c879d55 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -2,6 +2,7 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. +import json import unittest from unittest.mock import Mock, PropertyMock, call, patch @@ -175,3 +176,94 @@ def test_given_unit_is_leader_when_on_authorise_charm_action_then_status_is_acti self.harness.charm._on_authorise_charm_action(event) self.assertEqual(self.harness.charm.unit.status, ActiveStatus()) + + def setup_secrets_relation(self, nb_units: int = 1) -> tuple: + app_name = "app-a" + unit_name = app_name + "/0" + relation_name = "secrets" + + host_ip = "10.20.20.1" + self.harness.add_network(host_ip, endpoint="secrets") + 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}) + units[unit_name] = egress_subnet + + return ( + app_name, + host_ip, + relation_name, + rel_id, + units, + ) + + @patch("vault.Vault.generate_role_secret_id") + @patch("vault.Vault.configure_approle") + @patch("vault.Vault.configure_policy") + @patch("vault.Vault.configure_secret_backend") + def test_ready_vault_kv_clients( + self, + configure_secret_backend, + configure_policy, + configure_approle, + generate_role_secret_id, + ): + from charms.vault_k8s.v0.vault_kv import ReadyVaultKvClientsEvent + + from vault import SECRET_BACKEND_SHARED_HCL + + ( + app_name, + host_ip, + relation_name, + rel_id, + units, + ) = self.setup_secrets_relation(nb_units=3) + + configure_approle.return_value = "12345678" + generate_role_secret_id.return_value = "12345690" + + event = Mock(ReadyVaultKvClientsEvent) + event.secret_backend = "charm-" + app_name + event.relation_name = relation_name + event.relation_id = rel_id + self.harness.charm._on_ready_vault_kv_clients(event) + + configure_secret_backend_calls = [call(event.secret_backend)] + configure_policy_calls = [] + configure_approle_calls = [] + generate_role_secret_id_calls = [] + + for unit, egress_subnet in units.items(): + unit_name = unit.replace("/", "-") + policy_name = role_name = "charm-" + unit_name + configure_policy_calls.append( + call(policy_name, SECRET_BACKEND_SHARED_HCL.format(backend=event.secret_backend)) + ) + configure_approle_calls.append(call(role_name, [egress_subnet], [policy_name])) + generate_role_secret_id_calls.append(call(role_name, [egress_subnet])) + + configure_secret_backend.assert_has_calls(configure_secret_backend_calls) + configure_policy.assert_has_calls(configure_policy_calls, any_order=True) + configure_approle.assert_has_calls(configure_approle_calls, any_order=True) + generate_role_secret_id.assert_has_calls(generate_role_secret_id_calls, any_order=True) + + assert self.harness.get_relation_data(rel_id, self.harness.charm.app.name) == { + "vault_url": f"http://{host_ip}:{self.harness.charm.VAULT_PORT}", + "kv_mountpoint": event.secret_backend, + "credentials": json.dumps( + { + unit_name.replace("/", "-"): { + "role_id": configure_approle.return_value, + "role_secret_id": generate_role_secret_id.return_value, + } + for unit_name in units + }, + sort_keys=True, + ), + }