Files
charm-keystone-openidc/src/charm.py
Felipe Reyes 43a2215d3c Add support for websso-fid-service-provider relation.
This change adds support to relate the keystone-openidc charm to
openstack-dashboard allowing it expose a OpenID Connect backend for
logging into Horizon.

The configuration option 'user-facing-name' allows operator to set a
user friendly name that gets displayed in the list of choices available
for logging in.

Change-Id: Ia09cb5b68bc35d25f5b012f0011697966827eb03
2022-09-26 21:33:00 -03:00

469 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
#
# Copyright 2022 Canonical Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import json
import logging
import os
import subprocess
from typing import List, Optional
from uuid import uuid4
import ops_openstack.core
import requests
from ops.main import main
from ops.model import StatusBase, ActiveStatus, BlockedStatus
from ops_openstack.adapters import (
ConfigurationAdapter,
)
from charmhelpers.contrib.openstack import templating as os_templating
from charmhelpers.core import host as ch_host
from charmhelpers.core import templating
logger = logging.getLogger(__name__)
SYSTEM_CA_CERT = '/etc/ssl/certs/ca-certificates.crt'
CONFIG_DIR = '/etc/apache2/openidc'
HTTPS = 'https://'
class KeystoneOpenIDCError(Exception):
pass
class CharmConfigError(KeystoneOpenIDCError):
def __init__(self, msg):
"""Charm configuration error exception sets the unit in blocked state
:param msg: message to be set in the workload status.
"""
self.msg = msg
class KeystoneOpenIDCOptions(ConfigurationAdapter):
def __init__(self, charm_instance):
self.charm_instance = charm_instance
super().__init__(charm_instance)
def _get_principal_data(self):
relation = self.charm_instance.model.get_relation(
'keystone-fid-service-provider')
if relation and len(relation.units) > 0:
logger.debug('related units via keystone-fid-service-provider: %s',
relation.units)
return relation.data[list(relation.units)[0]]
else:
logger.debug('There are no related units via '
'keystone-fid-service-provider')
return None
@property
def hostname(self) -> Optional[str]:
"""Hostname as advertised by the principal charm."""
data = self._get_principal_data()
try:
return json.loads(data['hostname'])
except (TypeError, KeyError):
logger.debug('keystone hostname no available yet')
return None
@property
def openidc_location_config(self) -> str:
"""Path to the file with the OpenID Connect configuration."""
return os.path.join(self.charm_instance.config_dir,
f'openidc-location.{self.idp_id}.conf')
@property
def oidc_auth_path(self) -> str:
return (f'/v3/OS-FEDERATION/identity_providers/{self.idp_id}'
f'/protocols/openid/auth')
@property
def idp_id(self) -> str:
"""Identity provider name to use for URL generation."""
return 'openid'
@property
def scheme(self) -> Optional[str]:
data = self._get_principal_data()
try:
tls_enabled = json.loads(data['tls-enabled'])
return 'https' if tls_enabled else 'http'
except (TypeError, KeyError):
return None
@property
def port(self) -> Optional[int]:
data = self._get_principal_data()
try:
return json.loads(data['port'])
except (TypeError, KeyError):
return None
@property
def oidc_crypto_passphrase(self) -> Optional[str]:
relation = self.charm_instance.model.get_relation('cluster')
if not relation:
return None
data = relation.data[self.charm_instance.unit.app]
if not data:
logger.debug('data bag on peer relation not found, the cluster '
'relation is not ready.')
return None
crypto_passphrase = data.get('oidc-crypto-passphrase')
if crypto_passphrase:
logger.debug('Using oidc-crypto-passphrase from app databag')
return crypto_passphrase
else:
logger.warning('The oidc-crypto-passphrase has not been set')
return None
@property
def provider_metadata(self):
"""Metadata content offered by the Identity Provider.
The content available at the url configured in
oidc-provider-metadata-url is read and parsed as json.
"""
if self.oidc_provider_metadata_url:
logging.info('GETing content from %s',
self.oidc_provider_metadata_url)
try:
r = requests.get(self.oidc_provider_metadata_url,
verify=SYSTEM_CA_CERT)
return r.json()
except Exception:
logger.exception(('Failed to GET json content from provider '
'metadata url: %s'),
self.oidc_provider_metadata_url)
return None
else:
logging.info('Metadata was not retrieved since '
'oidc-provider-metadata-url is not set')
return None
@property
def oauth_introspection_endpoint(self):
if self.oidc_oauth_introspection_endpoint:
logger.debug('Using oidc_oauth_introspection_endpoint from config')
return self.oidc_oauth_introspection_endpoint
metadata = self.provider_metadata
if 'introspection_endpoint' in metadata:
logger.debug('Using introspection_endpoint from metadata')
return metadata['introspection_endpoint']
else:
logger.warning('OAuth introspection endpoint not found '
'in metadata')
return None
class KeystoneOpenIDCCharm(ops_openstack.core.OSBaseCharm):
PACKAGES = ['libapache2-mod-auth-openidc']
REQUIRED_RELATIONS = ['keystone-fid-service-provider',
'websso-fid-service-provider',
'cluster']
REQUIRED_KEYS = ['oidc_crypto_passphrase', 'oidc_client_id',
'hostname', 'port', 'scheme']
APACHE2_MODULE = 'auth_openidc'
CONFIG_FILE_OWNER = 'root'
CONFIG_FILE_GROUP = 'www-data'
release = 'xena' # First release supported.
auth_method = 'openid' # the driver to be used.
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
super().register_status_check(self._check_status)
self.options = KeystoneOpenIDCOptions(self)
# handlers
self.framework.observe(self.on.start, self._on_start)
self.framework.observe(self.on.config_changed, self._on_config_changed)
self.framework.observe(self.on.cluster_relation_created,
self._on_cluster_relation_created)
self.framework.observe(self.on.cluster_relation_changed,
self._on_cluster_relation_changed)
# keystone-fid-service-provider
self.framework.observe(
self.on.keystone_fid_service_provider_relation_changed,
self._on_keystone_fid_service_provider_relation_changed
)
# websso-fid-service-provider
self.framework.observe(
self.on.websso_fid_service_provider_relation_changed,
self._on_websso_fid_service_provider_relation_changed
)
# Event handlers
def on_install(self, _):
"""Install hook handler.
This event handler installs the list of packages defined in the
property PACKAGES and enables the openidc apache module.
"""
super().on_install(_)
self.enable_module()
def _on_start(self, _):
"""Start hook handler.
Set the flag `is_started` which is consumed by the update-status
hook. This charm doesn't run new services, so there is no need to
start anything.
"""
self._stored.is_started = True
def _on_keystone_fid_service_provider_relation_changed(self, event):
if not self.is_data_ready():
logger.debug('relation data is not ready yet (%s)', event)
# force the update of the workload message to bubble up the
# internal state of the charm
self.update_status()
return
self.update_principal_data()
self.update_config_if_needed()
def update_principal_data(self):
relation = self.model.get_relation('keystone-fid-service-provider')
if not relation:
logger.debug('There is no relation to the principal charm, '
'nothing to update')
return
data = relation.data[self.unit]
# When (if) this patch is merged, we can use auth-method
# https://review.opendev.org/c/openstack/charm-keystone/+/852601
# data['auth-method'] = json.dumps(self.auth_method)
data['protocol-name'] = json.dumps(self.options.idp_id)
data['remote-id-attribute'] = json.dumps(
self.options.remote_id_attribute)
def _on_websso_fid_service_provider_relation_changed(self, event):
self._update_websso_data()
def _update_websso_data(self):
"""Update websso-fid-service-provider relation data.
When there is a relation established via websso-fid-service-provider
this handler will take care of update the relation data with the
information that openstack-dashboard expects to enable WebSSO
Federation.
"""
relation = self.model.get_relation('websso-fid-service-provider')
if not relation:
logger.debug('There is not relation established via '
'websso-fid-service-provider interface')
return
data = relation.data[self.unit]
data['protocol-name'] = json.dumps(self.options.idp_id)
data['idp-name'] = json.dumps(self.options.protocol_id)
data['user-facing-name'] = json.dumps(self.options.user_facing_name)
def _on_config_changed(self, event):
if not self.is_data_ready():
logger.debug('relation data is not ready yet (%s)', event)
# force the update of the workload message to bubble up the
# internal state of the charm
self.update_status()
return
self._stored.is_started = True
self.update_config_if_needed()
self.update_principal_data()
self._update_websso_data()
def update_config_if_needed(self):
with ch_host.restart_on_change(
self.restart_map,
restart_functions=self.restart_functions):
self.render_config()
def _on_cluster_relation_created(self, _):
if self.unit.is_leader():
# we need to set the client secret since we are the leader and the
# secret hasn't been set.
data = None
relations = self.framework.model.relations.get(
'cluster')
for relation in relations:
data = relation.data[self.unit.app]
break
logger.info('Generating oidc-crypto-passphrase')
data.update({'oidc-crypto-passphrase': str(uuid4())})
else:
logger.debug('Not leader, skipping oidc-crypto-passphrase '
'generation')
def _on_cluster_relation_changed(self, _):
self._on_config_changed(_)
def is_data_ready(self) -> bool:
if not self.model.get_relation('cluster'):
return False
return len(self.find_missing_keys()) == 0
def find_missing_keys(self) -> List[str]:
"""Find keys not set that are needed for the charm to work correctly.
:returns: List of configuration keys that need to be set and are not.
:raises CharmConfigError: when a configuration key is set, yet
semantically incorrect.
"""
options = KeystoneOpenIDCOptions(self)
missing_keys = []
for key in self.REQUIRED_KEYS:
if getattr(options, key) in [None, '']:
missing_keys.append(key)
if not options.oidc_provider_metadata_url:
# list of configuration keys that need to be set when there is no
# metadata url to discover them.
keys = ['oidc_provider_issuer',
'oidc_provider_auth_endpoint',
'oidc_provider_token_endpoint',
'oidc_provider_token_endpoint_auth',
'oidc_provider_user_info_endpoint',
'oidc_provider_jwks_uri']
values = map(lambda k: getattr(options, k), keys)
if not any(values):
# None of the options is configured, so we inform that metadata
# is missing instead of assuming the user
# wants to use the metadata url.
missing_keys.append('oidc_provider_metadata_url')
elif not all(values):
missing_keys += list(filter(lambda k: not getattr(options, k),
keys))
if options.enable_oauth and options.oidc_provider_metadata_url:
if options.oidc_oauth_verify_jwks_uri:
if not options.oidc_oauth_verify_jwks_uri.startswith(HTTPS):
msg = ('oidc-auth-verify-jwks-uri is not set to a HTTPS '
'endpoint')
logger.error(msg)
raise CharmConfigError(msg)
else:
if not options.oidc_oauth_introspection_endpoint:
try:
endpoint = options.provider_metadata.get(
'introspection_endpoint')
if not endpoint.startswith(HTTPS):
msg = ('The introspection endpoint referenced in '
'the metadata url is not a HTTPS service, '
'which is the only kind scheme valid.')
logger.error(msg)
raise CharmConfigError(msg)
except AttributeError as ex:
logger.debug('%s', ex)
missing_keys.append(
'oidc-oauth-introspection-endpoint')
if missing_keys:
logger.debug('Incomplete data: %s', ' '.join(missing_keys))
return missing_keys
def services(self) -> List[str]:
"""Determine the list of services that should be running."""
return []
def _check_status(self) -> StatusBase:
try:
if self.is_data_ready():
return ActiveStatus()
else:
missing_keys = self.find_missing_keys()
if missing_keys:
msg = 'required keys: %s' % ', '.join(missing_keys)
else:
msg = 'incomplete data'
return BlockedStatus(msg)
except CharmConfigError as ex:
return BlockedStatus(ex.msg)
def enable_module(self):
"""Enable oidc Apache module."""
logger.info('Enabling apache2 module: %s', self.APACHE2_MODULE)
subprocess.check_call(['a2enmod', self.APACHE2_MODULE])
def disable_module(self):
"""Disable oidc Apache module."""
logger.info('Disabling apache2 module: %s', self.APACHE2_MODULE)
subprocess.check_call(['a2dismod', self.APACHE2_MODULE])
def request_restart(self, service_name=None):
"""Request a restart of the service to the principal.
:param service_name: name of the service to restart, but unused.
"""
relation = self.model.get_relation('keystone-fid-service-provider')
data = relation.data[self.unit]
logger.info('Requesting a restart to the principal charm')
data['restart-nonce'] = json.dumps(str(uuid4()))
def render_config(self):
"""Render Service Provider configuration files to be used by Apache."""
ch_host.mkdir(self.config_dir,
perms=0o750,
owner=self.CONFIG_FILE_OWNER,
group=self.CONFIG_FILE_GROUP)
templating.render(
source='apache-openidc-location.conf',
template_loader=os_templating.get_loader('templates/',
self.release),
target=self.options.openidc_location_config,
context={'options': KeystoneOpenIDCOptions(self)},
owner=self.CONFIG_FILE_OWNER,
group=self.CONFIG_FILE_GROUP,
perms=0o440
)
# properties
@property
def restart_map(self):
return {self.options.openidc_location_config: ['apache2']}
@property
def restart_functions(self):
return {'apache2': self.request_restart}
@property
def config_dir(self):
return CONFIG_DIR
if __name__ == "__main__":
main(KeystoneOpenIDCCharm)