Add "idp-metadata-url" option

Add an option to allow retrieving the IDP metadata from a URL and
keeping the metadata auto-updated on charm hook execution.

Change-Id: I65b20e52835497a3fe57571794f332b2b4327fba
Signed-off-by: Yanos Angelopoulos <yanos@admin.grnet.gr>
Co-authored-by: Stamatis Katsaounis <skatsaounis@admin.grnet.gr>
Co-authored-by: Dmitrii Shcherbakov <dmitrii.shcherbakov@canonical.com>
This commit is contained in:
Yanos Angelopoulos 2019-10-23 17:45:07 +03:00 committed by Dmitrii Shcherbakov
parent 7886e61203
commit fde3962318
4 changed files with 147 additions and 19 deletions

View File

@ -69,3 +69,10 @@ options:
IDP discovery service URL. If set to "" (default) no discovery IDP discovery service URL. If set to "" (default) no discovery
service will be used. If used, the resource "idp-metadata" must service will be used. If used, the resource "idp-metadata" must
be an XML file containing descriptors for multiple IDPs be an XML file containing descriptors for multiple IDPs
idp-metadata-url:
type: string
default:
description: |
An optional URL to retrieve IDP metadata from. If set, takes priority
over the "idp-metadata" resource. Auto-updates of metadata occur during
any hook execution, including update-status.

View File

