Implement relation for user ops
Change-Id: I81f9cfc548e002de71dbafa9254c6059fb9226d9
This commit is contained in:
@@ -14,14 +14,19 @@
|
|||||||
|
|
||||||
"""Base classes for defining a charm using the Operator framework."""
|
"""Base classes for defining a charm using the Operator framework."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from typing import (
|
from typing import (
|
||||||
Callable,
|
Callable,
|
||||||
Dict,
|
Dict,
|
||||||
|
FrozenSet,
|
||||||
List,
|
List,
|
||||||
Optional,
|
Optional,
|
||||||
Tuple,
|
Tuple,
|
||||||
|
Union,
|
||||||
)
|
)
|
||||||
from urllib.parse import (
|
from urllib.parse import (
|
||||||
urlparse,
|
urlparse,
|
||||||
@@ -1141,10 +1146,10 @@ class IdentityResourceRequiresHandler(RelationHandler):
|
|||||||
|
|
||||||
def setup_event_handler(self):
|
def setup_event_handler(self):
|
||||||
"""Configure event handlers for an Identity resource relation."""
|
"""Configure event handlers for an Identity resource relation."""
|
||||||
import charms.keystone_k8s.v0.identity_resource as id_ops
|
import charms.keystone_k8s.v0.identity_resource as ops_svc
|
||||||
|
|
||||||
logger.debug("Setting up Identity Resource event handler")
|
logger.debug("Setting up Identity Resource event handler")
|
||||||
ops_svc = id_ops.IdentityResourceRequires(
|
ops_svc = ops_svc.IdentityResourceRequires(
|
||||||
self.charm,
|
self.charm,
|
||||||
self.relation_name,
|
self.relation_name,
|
||||||
)
|
)
|
||||||
@@ -1318,3 +1323,440 @@ class CephAccessRequiresHandler(RelationHandler):
|
|||||||
ctxt["key"] = data.get("key")
|
ctxt["key"] = data.get("key")
|
||||||
ctxt["uuid"] = data.get("uuid")
|
ctxt["uuid"] = data.get("uuid")
|
||||||
return ctxt
|
return ctxt
|
||||||
|
|
||||||
|
|
||||||
|
ExtraOpsProcess = Callable[[ops.EventBase, dict], None]
|
||||||
|
|
||||||
|
|
||||||
|
class UserIdentityResourceRequiresHandler(RelationHandler):
|
||||||
|
"""Handle user management on IdentityResource relation."""
|
||||||
|
|
||||||
|
CREDENTIALS_SECRET_PREFIX = "user-identity-resource-"
|
||||||
|
CONFIGURE_SECRET_PREFIX = "configure-credential-"
|
||||||
|
|
||||||
|
resource_identifiers: FrozenSet[str] = frozenset(
|
||||||
|
{
|
||||||
|
"name",
|
||||||
|
"email",
|
||||||
|
"description",
|
||||||
|
"domain",
|
||||||
|
"project",
|
||||||
|
"project_domain",
|
||||||
|
"enable",
|
||||||
|
"may_exist",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
charm: ops.CharmBase,
|
||||||
|
relation_name: str,
|
||||||
|
callback_f: Callable,
|
||||||
|
mandatory: bool,
|
||||||
|
name: str,
|
||||||
|
domain: str,
|
||||||
|
email: Optional[str] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
project: Optional[str] = None,
|
||||||
|
project_domain: Optional[str] = None,
|
||||||
|
enable: bool = True,
|
||||||
|
may_exist: bool = True,
|
||||||
|
role: Optional[str] = None,
|
||||||
|
add_suffix: bool = False,
|
||||||
|
rotate: ops.SecretRotate = ops.SecretRotate.NEVER,
|
||||||
|
extra_ops: Optional[List[Union[dict, Callable]]] = None,
|
||||||
|
extra_ops_process: Optional[ExtraOpsProcess] = None,
|
||||||
|
):
|
||||||
|
self.username = name
|
||||||
|
super().__init__(charm, relation_name, callback_f, mandatory)
|
||||||
|
self.charm = charm
|
||||||
|
self.add_suffix = add_suffix
|
||||||
|
# add_suffix is used to add suffix to username to create unique user
|
||||||
|
self.role = role
|
||||||
|
self.rotate = rotate
|
||||||
|
self.extra_ops = extra_ops
|
||||||
|
self.extra_ops_process = extra_ops_process
|
||||||
|
|
||||||
|
self._params = {}
|
||||||
|
_locals = locals()
|
||||||
|
for keys in self.resource_identifiers:
|
||||||
|
value = _locals.get(keys)
|
||||||
|
if value is not None:
|
||||||
|
self._params[keys] = value
|
||||||
|
|
||||||
|
def setup_event_handler(self) -> ops.Object:
|
||||||
|
"""Configure event handlers for the relation."""
|
||||||
|
import charms.keystone_k8s.v0.identity_resource as id_ops
|
||||||
|
|
||||||
|
logger.debug("Setting up Identity Resource event handler")
|
||||||
|
ops_svc = id_ops.IdentityResourceRequires(
|
||||||
|
self.charm,
|
||||||
|
self.relation_name,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
ops_svc.on.provider_ready,
|
||||||
|
self._on_provider_ready,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
ops_svc.on.provider_goneaway,
|
||||||
|
self._on_provider_goneaway,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
ops_svc.on.response_available,
|
||||||
|
self._on_response_available,
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
self.charm.on.secret_changed, self._on_secret_changed
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
self.charm.on.secret_rotate, self._on_secret_rotate
|
||||||
|
)
|
||||||
|
self.framework.observe(
|
||||||
|
self.charm.on.secret_remove, self._on_secret_remove
|
||||||
|
)
|
||||||
|
return ops_svc
|
||||||
|
|
||||||
|
def _hash_ops(self, ops: list) -> str:
|
||||||
|
"""Hash ops request."""
|
||||||
|
return hashlib.sha256(json.dumps(ops).encode()).hexdigest()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
"""Secret label to share over keystone resource relation."""
|
||||||
|
return self.CREDENTIALS_SECRET_PREFIX + self.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_label(self) -> str:
|
||||||
|
"""Secret label to template configuration from."""
|
||||||
|
return self.CONFIGURE_SECRET_PREFIX + self.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _create_user_tag(self) -> str:
|
||||||
|
return "create_user_" + self.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _delete_user_tag(self) -> str:
|
||||||
|
return "delete_user_" + self.username
|
||||||
|
|
||||||
|
def random_string(self, length: int) -> str:
|
||||||
|
"""Utility function to generate secure random string."""
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for i in range(length))
|
||||||
|
|
||||||
|
def _ensure_credentials(self, refresh_user: bool = False) -> str:
|
||||||
|
credentials_id = self.charm.leader_get(self.label)
|
||||||
|
suffix_length = 6
|
||||||
|
password_length = 18
|
||||||
|
|
||||||
|
if credentials_id:
|
||||||
|
if refresh_user:
|
||||||
|
username = self.username
|
||||||
|
if self.add_suffix:
|
||||||
|
suffix = self.random_string(suffix_length)
|
||||||
|
username += "-" + suffix
|
||||||
|
secret = self.model.get_secret(id=credentials_id)
|
||||||
|
secret.set_content(
|
||||||
|
{
|
||||||
|
"username": username,
|
||||||
|
"password": self.random_string(password_length),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return credentials_id
|
||||||
|
|
||||||
|
username = self.username
|
||||||
|
password = self.random_string(password_length)
|
||||||
|
if self.add_suffix:
|
||||||
|
suffix = self.random_string(suffix_length)
|
||||||
|
username += "-" + suffix
|
||||||
|
secret = self.model.app.add_secret(
|
||||||
|
{"username": username, "password": password},
|
||||||
|
label=self.label,
|
||||||
|
rotate=self.rotate,
|
||||||
|
)
|
||||||
|
self.charm.leader_set({self.label: secret.id})
|
||||||
|
return secret.id # type: ignore[union-attr]
|
||||||
|
|
||||||
|
def _grant_ops_secret(self, relation: ops.Relation):
|
||||||
|
secret = self.model.get_secret(id=self._ensure_credentials())
|
||||||
|
secret.grant(relation)
|
||||||
|
|
||||||
|
def _get_credentials(self) -> Tuple[str, str]:
|
||||||
|
credentials_id = self._ensure_credentials()
|
||||||
|
secret = self.model.get_secret(id=credentials_id)
|
||||||
|
content = secret.get_content()
|
||||||
|
return content["username"], content["password"]
|
||||||
|
|
||||||
|
def get_config_credentials(self) -> Optional[Tuple[str, str]]:
|
||||||
|
"""Get credential from config secret."""
|
||||||
|
credentials_id = self.charm.leader_get(self.config_label)
|
||||||
|
if not credentials_id:
|
||||||
|
return None
|
||||||
|
secret = self.model.get_secret(id=credentials_id)
|
||||||
|
content = secret.get_content()
|
||||||
|
return content["username"], content["password"]
|
||||||
|
|
||||||
|
def _update_config_credentials(self) -> bool:
|
||||||
|
"""Update config credentials.
|
||||||
|
|
||||||
|
Returns True if credentials are updated, False otherwise.
|
||||||
|
"""
|
||||||
|
credentials_id = self.charm.leader_get(self.config_label)
|
||||||
|
username, password = self._get_credentials()
|
||||||
|
content = {"username": username, "password": password}
|
||||||
|
if credentials_id is None:
|
||||||
|
secret = self.model.app.add_secret(
|
||||||
|
content, label=self.config_label
|
||||||
|
)
|
||||||
|
self.charm.leader_set({self.config_label: secret.id})
|
||||||
|
return True
|
||||||
|
|
||||||
|
secret = self.model.get_secret(id=credentials_id)
|
||||||
|
old_content = secret.get_content()
|
||||||
|
if old_content != content:
|
||||||
|
secret.set_content(content)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _create_user_request(self) -> dict:
|
||||||
|
credentials_id = self._ensure_credentials()
|
||||||
|
username, _ = self._get_credentials()
|
||||||
|
requests = []
|
||||||
|
domain = self._params["domain"]
|
||||||
|
create_domain = {
|
||||||
|
"name": "create_domain",
|
||||||
|
"params": {"name": domain, "enable": True},
|
||||||
|
}
|
||||||
|
requests.append(create_domain)
|
||||||
|
if self.role:
|
||||||
|
create_role = {
|
||||||
|
"name": "create_role",
|
||||||
|
"params": {"name": self.role},
|
||||||
|
}
|
||||||
|
requests.append(create_role)
|
||||||
|
params = self._params.copy()
|
||||||
|
params.pop("name", None)
|
||||||
|
create_user = {
|
||||||
|
"name": "create_user",
|
||||||
|
"params": {
|
||||||
|
"name": username,
|
||||||
|
"password": credentials_id,
|
||||||
|
**params,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
requests.append(create_user)
|
||||||
|
requests.extend(self._create_role_requests(username, domain))
|
||||||
|
if self.extra_ops:
|
||||||
|
for extra_op in self.extra_ops:
|
||||||
|
if isinstance(extra_op, dict):
|
||||||
|
requests.append(extra_op)
|
||||||
|
elif callable(extra_op):
|
||||||
|
requests.append(extra_op())
|
||||||
|
else:
|
||||||
|
logger.debug(f"Invalid type of extra_op: {extra_op!r}")
|
||||||
|
|
||||||
|
request = {
|
||||||
|
"id": self._hash_ops(requests),
|
||||||
|
"tag": self._create_user_tag,
|
||||||
|
"ops": requests,
|
||||||
|
}
|
||||||
|
return request
|
||||||
|
|
||||||
|
def _create_role_requests(
|
||||||
|
self, username, domain: Optional[str]
|
||||||
|
) -> List[dict]:
|
||||||
|
requests = []
|
||||||
|
if self.role:
|
||||||
|
params = {
|
||||||
|
"role": self.role,
|
||||||
|
}
|
||||||
|
if domain:
|
||||||
|
params["domain"] = domain
|
||||||
|
params["user_domain"] = domain
|
||||||
|
project_domain = self._params.get("project_domain")
|
||||||
|
if project_domain:
|
||||||
|
params["project_domain"] = project_domain
|
||||||
|
params["user"] = username
|
||||||
|
grant_role_domain = {"name": "grant_role", "params": params}
|
||||||
|
requests.append(grant_role_domain)
|
||||||
|
project = self._params.get("project")
|
||||||
|
if project:
|
||||||
|
requests.append(
|
||||||
|
{
|
||||||
|
"name": "show_project",
|
||||||
|
"params": {
|
||||||
|
"name": project,
|
||||||
|
"domain": project_domain or domain,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
params = {
|
||||||
|
"project": "{{ show_project[0].id }}",
|
||||||
|
"role": "{{ create_role[0].id }}",
|
||||||
|
"user": "{{ create_user[0].id }}",
|
||||||
|
"user_domain": "{{ create_domain[0].id }}",
|
||||||
|
}
|
||||||
|
if project_domain:
|
||||||
|
params[
|
||||||
|
"project_domain"
|
||||||
|
] = "{{ show_project[0].domain_id }}"
|
||||||
|
requests.append(
|
||||||
|
{
|
||||||
|
"name": "grant_role",
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return requests
|
||||||
|
|
||||||
|
def _delete_user_request(self, users: List[str]) -> dict:
|
||||||
|
requests = []
|
||||||
|
for user in users:
|
||||||
|
params = {"name": user}
|
||||||
|
domain = self._params.get("domain")
|
||||||
|
if domain:
|
||||||
|
params["domain"] = domain
|
||||||
|
requests.append(
|
||||||
|
{
|
||||||
|
"name": "delete_user",
|
||||||
|
"params": params,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": self._hash_ops(requests),
|
||||||
|
"tag": self._delete_user_tag,
|
||||||
|
"ops": requests,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _process_create_user_response(self, response: dict) -> None:
|
||||||
|
if {op.get("return-code") for op in response.get("ops", [])} == {0}:
|
||||||
|
logger.debug("Create user completed.")
|
||||||
|
config_credentials = self.get_config_credentials()
|
||||||
|
credentials_updated = self._update_config_credentials()
|
||||||
|
if config_credentials and credentials_updated:
|
||||||
|
username = config_credentials[0]
|
||||||
|
self.add_user_to_delete_user_list(username)
|
||||||
|
else:
|
||||||
|
logger.debug("Error in creation of user ops " f"{response}")
|
||||||
|
|
||||||
|
def add_user_to_delete_user_list(self, user: str) -> None:
|
||||||
|
"""Update users list to delete."""
|
||||||
|
logger.debug(f"Adding user to delete list {user}")
|
||||||
|
old_users = self.charm.leader_get("old_users")
|
||||||
|
delete_users = json.loads(old_users) if old_users else []
|
||||||
|
if user not in delete_users:
|
||||||
|
delete_users.append(user)
|
||||||
|
self.charm.leader_set({"old_users": json.dumps(delete_users)})
|
||||||
|
|
||||||
|
def _process_delete_user_response(self, response: dict) -> None:
|
||||||
|
deleted_users = []
|
||||||
|
for op in response.get("ops", []):
|
||||||
|
if op.get("return-code") == 0:
|
||||||
|
deleted_users.append(op.get("value").get("name"))
|
||||||
|
else:
|
||||||
|
logger.debug(f"Error in running delete user for op {op}")
|
||||||
|
|
||||||
|
if deleted_users:
|
||||||
|
logger.debug(f"Deleted users: {deleted_users}")
|
||||||
|
|
||||||
|
old_users = self.charm.leader_get("old_users")
|
||||||
|
users_to_delete = json.loads(old_users) if old_users else []
|
||||||
|
new_users_to_delete = [
|
||||||
|
x for x in users_to_delete if x not in deleted_users
|
||||||
|
]
|
||||||
|
self.charm.leader_set({"old_users": json.dumps(new_users_to_delete)})
|
||||||
|
|
||||||
|
def _on_secret_changed(self, event: ops.SecretChangedEvent):
|
||||||
|
logger.debug(
|
||||||
|
f"secret-changed triggered for label {event.secret.label}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Secret change on configured user secret
|
||||||
|
if event.secret.label == self.config_label:
|
||||||
|
logger.debug(
|
||||||
|
"Calling configure charm to populate user info in "
|
||||||
|
"configuration files"
|
||||||
|
)
|
||||||
|
self.callback_f(event)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Ignoring the secret-changed event for label "
|
||||||
|
f"{event.secret.label}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_secret_rotate(self, event: ops.SecretRotateEvent):
|
||||||
|
# All the juju secrets are created on leader unit, so return
|
||||||
|
# if unit is not leader at this stage instead of checking at
|
||||||
|
# each secret.
|
||||||
|
logger.debug(f"secret-rotate triggered for label {event.secret.label}")
|
||||||
|
if not self.model.unit.is_leader():
|
||||||
|
logger.debug("Not leader unit, no action required")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Secret rotate on stack user secret sent to ops
|
||||||
|
if event.secret.label == self.label:
|
||||||
|
self._ensure_credentials(refresh_user=True)
|
||||||
|
request = self._create_user_request()
|
||||||
|
logger.debug(f"Sending ops request: {request}")
|
||||||
|
self.interface.request_ops(request)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Ignoring the secret-rotate event for label "
|
||||||
|
f"{event.secret.label}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_secret_remove(self, event: ops.SecretRemoveEvent):
|
||||||
|
logger.debug(f"secret-remove triggered for label {event.secret.label}")
|
||||||
|
if not self.model.unit.is_leader():
|
||||||
|
logger.debug("Not leader unit, no action required")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Secret remove on configured stack admin secret
|
||||||
|
if event.secret.label == self.config_label:
|
||||||
|
old_users = self.charm.leader_get("old_users")
|
||||||
|
users_to_delete = json.loads(old_users) if old_users else []
|
||||||
|
|
||||||
|
if not users_to_delete:
|
||||||
|
return
|
||||||
|
|
||||||
|
request = self._delete_user_request(users_to_delete)
|
||||||
|
logger.debug(f"Sending ops request: {request}")
|
||||||
|
self.interface.request_ops(request)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Ignoring the secret-remove event for label "
|
||||||
|
f"{event.secret.label}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_provider_ready(self, event) -> None:
|
||||||
|
"""Handles response available events."""
|
||||||
|
logger.info("Handle response from identity ops")
|
||||||
|
if not self.model.unit.is_leader():
|
||||||
|
return
|
||||||
|
self.interface.request_ops(self._create_user_request())
|
||||||
|
self._grant_ops_secret(event.relation)
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
def _on_response_available(self, event) -> None:
|
||||||
|
"""Handles response available events."""
|
||||||
|
if not self.model.unit.is_leader():
|
||||||
|
return
|
||||||
|
logger.info("Handle response from identity ops")
|
||||||
|
|
||||||
|
response = self.interface.response
|
||||||
|
tag = response.get("tag")
|
||||||
|
if tag == self._create_user_tag:
|
||||||
|
self._process_create_user_response(response)
|
||||||
|
if self.extra_ops_process is not None:
|
||||||
|
self.extra_ops_process(event, response)
|
||||||
|
elif tag == self._delete_user_tag:
|
||||||
|
self._process_delete_user_response(response)
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
def _on_provider_goneaway(self, event) -> None:
|
||||||
|
"""Handle gone_away event."""
|
||||||
|
self.callback_f(event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
"""Whether the relation is ready."""
|
||||||
|
return self.get_config_credentials() is not None
|
||||||
|
Reference in New Issue
Block a user