Merge "Add openstack_groups to assertion"
This commit is contained in:
commit
9f9040257f
@ -12,6 +12,7 @@
|
||||
<Attribute id="openstack_roles" name="openstack_roles"/>
|
||||
<Attribute id="openstack_user" name="openstack_user"/>
|
||||
<Attribute id="openstack_user_domain" name="openstack_user_domain"/>
|
||||
<Attribute id="openstack_groups" name="openstack_groups"/>
|
||||
|
||||
<!-- First some useful eduPerson attributes that many sites might use. -->
|
||||
<Attribute name="urn:mace:dir:attribute-def:eduPersonPrincipalName" id="eppn">
|
||||
|
@ -892,7 +892,7 @@ look as follows:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<!-- example from a K2k Shibboleth implementation -->
|
||||
<!-- example 1 from a K2k Shibboleth implementation -->
|
||||
<Attribute name="openstack_user" id="openstack_user"/>
|
||||
<Attribute name="openstack_user_domain" id="openstack_user_domain"/>
|
||||
|
||||
@ -933,6 +933,57 @@ names we have in the Identity Provider. It will map any user with the name
|
||||
]
|
||||
}
|
||||
|
||||
A keystone user's groups can also be mapped to groups in the service provider.
|
||||
For example, with the following attributes declared in Shibboleth's attributes file:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<!-- example 2 from a K2k Shibboleth implementation -->
|
||||
<Attribute name="openstack_user" id="openstack_user"/>
|
||||
<Attribute name="openstack_groups" id="openstack_groups"/>
|
||||
|
||||
Then the following mapping can be used to map the user's group membership from the keystone
|
||||
IdP to groups in the keystone SP:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"rules": [
|
||||
{
|
||||
"local":
|
||||
[
|
||||
{
|
||||
"user":
|
||||
{
|
||||
"name": "{0}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"groups": "{1}"
|
||||
}
|
||||
],
|
||||
"remote":
|
||||
[
|
||||
{
|
||||
"type": "openstack_user"
|
||||
},
|
||||
{
|
||||
"type": "openstack_groups"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
``openstack_user``, and ``openstack_groups`` will be matched by service
|
||||
provider to the attribute names we have in the Identity Provider. It will
|
||||
take the ``openstack_user`` attribute and finds in the assertion then inserts
|
||||
it directly in the mapping. The identity provider will set the value of
|
||||
``openstack_groups`` by group name and domain name to which the user belongs
|
||||
in the Idp. Suppose the user belongs to 'group1' in domain 'Default' in the IdP
|
||||
then it will map to a group with the same name and same domain's name in the SP.
|
||||
|
||||
The possible attributes that can be used in a mapping are `openstack_user`,
|
||||
`openstack_user_domain`, `openstack_roles`, `openstack_project`, and
|
||||
`openstack_project_domain`.
|
||||
`openstack_user_domain`, `openstack_roles`, `openstack_project`,
|
||||
`openstack_project_domain` and `openstack_groups`.
|
||||
|
@ -236,11 +236,12 @@ attributes will be needed in ``/etc/shibboleth/attribute-map.xml``:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<Attribute name="openstack_user" id="openstack_user"/>
|
||||
<Attribute name="openstack_roles" id="openstack_roles"/>
|
||||
<Attribute name="openstack_project" id="openstack_project"/>
|
||||
<Attribute name="openstack_user_domain" id="openstack_user_domain"/>
|
||||
<Attribute name="openstack_project_domain" id="openstack_project_domain"/>
|
||||
<Attribute name="openstack_user" id="openstack_user"/>
|
||||
<Attribute name="openstack_roles" id="openstack_roles"/>
|
||||
<Attribute name="openstack_project" id="openstack_project"/>
|
||||
<Attribute name="openstack_user_domain" id="openstack_user_domain"/>
|
||||
<Attribute name="openstack_project_domain" id="openstack_project_domain"/>
|
||||
<Attribute name="openstack_groups" id="openstack_groups"/>
|
||||
|
||||
And update the ``REMOTE_USER`` variable in ``/etc/shibboleth/shibboleth2.xml``
|
||||
if desired:
|
||||
|
@ -10,6 +10,8 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from oslo_serialization import jsonutils
|
||||
|
||||
from keystone.common import provider_api
|
||||
import keystone.conf
|
||||
from keystone import exception
|
||||
@ -47,8 +49,28 @@ def create_base_saml_assertion(auth):
|
||||
project_domain_name = token.project_domain['name']
|
||||
subject_domain_name = token.user_domain['name']
|
||||
|
||||
def group_membership():
|
||||
"""Return a list of dictionaries serialized as strings.
|
||||
|
||||
The expected return structure is::
|
||||
|
||||
['JSON:{"name":"group1","domain":{"name":"Default"}}',
|
||||
'JSON:{"name":"group2","domain":{"name":"Default"}}']
|
||||
"""
|
||||
user_groups = []
|
||||
groups = PROVIDERS.identity_api.list_groups_for_user(
|
||||
token.user_id)
|
||||
for group in groups:
|
||||
user_group = {}
|
||||
group_domain_name = PROVIDERS.resource_api.get_domain(
|
||||
group['domain_id'])['name']
|
||||
user_group["name"] = group['name']
|
||||
user_group["domain"] = {'name': group_domain_name}
|
||||
user_groups.append('JSON:' + jsonutils.dumps(user_group))
|
||||
return user_groups
|
||||
groups = group_membership()
|
||||
generator = keystone_idp.SAMLGenerator()
|
||||
response = generator.samlize_token(
|
||||
issuer, sp_url, subject, subject_domain_name,
|
||||
role_names, project, project_domain_name)
|
||||
role_names, project, project_domain_name, groups)
|
||||
return response, service_provider
|
||||
|
@ -48,7 +48,8 @@ class SAMLGenerator(object):
|
||||
self.assertion_id = uuid.uuid4().hex
|
||||
|
||||
def samlize_token(self, issuer, recipient, user, user_domain_name, roles,
|
||||
project, project_domain_name, expires_in=None):
|
||||
project, project_domain_name, groups,
|
||||
expires_in=None):
|
||||
"""Convert Keystone attributes to a SAML assertion.
|
||||
|
||||
:param issuer: URL of the issuing party
|
||||
@ -65,6 +66,9 @@ class SAMLGenerator(object):
|
||||
:type project: string
|
||||
:param project_domain_name: Project Domain name
|
||||
:type project_domain_name: string
|
||||
:param groups: List of strings of user groups and domain name, where
|
||||
strings are serialized dictionaries.
|
||||
:type groups: list
|
||||
:param expires_in: Sets how long the assertion is valid for, in seconds
|
||||
:type expires_in: int
|
||||
|
||||
@ -76,7 +80,8 @@ class SAMLGenerator(object):
|
||||
saml_issuer = self._create_issuer(issuer)
|
||||
subject = self._create_subject(user, expiration_time, recipient)
|
||||
attribute_statement = self._create_attribute_statement(
|
||||
user, user_domain_name, roles, project, project_domain_name)
|
||||
user, user_domain_name, roles, project, project_domain_name,
|
||||
groups)
|
||||
authn_statement = self._create_authn_statement(issuer, expiration_time)
|
||||
signature = self._create_signature()
|
||||
|
||||
@ -162,7 +167,8 @@ class SAMLGenerator(object):
|
||||
return subject
|
||||
|
||||
def _create_attribute_statement(self, user, user_domain_name, roles,
|
||||
project, project_domain_name):
|
||||
project, project_domain_name,
|
||||
groups):
|
||||
"""Create an object that represents a SAML AttributeStatement.
|
||||
|
||||
<ns0:AttributeStatement>
|
||||
@ -188,6 +194,15 @@ class SAMLGenerator(object):
|
||||
<ns0:AttributeValue
|
||||
xsi:type="xs:string">Default</ns0:AttributeValue>
|
||||
</ns0:Attribute>
|
||||
<ns0:Attribute Name="openstack_groups">
|
||||
<ns0:AttributeValue
|
||||
xsi:type="xs:string">JSON:{"name":"group1","domain":{"name":"Default"}}
|
||||
</ns0:AttributeValue>
|
||||
<ns0:AttributeValue
|
||||
xsi:type="xs:string">JSON:{"name":"group2","domain":{"name":"Default"}}
|
||||
</ns0:AttributeValue>
|
||||
</ns0:Attribute>
|
||||
|
||||
</ns0:AttributeStatement>
|
||||
|
||||
:returns: XML <AttributeStatement> object
|
||||
@ -218,6 +233,11 @@ class SAMLGenerator(object):
|
||||
attribute_statement.attribute.append(project_attribute)
|
||||
attribute_statement.attribute.append(project_domain_attribute)
|
||||
attribute_statement.attribute.append(user_domain_attribute)
|
||||
|
||||
if groups:
|
||||
groups_attribute = _build_attribute(
|
||||
'openstack_groups', groups)
|
||||
attribute_statement.attribute.append(groups_attribute)
|
||||
return attribute_statement
|
||||
|
||||
def _create_authn_statement(self, issuer, expiration_time):
|
||||
|
@ -19,6 +19,7 @@ import flask
|
||||
import jsonschema
|
||||
from oslo_config import cfg
|
||||
from oslo_log import log
|
||||
from oslo_serialization import jsonutils
|
||||
from oslo_utils import timeutils
|
||||
|
||||
from keystone.common import provider_api
|
||||
@ -554,6 +555,48 @@ class RuleProcessor(object):
|
||||
LOG.debug('mapped_properties: %s', mapped_properties)
|
||||
return mapped_properties
|
||||
|
||||
def _normalize_groups(self, identity_value):
|
||||
# In this case, identity_value['groups'] is a string
|
||||
# representation of a list, and we want a real list. This is
|
||||
# due to the way we do direct mapping substitutions today (see
|
||||
# function _update_local_mapping() )
|
||||
if 'name' in identity_value['groups']:
|
||||
try:
|
||||
group_names_list = ast.literal_eval(
|
||||
identity_value['groups'])
|
||||
except (ValueError, SyntaxError):
|
||||
group_names_list = [identity_value['groups']]
|
||||
|
||||
def convert_json(group):
|
||||
if group.startswith('JSON:'):
|
||||
return jsonutils.loads(group.lstrip('JSON:'))
|
||||
return group
|
||||
|
||||
group_dicts = [convert_json(g) for g in group_names_list]
|
||||
for g in group_dicts:
|
||||
if 'domain' not in g:
|
||||
msg = _("Invalid rule: %(identity_value)s. Both "
|
||||
"'groups' and 'domain' keywords must be "
|
||||
"specified.")
|
||||
msg = msg % {'identity_value': identity_value}
|
||||
raise exception.ValidationError(msg)
|
||||
else:
|
||||
if 'domain' not in identity_value:
|
||||
msg = _("Invalid rule: %(identity_value)s. Both "
|
||||
"'groups' and 'domain' keywords must be "
|
||||
"specified.")
|
||||
msg = msg % {'identity_value': identity_value}
|
||||
raise exception.ValidationError(msg)
|
||||
try:
|
||||
group_names_list = ast.literal_eval(
|
||||
identity_value['groups'])
|
||||
except (ValueError, SyntaxError):
|
||||
group_names_list = [identity_value['groups']]
|
||||
domain = identity_value['domain']
|
||||
group_dicts = [{'name': name, 'domain': domain} for name in
|
||||
group_names_list]
|
||||
return group_dicts
|
||||
|
||||
def _transform(self, identity_values):
|
||||
"""Transform local mappings, to an easier to understand format.
|
||||
|
||||
@ -641,24 +684,7 @@ class RuleProcessor(object):
|
||||
groups_by_domain.setdefault(domain, list()).append(group)
|
||||
group_names.extend(extract_groups(groups_by_domain))
|
||||
if 'groups' in identity_value:
|
||||
if 'domain' not in identity_value:
|
||||
msg = _("Invalid rule: %(identity_value)s. Both 'groups' "
|
||||
"and 'domain' keywords must be specified.")
|
||||
msg = msg % {'identity_value': identity_value}
|
||||
raise exception.ValidationError(msg)
|
||||
# In this case, identity_value['groups'] is a string
|
||||
# representation of a list, and we want a real list. This is
|
||||
# due to the way we do direct mapping substitutions today (see
|
||||
# function _update_local_mapping() )
|
||||
try:
|
||||
group_names_list = ast.literal_eval(
|
||||
identity_value['groups'])
|
||||
except (ValueError, SyntaxError):
|
||||
group_names_list = [identity_value['groups']]
|
||||
domain = identity_value['domain']
|
||||
group_dicts = [{'name': name, 'domain': domain} for name in
|
||||
group_names_list]
|
||||
|
||||
group_dicts = self._normalize_groups(identity_value)
|
||||
group_names.extend(group_dicts)
|
||||
if 'group_ids' in identity_value:
|
||||
# If identity_values['group_ids'] is a string representation
|
||||
|
@ -801,6 +801,31 @@ class MappingRuleEngineTests(unit.BaseTestCase):
|
||||
]
|
||||
self.assertEqual(expected_projects, values['projects'])
|
||||
|
||||
def test_rule_engine_for_groups_and_domain(self):
|
||||
"""Should return user's groups and group domain.
|
||||
|
||||
The GROUP_DOMAIN_ASSERTION should successfully have a match in
|
||||
MAPPING_GROUPS_DOMAIN_OF_USER. This will test the case where a groups
|
||||
with its domain will exist`, and return user's groups and group domain.
|
||||
|
||||
"""
|
||||
mapping = mapping_fixtures.MAPPING_GROUPS_DOMAIN_OF_USER
|
||||
assertion = mapping_fixtures.GROUPS_DOMAIN_ASSERTION
|
||||
rp = mapping_utils.RuleProcessor(FAKE_MAPPING_ID, mapping['rules'])
|
||||
values = rp.process(assertion)
|
||||
|
||||
self.assertValidMappedUserObject(values)
|
||||
user_name = assertion.get('openstack_user')
|
||||
user_groups = ['group1', 'group2'] # since we know the input assertion
|
||||
groups = values.get('group_names', {})
|
||||
group_list = [g.get('name') for g in groups]
|
||||
group_ids = values.get('group_ids')
|
||||
name = values.get('user', {}).get('name')
|
||||
|
||||
self.assertEqual(user_name, name)
|
||||
self.assertEqual(user_groups, group_list)
|
||||
self.assertEqual([], group_ids, )
|
||||
|
||||
|
||||
class TestUnicodeAssertionData(unit.BaseTestCase):
|
||||
"""Ensure that unicode data in the assertion headers works.
|
||||
|
@ -1492,6 +1492,35 @@ MAPPING_GROUPS_WITH_EMAIL = {
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
MAPPING_GROUPS_DOMAIN_OF_USER = {
|
||||
"rules": [
|
||||
{
|
||||
"local":
|
||||
[
|
||||
{
|
||||
"user":
|
||||
{
|
||||
"name": "{0}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"groups": "{1}"
|
||||
}
|
||||
],
|
||||
"remote":
|
||||
[
|
||||
{
|
||||
"type": "openstack_user"
|
||||
},
|
||||
{
|
||||
"type": "openstack_groups"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
EMPLOYEE_ASSERTION = {
|
||||
'Email': 'tim@example.com',
|
||||
'UserName': 'tbo',
|
||||
@ -1650,6 +1679,14 @@ GROUPS_ASSERTION_ONLY_ONE_GROUP = {
|
||||
'groups': 'ALL USERS'
|
||||
}
|
||||
|
||||
GROUPS_DOMAIN_ASSERTION = {
|
||||
'openstack_user': 'bwilliams',
|
||||
'openstack_user_domain': 'default',
|
||||
'openstack_roles': 'Admin',
|
||||
'openstack_groups': 'JSON:{"name":"group1","domain":{"name":"xxx"}};'
|
||||
'JSON:{"name":"group2","domain":{"name":"yyy"}}'
|
||||
}
|
||||
|
||||
MAPPING_UNICODE = {
|
||||
"rules": [
|
||||
{
|
||||
|
@ -65,5 +65,9 @@ UHeBXxQq/GmfBv3l+V5ObQ+EHKnyDodLHCk=</ns1:X509Certificate>
|
||||
<ns0:Attribute Name="openstack_project_domain" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
|
||||
<ns0:AttributeValue xsi:type="xs:string">project_domain</ns0:AttributeValue>
|
||||
</ns0:Attribute>
|
||||
<ns0:Attribute Name="openstack_groups" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:uri">
|
||||
<ns0:AttributeValue xsi:type="xs:string">JSON:{"name":"group1","domain":{"name":"Default"}}</ns0:AttributeValue>
|
||||
<ns0:AttributeValue xsi:type="xs:string">JSON:{"name":"group2","domain":{"name":"Default"}}</ns0:AttributeValue>
|
||||
</ns0:Attribute>
|
||||
</ns0:AttributeStatement>
|
||||
</ns0:Assertion>
|
||||
|
@ -3817,6 +3817,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
ROLES = ['admin', 'member']
|
||||
PROJECT = 'development'
|
||||
PROJECT_DOMAIN = 'project_domain'
|
||||
GROUPS = ['JSON:{"name":"group1","domain":{"name":"Default"}}',
|
||||
'JSON:{"name":"group2","domain":{"name":"Default"}}']
|
||||
SAML_GENERATION_ROUTE = '/auth/OS-FEDERATION/saml2'
|
||||
ECP_GENERATION_ROUTE = '/auth/OS-FEDERATION/saml2/ecp'
|
||||
ASSERTION_VERSION = "2.0"
|
||||
@ -3848,7 +3850,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
self.SUBJECT,
|
||||
self.SUBJECT_DOMAIN,
|
||||
self.ROLES, self.PROJECT,
|
||||
self.PROJECT_DOMAIN)
|
||||
self.PROJECT_DOMAIN,
|
||||
self.GROUPS)
|
||||
|
||||
assertion = response.assertion
|
||||
self.assertIsNotNone(assertion)
|
||||
@ -3878,6 +3881,10 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
self.assertEqual(self.PROJECT_DOMAIN,
|
||||
project_domain_attribute.attribute_value[0].text)
|
||||
|
||||
group_attribute = assertion.attribute_statement[0].attribute[5]
|
||||
for attribute_value in group_attribute.attribute_value:
|
||||
self.assertIn(attribute_value.text, self.GROUPS)
|
||||
|
||||
def test_comma_in_certfile_path(self):
|
||||
self.config_fixture.config(
|
||||
group='saml',
|
||||
@ -3892,7 +3899,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
self.SUBJECT_DOMAIN,
|
||||
self.ROLES,
|
||||
self.PROJECT,
|
||||
self.PROJECT_DOMAIN)
|
||||
self.PROJECT_DOMAIN,
|
||||
self.GROUPS)
|
||||
|
||||
def test_comma_in_keyfile_path(self):
|
||||
self.config_fixture.config(
|
||||
@ -3908,7 +3916,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
self.SUBJECT_DOMAIN,
|
||||
self.ROLES,
|
||||
self.PROJECT,
|
||||
self.PROJECT_DOMAIN)
|
||||
self.PROJECT_DOMAIN,
|
||||
self.GROUPS)
|
||||
|
||||
def test_verify_assertion_object(self):
|
||||
"""Test that the Assertion object is built properly.
|
||||
@ -3924,7 +3933,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
self.SUBJECT,
|
||||
self.SUBJECT_DOMAIN,
|
||||
self.ROLES, self.PROJECT,
|
||||
self.PROJECT_DOMAIN)
|
||||
self.PROJECT_DOMAIN,
|
||||
self.GROUPS)
|
||||
assertion = response.assertion
|
||||
self.assertEqual(self.ASSERTION_VERSION, assertion.version)
|
||||
|
||||
@ -3943,7 +3953,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
self.SUBJECT,
|
||||
self.SUBJECT_DOMAIN,
|
||||
self.ROLES, self.PROJECT,
|
||||
self.PROJECT_DOMAIN)
|
||||
self.PROJECT_DOMAIN,
|
||||
self.GROUPS)
|
||||
|
||||
saml_str = response.to_string()
|
||||
response = etree.fromstring(saml_str)
|
||||
@ -3969,6 +3980,10 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
project_domain_attribute = assertion[4][4]
|
||||
self.assertEqual(self.PROJECT_DOMAIN, project_domain_attribute[0].text)
|
||||
|
||||
group_attribute = assertion[4][5]
|
||||
for attribute_value in group_attribute:
|
||||
self.assertIn(attribute_value.text, self.GROUPS)
|
||||
|
||||
def test_assertion_using_explicit_namespace_prefixes(self):
|
||||
def mocked_subprocess_check_output(*popenargs, **kwargs):
|
||||
# the last option is the assertion file to be signed
|
||||
@ -3987,7 +4002,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
self.SUBJECT,
|
||||
self.SUBJECT_DOMAIN,
|
||||
self.ROLES, self.PROJECT,
|
||||
self.PROJECT_DOMAIN)
|
||||
self.PROJECT_DOMAIN,
|
||||
self.GROUPS)
|
||||
assertion_xml = response.assertion.to_string()
|
||||
# The expected values in the assertions bellow need to be 'str' in
|
||||
# Python 2 and 'bytes' in Python 3
|
||||
@ -4016,7 +4032,8 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
response = generator.samlize_token(self.ISSUER, self.RECIPIENT,
|
||||
self.SUBJECT, self.SUBJECT_DOMAIN,
|
||||
self.ROLES, self.PROJECT,
|
||||
self.PROJECT_DOMAIN)
|
||||
self.PROJECT_DOMAIN,
|
||||
self.GROUPS)
|
||||
|
||||
signature = response.assertion.signature
|
||||
self.assertIsNotNone(signature)
|
||||
@ -4131,6 +4148,9 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
project_domain_attribute = assertion[4][4]
|
||||
self.assertIsInstance(project_domain_attribute[0].text, str)
|
||||
|
||||
group_attribute = assertion[4][5]
|
||||
self.assertIsInstance(group_attribute[0].text, str)
|
||||
|
||||
def test_invalid_scope_body(self):
|
||||
"""Test that missing the scope in request body raises an exception.
|
||||
|
||||
@ -4246,6 +4266,9 @@ class SAMLGenerationTests(test_v3.RestfulTestCase):
|
||||
project_domain_attribute = assertion[4][4]
|
||||
self.assertIsInstance(project_domain_attribute[0].text, str)
|
||||
|
||||
group_attribute = assertion[4][5]
|
||||
self.assertIsInstance(group_attribute[0].text, str)
|
||||
|
||||
@mock.patch('saml2.create_class_from_xml_string')
|
||||
@mock.patch('oslo_utils.fileutils.write_to_tempfile')
|
||||
@mock.patch.object(subprocess, 'check_output')
|
||||
|
7
releasenotes/notes/bug-1641625-fe463874dc5edb10.yaml
Normal file
7
releasenotes/notes/bug-1641625-fe463874dc5edb10.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
---
|
||||
features:
|
||||
- |
|
||||
[`bug 1641625 <https://bugs.launchpad.net/keystone/+bug/1641625>`_]
|
||||
The keystone configured as an identity provider now includes an additional
|
||||
attribute called `openstack_groups` in the assertion when generating SAML
|
||||
assertions.
|
Loading…
Reference in New Issue
Block a user