Commit 6f3751cc authored by Dmitrii Shcherbakov's avatar Dmitrii Shcherbakov
Browse files

add support for Federated IDentity (FID) and WebSSO

* add support for relating with subordinate charms providing Service
Provider functionality via apache2 authentication modules;
* enable additional authentication methods on the keystone side to
accept parsed assertion data provided via apache2 authentication module
variables exported to WSGI environment;
* move https frontend and WSGI API apache config files to keystone
instead of relying on charm-helpers as modifications are needed there to
add IncludeOptional directives. openstack_https_frontend.conf is added
on purpose as ServerName cannot be correctly determined after ProxyPass
which results in TLS errors during SAML exchange process;
* add an additional relation to openstack-dashboard to provide URL
information necessary to trust 'origin' parameter in WebSSO URLs used by
horizon during the authentication process. Also add a context to render
the federation section that is used to render this information in
keystone.conf;

Subordinates can choose to use different apache2 authentication modules.
If those modules support vhost-level variables then multiple
subordinates for the same module can be used. For example,
mod_auth_mellon can be used multiple times in different vhosts to
protect federated token endpoints related to different identity provider
and protocol combinations).

Trusted dashboard relation could be used to provide dashboard origin URL
from a different site via cross-model relations.

NOTE: this functionality will be triggered only on Ocata+ (inclusive)

