Skip to content

Commit

Permalink
[Feat] implement vault-kv relation
Browse files Browse the repository at this point in the history
  • Loading branch information
gboutry committed Sep 15, 2023
1 parent c70f037 commit 02f6a59
Show file tree
Hide file tree
Showing 8 changed files with 1,079 additions and 2 deletions.
424 changes: 424 additions & 0 deletions lib/charms/vault_k8s/v0/vault_kv.py

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ peers:
vault-peers:
interface: vault-peer

provides:
vault-kv:
interface: vault-kv

assumes:
- juju >= 3.1
- k8s-api
114 changes: 114 additions & 0 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
generate_csr,
generate_private_key,
)
from charms.vault_k8s.v0.vault_kv import NewVaultKvClientAttachedEvent, VaultKvProvides
from ops.charm import (
CharmBase,
ConfigChangedEvent,
Expand All @@ -33,6 +34,8 @@
ActiveStatus,
MaintenanceStatus,
ModelError,
Relation,
Secret,
SecretNotFoundError,
WaitingStatus,
)
Expand All @@ -47,6 +50,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_"


class PeerSecretError(Exception):
Expand Down Expand Up @@ -97,13 +102,17 @@ 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)
self.framework.observe(
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.
Expand Down Expand Up @@ -161,6 +170,7 @@ def _on_install(self, event: InstallEvent):
self._set_initialization_secret_in_peer_relation(root_token, unseal_keys)
vault.set_token(token=root_token)
vault.unseal(unseal_keys=unseal_keys)
vault.enable_approle_auth()

def _on_config_changed(self, event: ConfigChangedEvent) -> None:
"""Handler triggered whenever there is a config-changed event.
Expand Down Expand Up @@ -220,6 +230,7 @@ def _on_config_changed(self, event: ConfigChangedEvent) -> None:
return
if vault.is_sealed():
vault.unseal(unseal_keys=unseal_keys)
vault.enable_approle_auth()
self._set_peer_relation_node_api_address()
self.unit.status = ActiveStatus()

Expand Down Expand Up @@ -256,6 +267,99 @@ 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():
self.unit.status = WaitingStatus("Waiting for peer relation")
event.defer()
return
try:
root_token, _ = self._get_initialization_secret_from_peer_relation()
except PeerSecretError:
self.unit.status = WaitingStatus("Waiting for vault initialization secret")
event.defer()
return

relation = self.model.get_relation(event.relation_name, event.relation_id)

if relation is None or relation.app is None:
logger.debug("Relation or remote application is None, skipping")
return

vault = Vault(url=self._api_address)
vault.set_token(token=root_token)

if not vault.is_api_available():
self.unit.status = WaitingStatus("Waiting for vault to be available")
event.defer()
return

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)

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
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])
unit_secret = self.vault_kv.get_unit_credentials(relation, nonce)
secret = self._create_or_update_kv_secret(
vault,
relation,
role_id,
role_name,
egress_subnet,
unit_secret,
)
if secret is not None:
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_id: Optional[str],
) -> Optional[Secret]:
"""Create or update a KV secret for a unit."""
label = KV_SECRET_PREFIX + role_name
try:
secret = self.model.get_secret(id=secret_id, label=label)
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 None
credentials["role-secret-id"] = vault.generate_role_secret_id(
role_name, [egress_subnet]
)
secret.set_content(credentials)
except SecretNotFoundError:
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,
)
secret.grant(relation)
return secret

def _delete_vault_data(self) -> None:
"""Delete Vault's data."""
try:
Expand All @@ -277,6 +381,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.
Expand Down
51 changes: 51 additions & 0 deletions src/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@

logger = logging.getLogger(__name__)

KV_MOUNT_HCL = """
path "{mount}/*" {{
capabilities = ["create", "read", "update", "delete", "list"]
}}
path "sys/internal/ui/mounts/{mount}" {{
capabilities = ["read"]
}}
"""


class VaultError(Exception):
"""Exception raised for Vault errors."""
Expand Down Expand Up @@ -82,3 +91,45 @@ 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."""
self._client.sys.create_or_update_policy(policy, KV_MOUNT_HCL.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"]
Loading

0 comments on commit 02f6a59

Please sign in to comment.