@ -24,11 +24,14 @@ import charms_openstack.adapters
import os import os
import subprocess import subprocess
import urllib.request
import urllib.error
from lxml import etree from lxml import etree
from cryptography.hazmat.backends import default_backend from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
CONFIGS = (IDP_METADATA, SP_METADATA, SP_PRIVATE_KEY, CONFIGS = (IDP_METADATA, SP_METADATA, SP_PRIVATE_KEY,
SP_LOCATION_CONFIG,) = [ SP_LOCATION_CONFIG,) = [
os.path.join('/etc/apache2/mellon', os.path.join('/etc/apache2/mellon',
@ -130,29 +133,63 @@ class KeystoneSAMLMellonConfigurationAdapter(
IDP_METADATA_INVALID = ('idp-metadata resource is not a well-formed' IDP_METADATA_INVALID = ('idp-metadata resource is not a well-formed'
' xml file') ' xml file')
IDP_METADATA_URL_ERROR = ('an error has occurred during idp-metadata-url'
' config option processing')
IDP_METADATA_NOT_PROVIDED = ('idp-metadata resource has not been provided')
@property @property
def idp_metadata(self): def idp_metadata(self):
idp_metadata_path = hookenv.resource_get('idp-metadata') idp_metadata_content = None
if os.path.exists(idp_metadata_path) and not self._idp_metadata: if self.idp_metadata_url is None:
with open(idp_metadata_path) as f: # Get metadata from resource
content = f.read() idp_metadata_path = hookenv.resource_get('idp-metadata')
try: if (idp_metadata_path and
etree.fromstring(content.encode()) os.path.exists(idp_metadata_path) and not
self._idp_metadata = content self._idp_metadata):
self._validation_errors['idp-metadata'] = None with open(idp_metadata_path, 'r', encoding='utf-8') as f:
except etree.XMLSyntaxError: idp_metadata_content = f.read()
self._idp_metadata = '' else:
self._validation_errors['idp-metadata'] = ( self._validation_errors['idp-metadata'] =\
self.IDP_METADATA_INVALID) self.IDP_METADATA_NOT_PROVIDED
else:
try:
response = urllib.request.urlopen(self.idp_metadata_url)
except urllib.error.URLError as e:
self._validation_errors['idp-metadata'] = '{}: {}'.format(
self.IDP_METADATA_URL_ERROR,
e.reason
)
return self._idp_metadata
encoded_content = response.read()
idp_metadata_content = encoded_content.decode("utf-8")
# Metadata has been provided either via a resource or downloaded from
# the specified URL.
if idp_metadata_content is not None:
try:
etree.fromstring(idp_metadata_content.encode())
self._idp_metadata = idp_metadata_content
self._validation_errors['idp-metadata'] = None
except etree.XMLSyntaxError:
self._idp_metadata = None
self._validation_errors['idp-metadata'] = (
self.IDP_METADATA_INVALID)
return self._idp_metadata return self._idp_metadata
SP_SIGNING_KEYINFO_INVALID = ('sp-signing-keyinfo resource is not a' SP_SIGNING_KEYINFO_INVALID = ('sp-signing-keyinfo resource is not a'
' well-formed xml file') ' well-formed xml file')
SP_SIGNING_KEYINFO_NOT_PROVIDED = ('sp-signing-keyinfo resource has not'
' been provided')
@property @property
def sp_signing_keyinfo(self): def sp_signing_keyinfo(self):
info_path = hookenv.resource_get('sp-signing-keyinfo') info_path = hookenv.resource_get('sp-signing-keyinfo')
if not info_path:
self._sp_signing_keyinfo = None
self._validation_errors['sp-signing-keyinfo'] = (
self.SP_SIGNING_KEYINFO_NOT_PROVIDED)
return self._sp_signing_keyinfo
if os.path.exists(info_path) and not self._sp_signing_keyinfo: if os.path.exists(info_path) and not self._sp_signing_keyinfo:
self._sp_signing_keyinfo = None self._sp_signing_keyinfo = None
with open(info_path) as f: with open(info_path) as f:
@ -167,12 +204,19 @@ class KeystoneSAMLMellonConfigurationAdapter(
self.SP_SIGNING_KEYINFO_INVALID) self.SP_SIGNING_KEYINFO_INVALID)
return self._sp_signing_keyinfo return self._sp_signing_keyinfo
SP_PRIVATE_KEY_INVALID = ('resource is not a well-formed' SP_PRIVATE_KEY_INVALID = ('sp-private-key resource is not a well-formed'
' RFC 5958 (PKCS#8) key') ' RFC 5958 (PKCS#8) key')
SP_PRIVATE_KEY_NOT_PROVIDED = ('sp-private-key resource has not'
' been provided')
@property @property
def sp_private_key(self): def sp_private_key(self):
pk_path = hookenv.resource_get('sp-private-key') pk_path = hookenv.resource_get('sp-private-key')
if not pk_path:
self._sp_private_key = None
self._validation_errors['sp-private-key'] = (
self.SP_PRIVATE_KEY_NOT_PROVIDED)
return self._sp_private_key
if os.path.exists(pk_path) and not self._sp_private_key: if os.path.exists(pk_path) and not self._sp_private_key:
with open(pk_path) as f: with open(pk_path) as f:
content = f.read() content = f.read()
@ -245,8 +289,8 @@ class KeystoneSAMLMellonCharm(charms_openstack.charm.OpenStackCharm):
'protocol-name': self.options.protocol_name, 'protocol-name': self.options.protocol_name,
'user-facing-name': self.options.user_facing_name, 'user-facing-name': self.options.user_facing_name,
'idp-metadata': self.options.idp_metadata, 'idp-metadata': self.options.idp_metadata,
'sp-private-key': self.options.sp_private_key,
'sp-signing-keyinfo': self.options.sp_signing_keyinfo, 'sp-signing-keyinfo': self.options.sp_signing_keyinfo,
'sp-private-key': self.options.sp_private_key,
'nameid-formats': self.options.nameid_formats, 'nameid-formats': self.options.nameid_formats,
} }

View File

@ -32,7 +32,7 @@ target_deploy_status:
workload-status-message: Vault needs to be initialized workload-status-message: Vault needs to be initialized
keystone-saml-mellon: keystone-saml-mellon:
workload-status: blocked workload-status: blocked
workload-status-message: "Configuration is incomplete. idp-metadata: idp-metadata resource is not a well-formed xml file" workload-status-message: "Configuration is incomplete. idp-metadata: idp-metadata resource has not been provided,sp-signing-keyinfo: sp-signing-keyinfo resource has not been provided,sp-private-key: sp-private-key resource has not been provided"
tests_options: tests_options:
force_deploy: force_deploy:

View File

@ -13,6 +13,7 @@
# limitations under the License. # limitations under the License.
import mock import mock
import urllib.error
import charms_openstack.test_utils as test_utils import charms_openstack.test_utils as test_utils
@ -53,7 +54,8 @@ class Helper(test_utils.PatchHelper):
"protocol-name": self.protocol_name, "protocol-name": self.protocol_name,
"user-facing-name": self.user_facing_name, "user-facing-name": self.user_facing_name,
"nameid-formats": self.nameid_formats, "nameid-formats": self.nameid_formats,
"subject-confirmation-data-address-check": False "subject-confirmation-data-address-check": False,
"idp-metadata-url": None
} }
self.resources = { self.resources = {
"idp-metadata": "/path/to/idp-metadata.xml", "idp-metadata": "/path/to/idp-metadata.xml",
@ -194,16 +196,91 @@ class TestKeystoneSAMLMellonConfigurationAdapter(Helper):
"</EntityDescriptor>") "</EntityDescriptor>")
self.file.read.return_value = self.idp_metadata_xml self.file.read.return_value = self.idp_metadata_xml
self.assertEqual(ksmca.idp_metadata, self.idp_metadata_xml) self.assertEqual(ksmca.idp_metadata, self.idp_metadata_xml)
self.open.assert_called_with(self.resources["idp-metadata"]) self.open.assert_called_with(self.resources["idp-metadata"],
'r', encoding='utf-8')
# Inalid XML # Invalid XML
ksmca._idp_metadata = None ksmca._idp_metadata = None
self.file.read.return_value = "INVALID XML" self.file.read.return_value = "INVALID XML"
self.assertEqual(ksmca.idp_metadata, "") self.assertEqual(ksmca.idp_metadata, None)
self.assertEqual( self.assertEqual(
ksmca._validation_errors, ksmca._validation_errors,
{"idp-metadata": ksmca.IDP_METADATA_INVALID}) {"idp-metadata": ksmca.IDP_METADATA_INVALID})
def test_no_resources(self):
resources = {
"idp-metadata": False,
"sp-private-key": False,
"sp-signing-keyinfo": False
}
self.patch_object(keystone_saml_mellon.hookenv, 'resource_get',
side_effect=FakeResourceGet(resources))
ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter()
self.assertEqual(ksmca.idp_metadata, None)
self.assertEqual(
ksmca._validation_errors,
{"idp-metadata": ksmca.IDP_METADATA_NOT_PROVIDED})
self.assertEqual(ksmca.sp_signing_keyinfo, None)
self.assertEqual(
ksmca._validation_errors,
{"idp-metadata": ksmca.IDP_METADATA_NOT_PROVIDED,
"sp-signing-keyinfo": ksmca.SP_SIGNING_KEYINFO_NOT_PROVIDED})
self.assertEqual(ksmca.sp_private_key, None)
self.assertEqual(
ksmca._validation_errors,
{"idp-metadata": ksmca.IDP_METADATA_NOT_PROVIDED,
"sp-signing-keyinfo": ksmca.SP_SIGNING_KEYINFO_NOT_PROVIDED,
"sp-private-key": ksmca.SP_PRIVATE_KEY_NOT_PROVIDED})
def test_idp_metadata_url(self):
self.test_config.update(
{'idp-metadata-url': 'https://samltest.id/saml/idp'}
)
self.patch_object(keystone_saml_mellon.hookenv, 'config',
side_effect=FakeConfig(self.test_config))
resources = {
"idp-metadata": False,
"sp-private-key": False,
"sp-signing-keyinfo": False
}
self.patch_object(keystone_saml_mellon.hookenv, 'resource_get',
side_effect=FakeResourceGet(resources))
response = mock.MagicMock()
idp_meta_xml = (
"<?xml version='1.0' encoding='UTF-8'?>"
"<EntityDescriptor entityID='https://samltest.id/saml/idp'> "
"</EntityDescriptor>"
)
response.read.return_value = idp_meta_xml.encode('utf-8')
self.patch_object(keystone_saml_mellon.urllib.request, 'urlopen',
return_value=response)
ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter()
# Valid XML
self.assertEqual(ksmca.idp_metadata, idp_meta_xml)
# Invalid XML
response = mock.MagicMock()
response.read.return_value = "foobar42".encode('utf-8')
self.patch_object(keystone_saml_mellon.urllib.request, 'urlopen',
return_value=response)
ksmca._idp_metadata = None
self.assertEqual(ksmca.idp_metadata, None)
self.assertEqual(
ksmca._validation_errors,
{"idp-metadata": ksmca.IDP_METADATA_INVALID})
# Invalid URL
self.patch_object(keystone_saml_mellon.urllib.request, 'urlopen',
side_effect=urllib.error.URLError('invalid URL'))
self.assertEqual(ksmca.idp_metadata, None)
self.assertEqual(
ksmca._validation_errors,
{"idp-metadata": ksmca.IDP_METADATA_URL_ERROR + ': invalid URL'})
def test_sp_signing_keyinfo(self): def test_sp_signing_keyinfo(self):
self.os.path.exists.return_value = True self.os.path.exists.return_value = True
ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter() ksmca = keystone_saml_mellon.KeystoneSAMLMellonConfigurationAdapter()