Browse Source

initial functional version

changes/88/659888/1
Dmitrii Shcherbakov 4 years ago
commit
acc262deea
  1. 202
      LICENSE
  2. 5
      rebuild
  3. 13
      requirements.txt
  4. 194
      src/README.md
  5. 52
      src/config.yaml
  6. 16
      src/copyright
  7. 72
      src/icon.svg
  8. 7
      src/layer.yaml
  9. 0
      src/lib/charm/openstack/__init__.py
  10. 344
      src/lib/charm/openstack/keystone_saml_mellon.py
  11. 74
      src/metadata.yaml
  12. 137
      src/reactive/keystone_saml_mellon_handlers.py
  13. 47
      src/templates/apache-mellon-location.conf
  14. 17
      src/templates/mellon-sp-metadata.xml
  15. 33
      src/test-requirements.txt
  16. 53
      src/tox.ini
  17. 7
      test-requirements.txt
  18. 55
      tox.ini
  19. 22
      unit_tests/__init__.py

202
LICENSE

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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.

5
rebuild

@ -0,0 +1,5 @@
# This file is used to trigger rebuilds
# when dependencies of the charm change,
# but nothing in the charm needs to.
# simply change the uuid to something new
5572890c-916b-4ec7-a77b-a9e9f53471ae

13
requirements.txt

@ -0,0 +1,13 @@
# The order of packages is significant, because pip processes them in the order
# of appearance. Changing the order has an impact on the overall integration
# process, which may cause wedges in the gate later.
pbr>=1.8.0,<1.9.0
PyYAML>=3.1.0
simplejson>=2.2.0
netifaces>=0.10.4
netaddr>=0.7.12,!=0.7.16
Jinja2>=2.6 # BSD License (3 clause)
six>=1.9.0
dnspython>=1.12.0
psutil>=1.1.1,<2.0.0
charm-tools

194
src/README.md

