Projet

Général

Profil

0004-auth_saml-add-more-mapping-actions-in-A2_ATTRIBUTE_M.patch

Benjamin Dauvergne, 09 août 2019 12:20

Télécharger (14,5 ko)

Voir les différences:

Subject: [PATCH 4/4] auth_saml: add more mapping actions in
 A2_ATTRIBUTE_MAPPING (#35302)

 src/authentic2_auth_saml/adapters.py | 210 +++++++++++++++++++++++----
 tests/test_auth_saml.py              |  51 ++++++-
 2 files changed, 232 insertions(+), 29 deletions(-)
src/authentic2_auth_saml/adapters.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
from __future__ import unicode_literals
18

  
17 19
import logging
18 20

  
21
from django.utils import six
22
from django.core.exceptions import MultipleObjectsReturned
23
from django.db.transaction import atomic
24

  
19 25
from mellon.adapters import DefaultAdapter, UserCreationError
20 26
from mellon.utils import get_setting
21 27

  
22 28
from authentic2 import utils
29
from authentic2.utils.evaluate import evaluate_condition
30
from authentic2.a2_rbac.models import Role, OrganizationalUnit as OU
23 31

  
24 32
logger = logging.getLogger('authentic2.auth_saml')
25 33

  
26 34

  
35
@six.python_2_unicode_compatible
36
class MappingError(Exception):
37
    details = None
38

  
39
    def __init__(self, message, details=None):
40
        if details:
41
            self.details = details
42
        super(MappingError, self).__init__(message)
43

  
44
    def __str__(self):
45
        s = six.text_type(self.args[0])
46
        if self.details:
47
            s += ' ' + repr(self.details)
48
        return s
49

  
50

  
51
class SamlConditionContextProxy(object):
52
    def __init__(self, saml_attributes):
53
        self.saml_attributes = saml_attributes
54

  
55
    def __getitem__(self, key):
56
        if key.endswith('__list'):
57
            return self.saml_attributes[key[:-len('__list')]]
58
        else:
59
            v = self.saml_attributes[key]
60
            if isinstance(v, list):
61
                return v[0] if v else None
62
            else:
63
                return v
64

  
65

  
27 66
class AuthenticAdapter(DefaultAdapter):
28 67
    def create_user(self, user_class):
29 68
        return user_class.objects.create()
30 69

  
31 70
    def finish_create_user(self, idp, saml_attributes, user):
32
        self.provision_a2_attributes(user, idp, saml_attributes, do_raise=True)
71
        try:
72
            self.provision_a2_attributes(user, idp, saml_attributes)
73
        except MappingError as e:
74
            raise UserCreationError('user creation failed on a mandatory mapping action: %s' % e)
33 75

  
34 76
    def provision(self, user, idp, saml_attributes):
35 77
        super(AuthenticAdapter, self).provision(user, idp, saml_attributes)
36
        self.provision_a2_attributes(user, idp, saml_attributes)
78
        try:
79
            self.provision_a2_attributes(user, idp, saml_attributes)
80
        except MappingError as e:
81
            logger.warning('auth_saml: failure during attribute provisionning %s', e)
37 82

  
38
    def provision_a2_attributes(self, user, idp, saml_attributes, do_raise=False):
83
    @atomic
84
    def provision_a2_attributes(self, user, idp, saml_attributes):
39 85
        '''Copy incoming SAML attributes to user attributes, A2_ATTRIBUTE_MAPPING must be a list of
40 86
           dictionaries like:
41 87

  
......
50 96
            mandatory, login will fail.
51 97
        '''
52 98

  
99
        saml_attributes = saml_attributes.copy()
53 100
        attribute_mapping = get_setting(idp, 'A2_ATTRIBUTE_MAPPING', [])
101

  
102
        if not attribute_mapping:
103
            return
104

  
105
        if not isinstance(attribute_mapping, list):
106
            raise MappingError('invalid A2_ATTRIBUTE_MAPPING')
107

  
54 108
        user_modified = False
55 109
        for mapping in attribute_mapping:
56
            attribute = mapping['attribute']
57
            saml_attribute = mapping['saml_attribute']
58
            mandatory = mapping.get('mandatory', False)
59
            logger.debug('auth_saml: trying mapping attribute from %r to %r', saml_attribute, attribute,
60
                         extra={'user': user})
61
            if saml_attribute not in saml_attributes:
62
                if mandatory:
63
                    logger.error('auth_saml: mandatory saml attribute %r is missing', saml_attribute,
64
                                 extra={'attributes': repr(saml_attributes), 'user': user})
65
                    if do_raise:
66
                        raise UserCreationError('missing saml_attribute %r' % saml_attribute)
67
                else:
68
                    logger.debug('auth_saml: saml_attribute %r not found', saml_attribute, extra={'user': user})
69
                continue
110
            if not isinstance(mapping, dict):
111
                raise MappingError('invalid mapping action', details={'mapping': mapping})
112
            action = mapping.get('action', 'set-attribute')
113
            mandatory = mapping.get('mandatory', False) is True
114
            method = None
115
            if isinstance(action, six.string_types):
116
                try:
117
                    method = getattr(self, 'action_' + action.replace('-', '_'))
118
                except AttributeError:
119
                    pass
120
            if not method:
121
                raise MappingError('invalid action %r' % action)
70 122
            try:
71
                value = saml_attributes[saml_attribute]
72
                if self.set_user_attribute(user, attribute, value):
123
                logger.debug('auth_saml: applying provisionning mapping %s', mapping)
124
                if method(user, idp, saml_attributes, mapping):
73 125
                    user_modified = True
74
            except Exception as e:
75
                logger.error(u'failed to set attribute %r from saml attribute %r with value %r: %s',
76
                             attribute, saml_attribute, value, e,
77
                             extra={'attributes': repr(saml_attributes), 'user': user})
78
                if mandatory and do_raise:
79
                    raise UserCreationError('could not set attribute %s' % attribute, e)
126
            except MappingError as e:
127
                if mandatory:
128
                    # it's mandatory, provisionning should fail completely
129
                    raise e
130
                else:
131
                    logger.debug('auth_saml: action mapping %r failed: %s', mapping, e)
132

  
80 133
        if user_modified:
81 134
            user.save()
82 135

  
136
    def action_rename(self, user, idp, saml_attributes, mapping):
137
        from_name = mapping.get('from')
138
        if not from_name or not isinstance(from_name, six.string_types):
139
            raise MappingError('missing from in rename')
140
        to_name = mapping.get('to')
141
        if not to_name or not isinstance(to_name, six.string_types):
142
            raise MappingError('missing to in rename')
143
        if from_name in saml_attributes:
144
            saml_attributes[to_name] = saml_attributes[from_name]
145

  
146
    def action_set_attribute(self, user, idp, saml_attributes, mapping):
147
        attribute = mapping.get('attribute')
148
        if not attribute or not isinstance(attribute, six.string_types):
149
            raise MappingError('missing attribute key')
150

  
151
        saml_attribute = mapping.get('saml_attribute')
152
        if not saml_attribute or not isinstance(saml_attribute, six.string_types):
153
            raise MappingError('missing saml_attribute key')
154

  
155
        if saml_attribute not in saml_attributes:
156
            raise MappingError('unknown saml_attribute', details={'saml_attribute': saml_attribute})
157
        value = saml_attributes[saml_attribute]
158
        return self.set_user_attribute(user, attribute, value)
159

  
83 160
    def set_user_attribute(self, user, attribute, value):
84 161
        if isinstance(value, list):
85 162
            if len(value) > 1:
86
                raise ValueError('too much values')
163
                raise MappingError('too much values')
87 164
            value = value[0]
88 165
        if attribute in ('first_name', 'last_name', 'email', 'username'):
89 166
            if getattr(user, attribute) != value:
......
97 174
                return True
98 175
        return False
99 176

  
177
    def get_ou(self, role_desc):
178
        ou_desc = role_desc.get('ou')
179
        if ou_desc is None:
180
            return None
181
        if not isinstance(ou_desc, dict):
182
            raise MappingError('invalid ou description')
183
        slug = ou_desc.get('slug')
184
        name = ou_desc.get('name')
185
        if slug:
186
            if not isinstance(slug, six.string_types):
187
                raise MappingError('invalid ou.slug in ou description')
188
            try:
189
                return OU.objects.get(slug=slug)
190
            except OU.DoesNotExist:
191
                raise MappingError('unknown ou', details={'slug': slug})
192
        elif name:
193
            if not isinstance(name, six.string_types):
194
                raise MappingError('invalid ou.slug in ou description')
195
            try:
196
                return OU.objects.get(name=name)
197
            except OU.DoesNotExist:
198
                raise MappingError('unknown ou', details={'name': name})
199
        else:
200
            raise MappingError('invalid ou description')
201

  
202
    def get_role(self, mapping):
203
        role_desc = mapping.get('role')
204
        if not role_desc or not isinstance(role_desc, dict):
205
            raise MappingError('missing or invalid role description')
206
        slug = role_desc.get('slug')
207
        name = role_desc.get('name')
208
        ou = self.get_ou(role_desc)
209

  
210
        kwargs = {}
211
        if ou:
212
            kwargs['ou'] = ou
213

  
214
        if slug:
215
            if not isinstance(slug, six.string_types):
216
                raise MappingError('invalid role slug', details={'slug': slug})
217
            kwargs['slug'] = slug
218
        elif name:
219
            if not isinstance(name, six.string_types):
220
                raise MappingError('invalid role name', details={'name': name})
221
            kwargs['name'] = name
222
        else:
223
            raise MappingError('invalid role description')
224

  
225
        try:
226
            return Role.objects.get(**kwargs)
227
        except Role.DoesNotExist:
228
            raise MappingError('unknown role', details=kwargs)
229
        except MultipleObjectsReturned:
230
            raise MappingError('ambiuous role description', details=kwargs)
231

  
232
    def evaluate_condition(self, user, saml_attributes, mapping):
233
        condition = mapping.get('condition')
234
        if condition is None:
235
            return True
236
        if not isinstance(condition, six.string_types):
237
            raise MappingError('invalid condition')
238
        try:
239
            # use a proxy to simplify condition expressions as subscript is forbidden
240
            # you can write "email == 'a@b.com'" but also "'a@b.com' in email__list"
241
            value = evaluate_condition(condition, SamlConditionContextProxy(saml_attributes))
242
            logger.debug('auth_saml: condition %r is %s', condition, value, extra={'user': user})
243
            return value
244
        except Exception as e:
245
            raise MappingError('condition evaluation failed', details={'error': six.text_type(e)})
246

  
247
    def action_toggle_role(self, user, idp, saml_attributes, mapping):
248
        role = self.get_role(mapping)
249
        if self.evaluate_condition(user, saml_attributes, mapping):
250
            if role not in user.roles.all():
251
                logger.info('auth_saml: adding role "%s"', role, extra={'user': user})
252
                user.roles.add(role)
253
        else:
254
            if role in user.roles.all():
255
                logger.info('auth_saml: removing role "%s"', role, extra={'user': user})
256
                user.roles.remove(role)
257

  
100 258
    def auth_login(self, request, user):
101 259
        utils.login(request, user, 'saml')
tests/test_auth_saml.py
24 24
from authentic2.models import Attribute
25 25

  
26 26

  
27
def test_provision_attributes(db, caplog):
27
def test_provision_attributes(db, caplog, simple_role):
28 28
    from authentic2_auth_saml.adapters import AuthenticAdapter
29 29

  
30 30
    adapter = AuthenticAdapter()
......
39 39
                'saml_attribute': 'mail',
40 40
                'mandatory': True,
41 41
            },