Change-Id: I1ef623b0b0e2a9f68cec4be550965c5e15e5f561
parent cabb2377
keystone_hooks.py
\ No newline at end of file
keystone_hooks.py
\ No newline at end of file
keystone_hooks.py
\ No newline at end of file
keystone_hooks.py
\ No newline at end of file
......@@ -14,6 +14,7 @@
import hashlib
import os
import json
from base64 import b64decode
......@@ -39,6 +40,9 @@ from charmhelpers.core.hookenv import (
leader_get,
DEBUG,
INFO,
related_units,
relation_ids,
relation_get,
)
from charmhelpers.core.strutils import (
......@@ -405,3 +409,46 @@ class TokenFlushContext(context.OSContextGenerator):
'token_flush': is_elected_leader(DC_RESOURCE_NAME)
}
return ctxt
class KeystoneFIDServiceProviderContext(context.OSContextGenerator):
interfaces = ['keystone-fid-service-provider']
def __call__(self):
fid_sp_keys = ['protocol-name', 'remote-id-attribute']
fid_sps = []
for rid in relation_ids("keystone-fid-service-provider"):
for unit in related_units(rid):
rdata = relation_get(unit=unit, rid=rid)
if set(rdata).issuperset(set(fid_sp_keys)):
fid_sps.append({
k: json.loads(v) for k, v in rdata.items()
if k in fid_sp_keys
})
# populate the context with data from one or more
# service providers
ctxt = ({'fid_sps': fid_sps}
if fid_sps else {})
return ctxt
class WebSSOTrustedDashboardContext(context.OSContextGenerator):
interfaces = ['websso-trusted-dashboard']
def __call__(self):
trusted_dashboard_keys = ['scheme', 'hostname', 'path']
trusted_dashboards = set()
for rid in relation_ids("websso-trusted-dashboard"):
for unit in related_units(rid):
rdata = relation_get(unit=unit, rid=rid)
if set(rdata).issuperset(set(trusted_dashboard_keys)):
scheme = rdata.get('scheme')
hostname = rdata.get('hostname')
path = rdata.get('path')
url = '{}{}{}'.format(scheme, hostname, path)
trusted_dashboards.add(url)
# populate the context with data from one or more
# service providers
ctxt = ({'trusted_dashboards': trusted_dashboards}
if trusted_dashboards else {})
return ctxt
......@@ -40,6 +40,7 @@ from charmhelpers.core.hookenv import (
status_set,
open_port,
is_leader,
relation_id,
)
from charmhelpers.core.host import (
......@@ -121,7 +122,7 @@ from keystone_utils import (
ADMIN_DOMAIN,
ADMIN_PROJECT,
create_or_show_domain,
keystone_service,
restart_keystone,
)
from charmhelpers.contrib.hahelpers.cluster import (
......@@ -272,6 +273,7 @@ def config_changed_postupgrade():
update_all_identity_relation_units()
update_all_domain_backends()
update_all_fid_backends()
# Ensure sync request is sent out (needed for any/all ssl change)
send_ssl_sync_request()
......@@ -381,6 +383,17 @@ def update_all_domain_backends():
domain_backend_changed(relation_id=rid, unit=unit)
def update_all_fid_backends():
if CompareOpenStackReleases(os_release('keystone-common')) < 'ocata':
log('Ignoring keystone-fid-service-provider relation as it is'
' not supported on releases older than Ocata')
return
"""If there are any config changes, e.g. for domain or service port
make sure to update those for all relation-level buckets"""
for rid in relation_ids('keystone-fid-service-provider'):
update_keystone_fid_service_provider(relation_id=rid)
def leader_init_db_if_ready(use_current_context=False):
""" Initialise the keystone db if it is ready and mark it as initialised.
......@@ -784,11 +797,7 @@ def domain_backend_changed(relation_id=None, unit=None):
domain_nonce_key = 'domain-restart-nonce-{}'.format(domain_name)
db = unitdata.kv()
if restart_nonce != db.get(domain_nonce_key):
if not is_unit_paused_set():
if snap_install_requested():
service_restart('snap.keystone.*')
else:
service_restart(keystone_service())
restart_keystone()
db.set(domain_nonce_key, restart_nonce)
db.flush()
......@@ -869,6 +878,80 @@ def update_nrpe_config():
nrpe_setup.write()
@hooks.hook('keystone-fid-service-provider-relation-joined',
'keystone-fid-service-provider-relation-changed')
def keystone_fid_service_provider_changed():
if get_api_version() < 3:
log('Identity federation is only supported with keystone v3')
return
if CompareOpenStackReleases(os_release('keystone-common')) < 'ocata':
log('Ignoring keystone-fid-service-provider relation as it is'
' not supported on releases older than Ocata')
return
# for the join case a keystone public-facing hostname and service
# port need to be set
update_keystone_fid_service_provider(relation_id=relation_id())
# handle relation data updates (if any), e.g. remote_id_attribute
# and a restart will be handled via a nonce, not restart_on_change
CONFIGS.write(KEYSTONE_CONF)
# The relation is container-scoped so this keystone unit's unitdata
# will only contain a nonce of a single fid subordinate for a given
# fid backend (relation id)
restart_nonce = relation_get('restart-nonce')
if restart_nonce:
nonce = json.loads(restart_nonce)
# multiplex by relation id for multiple federated identity
# provider charms
fid_nonce_key = 'fid-restart-nonce-{}'.format(relation_id())
db = unitdata.kv()
if restart_nonce != db.get(fid_nonce_key):
restart_keystone()
db.set(fid_nonce_key, nonce)
db.flush()
@hooks.hook('keystone-fid-service-provider-relation-broken')
def keystone_fid_service_provider_broken():
if CompareOpenStackReleases(os_release('keystone-common')) < 'ocata':
log('Ignoring keystone-fid-service-provider relation as it is'
' not supported on releases older than Ocata')
return
restart_keystone()
@hooks.hook('websso-trusted-dashboard-relation-joined',
'websso-trusted-dashboard-relation-changed',
'websso-trusted-dashboard-relation-broken')
@restart_on_change(restart_map(), restart_functions=restart_function_map())
def websso_trusted_dashboard_changed():
if get_api_version() < 3:
log('WebSSO is only supported with keystone v3')
return
if CompareOpenStackReleases(os_release('keystone-common')) < 'ocata':
log('Ignoring WebSSO relation as it is not supported on'
' releases older than Ocata')
return
CONFIGS.write(KEYSTONE_CONF)
def update_keystone_fid_service_provider(relation_id=None):
tls_enabled = (config('ssl_cert') is not None and
config('ssl_key') is not None)
# reactive endpoints implementation on the other side, hence
# json-encoded values
fid_settings = {
'hostname': json.dumps(config('os-public-hostname')),
'port': json.dumps(config('service-port')),
'tls-enabled': json.dumps(tls_enabled),
}
relation_set(relation_id=relation_id,
relation_settings=fid_settings)
def main():
try:
hooks.execute(sys.argv)
......
......@@ -72,6 +72,7 @@ from charmhelpers.contrib.openstack.utils import (
install_os_snaps,
get_snaps_install_info_from_origin,
enable_memcache,
is_unit_paused_set,
)
from charmhelpers.core.strutils import (
......@@ -245,7 +246,9 @@ BASE_RESOURCE_MAP = OrderedDict([
keystone_context.HAProxyContext(),
context.BindHostContext(),
context.WorkerConfigContext(),
context.MemcacheContext(package='keystone')],
context.MemcacheContext(package='keystone'),
keystone_context.KeystoneFIDServiceProviderContext(),
keystone_context.WebSSOTrustedDashboardContext()],
}),
(KEYSTONE_LOGGER_CONF, {
'contexts': [keystone_context.KeystoneLoggingContext()],
......@@ -2574,3 +2577,11 @@ def post_snap_install():
if os.path.exists(PASTE_SRC):
log("Perfoming post snap install tasks", INFO)
shutil.copy(PASTE_SRC, PASTE_DST)
def restart_keystone():
if not is_unit_paused_set():
if snap_install_requested():
service_restart('snap.keystone.*')
else:
service_restart(keystone_service())
keystone_hooks.py
\ No newline at end of file
keystone_hooks.py
\ No newline at end of file
keystone_hooks.py
\ No newline at end of file
keystone_hooks.py
\ No newline at end of file
......@@ -39,6 +39,11 @@ requires:
domain-backend:
interface: keystone-domain-backend
scope: container
keystone-fid-service-provider:
interface: keystone-fid-service-provider
scope: container
websso-trusted-dashboard:
interface: websso-trusted-dashboard
peers:
cluster:
interface: keystone-ha
......@@ -67,7 +67,7 @@ driver = {{ assignment_backend }}
[oauth1]
[auth]
methods = external,password,token,oauth1
methods = external,password,token,oauth1,mapped,openid
password = keystone.auth.plugins.password.Password
token = keystone.auth.plugins.token.Token
oauth1 = keystone.auth.plugins.oauth1.OAuth
......@@ -115,3 +115,5 @@ group_allow_delete = False
admin_project_domain_name = {{ admin_domain_name }}
admin_project_name = admin
{% endif -%}
{% include "parts/section-federation" %}
{% if endpoints -%}
{% for ext_port in ext_ports -%}
Listen {{ ext_port }}
{% endfor -%}
{% for address, endpoint, ext, int in endpoints -%}
<VirtualHost {{ address }}:{{ ext }}>
ServerName {{ endpoint }}
SSLEngine on
SSLProtocol +TLSv1 +TLSv1.1 +TLSv1.2
SSLCipherSuite HIGH:!RC4:!MD5:!aNULL:!eNULL:!EXP:!LOW:!MEDIUM
SSLCertificateFile /etc/apache2/ssl/{{ namespace }}/cert_{{ endpoint }}
# See LP 1484489 - this is to support <= 2.4.7 and >= 2.4.8
SSLCertificateChainFile /etc/apache2/ssl/{{ namespace }}/cert_{{ endpoint }}
SSLCertificateKeyFile /etc/apache2/ssl/{{ namespace }}/key_{{ endpoint }}
ProxyPass / http://localhost:{{ int }}/
ProxyPassReverse / http://localhost:{{ int }}/
ProxyPreserveHost on
RequestHeader set X-Forwarded-Proto "https"
IncludeOptional /etc/apache2/mellon*/sp-location*.conf
</VirtualHost>
{% endfor -%}
<Proxy *>
Order deny,allow
Allow from all
</Proxy>
<Location />
Order allow,deny
Allow from all
</Location>
{% endif -%}
{% if trusted_dashboards %}
[federation]
{% for dashboard_url in trusted_dashboards -%}
trusted_dashboard = {{ dashboard_url }}
{% endfor -%}
{% endif %}
{% for sp in fid_sps -%}
[{{ sp['protocol-name'] }}]
remote_id_attribute = {{ sp['remote-id-attribute'] }}
{% endfor -%}
# Configuration file maintained by Juju. Local changes may be overwritten.
{% if port -%}
Listen {{ port }}
{% endif -%}
{% if admin_port -%}
Listen {{ admin_port }}
{% endif -%}
{% if public_port -%}
Listen {{ public_port }}
{% endif -%}
{% if port -%}
<VirtualHost *:{{ port }}>
WSGIDaemonProcess {{ service_name }} processes={{ processes }} threads={{ threads }} user={{ service_name }} group={{ service_name }} \
display-name=%{GROUP}
WSGIProcessGroup {{ service_name }}
WSGIScriptAlias / {{ script }}
WSGIApplicationGroup %{GLOBAL}
WSGIPassAuthorization On
<IfVersion >= 2.4>
ErrorLogFormat "%{cu}t %M"
</IfVersion>
ErrorLog /var/log/apache2/{{ service_name }}_error.log
CustomLog /var/log/apache2/{{ service_name }}_access.log combined
<Directory /usr/bin>
<IfVersion >= 2.4>
Require all granted
</IfVersion>
<IfVersion < 2.4>
Order allow,deny
Allow from all
</IfVersion>
</Directory>
IncludeOptional /etc/apache2/mellon*/sp-location*.conf
</VirtualHost>
{% endif -%}
{% if admin_port -%}
<VirtualHost *:{{ admin_port }}>
WSGIDaemonProcess {{ service_name }}-admin processes={{ admin_processes }} threads={{ threads }} user={{ service_name }} group={{ service_name }} \
display-name=%{GROUP}
WSGIProcessGroup {{ service_name }}-admin
WSGIScriptAlias / {{ admin_script }}
WSGIApplicationGroup %{GLOBAL}
WSGIPassAuthorization On
<IfVersion >= 2.4>
ErrorLogFormat "%{cu}t %M"
</IfVersion>
ErrorLog /var/log/apache2/{{ service_name }}_error.log
CustomLog /var/log/apache2/{{ service_name }}_access.log combined
<Directory /usr/bin>
<IfVersion >= 2.4>
Require all granted
</IfVersion>
<IfVersion < 2.4>
Order allow,deny
Allow from all
</IfVersion>
</Directory>
IncludeOptional /etc/apache2/mellon*/sp-location*.conf
</VirtualHost>
{% endif -%}
{% if public_port -%}
<VirtualHost *:{{ public_port }}>
WSGIDaemonProcess {{ service_name }}-public processes={{ public_processes }} threads={{ threads }} user={{ service_name }} group={{ service_name }} \
display-name=%{GROUP}
WSGIProcessGroup {{ service_name }}-public
WSGIScriptAlias / {{ public_script }}
WSGIApplicationGroup %{GLOBAL}
WSGIPassAuthorization On
<IfVersion >= 2.4>
ErrorLogFormat "%{cu}t %M"
</IfVersion>
ErrorLog /var/log/apache2/{{ service_name }}_error.log
CustomLog /var/log/apache2/{{ service_name }}_access.log combined
<Directory /usr/bin>
<IfVersion >= 2.4>
Require all granted
</IfVersion>
<IfVersion < 2.4>
Order allow,deny
Allow from all
</IfVersion>
</Directory>
IncludeOptional /etc/apache2/mellon*/sp-location*.conf
</VirtualHost>
{% endif -%}
......@@ -217,3 +217,204 @@ class TestKeystoneContexts(CharmTestCase):
mock_is_elected_leader.return_value = True
self.assertEqual({'token_flush': True}, ctxt())
@patch.object(context, 'relation_ids')
@patch.object(context, 'related_units')
@patch.object(context, 'relation_get')
def test_keystone_fid_service_provider_rdata(
self, mock_relation_get, mock_related_units,
mock_relation_ids):
os.environ['JUJU_UNIT_NAME'] = 'keystone'
def relation_ids_side_effect(rname):
return {
'keystone-fid-service-provider': {
'keystone-fid-service-provider:0',
'keystone-fid-service-provider:1',
'keystone-fid-service-provider:2'
}
}[rname]
mock_relation_ids.side_effect = relation_ids_side_effect
def related_units_side_effect(rid):
return {
'keystone-fid-service-provider:0': ['sp-mellon/0'],
'keystone-fid-service-provider:1': ['sp-shib/0'],
'keystone-fid-service-provider:2': ['sp-oidc/0'],
}[rid]
mock_related_units.side_effect = related_units_side_effect
def relation_get_side_effect(unit, rid):
# one unit only as the relation is container-scoped
return {
"keystone-fid-service-provider:0": {
"sp-mellon/0": {
"ingress-address": '10.0.0.10',
"protocol-name": '"saml2"',
"remote-id-attribute": '"MELLON_IDP"',
},
},
"keystone-fid-service-provider:1": {
"sp-shib/0": {
"ingress-address": '10.0.0.10',
"protocol-name": '"mapped"',
"remote-id-attribute": '"Shib-Identity-Provider"',
},
},
"keystone-fid-service-provider:2": {
"sp-oidc/0": {
"ingress-address": '10.0.0.10',
"protocol-name": '"oidc"',
"remote-id-attribute": '"HTTP_OIDC_ISS"',
},
},
}[rid][unit]
mock_relation_get.side_effect = relation_get_side_effect
ctxt = context.KeystoneFIDServiceProviderContext()
self.maxDiff = None
self.assertItemsEqual(
ctxt(),
{
"fid_sps": [
{
"protocol-name": "saml2",
"remote-id-attribute": "MELLON_IDP",
},
{
"protocol-name": "mapped",
"remote-id-attribute": "Shib-Identity-Provider",
},
{
"protocol-name": "oidc",
"remote-id-attribute": "HTTP_OIDC_ISS",
},
]
}
)
@patch.object(context, 'relation_ids')
def test_keystone_fid_service_provider_empty(
self, mock_relation_ids):
os.environ['JUJU_UNIT_NAME'] = 'keystone'
def relation_ids_side_effect(rname):
return {
'keystone-fid-service-provider': {}
}[rname]
mock_relation_ids.side_effect = relation_ids_side_effect
ctxt = context.KeystoneFIDServiceProviderContext()
self.maxDiff = None
self.assertItemsEqual(ctxt(), {})
@patch.object(context, 'relation_ids')
@patch.object(context, 'related_units')
@patch.object(context, 'relation_get')
def test_websso_trusted_dashboard_urls_generated(
self, mock_relation_get, mock_related_units,
mock_relation_ids):
os.environ['JUJU_UNIT_NAME'] = 'keystone'
def relation_ids_side_effect(rname):
return {
'websso-trusted-dashboard': {
'websso-trusted-dashboard:0',
'websso-trusted-dashboard:1',
'websso-trusted-dashboard:2'
}
}[rname]
mock_relation_ids.side_effect = relation_ids_side_effect
def related_units_side_effect(rid):
return {
'websso-trusted-dashboard:0': ['dashboard-blue/0',
'dashboard-blue/1'],
'websso-trusted-dashboard:1': ['dashboard-red/0',
'dashboard-red/1'],
'websso-trusted-dashboard:2': ['dashboard-green/0',
'dashboard-green/1']
}[rid]
mock_related_units.side_effect = related_units_side_effect
def relation_get_side_effect(unit, rid):
return {
"websso-trusted-dashboard:0": {
"dashboard-blue/0": { # dns-ha
"ingress-address": '10.0.0.10',
"scheme": "https://",
"hostname": "horizon.intranet.test",
"path": "/auth/websso/",
},
"dashboard-blue/1": { # dns-ha
"ingress-address": '10.0.0.11',
"scheme": "https://",
"hostname": "horizon.intranet.test",
"path": "/auth/websso/",
},
},
"websso-trusted-dashboard:1": {
"dashboard-red/0": { # vip
"ingress-address": '10.0.0.12',
"scheme": "https://",
"hostname": "10.0.0.100",
"path": "/auth/websso/",
},
"dashboard-red/1": { # vip
"ingress-address": '10.0.0.13',
"scheme": "https://",
"hostname": "10.0.0.100",
"path": "/auth/websso/",
},
},
"websso-trusted-dashboard:2": {
"dashboard-green/0": { # vip-less, dns-ha-less
"ingress-address": '10.0.0.14',
"scheme": "http://",
"hostname": "10.0.0.14",
"path": "/auth/websso/",
},
"dashboard-green/1": {
"ingress-address": '10.0.0.15',
"scheme": "http://",
"hostname": "10.0.0.15",
"path": "/auth/websso/",
},
},
}[rid][unit]
mock_relation_get.side_effect = relation_get_side_effect
ctxt = context.WebSSOTrustedDashboardContext()
self.maxDiff = None
self.assertEqual(
ctxt(),
{
'trusted_dashboards': set([
'https://horizon.intranet.test/auth/websso/',
'https://10.0.0.100/auth/websso/',
'http://10.0.0.14/auth/websso/',
'http://10.0.0.15/auth/websso/',
])
}
)
@patch.object(context, 'relation_ids')
</