Charmed-Kubernetes/kubernetes-control-plane/lib/charms/layer/vault_kv.py

274 lines
9.1 KiB
Python

import json
from hashlib import md5
from charmhelpers.core import hookenv
from charmhelpers.core import unitdata
from charmhelpers.contrib.openstack.vaultlocker import retrieve_secret_id
from charms.reactive import data_changed
from charms.reactive import endpoint_from_flag
from charms.reactive import set_flag, clear_flag, get_flags
import requests
import hvac
def log(msg, *args, **kwargs):
hookenv.log(
"vault-kv.log: {}".format(msg.format(*args, **kwargs)), level=hookenv.DEBUG
)
class VaultNotReady(Exception):
"""
Exception indicating that Vault was accessed before it was ready.
"""
pass
class _Singleton(type):
# metaclass to make a class a singleton
def __call__(cls, *args, **kwargs):
if not isinstance(getattr(cls, "_singleton_instance", None), cls):
cls._singleton_instance = super().__call__(*args, **kwargs)
return cls._singleton_instance
class _VaultBaseKV(dict, metaclass=_Singleton):
_path = None # set by subclasses
def __init__(self):
response = self._client.read(self._path)
data = response["data"] if response else {}
super().__init__(data)
@property
def _client(self):
"""
Get an authenticated hvac.Client.
The authentication token for the client is only valid for 60 seconds,
after which a new client will need to be authenticated.
"""
try:
log(
"Logging {cls} in to {vault_url}",
cls=type(self).__name__,
vault_url=self._config["vault_url"],
)
client = hvac.Client(url=self._config["vault_url"])
client.auth_approle(self._config["role_id"], self._config["secret_id"])
return client
except (
requests.exceptions.ConnectionError,
hvac.exceptions.VaultDown,
hvac.exceptions.VaultNotInitialized,
hvac.exceptions.BadGateway,
) as e:
raise VaultNotReady() from e
@property
def _config(self):
_VaultBaseKV._config = get_vault_config()
return _VaultBaseKV._config
def __setitem__(self, key, value):
log("Writing data to vault")
self._client.write(self._path, **{key: value})
super().__setitem__(key, value)
def set(self, key, value):
# alias in case a KV-like interface is preferred
self[key] = value
class VaultUnitKV(_VaultBaseKV):
"""
A simplified interface for storing data in Vault, with the data scoped to
the current unit.
Keys must be strings, but data can be structured as long as it is
JSON-serializable.
This class can be used as a dict, or you can use `self.get` and `self.set`
for a more KV-like interface. When values are set, via either style, they
are immediately persisted to Vault. Values are also cached in memory.
Note: This class is a singleton.
"""
def __init__(self):
unit_num = hookenv.local_unit().split("/")[1]
self._path = "{}/kv/unit/{}".format(self._config["secret_backend"], unit_num)
super().__init__()
class VaultAppKV(_VaultBaseKV):
"""
A simplified interface for storing data in Vault, with data shared by every
unit of the application.
Keys must be strings, but data can be structured as long as it is
JSON-serializable.
This class can be used as a dict, or you can use `self.get` and `self.set`
for a more KV-like interface. When values are set, via either style, they
are immediately persisted to Vault. Values are also cached in memory.
Note: This is intended to be used as a secure replacement for leadership
data. Therefore, only the leader should set data here. This is not
enforced, but data changed by non-leaders will not trigger hooks on other
units, so they may not be notified of changes in a timely fashion.
Note: This class is a singleton.
"""
def __init__(self):
self._path = "{}/kv/app".format(self._config["secret_backend"])
self._hash_path = "{}/kv/app-hashes/{}".format(
self._config["secret_backend"], hookenv.local_unit().split("/")[1]
)
super().__init__()
self._load_hashes()
def _load_hashes(self):
log("Reading hashes from {}", self._hash_path)
response = self._client.read(self._hash_path)
self._old_hashes = response["data"] if response else {}
self._new_hashes = {}
for key in self.keys():
self._rehash(key)
def _rehash(self, key):
serialized = json.dumps(self[key], sort_keys=True).encode("utf8")
self._new_hashes[key] = md5(serialized).hexdigest()
def __setitem__(self, key, value):
super().__setitem__(key, value)
self._rehash(key)
self._manage_flags(key)
def _manage_flags(self, key):
flag_any_changed = "layer.vault-kv.app-kv.changed"
flag_key_changed = "layer.vault-kv.app-kv.changed.{}".format(key)
flag_key_set = "layer.vault-kv.app-kv.set.{}".format(key)
if self.is_changed(key):
# clear then set flag to ensure triggers are run even if the main
# flag was never cleared
clear_flag(flag_any_changed)
set_flag(flag_any_changed)
clear_flag(flag_key_changed)
set_flag(flag_key_changed)
if self.get(key) is not None:
set_flag(flag_key_set)
else:
clear_flag(flag_key_set)
@classmethod
def _clear_all_flags(cls):
for flag in get_flags():
if flag.startswith("layer.vault-kv.app-kv."):
clear_flag(flag)
def is_changed(self, key):
"""
Determine if the value for the given key has changed.
In order to detect changes, hashes of the values are also stored
in Vault. These hashes are updated automatically at exit via
`self.update_hashes()`.
"""
return self._new_hashes.get(key) != self._old_hashes.get(key)
def any_changed(self):
"""
Determine if any data has changed.
In order to detect changes, hashes of the values are also stored
in Vault. These hashes are updated automatically at exit via
`self.update_hashes()`.
"""
all_keys = self._new_hashes.keys() | self._old_hashes.keys()
return any(self.is_changed(key) for key in all_keys)
def update_hashes(self):
"""
Update the hashes in Vault, thus marking all fields as unchanged.
This is done automatically at exit.
"""
log("Writing hashes to {}", self._hash_path)
self._client.write(self._hash_path, **self._new_hashes)
self._old_hashes.clear()
self._old_hashes.update(self._new_hashes)
def get_vault_config():
"""
Get the config data needed for this application to access Vault.
This is only needed if you're using another application, such as
VaultLocker, using the secrets backend provided by this layer.
Returns a dictionary containing the following keys:
* vault_url
* secret_backend
* role_id
* secret_id
Note: This data is cached in [UnitData][] so anything with access to that
could access Vault as this application.
If any of this data changes (such as the secret_id being rotated), this
layer will set the `layer.vault-kv.config.changed` flag.
If this is called before the Vault relation is available, it will raise
`VaultNotReady`.
[UnitData]: https://charm-helpers.readthedocs.io/en/latest/api/charmhelpers.core.unitdata.html
""" # noqa
vault = endpoint_from_flag("vault-kv.available")
if not (vault and vault.vault_url and vault.unit_role_id and vault.unit_token):
raise VaultNotReady()
vault_config = {
"vault_url": vault.vault_url,
"secret_backend": _get_secret_backend(),
"role_id": vault.unit_role_id,
"secret_id": _get_secret_id(vault),
}
return vault_config
def _get_secret_backend():
app_name = hookenv.application_name()
return "charm-{}".format(app_name)
def _get_secret_id(vault):
token = vault.unit_token
if data_changed("layer.vault-kv.token", token):
log("Changed unit_token, getting new secret_id")
# token is one-shot, but if it changes it might mean that we're
# being told to rotate the secret ID, or we might not have fetched
# one yet
vault_url = vault.vault_url
try:
secret_id = retrieve_secret_id(vault_url, token)
except (
requests.exceptions.ConnectionError,
hvac.exceptions.VaultDown,
hvac.exceptions.VaultNotInitialized,
hvac.exceptions.BadGateway,
) as e:
raise VaultNotReady() from e
unitdata.kv().set("layer.vault-kv.secret_id", secret_id)
# have to flush immediately because if we don't and hit some error
# elsewhere, it could get us into a state where we have forgotten the
# secret ID and can't retrieve it again because we've already used the
# token
unitdata.kv().flush()
else:
secret_id = unitdata.kv().get("layer.vault-kv.secret_id")
return secret_id