42
            {
43
                'action': 'rename',
44
                'from': 'http://fucking/attribute/givenName',
45
                'to': 'first_name'
46
            },
42 47
            {
43 48
                'attribute': 'title',
44 49
                'saml_attribute': 'title',
45 50
            },
51
            {
52
                'attribute': 'first_name',
53
                'saml_attribute': 'first_name',
54
            },
55
            {
56
                'action': 'toggle-role',
57
                'role': {
58
                    'name': simple_role.name,
59
                    'ou': {
60
                        'name': simple_role.ou.name,
61
                    },
62
                },
63
                'condition': "roles == 'A'",
64
            }
46 65
        ]
47 66
    }
48 67

  
......
50 69
        u'issuer': 'https://idp.com/',
51 70
        u'name_id_content': 'xxx',
52 71
        u'name_id_format': lasso.SAML2_NAME_IDENTIFIER_FORMAT_PERSISTENT,
53
        u'mail': u'john.doe@example.com',
54
        u'title': u'Mr.',
72
        u'mail': [u'john.doe@example.com'],
73
        u'title': [u'Mr.'],
74
        u'http://fucking/attribute/givenName': ['John'],
55 75
    }
56 76
    user = adapter.lookup_user(idp, saml_attributes)
57 77
    user.refresh_from_db()