@ -0,0 +1,194 @@
# Overview
This subordinate charm provides a way to integrate a SAML-based identity
provider with Keystone using Mellon Apache web server authentication
module (mod_auth_mellon) and lasso as its dependency. Mellon acts as a
Service Provider in this case and provides SAML token attributes as WSGI
environment variables to Keystone which does not itself participate in
SAML exchanges - it merely interprets results of such exchanges
and maps assertion-derived attributes to entities (such as groups,
roles, projects and domains) in a local Keystone SQL database.
In general, any identity provider that conforms to SAML 2.0 will be
possible to integrate using this charm.
The following documentation is useful to better understand the charm
implementation:
* https://github.com/UNINETT/mod_auth_mellon/blob/master/doc/user_guide/mellon_user_guide.adoc
* https://github.com/UNINETT/mod_auth_mellon/blob/master/doc/user_guide/images/saml-web-sso.svg
* http://lasso.entrouvert.org/
* https://www.oasis-open.org/standards#samlv2.0
# Usage
Use this charm with the Keystone charm, running with preferred-api-version=3:
juju deploy keystone
juju config keystone preferred-api-version=3 # other settings
juju deploy openstack-dashboard # settings
juju deploy keystone-saml-mellon
juju add-relation keystone keystone-saml-mellon
juju add-relation keystone openstack-dashboard
In a bundle:
```
applications:
# ...
keystone-saml-mellon:
charm: cs:~dmitriis/keystone-saml-mellon
num_units: 0
options:
idp-name: 'myidp'
protocol-name: 'saml2'
user-facing-name: "myidp via saml2'
resources:
idp-metadata: "./FederationMetadata.xml"
sp-signing-keyinfo: "./sp-keyinfo.xml"
sp-private-key: "./mellon.pem"
relations:
# ...
- [ keystone, keystone-saml-mellon ]
- [ openstack-dashboard, keystone-saml-mellon ]
- [ "openstack-dashboard:websso-trusted-dashboard", "keystone:websso-trusted-dashboard" ]
```
# Prerequisites
In order to use this charm, there are several prerequisites that need to be
taken into account which require certain infrastructure to be set up out of
band, namely:
* PKI;
* DNS;
* NTP;
* idP.
On the Keystone charm side, this means that ssl_ca, ssl_cert, ssl_key,
use-https and os-public-hostname must be set.
Several key pairs can be used in a generic SAML exchange along with
certificates containing public keys. Besides the pairs used for message-level
signing and encryption there are also TLS certificates used for transport
layer encryption when a browser connects to a protected URL on the SP side or
when it gets redirected to an idP endpoint for authentication. In summary:
* Service Provider (Keystone) TLS termination certificates, keys and CA;
* Service Provider signing and encryption private keys and associated
public keys (SAML-level);
* Identity Provider TLS termination certificates, keys and CA;
* Identity Provider signing and encryption private keys and associated public
keys (SAML-level).
For a successful authentication to happen the following needs to hold:
* A user agent (browser) needs to
* trust an issuer (CA) of TLS certificates of an SP used for HTTPS;
* trust an issuer (CA) TLS certificates of an idP used for HTTPS;
* be able to resolve domain names present in subject or subjAltName fields.
* An SP needs to:
* be able to verify signed SAML messages sent by an idP via
public keys contained in certificates provided in the idP's metadata XML
and, if SAML-level encryption is enabled, decrypt those messages;
* An idP needs to:
* be able to verify signed SAML messages sent by an SP via
public keys contained in certificates provided in the SP's metadata XML
and, if SAML-level encryption is enabled, decrypt those messages.
Note that this does not mean that any actual checks are performed for
certificates related to SAML - only key material is used and there does
NOT have to be any PKI actually in-place, not even expiration times are
checked as per Mellon documentation. In that sense trust is very explicitly
defined by out of band mutual synchronization of SP and idP metadata files.
See SAML V2.0 Metadata Interoperability Profile (2.6.1) key processing
section for a normative reference.
However, this does not mean that no PKI will be in place - TLS certificates
used for HTTPS connectivity have to be verifiable by the entities that use
them. With Redirect or POST binding this is mainly about user agent being
able to validate SP or idP certificates - there is no direct communication
between the two outside the metadata synchronization step which is performed
by an operator out of band.
Additionally, for successful certificate verification clocks of all parties
need to be properly synchronized which is why it is important for NTP agents
to be able to reach proper NTP servers on SP and idP.
# Post-deployment Configuration
There are several post-deployment steps that have to be performed in order to
start using federated identity functionality in Keystone. They depend on the
chosen config values and also on an IDP configuration as it may put different
NameID values and attributes into SAML tokens. Token attributes are parsed by
mod_auth_mellon and are placed into WSGI environment which are used by
Keystone and they have the following format: "MELLON_<attribute_name>"
(one attribute can have multiple values in SAML). Both NameID and attribute
values can be used in mappings to map SAML token content to existing and, in
case of projects, potentially non-existing entities in Keystone database.
In order to take the above into account several objects need to be created:
* a domain used for federated users;
* (optional) a project to be used by federated users;
* one or more groups to place federated users into;
* role assignments for the groups above;
* an identity provider object;
* a mapping of NameID and SAML token attributes to Keystone entities;
* a federation protocol object.
```
cat > rules.json <<EOF
[{
"local": [
{
"user": {
"name": "{0}"
},
"group": {
"domain": {
"name": "federated_domain"
},
"name": "federated_users"
},
"projects": [
{
"name": "{0}",
"roles": [
{
"name": "Member"
}
]
}
]
}
],
"remote": [
{
"type": "MELLON_NAME_ID"
},
{
"type": "MELLON_groups",
"any_one_of": ["openstack-users"]
}
]
}]
EOF
openstack domain create federated_domain
openstack project create federated_project --domain federated_domain
openstack group create federated_users --domain federated_domain
# created group id: 0427a780b34441488f064526a9890edd
openstack role add --group 0427a780b34441488f064526a9890edd --domain federated_domain Member
openstack identity provider create --remote-id https://adfs.intranet.test/adfs/services/trust myidp
openstack mapping create --rules rules.json myidp_mapping
openstack federation protocol create mapped --mapping myidp_mapping --identity-provider myidp
# list related projects
openstack federation project list
```
# Bugs
Please report bugs on [Launchpad](https://bugs.launchpad.net/charm-keystone-saml-mellon/+filebug).
For general questions please refer to the OpenStack [Charm Guide](http://docs.openstack.org/developer/charm-guide/).

52
src/config.yaml

@ -0,0 +1,52 @@
options:
protocol-name:
type: string
default: 'mapped'
description: |
Protocol name to use for URL and generation. Must match the one that
will be configured via OS-FEDERATION API.
idp-name:
type: string
default: 'myidp'
description: |
Identity provider name to use for URL generation. Must match the one
that will be configured via OS-FEDERATION API.
user-facing-name:
type: string
default: 'myidp via mapped'
description: |
A user-facing name to be used for the identity provider and protocol
combination. Used in the OpenStack dashboard.
saml-encryption:
type: boolean
default: false
description: |
(optional)
Specifies whether SAML assertion encryption should be used. In many
cases this option is not needed as TLS is used to encrypt data at
the transport level. This option results in Service Provider metadata
rendered with the same KeyInfo used for both singing and encryption.
In practice, this means that the private key specified in sp-private-key
will be used for both signing SAML messages to an idP and decryption of
messages sent by idP. idP has to receive the SP metadata file with a
public key (or a cert) present with use="encryption" specified.
nameid-formats:
type: string
default: "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified,urn:oasis:names:tc:SAML:2.0:nameid-format:transient,urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress,urn:oasis:names:tc:SAML:2.0:nameid-format:persistent,urn:mace:shibboleth:1.0:nameIdentifier"
description: |
NameIDFormat entries to be used in Service Provider metadata file and in
SAML requests (comma-separated). Different NameID formats could be used
like transient, persistent, X509SubjectName, emailAddress, unspecified
and so on.
subject-confirmation-data-address-check:
type: boolean
default: true
description: |
This option is used to control the checking of client IP address
against the address returned by the IdP in Address attribute of
the SubjectConfirmationData node. Can be useful if your SP is
behind a reverse proxy or any kind of strange network topology
making IP address of client different for the IdP and the SP.
Default is on.
This can be used for testing with something like testshib if
you are behind a NAT.

16
src/copyright

@ -0,0 +1,16 @@
Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0
Files: *
Copyright: 2018, Canonical Ltd.
License: Apache-2.0
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.

72
src/icon.svg

@ -0,0 +1,72 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="100"
height="100"
viewBox="0 0 26.458333 26.458334"
version="1.1"
id="svg8"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
sodipodi:docname="SAML.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#000000"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="9.4171875"
inkscape:cx="47.529241"
inkscape:cy="54.618138"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="3706"
inkscape:window-height="2050"
inkscape:window-x="134"
inkscape:window-y="68"
inkscape:window-maximized="1"
inkscape:pagecheckerboard="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-0.84287374,-270.48546)"
style="display:inline">
<text
xml:space="preserve"
style="font-style:normal;font-weight:normal;font-size:2.13460207px;line-height:1.25;font-family:sans-serif;letter-spacing:0px;word-spacing:0px;fill:#7b2272;fill-opacity:1;stroke:none;stroke-width:0.26682526"
x="2.8836255"
y="284.00653"
id="text1053"
transform="scale(0.9915977,1.0084735)"><tspan
sodipodi:role="line"
id="tspan1051"
x="2.8836255"
y="284.00653"
style="font-size:8.46666622px;fill:#7b2272;fill-opacity:1;stroke-width:0.26682526">SAML</tspan></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

7
src/layer.yaml

@ -0,0 +1,7 @@
includes: ['layer:openstack', 'layer:leadership', 'interface:keystone-fid-service-provider', 'interface:websso-fid-service-provider', 'interface:juju-info']
options:
basic:
use_venv: True
include_system_packages: True
packages: ['python3-lxml', 'python3-cryptography']
repo: https://github.com/dshcherb/charm-keystone-saml-mellon

0
src/lib/charm/openstack/__init__.py

344
src/lib/charm/openstack/keystone_saml_mellon.py

@ -0,0 +1,344 @@
#
# Copyright 2017 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 charmhelpers.core as core
import charmhelpers.core.host as ch_host
import charmhelpers.core.hookenv as hookenv
import charmhelpers.core.unitdata as unitdata
import charmhelpers.contrib.openstack.templating as os_templating
import charmhelpers.contrib.openstack.utils as os_utils
import charms_openstack.charm
import charms_openstack.adapters
import os
import subprocess
from lxml import etree
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
# release detection is done via keystone package given that
# openstack-origin is not present in the subordinate charm
# see https://github.com/juju/charm-helpers/issues/83
from charms_openstack.charm.core import (
register_os_release_selector
)
OPENSTACK_RELEASE_KEY = 'charmers.openstack-release-version'
CONFIGS = (IDP_METADATA, SP_METADATA, SP_PRIVATE_KEY,
SP_LOCATION_CONFIG,) = [
os.path.join('/etc/apache2/mellon',
f.format(hookenv.service_name())) for f in [
'idp-meta.{}.xml',
'sp-meta.{}.xml',
'sp-pk.{}.pem',
'sp-location.{}.conf']]
@register_os_release_selector
def select_release():
"""Determine the release based on the keystone package version.
Note that this function caches the release after the first install so
that it doesn't need to keep going and getting it from the package
information.
"""
release_version = unitdata.kv().get(OPENSTACK_RELEASE_KEY, None)
if release_version is None:
release_version = os_utils.os_release('keystone')
unitdata.kv().set(OPENSTACK_RELEASE_KEY, release_version)
return release_version
class KeystoneSAMLMellonConfigurationAdapter(
charms_openstack.adapters.ConfigurationAdapter):
def __init__(self, charm_instance=None):
super().__init__(charm_instance=charm_instance)
self._idp_metadata = None
self._sp_private_key = None
self._sp_signing_keyinfo = None
self._validation_errors = {}
@property
def validation_errors(self):
return {k: v for k, v in
self._validation_errors.items() if v}
@property
def idp_metadata_file(self):
return IDP_METADATA
@property
def sp_metadata_file(self):
return SP_METADATA
@property
def sp_private_key_file(self):
return SP_PRIVATE_KEY
@property
def sp_location_config(self):
return SP_LOCATION_CONFIG
@property
def keystone_host(self):
return unitdata.kv().get('hostname')
@property
def keystone_port(self):
return unitdata.kv().get('port')
@property
def tls_enabled(self):
return unitdata.kv().get('tls-enabled')
@property
def keystone_base_url(self):
scheme = 'https' if self.tls_enabled else 'http'
return ('{}://{}:{}'.format(scheme, self.keystone_host,
self.keystone_port))
@property
def sp_idp_path(self):
return ('/v3/OS-FEDERATION/identity_providers/{}'
.format(self.idp_name))
@property
def sp_protocol_path(self):
return ('{}/protocols/{}'
.format(self.sp_idp_path, self.protocol_name))
@property
def sp_auth_path(self):
return '{}/auth'.format(self.sp_protocol_path)
@property
def mellon_endpoint_path(self):
return '{}/mellon'.format(self.sp_auth_path)
@property
def websso_auth_protocol_path(self):
return ('/v3/auth/OS-FEDERATION/websso/{}'
.format(self.protocol_name))
@property
def websso_auth_idp_protocol_path(self):
return ('/v3/auth/OS-FEDERATION/identity_providers'
'/{}/protocols/{}/websso'.format(
self.idp_name,
self.protocol_name
))
@property
def sp_post_response_path(self):
return '{}/postResponse'.format(self.mellon_endpoint_path)
@property
def sp_auth_url(self):
return '{}{}'.format(self.keystone_base_url,
self.sp_auth_path)
@property
def sp_logout_url(self):
return '{}/logout'.format(self.mellon_endpoint_path)
@property
def sp_post_response_url(self):
return '{}{}'.format(self.keystone_base_url,
self.sp_post_response_path)
@property
def mellon_subject_confirmation_data_address_check(self):
return ('On' if self.subject_confirmation_data_address_check
else 'Off')
@property
def supported_nameid_formats(self):
return self.nameid_formats.split(',')
IDP_METADATA_INVALID = ('idp-metadata resource is not a well-formed'
' xml file')
@property
def idp_metadata(self):
idp_metadata_path = hookenv.resource_get('idp-metadata')
if os.path.exists(idp_metadata_path) and not self._idp_metadata:
with open(idp_metadata_path) as f:
content = f.read()
try:
etree.fromstring(content)
self._idp_metadata = content
self._validation_errors['idp-metadata'] = None
except etree.XMLSyntaxError:
self._idp_metadata = ''
self._validation_errors['idp-metadata'] = (
self.IDP_METADATA_INVALID)
return self._idp_metadata
SP_SIGNING_KEYINFO_INVALID = ('sp-signing-keyinfo resource is not a'
' well-formed xml file')
@property
def sp_signing_keyinfo(self):
info_path = hookenv.resource_get('sp-signing-keyinfo')
if os.path.exists(info_path) and not self._sp_signing_keyinfo:
self._sp_signing_keyinfo = None
with open(info_path) as f:
content = f.read()
try:
etree.fromstring(content)
self._sp_signing_keyinfo = content
self._validation_errors['sp-signing-keyinfo'] = None
except etree.XMLSyntaxError:
self._sp_signing_keyinfo = ''
self._validation_errors['sp-signing-keyinfo'] = (
self.SP_SIGNING_KEYINFO_INVALID)
return self._sp_signing_keyinfo
SP_PRIVATE_KEY_INVALID = ('resource is not a well-formed'
' RFC 5958 (PKCS#8) key')
@property
def sp_private_key(self):
pk_path = hookenv.resource_get('sp-private-key')
if os.path.exists(pk_path) and not self._sp_private_key:
with open(pk_path) as f:
content = f.read()
try:
serialization.load_pem_private_key(
content.encode(),
password=None,
backend=default_backend()
)
self._sp_private_key = content
self._validation_errors['sp-private-key'] = None
except ValueError:
self._sp_private_key = ''
self._validation_errors['sp-private-key'] = (
self.SP_PRIVATE_KEY_INVALID)
return self._sp_private_key
class KeystoneSAMLMellonCharm(charms_openstack.charm.OpenStackCharm):
# Internal name of charm
service_name = name = 'keystone-saml-mellon'
# Package to derive application version from
version_package = 'keystone'
# First release supported
release = 'mitaka'
# List of packages to install for this charm
packages = ['libapache2-mod-auth-mellon']
configuration_class = KeystoneSAMLMellonConfigurationAdapter
# render idP metadata provided out of band to establish
# SP -> idP trust. A domain name config parameter is evaluated at
# class definition time but this happens every event execution,
# including config-changed. Changing domain-name dynamically is not
# a real use-case anyway and it should be defined deployment time.
string_templates = {
IDP_METADATA: ('options', 'idp_metadata'),
SP_PRIVATE_KEY: ('options', 'sp_private_key'),
}
def configuration_complete(self):
"""Determine whether sufficient configuration has been provided
via charm config options and resources.
:returns: boolean indicating whether configuration is complete
"""
required_config = {
'idp-name': self.options.idp_name,
'protocol-name': self.options.protocol_name,
'user-facing-name': self.options.user_facing_name,
'idp-metadata': self.options.idp_metadata,
'sp-private-key': self.options.sp_private_key,
'sp-signing-keyinfo': self.options.sp_signing_keyinfo,
'nameid-formats': self.options.nameid_formats,
}
return all(required_config.values())
def assess_status(self):
"""Determine the current application status for the charm"""
hookenv.application_version_set(self.application_version)
if not self.configuration_complete():
errors = [
'{}: {}'.format(k, v)
for k, v in self.options.validation_errors.items() if v]
status_msg = 'Configuration is incomplete. {}'.format(
','.join(errors))
hookenv.status_set('blocked', status_msg)
else:
hookenv.status_set('active',
'Unit is ready')
def render_config(self):
"""
Render Service Provider configuration file to be used by Apache
and provided to idP out of band to establish mutual trust.
"""
owner = 'root'
group = 'www-data'
# group read and exec is needed for mellon to read the rendered
# files, otherwise it will fail in a cryptic way
dperms = 0o650
# file permissions are a bit more restrictive than defaults in
# charm-helpers but directory permissions are the main protection
# mechanism in this case
fileperms = 0o440
# ensure that a directory we need is there
ch_host.mkdir('/etc/apache2/mellon', perms=dperms, owner=owner,
group=group)
self.render_configs(self.string_templates.keys())
core.templating.render(
source='mellon-sp-metadata.xml',
template_loader=os_templating.get_loader(
'templates/', self.release),
target=self.options.sp_metadata_file,
context=self.adapters_instance,
owner=owner,
group=group,
perms=fileperms
)
core.templating.render(
source='apache-mellon-location.conf',
template_loader=os_templating.get_loader(
'templates/', self.release),
target=self.options.sp_location_config,
context=self.adapters_instance,
owner=owner,
group=group,
perms=fileperms
)
def remove_config(self):
for f in CONFIGS:
if os.path.exists(f):
os.unlink(f)
def enable_module(self):
subprocess.check_call(['a2enmod', 'auth_mellon'])
def disable_module(self):
subprocess.check_call(['a2dismod', 'auth_mellon'])

74
src/metadata.yaml

@ -0,0 +1,74 @@
name: keystone-saml-mellon
subordinate: true
maintainer: OpenStack Charmers <openstack-charmers@lists.ubuntu.com>
summary: Federated identity with SAML via Mellon Service Provider
description:
The main goal of this charm is to generate the necessary configuration
for use in the Keystone charm related to Service Provider config
generation, trust establishment between a remote idP and SP via
certificates and signaling Keystone service restart.
Keystone has a concept of a federated backend which serves multiple
purposes including being a backend part of a Service Provider in an
authentication scenario where SAML is used. Unless ECP is used on a
keystone client side, SAML-related exchange is performed in an Apache
authentication module (Mellon in case of this charm) and SAML
assertions are converted to WSGI environment variables passed down to
a particular mod_wsgi interpreter running Keystone code. Keystone has
an authentication plug-in called "mapped" which does the rest of the
work of resolving symbolic attributes and using them in mappings
defined by an operator or validating the existence of referenced IDs.
tags:
- openstack
- identity
- federation
- idP
series:
- xenial
- bionic
- artful
- trusty
provides:
keystone-fid-service-provider:
interface: keystone-fid-service-provider
scope: container
websso-fid-service-provider:
interface: websso-fid-service-provider
scope: global
requires:
container:
interface: juju-info
scope: container
resources:
idp-metadata:
type: file
filename: 'idp-metadata.xml'
description: |
Identity Provider metadata XML file that conforms to
saml-metadata-2.0-os specification. This file contains idP
identification information and its certificates with public keys
that can be used for signing and encryption on the idP side in
IDPSSODescriptor and other information which can be used on the
service provider side to interact with that idP.
sp-private-key:
type: file
filename: 'sp-private-key.pem'
description: |
Private key used by Service Provider (mod_auth_mellon) to sign
and/or SAML-level (not transport-level) encryption.
sp-signing-keyinfo:
type: file
filename: 'sp-signing-keyinfo.xml'
description: |
Specifies a signing KeyInfo portion of SPSSODescriptor to be used
in Service Provider metadata. This should be an XML portion
which in the simplest case is formatted as shown below:
This fragment should contain a certificate that contains a public
key of a Service Provider in case an idP requires that SAML
requests are signed.
The term “signing certificate” is a misnomer. A signing
certificate in metadata is actually used for signature
verification, not signing. The private signing key is held
securely by the signing party (SP in this case). In a SAML
exchange an SP signs SAML messages with its private key and idP
validates them via a public key embedded in a certificate present
in the SP's metadata XML and vice versa for idP.

137
src/reactive/keystone_saml_mellon_handlers.py

@ -0,0 +1,137 @@
#
# Copyright 2017 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 uuid
# import to trigger openstack charm metaclass init
import charm.openstack.keystone_saml_mellon # noqa
import charms_openstack.charm as charm
import charms.reactive as reactive
import charms.reactive.flags as flags
import charmhelpers.core.unitdata as unitdata
from charms.reactive.relations import (
endpoint_from_flag,
)
charm.use_defaults(
'charm.installed',
'update-status')
# if config has been changed we need to re-evaluate flags
# config.changed is set and cleared (atexit) in layer-basic
flags.register_trigger(when='config.changed',
clear_flag='config.rendered')
flags.register_trigger(when='upgraded', clear_flag='config.rendered')
flags.register_trigger(when='config.changed',
clear_flag='config.complete')
flags.register_trigger(
when='endpoint.keystone-fid-service-provider.changed',
clear_flag='keystone-data.complete'
)
@reactive.hook('upgrade-charm')
def default_upgrade_charm():
"""Default handler for the 'upgrade-charm' hook.
This calls the charm.singleton.upgrade_charm() function as a default.
"""
reactive.set_state('upgraded')
# clear the upgraded state once config.rendered is set again
flags.register_trigger(when='config.rendered', clear_flag='upgraded')
@reactive.when_not('endpoint.keystone-fid-service-provider.joined')
def keystone_departed():
"""
Service restart should be handled on the keystone side
in this case.
"""
with charm.provide_charm_instance() as charm_instance:
charm_instance.remove_config()
@reactive.when('endpoint.keystone-fid-service-provider.joined')
@reactive.when_not('config.complete')
def config_changed():
with charm.provide_charm_instance() as charm_instance:
if charm_instance.configuration_complete():
flags.set_flag('config.complete')
@reactive.when('endpoint.keystone-fid-service-provider.joined')
@reactive.when_not('keystone-data.complete')
def keystone_data_changed(fid_sp):
primary_data = fid_sp.all_joined_units[0].received
if primary_data:
hostname = primary_data.get('hostname')
port = primary_data.get('port')
tls_enabled = primary_data.get('tls-enabled')
# a basic check on the fact that keystone provided us with
# hostname and port information
if hostname and port:
# save hostname and port data in local storage for future
# use - in case config is incomplete but a relation is
# we need to store this across charm hook invocations
unitdb = unitdata.kv()
unitdb.set('hostname', hostname)
unitdb.set('port', port)
unitdb.set('tls-enabled', tls_enabled)
flags.set_flag('keystone-data.complete')
@reactive.when('endpoint.keystone-fid-service-provider.joined')
@reactive.when('config.complete')
@reactive.when('keystone-data.complete')
@reactive.when_not('config.rendered')
def render_config():
# don't always have a relation context - obtain from the flag
fid_sp = endpoint_from_flag(
'endpoint.keystone-fid-service-provider.joined')
# get the first relation object as we only have one primary relation
rel = fid_sp.relations[0]
with charm.provide_charm_instance() as charm_instance:
charm_instance.render_config()
flags.set_flag('config.rendered')
# Trigger keystone restart. The relation is container-scoped
# so a per-unit db of a remote unit will only contain a nonce
# of a single subordinate
rel.to_publish['restart-nonce'] = str(uuid.uuid4())
@reactive.when('endpoint.websso-fid-service-provider.joined')
@reactive.when('config.complete')
@reactive.when('keystone-data.complete')
@reactive.when('config.rendered')
def configure_websso():
# don't always have a relation context - obtain from the flag
websso_fid_sp = endpoint_from_flag(
'endpoint.websso-fid-service-provider.joined')
with charm.provide_charm_instance() as charm_instance:
# publish config options for all remote units of a given rel
options = charm_instance.options
websso_fid_sp.publish(options.protocol_name,
options.idp_name,
options.user_facing_name)
@reactive.when_not('always.run')
def assess_status():
with charm.provide_charm_instance() as charm_instance:
charm_instance.assess_status()

47
src/templates/apache-mellon-location.conf

@ -0,0 +1,47 @@
<Location {{ options.sp_auth_path }}>
MellonEnable "info"
MellonSPPrivateKeyFile {{ options.sp_private_key_file }}
MellonSPMetadataFile {{ options.sp_metadata_file }}
MellonIdPMetadataFile {{ options.idp_metadata_file }}
MellonEndpointPath {{ options.mellon_endpoint_path }}
MellonIdP "IDP"
AuthType "Mellon"
MellonEnable "auth"
MellonSubjectConfirmationDataAddressCheck {{ options.mellon_subject_confirmation_data_address_check }}
AuthType "Mellon"
Require valid-user
MellonEnable "auth"
MellonMergeEnvVars On ";"
</Location>
<Location {{ '~' }} "{{ options.websso_auth_protocol_path }}">
MellonEnable "info"
MellonSPPrivateKeyFile {{ options.sp_private_key_file }}
MellonSPMetadataFile {{ options.sp_metadata_file }}
MellonIdPMetadataFile {{ options.idp_metadata_file }}
MellonEndpointPath {{ options.mellon_endpoint_path }}
MellonIdP "IDP"
AuthType "Mellon"
MellonEnable "auth"
MellonSubjectConfirmationDataAddressCheck {{ options.mellon_subject_confirmation_data_address_check }}
AuthType "Mellon"
Require valid-user
MellonEnable "auth"
MellonMergeEnvVars On ";"
</Location>
<Location {{ '~' }} "{{ options.websso_auth_idp_protocol_path }}">
MellonEnable "info"
MellonSPPrivateKeyFile {{ options.sp_private_key_file }}
MellonSPMetadataFile {{ options.sp_metadata_file }}
MellonIdPMetadataFile {{ options.idp_metadata_file }}
MellonEndpointPath {{ options.mellon_endpoint_path }}
MellonIdP "IDP"
AuthType "Mellon"
MellonEnable "auth"
MellonSubjectConfirmationDataAddressCheck {{ options.mellon_subject_confirmation_data_address_check }}
AuthType "Mellon"
Require valid-user
MellonEnable "auth"
MellonMergeEnvVars On ";"
</Location>

17
src/templates/mellon-sp-metadata.xml

@ -0,0 +1,17 @@
<EntityDescriptor entityID="{{ options.sp_auth_url }}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<KeyDescriptor use="signing">
{{ options.sp_signing_keyinfo }}
</KeyDescriptor>
{% if options.saml_encryption %}
<KeyDescriptor use="encryption">
{{ options.sp_signing_keyinfo }}
</KeyDescriptor>
{% endif %}
<SingleLogoutService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="{{ options.logout_url }}"/>
<AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{{ options.sp_post_response_url }}" index="0"/>
{% for format in options.supported_nameid_formats -%}
<NameIDFormat>{{ format }}</NameIDFormat>
{% endfor -%}
</SPSSODescriptor>
</EntityDescriptor>

33
src/test-requirements.txt

@ -0,0 +1,33 @@
# The order of packages is significant, because pip processes them in the order
# of appearance. Changing the order has an impact on the overall integration
# process, which may cause wedges in the gate later.
coverage>=3.6
mock>=1.2
flake8>=2.2.4,<=2.4.1
os-testr>=0.4.1
charm-tools>=2.0.0
requests==2.6.0
# amulet deployment helpers
git+https://github.com/juju/charm-helpers#egg=charmhelpers
# BEGIN: Amulet OpenStack Charm Helper Requirements
# Liberty client lower constraints
amulet>=1.14.3,<2.0
bundletester>=0.6.1,<1.0
aodhclient>=0.1.0
python-barbicanclient>=4.0.1
python-ceilometerclient>=1.5.0
python-cinderclient>=1.4.0
python-designateclient>=1.5
python-glanceclient>=1.1.0
python-heatclient>=0.8.0
python-keystoneclient>=1.7.1
python-manilaclient>=1.8.1
python-neutronclient>=3.1.0
python-novaclient>=2.30.1
python-openstackclient>=1.7.0
python-swiftclient>=2.6.0
pika>=0.10.0,<1.0
distro-info
# END: Amulet OpenStack Charm Helper Requirements
# NOTE: workaround for 14.04 pip/tox
pytz

53
src/tox.ini

@ -0,0 +1,53 @@
# Source charm: ./src/tox.ini
# This file is managed centrally by release-tools and should not be modified
# within individual charm repos.
[tox]
envlist = pep8
skipsdist = True
[testenv]
setenv = VIRTUAL_ENV={envdir}
PYTHONHASHSEED=0
AMULET_SETUP_TIMEOUT=2700
whitelist_externals = juju
passenv = HOME TERM AMULET_* CS_API_*
deps = -r{toxinidir}/test-requirements.txt
install_command =
pip install --allow-unverified python-apt {opts} {packages}
[testenv:pep8]
basepython = python2.7
commands = charm-proof
[testenv:func27-noop]
# DRY RUN - For Debug
basepython = python2.7
commands =
bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" -n --no-destroy
[testenv:func27]
# Run all gate tests which are +x (expected to always pass)
basepython = python2.7
commands =
bundletester -vl DEBUG -r json -o func-results.json --test-pattern "gate-*" --no-destroy
[testenv:func27-smoke]
# Run a specific test as an Amulet smoke test (expected to always pass)
basepython = python2.7
commands =
bundletester -vl DEBUG -r json -o func-results.json gate-basic-xenial-pike --no-destroy
[testenv:func27-dfs]
# Run all deploy-from-source tests which are +x (may not always pass!)
basepython = python2.7
commands =
bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dfs-*" --no-destroy
[testenv:func27-dev]
# Run all development test targets which are +x (may not always pass!)
basepython = python2.7
commands =
bundletester -vl DEBUG -r json -o func-results.json --test-pattern "dev-*" --no-destroy
[testenv:venv]
commands = {posargs}

7
test-requirements.txt

@ -0,0 +1,7 @@
# Lint and unit test requirements
flake8
os-testr>=0.4.1
charms.reactive
mock>=1.2
coverage>=3.6
git+https://github.com/openstack/charms.openstack.git#egg=charms-openstack

55
tox.ini

@ -0,0 +1,55 @@
# Source charm: ./tox.ini
# This file is managed centrally by release-tools and should not be modified
# within individual charm repos.
[tox]
skipsdist = True
envlist = pep8,py34,py35
skip_missing_interpreters = True
[testenv]
setenv = VIRTUAL_ENV={envdir}
PYTHONHASHSEED=0
TERM=linux
LAYER_PATH={toxinidir}/layers
INTERFACE_PATH={toxinidir}/interfaces
JUJU_REPOSITORY={toxinidir}/build
passenv = http_proxy https_proxy
install_command =
pip install {opts} {packages}
deps =
-r{toxinidir}/requirements.txt
[testenv:build]
basepython = python2.7
commands =
charm-build --log-level DEBUG -o {toxinidir}/build src {posargs}
[testenv:py27]
basepython = python2.7
# Reactive source charms are Python3-only, but a py27 unit test target
# is required by OpenStack Governance. Remove this shim as soon as
# permitted. http://governance.openstack.org/reference/cti/python_cti.html
whitelist_externals = true
commands = true
[testenv:py34]
basepython = python3.4
deps = -r{toxinidir}/test-requirements.txt
commands = ostestr {posargs}
[testenv:py35]
basepython = python3.5
deps = -r{toxinidir}/test-requirements.txt
commands = ostestr {posargs}
[testenv:pep8]
basepython = python3.5
deps = -r{toxinidir}/test-requirements.txt
commands = flake8 {posargs} src unit_tests
[testenv:venv]
commands = {posargs}
[flake8]
# E402 ignore necessary for path append before sys module import in actions
ignore = E402

22
unit_tests/__init__.py

@ -0,0 +1,22 @@
# Copyright 2016 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 sys
sys.path.append('src')
sys.path.append('src/lib')
# Mock out charmhelpers so that we can test without it.
import charms_openstack.test_mocks # noqa
charms_openstack.test_mocks.mock_charmhelpers()
Loading…
Cancel
Save