Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add vault-kv relation #30

Merged
merged 14 commits into from
Sep 28, 2023
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
529 changes: 529 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 @@ -49,6 +49,10 @@ peers:
vault-peers:
interface: vault-peer

provides:
vault-kv:
interface: vault-kv

assumes:
- juju >= 3.1
- k8s-api
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ jinja2
jsonschema
lightkube
lightkube-models
pydantic
pytest-interface-tester
pyhcl
requests
jsonschema
Expand Down
202 changes: 202 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 jinja2 import Environment, FileSystemLoader
from ops.charm import (
CharmBase,
Expand All @@ -34,6 +35,8 @@
ActiveStatus,
MaintenanceStatus,
ModelError,
Relation,
Secret,
SecretNotFoundError,
WaitingStatus,
)
Expand All @@ -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(
Expand Down Expand Up @@ -133,13 +138,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 @@ -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)
gruyaume marked this conversation as resolved.
Show resolved Hide resolved
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:
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/templates/kv_mount.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
path "{mount}/*" {{
capabilities = ["create", "read", "update", "delete", "list"]
}}
path "sys/internal/ui/mounts/{mount}" {{
capabilities = ["read"]
}}
44 changes: 44 additions & 0 deletions src/vault.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
gboutry marked this conversation as resolved.
Show resolved Hide resolved
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"]
Loading