58 78
    assert user.email == 'john.doe@example.com'
59 79
    assert user.attributes.title == 'Mr.'
80
    assert user.first_name == 'John'
81
    assert simple_role not in user.roles.all()
82
    user.delete()
83

  
84
    # if a toggle-role is mandatory, failure to evaluate condition block user creation
85
    assert idp['A2_ATTRIBUTE_MAPPING'][-1]['action'] == 'toggle-role'
86
    idp['A2_ATTRIBUTE_MAPPING'][-1]['mandatory'] = True
87
    assert adapter.lookup_user(idp, saml_attributes) is None
88

  
89
    saml_attributes['roles'] = ['A']
90
    user = adapter.lookup_user(idp, saml_attributes)
91
    user.refresh_from_db()
92
    assert simple_role in user.roles.all()
93
    user.delete()
94

  
95
    idp['A2_ATTRIBUTE_MAPPING'][-1]['condition'] = "'A' in roles__list"
96
    user = adapter.lookup_user(idp, saml_attributes)
97
    user.refresh_from_db()
98
    assert simple_role in user.roles.all()
99

  
100
    saml_attributes['roles'] = []
101
    adapter.provision(user, idp, saml_attributes)
102
    # condition failed, so role should be removed
103
    assert simple_role not in user.roles.all()
104

  
60 105
    user.delete()
61 106

  
62 107
    # on missing mandatory attribute, no user is created
63
-